zeitlich 0.2.48 → 0.2.49

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 (46) hide show
  1. package/dist/{activities-BlQR5gX4.d.cts → activities-7OcT_vdR.d.cts} +3 -3
  2. package/dist/{activities-DCaIPQBT.d.ts → activities-zG_FBoY2.d.ts} +3 -3
  3. package/dist/adapters/thread/anthropic/index.d.cts +5 -5
  4. package/dist/adapters/thread/anthropic/index.d.ts +5 -5
  5. package/dist/adapters/thread/anthropic/workflow.d.cts +5 -5
  6. package/dist/adapters/thread/anthropic/workflow.d.ts +5 -5
  7. package/dist/adapters/thread/google-genai/index.d.cts +5 -5
  8. package/dist/adapters/thread/google-genai/index.d.ts +5 -5
  9. package/dist/adapters/thread/google-genai/workflow.d.cts +6 -6
  10. package/dist/adapters/thread/google-genai/workflow.d.ts +6 -6
  11. package/dist/adapters/thread/langchain/index.d.cts +5 -5
  12. package/dist/adapters/thread/langchain/index.d.ts +5 -5
  13. package/dist/adapters/thread/langchain/workflow.d.cts +5 -5
  14. package/dist/adapters/thread/langchain/workflow.d.ts +5 -5
  15. package/dist/{cold-store-UL13Sstw.d.cts → cold-store-CkWoNtMh.d.cts} +1 -1
  16. package/dist/{cold-store-aD4TSKlU.d.ts → cold-store-DKMAO1Dd.d.ts} +1 -1
  17. package/dist/index.cjs +33 -5
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +8 -8
  20. package/dist/index.d.ts +8 -8
  21. package/dist/index.js +33 -5
  22. package/dist/index.js.map +1 -1
  23. package/dist/{proxy-BAty3CWM.d.cts → proxy-B7CWEV-T.d.cts} +1 -1
  24. package/dist/{proxy-mbnwBhHw.d.ts → proxy-ByFHMVRX.d.ts} +1 -1
  25. package/dist/{thread-manager-DtEtbUkp.d.ts → thread-manager-7AW4rhfu.d.ts} +2 -2
  26. package/dist/{thread-manager-R6c3lnJy.d.cts → thread-manager-B9rtMEVn.d.cts} +2 -2
  27. package/dist/{thread-manager-DsXvJ5cJ.d.cts → thread-manager-Cibe0X5m.d.cts} +2 -2
  28. package/dist/{thread-manager-CICj68PI.d.ts → thread-manager-nK-WcFzM.d.ts} +2 -2
  29. package/dist/{types-DDLPnxBh.d.cts → types-BR-k7h0e.d.cts} +1 -1
  30. package/dist/{types-DF4wzWQG.d.ts → types-DO4Tkwxo.d.ts} +1 -1
  31. package/dist/{types-DwBYd0ij.d.ts → types-DeVNWqlb.d.ts} +23 -0
  32. package/dist/{types-DWeyCTYK.d.cts → types-XUUFvrJ9.d.cts} +23 -0
  33. package/dist/{workflow-DVNPR7eX.d.cts → workflow-KbGsxpfh.d.cts} +1 -1
  34. package/dist/{workflow-DdaU7_j4.d.ts → workflow-uhOIj9D-.d.ts} +1 -1
  35. package/dist/workflow.cjs +33 -5
  36. package/dist/workflow.cjs.map +1 -1
  37. package/dist/workflow.d.cts +2 -2
  38. package/dist/workflow.d.ts +2 -2
  39. package/dist/workflow.js +33 -5
  40. package/dist/workflow.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/lib/session/session.ts +11 -0
  43. package/src/lib/subagent/handler.ts +23 -0
  44. package/src/lib/subagent/subagent.integration.test.ts +198 -0
  45. package/src/lib/tool-router/router.ts +11 -3
  46. package/src/lib/tool-router/types.ts +23 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeitlich",
3
- "version": "0.2.48",
3
+ "version": "0.2.49",
4
4
  "description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -585,6 +585,17 @@ export async function createSession<
585
585
  ...(assistantId !== undefined && {
586
586
  assistantMessageId: assistantId,
587
587
  }),
588
+ // Hand handlers a way to persist the parent's slice
589
+ // mid-loop (subagents that fork or continue the parent's
590
+ // thread need this — otherwise the child loads a stale
591
+ // snapshot from the prior session, since `saveThreadState`
592
+ // would otherwise only run in the `finally` below).
593
+ persistThreadState: () =>
594
+ saveThreadState(
595
+ threadId,
596
+ stateManager.getPersistedSlice(),
597
+ threadKey
598
+ ),
588
599
  }
589
600
  );
590
601
 
@@ -439,6 +439,29 @@ export function createSubagentHandler<
439
439
  snapshotBaseCreatorAgent.set(childWorkflowId, config.agentName);
440
440
  }
441
441
 
442
+ // The parent's `PersistedThreadState` slice (`tasks` + custom
443
+ // state) only lands in storage in the session's `finally`. When
444
+ // the child reads from the parent's thread (`from-parent`
445
+ // fallback or an explicit args.threadId pointing at the parent),
446
+ // that `loadThreadState` would otherwise see the prior session's
447
+ // snapshot. Flush the live slice now via the session-supplied
448
+ // callback so the child sees the parent's current state.
449
+ if (
450
+ continuationThreadId &&
451
+ continuationThreadId === context.threadId &&
452
+ context.persistThreadState
453
+ ) {
454
+ try {
455
+ await context.persistThreadState();
456
+ } catch (err) {
457
+ log.warn("failed to persist parent thread state for subagent", {
458
+ subagent: config.agentName,
459
+ childWorkflowId,
460
+ error: err instanceof Error ? err.message : String(err),
461
+ });
462
+ }
463
+ }
464
+
442
465
  log.info("subagent spawned", {
443
466
  subagent: config.agentName,
444
467
  childWorkflowId,
@@ -813,6 +813,204 @@ describe("createSubagentHandler", () => {
813
813
  expect(workflowInput.thread).toBeUndefined();
814
814
  });
815
815
 
816
+ // --- persistThreadState: parent slice flush before child loads ---
817
+
818
+ it("calls persistThreadState before executeChild when forking parent's thread", async () => {
819
+ const { executeChild } = await import("@temporalio/workflow");
820
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
821
+ const persistThreadState = vi.fn(async () => undefined);
822
+
823
+ const subagent: SubagentConfig = {
824
+ agentName: "parent-fork-persist",
825
+ description: "Forks parent thread",
826
+ workflow: mockWorkflow(),
827
+ thread: "fork",
828
+ newThreadSource: "from-parent",
829
+ };
830
+
831
+ const { handler } = createSubagentHandler([subagent]);
832
+
833
+ await handler(
834
+ {
835
+ subagent: "parent-fork-persist",
836
+ description: "test",
837
+ prompt: "test",
838
+ },
839
+ {
840
+ threadId: "parent-t",
841
+ toolCallId: "tc",
842
+ toolName: "Subagent",
843
+ persistThreadState,
844
+ }
845
+ );
846
+
847
+ expect(persistThreadState).toHaveBeenCalledTimes(1);
848
+ const lastExecOrder =
849
+ execMock.mock.invocationCallOrder[
850
+ execMock.mock.invocationCallOrder.length - 1
851
+ ] ?? Infinity;
852
+ expect(persistThreadState.mock.invocationCallOrder[0]).toBeLessThan(
853
+ lastExecOrder
854
+ );
855
+ });
856
+
857
+ it("calls persistThreadState before executeChild when continuing parent's thread", async () => {
858
+ const { executeChild } = await import("@temporalio/workflow");
859
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
860
+ const persistThreadState = vi.fn(async () => undefined);
861
+
862
+ const subagent: SubagentConfig = {
863
+ agentName: "parent-continue-persist",
864
+ description: "Continues parent thread",
865
+ workflow: mockWorkflow(),
866
+ thread: "continue",
867
+ newThreadSource: "from-parent",
868
+ };
869
+
870
+ const { handler } = createSubagentHandler([subagent]);
871
+
872
+ await handler(
873
+ {
874
+ subagent: "parent-continue-persist",
875
+ description: "test",
876
+ prompt: "test",
877
+ },
878
+ {
879
+ threadId: "parent-t",
880
+ toolCallId: "tc",
881
+ toolName: "Subagent",
882
+ persistThreadState,
883
+ }
884
+ );
885
+
886
+ expect(persistThreadState).toHaveBeenCalledTimes(1);
887
+ const lastExecOrder =
888
+ execMock.mock.invocationCallOrder[
889
+ execMock.mock.invocationCallOrder.length - 1
890
+ ] ?? Infinity;
891
+ expect(persistThreadState.mock.invocationCallOrder[0]).toBeLessThan(
892
+ lastExecOrder
893
+ );
894
+ });
895
+
896
+ it("calls persistThreadState when args.threadId points at the parent's thread", async () => {
897
+ const persistThreadState = vi.fn(async () => undefined);
898
+
899
+ const subagent: SubagentConfig = {
900
+ agentName: "explicit-parent",
901
+ description: "Explicit parent threadId",
902
+ workflow: mockWorkflow(),
903
+ thread: "continue",
904
+ };
905
+
906
+ const { handler } = createSubagentHandler([subagent]);
907
+
908
+ await handler(
909
+ {
910
+ subagent: "explicit-parent",
911
+ description: "test",
912
+ prompt: "test",
913
+ threadId: "parent-t",
914
+ },
915
+ {
916
+ threadId: "parent-t",
917
+ toolCallId: "tc",
918
+ toolName: "Subagent",
919
+ persistThreadState,
920
+ }
921
+ );
922
+
923
+ expect(persistThreadState).toHaveBeenCalledTimes(1);
924
+ });
925
+
926
+ it("does not call persistThreadState when child thread is independent of the parent", async () => {
927
+ const persistThreadState = vi.fn(async () => undefined);
928
+
929
+ const subagent: SubagentConfig = {
930
+ agentName: "independent-thread",
931
+ description: "Continues a sibling thread",
932
+ workflow: mockWorkflow(),
933
+ thread: "continue",
934
+ };
935
+
936
+ const { handler } = createSubagentHandler([subagent]);
937
+
938
+ await handler(
939
+ {
940
+ subagent: "independent-thread",
941
+ description: "test",
942
+ prompt: "test",
943
+ threadId: "sibling-thread",
944
+ },
945
+ {
946
+ threadId: "parent-t",
947
+ toolCallId: "tc",
948
+ toolName: "Subagent",
949
+ persistThreadState,
950
+ }
951
+ );
952
+
953
+ expect(persistThreadState).not.toHaveBeenCalled();
954
+ });
955
+
956
+ it("does not call persistThreadState when the child starts a fresh thread", async () => {
957
+ const persistThreadState = vi.fn(async () => undefined);
958
+
959
+ const subagent: SubagentConfig = {
960
+ agentName: "fresh-thread",
961
+ description: "Always starts fresh",
962
+ workflow: mockWorkflow(),
963
+ };
964
+
965
+ const { handler } = createSubagentHandler([subagent]);
966
+
967
+ await handler(
968
+ { subagent: "fresh-thread", description: "test", prompt: "test" },
969
+ {
970
+ threadId: "parent-t",
971
+ toolCallId: "tc",
972
+ toolName: "Subagent",
973
+ persistThreadState,
974
+ }
975
+ );
976
+
977
+ expect(persistThreadState).not.toHaveBeenCalled();
978
+ });
979
+
980
+ it("still spawns the child when persistThreadState throws", async () => {
981
+ const { executeChild } = await import("@temporalio/workflow");
982
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
983
+ const persistThreadState = vi.fn(async () => {
984
+ throw new Error("redis down");
985
+ });
986
+ const callsBefore = execMock.mock.calls.length;
987
+
988
+ const subagent: SubagentConfig = {
989
+ agentName: "persist-fails",
990
+ description: "Persist failure should not block child",
991
+ workflow: mockWorkflow(),
992
+ thread: "fork",
993
+ newThreadSource: "from-parent",
994
+ };
995
+
996
+ const { handler } = createSubagentHandler([subagent]);
997
+
998
+ await expect(
999
+ handler(
1000
+ { subagent: "persist-fails", description: "test", prompt: "test" },
1001
+ {
1002
+ threadId: "parent-t",
1003
+ toolCallId: "tc",
1004
+ toolName: "Subagent",
1005
+ persistThreadState,
1006
+ }
1007
+ )
1008
+ ).resolves.toBeDefined();
1009
+
1010
+ expect(persistThreadState).toHaveBeenCalledTimes(1);
1011
+ expect(execMock.mock.calls.length).toBe(callsBefore + 1);
1012
+ });
1013
+
816
1014
  // --- Sandbox continuation ---
817
1015
 
818
1016
  it("does not pass sandbox when thread is fork (own sandbox)", async () => {
@@ -221,7 +221,8 @@ export function createToolRouter<T extends ToolMap>(
221
221
  turn: number,
222
222
  sandboxId?: string,
223
223
  onRewindRequested?: (signal: RewindSignal) => void,
224
- assistantMessageId?: string
224
+ assistantMessageId?: string,
225
+ persistThreadState?: () => Promise<void>
225
226
  ): Promise<ProcessedToolCall> {
226
227
  const startTime = Date.now();
227
228
  const tool = toolMap.get(toolCall.name);
@@ -265,6 +266,7 @@ export function createToolRouter<T extends ToolMap>(
265
266
  toolName: toolCall.name,
266
267
  ...(sandboxId !== undefined && { sandboxId }),
267
268
  ...(assistantMessageId !== undefined && { assistantMessageId }),
269
+ ...(persistThreadState !== undefined && { persistThreadState }),
268
270
  };
269
271
  const response = await tool.handler(
270
272
  effectiveArgs as Parameters<typeof tool.handler>[0],
@@ -422,6 +424,7 @@ export function createToolRouter<T extends ToolMap>(
422
424
  const turn = context?.turn ?? 0;
423
425
  const sandboxId = context?.sandboxId;
424
426
  const assistantMessageId = context?.assistantMessageId;
427
+ const persistThreadState = context?.persistThreadState;
425
428
 
426
429
  let rewindSignal: RewindSignal | undefined;
427
430
 
@@ -443,7 +446,8 @@ export function createToolRouter<T extends ToolMap>(
443
446
  turn,
444
447
  sandboxId,
445
448
  onRewindRequested,
446
- assistantMessageId
449
+ assistantMessageId,
450
+ persistThreadState
447
451
  )
448
452
  )
449
453
  )
@@ -471,7 +475,8 @@ export function createToolRouter<T extends ToolMap>(
471
475
  turn,
472
476
  sandboxId,
473
477
  undefined,
474
- assistantMessageId
478
+ assistantMessageId,
479
+ persistThreadState
475
480
  );
476
481
  if (outcome.kind === "rewind") {
477
482
  rewindSignal = outcome.signal;
@@ -510,6 +515,9 @@ export function createToolRouter<T extends ToolMap>(
510
515
  ...(context?.assistantMessageId !== undefined && {
511
516
  assistantMessageId: context.assistantMessageId,
512
517
  }),
518
+ ...(context?.persistThreadState !== undefined && {
519
+ persistThreadState: context.persistThreadState,
520
+ }),
513
521
  };
514
522
  const response = await handler(
515
523
  toolCall.args as ToolArgs<T, TName>,
@@ -190,6 +190,20 @@ export interface RouterContext {
190
190
  * thread so the child's first model call sees a well-formed history.
191
191
  */
192
192
  assistantMessageId?: string;
193
+ /**
194
+ * Persist the parent session's current `PersistedThreadState` slice
195
+ * (tasks + custom state) to the durable thread store. Wired up by
196
+ * the session — absent for manually-driven routers (tests, custom
197
+ * orchestrators).
198
+ *
199
+ * Subagent handlers invoke this before spawning a child that will
200
+ * read the parent's thread (`newThreadSource: "from-parent"` or an
201
+ * explicit parent threadId): the parent's slice otherwise only
202
+ * lands in storage at session-exit time, so the child would load a
203
+ * stale (or empty) snapshot. Best-effort — failures are logged by
204
+ * the session but never thrown.
205
+ */
206
+ persistThreadState?: () => Promise<void>;
193
207
  }
194
208
 
195
209
  /**
@@ -314,6 +328,15 @@ export interface ProcessToolCallsContext {
314
328
  * out of a parent-forked thread).
315
329
  */
316
330
  assistantMessageId?: string;
331
+ /**
332
+ * Optional callback that flushes the session's in-memory
333
+ * `PersistedThreadState` slice to the durable thread store. The
334
+ * router forwards it into every handler's {@link RouterContext}
335
+ * verbatim. The session uses this to let mid-loop tool handlers
336
+ * (notably subagents that fork or continue the parent's thread)
337
+ * persist the parent's slice before the child reads it.
338
+ */
339
+ persistThreadState?: () => Promise<void>;
317
340
  }
318
341
 
319
342
  /**