zeitlich 0.2.38 → 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 (125) hide show
  1. package/README.md +18 -0
  2. package/dist/{activities-BKhMtKDd.d.ts → activities-Bmu7XnaG.d.ts} +4 -6
  3. package/dist/{activities-CDcwkRZs.d.cts → activities-ByBFLvm2.d.cts} +4 -6
  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/thread/anthropic/index.cjs +140 -23
  11. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  12. package/dist/adapters/thread/anthropic/index.d.cts +8 -7
  13. package/dist/adapters/thread/anthropic/index.d.ts +8 -7
  14. package/dist/adapters/thread/anthropic/index.js +140 -24
  15. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  16. package/dist/adapters/thread/anthropic/workflow.cjs +8 -3
  17. package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
  18. package/dist/adapters/thread/anthropic/workflow.d.cts +5 -4
  19. package/dist/adapters/thread/anthropic/workflow.d.ts +5 -4
  20. package/dist/adapters/thread/anthropic/workflow.js +8 -4
  21. package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
  22. package/dist/adapters/thread/google-genai/index.cjs +140 -23
  23. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  24. package/dist/adapters/thread/google-genai/index.d.cts +5 -4
  25. package/dist/adapters/thread/google-genai/index.d.ts +5 -4
  26. package/dist/adapters/thread/google-genai/index.js +140 -24
  27. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  28. package/dist/adapters/thread/google-genai/workflow.cjs +8 -3
  29. package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
  30. package/dist/adapters/thread/google-genai/workflow.d.cts +5 -4
  31. package/dist/adapters/thread/google-genai/workflow.d.ts +5 -4
  32. package/dist/adapters/thread/google-genai/workflow.js +8 -4
  33. package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
  34. package/dist/adapters/thread/index.cjs +16 -0
  35. package/dist/adapters/thread/index.cjs.map +1 -0
  36. package/dist/adapters/thread/index.d.cts +34 -0
  37. package/dist/adapters/thread/index.d.ts +34 -0
  38. package/dist/adapters/thread/index.js +12 -0
  39. package/dist/adapters/thread/index.js.map +1 -0
  40. package/dist/adapters/thread/langchain/index.cjs +139 -24
  41. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  42. package/dist/adapters/thread/langchain/index.d.cts +8 -7
  43. package/dist/adapters/thread/langchain/index.d.ts +8 -7
  44. package/dist/adapters/thread/langchain/index.js +139 -25
  45. package/dist/adapters/thread/langchain/index.js.map +1 -1
  46. package/dist/adapters/thread/langchain/workflow.cjs +8 -3
  47. package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
  48. package/dist/adapters/thread/langchain/workflow.d.cts +5 -4
  49. package/dist/adapters/thread/langchain/workflow.d.ts +5 -4
  50. package/dist/adapters/thread/langchain/workflow.js +8 -4
  51. package/dist/adapters/thread/langchain/workflow.js.map +1 -1
  52. package/dist/index.cjs +266 -48
  53. package/dist/index.cjs.map +1 -1
  54. package/dist/index.d.cts +6 -6
  55. package/dist/index.d.ts +6 -6
  56. package/dist/index.js +263 -49
  57. package/dist/index.js.map +1 -1
  58. package/dist/{proxy-D_3x7RN4.d.cts → proxy-BAKzNGRq.d.cts} +1 -1
  59. package/dist/{proxy-CUlKSvZS.d.ts → proxy-DO_MXbY4.d.ts} +1 -1
  60. package/dist/{thread-manager-CVu7o2cs.d.ts → thread-manager-CcRXasqs.d.ts} +2 -4
  61. package/dist/{thread-manager-HSwyh28L.d.cts → thread-manager-ClwSaUnj.d.cts} +2 -4
  62. package/dist/{thread-manager-c1gPopAG.d.ts → thread-manager-D-7lp1JK.d.ts} +2 -4
  63. package/dist/{thread-manager-wGi-LqIP.d.cts → thread-manager-Y8Ucf0Tf.d.cts} +2 -4
  64. package/dist/{types-C06FwR96.d.cts → types-Bcbiq8iv.d.cts} +162 -44
  65. package/dist/{types-BH_IRryz.d.ts → types-DpHTX-iO.d.ts} +54 -6
  66. package/dist/{types-DNr31FzL.d.ts → types-Dt8-HBBT.d.ts} +162 -44
  67. package/dist/{types-BaOw4hKI.d.cts → types-hFFi-Zd9.d.cts} +54 -6
  68. package/dist/{workflow-CSCkpwAL.d.ts → workflow-Bmf9EtDW.d.ts} +82 -2
  69. package/dist/{workflow-DuvMZ8Vm.d.cts → workflow-Bx9utBwb.d.cts} +82 -2
  70. package/dist/workflow.cjs +188 -37
  71. package/dist/workflow.cjs.map +1 -1
  72. package/dist/workflow.d.cts +2 -2
  73. package/dist/workflow.d.ts +2 -2
  74. package/dist/workflow.js +185 -38
  75. package/dist/workflow.js.map +1 -1
  76. package/package.json +11 -1
  77. package/src/adapters/thread/adapter-id.test.ts +42 -0
  78. package/src/adapters/thread/anthropic/activities.ts +33 -7
  79. package/src/adapters/thread/anthropic/adapter-id.ts +16 -0
  80. package/src/adapters/thread/anthropic/fork-transform.test.ts +291 -0
  81. package/src/adapters/thread/anthropic/index.ts +3 -0
  82. package/src/adapters/thread/anthropic/model-invoker.ts +8 -4
  83. package/src/adapters/thread/anthropic/proxy.ts +3 -2
  84. package/src/adapters/thread/anthropic/thread-manager.ts +27 -4
  85. package/src/adapters/thread/google-genai/activities.ts +33 -7
  86. package/src/adapters/thread/google-genai/adapter-id.ts +16 -0
  87. package/src/adapters/thread/google-genai/fork-transform.test.ts +149 -0
  88. package/src/adapters/thread/google-genai/index.ts +3 -0
  89. package/src/adapters/thread/google-genai/model-invoker.ts +7 -3
  90. package/src/adapters/thread/google-genai/proxy.ts +3 -2
  91. package/src/adapters/thread/google-genai/thread-manager.ts +27 -4
  92. package/src/adapters/thread/index.ts +39 -0
  93. package/src/adapters/thread/langchain/activities.ts +33 -7
  94. package/src/adapters/thread/langchain/adapter-id.ts +16 -0
  95. package/src/adapters/thread/langchain/fork-transform.test.ts +142 -0
  96. package/src/adapters/thread/langchain/index.ts +3 -0
  97. package/src/adapters/thread/langchain/model-invoker.ts +8 -3
  98. package/src/adapters/thread/langchain/proxy.ts +3 -2
  99. package/src/adapters/thread/langchain/thread-manager.ts +27 -4
  100. package/src/lib/lifecycle.ts +3 -1
  101. package/src/lib/model/types.ts +7 -10
  102. package/src/lib/session/session-edge-cases.integration.test.ts +131 -63
  103. package/src/lib/session/session.integration.test.ts +174 -5
  104. package/src/lib/session/session.ts +68 -28
  105. package/src/lib/session/types.ts +60 -9
  106. package/src/lib/state/index.ts +1 -0
  107. package/src/lib/state/manager.integration.test.ts +109 -0
  108. package/src/lib/state/manager.ts +38 -8
  109. package/src/lib/state/types.ts +25 -0
  110. package/src/lib/subagent/handler.ts +124 -11
  111. package/src/lib/subagent/index.ts +5 -1
  112. package/src/lib/subagent/subagent.integration.test.ts +528 -0
  113. package/src/lib/subagent/types.ts +63 -14
  114. package/src/lib/subagent/workflow.ts +29 -2
  115. package/src/lib/thread/index.ts +5 -0
  116. package/src/lib/thread/keys.test.ts +101 -0
  117. package/src/lib/thread/keys.ts +94 -0
  118. package/src/lib/thread/manager.test.ts +139 -0
  119. package/src/lib/thread/manager.ts +92 -14
  120. package/src/lib/thread/proxy.ts +2 -0
  121. package/src/lib/thread/types.ts +60 -6
  122. package/src/lib/tool-router/types.ts +16 -8
  123. package/src/lib/types.ts +12 -0
  124. package/src/workflow.ts +12 -1
  125. package/tsup.config.ts +1 -0
@@ -16,6 +16,7 @@ import type {
16
16
  AgentState,
17
17
  AgentStateManager,
18
18
  JsonSerializable,
19
+ PersistedThreadState,
19
20
  } from "../state/types";
20
21
  import { createToolRouter } from "../tool-router/router";
21
22
  import type { ParsedToolCallUnion, ToolMap } from "../tool-router/types";
@@ -111,6 +112,7 @@ export async function createSession<
111
112
  sandbox: sandboxInit,
112
113
  sandboxShutdown = "destroy",
113
114
  onSandboxReady,
115
+ onSessionExit,
114
116
  virtualFs: virtualFsConfig,
115
117
  virtualFsOps,
116
118
  }: SessionConfig<T, M, TContent>): Promise<ZeitlichSession<M, boolean>> {
@@ -146,7 +148,8 @@ export async function createSession<
146
148
  appendSystemMessage,
147
149
  appendAgentMessage,
148
150
  forkThread,
149
- truncateThread,
151
+ loadThreadState,
152
+ saveThreadState,
150
153
  } = threadOps;
151
154
 
152
155
  const plugins: ToolMap[string][] = [];
@@ -314,7 +317,10 @@ export async function createSession<
314
317
  }
315
318
 
316
319
  if (sandboxId && sandboxOwned && onSandboxReady) {
317
- onSandboxReady(sandboxId);
320
+ onSandboxReady({
321
+ sandboxId,
322
+ ...(baseSnapshot && { baseSnapshot }),
323
+ });
318
324
  }
319
325
 
320
326
  // --- Virtual filesystem init (independent of sandbox) ----------------
@@ -366,10 +372,21 @@ export async function createSession<
366
372
  const systemPrompt = stateManager.getSystemPrompt();
367
373
 
368
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
+
369
382
  if (threadMode === "fork" && sourceThreadId) {
370
383
  await forkThread(sourceThreadId, threadId, threadKey);
384
+ const forkedSlice = await loadThreadState(threadId, threadKey);
385
+ if (forkedSlice) rehydrateFromSlice(forkedSlice);
371
386
  } else if (threadMode === "continue") {
372
387
  // "continue" — thread already exists, just append the new message
388
+ const continuedSlice = await loadThreadState(threadId, threadKey);
389
+ if (continuedSlice) rehydrateFromSlice(continuedSlice);
373
390
  } else {
374
391
  if (appendSystemPrompt) {
375
392
  if (
@@ -397,6 +414,13 @@ export async function createSession<
397
414
  let finalMessage: M | null = null;
398
415
 
399
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;
400
424
  while (
401
425
  stateManager.isRunning() &&
402
426
  !stateManager.isTerminal() &&
@@ -409,25 +433,17 @@ export async function createSession<
409
433
 
410
434
  stateManager.setTools(toolRouter.getToolDefinitions());
411
435
 
412
- const {
413
- message,
414
- rawToolCalls,
415
- usage,
416
- threadLengthAtCall,
417
- } = await runAgent({
436
+ assistantId ??= uuid4();
437
+
438
+ const { message, rawToolCalls, usage } = await runAgent({
418
439
  threadId,
419
440
  threadKey,
420
441
  agentName,
421
442
  metadata,
443
+ assistantMessageId: assistantId,
422
444
  });
423
445
 
424
- // The invoker loaded the thread right before calling the LLM,
425
- // so it already knows how many messages were stored at that
426
- // point — we use that directly as the rewind snapshot instead
427
- // of a separate activity round-trip.
428
- const preAssistantLength = threadLengthAtCall;
429
-
430
- await appendAgentMessage(threadId, uuid4(), message, threadKey);
446
+ await appendAgentMessage(threadId, assistantId, message, threadKey);
431
447
 
432
448
  if (usage) {
433
449
  stateManager.updateUsage(usage);
@@ -488,24 +504,19 @@ export async function createSession<
488
504
  toolCallId: rewind.toolCallId,
489
505
  toolName: rewind.toolName,
490
506
  });
491
- if (preAssistantLength === undefined) {
492
- throw ApplicationFailure.create({
493
- message:
494
- "Rewind requested but runAgent did not report " +
495
- "`threadLengthAtCall`; the adapter must populate it to " +
496
- "support rewinds.",
497
- nonRetryable: true,
498
- });
499
- }
500
- // Drop the assistant message + any already-saved tool results
501
- // so the LLM call can be retried from the pre-assistant state.
502
- // The turn counter is intentionally NOT rolled back — each
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
503
512
  // rewind still consumes one of the `maxTurns` budget so a
504
513
  // misbehaving tool cannot spin the session forever.
505
- await truncateThread(threadId, preAssistantLength, threadKey);
506
514
  continue;
507
515
  }
508
516
 
517
+ // Turn committed: fresh id for the next turn.
518
+ assistantId = undefined;
519
+
509
520
  if (stateManager.getStatus() === "WAITING_FOR_INPUT") {
510
521
  const conditionMet = await condition(
511
522
  () => stateManager.getStatus() === "RUNNING",
@@ -539,6 +550,28 @@ export async function createSession<
539
550
  });
540
551
  throw ApplicationFailure.fromError(error);
541
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
+
542
575
  await callSessionEnd(exitReason, stateManager.getTurns());
543
576
 
544
577
  if (sandboxOwned && sandboxId && sandboxOps) {
@@ -580,6 +613,13 @@ export async function createSession<
580
613
  ...(exitSnapshot && { hasExitSnapshot: true }),
581
614
  });
582
615
 
616
+ if (onSessionExit) {
617
+ onSessionExit({
618
+ ...(sandboxId && { sandboxId }),
619
+ ...(exitSnapshot && { snapshot: exitSnapshot }),
620
+ });
621
+ }
622
+
583
623
  return {
584
624
  threadId,
585
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,22 +57,52 @@ 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>;
62
72
  /**
63
- * Truncate the thread back to `length` messages. Used by the session's
64
- * rewind flow to roll the thread back before retrying a turn. The
65
- * session obtains `length` from `AgentResponse.threadLengthAtCall`,
66
- * which the model invoker computes for free from the messages it
67
- * loaded before invoking the LLM.
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.
68
83
  */
69
84
  truncateThread(
70
85
  threadId: string,
71
- length: number,
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,
72
106
  threadKey?: string
73
107
  ): Promise<void>;
74
108
  }
@@ -195,8 +229,25 @@ export interface SessionConfig<
195
229
  /**
196
230
  * Called as soon as the sandbox is created (or resumed/forked), before the
197
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.
198
246
  */
199
- onSandboxReady?: (sandboxId: string) => void;
247
+ onSessionExit?: (result: {
248
+ sandboxId?: string;
249
+ snapshot?: SandboxSnapshot;
250
+ }) => void;
200
251
 
201
252
  // ---------------------------------------------------------------------------
202
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