zeitlich 0.2.47 → 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 (50) hide show
  1. package/README.md +2 -0
  2. package/dist/{activities-CPwKoUlD.d.cts → activities-7OcT_vdR.d.cts} +3 -3
  3. package/dist/{activities-DlaBxNID.d.ts → activities-zG_FBoY2.d.ts} +3 -3
  4. package/dist/adapters/thread/anthropic/index.d.cts +5 -5
  5. package/dist/adapters/thread/anthropic/index.d.ts +5 -5
  6. package/dist/adapters/thread/anthropic/workflow.d.cts +5 -5
  7. package/dist/adapters/thread/anthropic/workflow.d.ts +5 -5
  8. package/dist/adapters/thread/google-genai/index.d.cts +5 -5
  9. package/dist/adapters/thread/google-genai/index.d.ts +5 -5
  10. package/dist/adapters/thread/google-genai/workflow.d.cts +6 -6
  11. package/dist/adapters/thread/google-genai/workflow.d.ts +6 -6
  12. package/dist/adapters/thread/langchain/index.d.cts +5 -5
  13. package/dist/adapters/thread/langchain/index.d.ts +5 -5
  14. package/dist/adapters/thread/langchain/workflow.d.cts +5 -5
  15. package/dist/adapters/thread/langchain/workflow.d.ts +5 -5
  16. package/dist/{cold-store-Z2wvK2cV.d.cts → cold-store-CkWoNtMh.d.cts} +1 -1
  17. package/dist/{cold-store-BDgJpwLI.d.ts → cold-store-DKMAO1Dd.d.ts} +1 -1
  18. package/dist/index.cjs +76 -10
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +8 -8
  21. package/dist/index.d.ts +8 -8
  22. package/dist/index.js +76 -10
  23. package/dist/index.js.map +1 -1
  24. package/dist/{proxy-CDh3Rsa7.d.cts → proxy-B7CWEV-T.d.cts} +1 -1
  25. package/dist/{proxy-Du8ggERu.d.ts → proxy-ByFHMVRX.d.ts} +1 -1
  26. package/dist/{thread-manager-DtHYws2F.d.ts → thread-manager-7AW4rhfu.d.ts} +2 -2
  27. package/dist/{thread-manager-D8zKNFZ9.d.cts → thread-manager-B9rtMEVn.d.cts} +2 -2
  28. package/dist/{thread-manager-BjoYYXgd.d.cts → thread-manager-Cibe0X5m.d.cts} +2 -2
  29. package/dist/{thread-manager-Dw96FKH1.d.ts → thread-manager-nK-WcFzM.d.ts} +2 -2
  30. package/dist/{types-BMJrsHo0.d.cts → types-BR-k7h0e.d.cts} +1 -1
  31. package/dist/{types-CtdOquo3.d.ts → types-DO4Tkwxo.d.ts} +1 -1
  32. package/dist/{types-qQVZfhoT.d.ts → types-DeVNWqlb.d.ts} +54 -0
  33. package/dist/{types-DNEl5uxQ.d.cts → types-XUUFvrJ9.d.cts} +54 -0
  34. package/dist/{workflow-BH9ImDGq.d.cts → workflow-KbGsxpfh.d.cts} +1 -1
  35. package/dist/{workflow-Cdw3-RNB.d.ts → workflow-uhOIj9D-.d.ts} +1 -1
  36. package/dist/workflow.cjs +76 -10
  37. package/dist/workflow.cjs.map +1 -1
  38. package/dist/workflow.d.cts +2 -2
  39. package/dist/workflow.d.ts +2 -2
  40. package/dist/workflow.js +76 -10
  41. package/dist/workflow.js.map +1 -1
  42. package/package.json +6 -6
  43. package/src/lib/lifecycle.ts +13 -1
  44. package/src/lib/session/session-edge-cases.integration.test.ts +44 -0
  45. package/src/lib/session/session.ts +26 -0
  46. package/src/lib/subagent/handler.ts +55 -6
  47. package/src/lib/subagent/subagent.integration.test.ts +239 -2
  48. package/src/lib/tool-router/router-edge-cases.integration.test.ts +36 -0
  49. package/src/lib/tool-router/router.ts +29 -3
  50. package/src/lib/tool-router/types.ts +43 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeitlich",
3
- "version": "0.2.47",
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",
@@ -208,11 +208,11 @@
208
208
  "@e2b/code-interpreter": "^2.3.3",
209
209
  "@eslint/js": "^10.0.1",
210
210
  "@google/genai": "^1.44.0",
211
- "@langchain/core": "^1.1.30",
212
- "@temporalio/common": "^1.17.0",
213
- "@temporalio/envconfig": "^1.17.0",
214
- "@temporalio/worker": "^1.17.0",
215
- "@temporalio/workflow": "^1.17.0",
211
+ "@langchain/core": "^1.1.48",
212
+ "@temporalio/common": "^1.17.2",
213
+ "@temporalio/envconfig": "^1.17.2",
214
+ "@temporalio/worker": "^1.17.2",
215
+ "@temporalio/workflow": "^1.17.2",
216
216
  "@types/node": "^25.3.3",
217
217
  "eslint": "^10.0.2",
218
218
  "husky": "^9.1.7",
@@ -11,11 +11,23 @@
11
11
  * continue there. When the adapter has `onForkPrepareThread` and/or
12
12
  * `onForkTransform` hooks configured, they are applied once to the forked
13
13
  * thread before the session starts.
14
+ *
15
+ * The optional `truncateAfterFork.fromMessageId` directs the session to
16
+ * call `truncateThread` on the freshly forked thread immediately after
17
+ * the fork, dropping that message and everything after. Used by
18
+ * subagents that fork their parent's thread mid-tool-call to strip the
19
+ * orphan assistant `tool_use` block (the one whose `tool_result` will
20
+ * never arrive in the child's thread) so the first model call doesn't
21
+ * reject on an unmatched tool-use/tool-result pair.
14
22
  */
15
23
  export type ThreadInit =
16
24
  | { mode: "new"; threadId?: string }
17
25
  | { mode: "continue"; threadId: string }
18
- | { mode: "fork"; threadId: string };
26
+ | {
27
+ mode: "fork";
28
+ threadId: string;
29
+ truncateAfterFork?: { fromMessageId: string };
30
+ };
19
31
 
20
32
  // ============================================================================
21
33
  // Sandbox lifecycle
@@ -660,6 +660,50 @@ describe("createSession edge cases", () => {
660
660
  const forkOp = forkOps[0];
661
661
  if (!forkOp) throw new Error("expected fork op");
662
662
  expect(forkOp.args[0]).toBe("original-thread");
663
+
664
+ // No truncateAfterFork directive ⇒ no truncate call.
665
+ const truncateOps = log.filter((l) => l.op === "truncateThread");
666
+ expect(truncateOps).toHaveLength(0);
667
+ });
668
+
669
+ it("fork thread mode truncates the forked thread at fromMessageId when truncateAfterFork is set", async () => {
670
+ const { ops, log } = createMockThreadOps();
671
+
672
+ const session = await createSession({
673
+ agentName: "TestAgent",
674
+ thread: {
675
+ mode: "fork",
676
+ threadId: "parent-thread",
677
+ truncateAfterFork: { fromMessageId: "parent-asst-msg-1" },
678
+ },
679
+ runAgent: createScriptedRunAgent([
680
+ { message: "forked & continued", toolCalls: [] },
681
+ ]),
682
+ threadOps: ops,
683
+ buildContextMessage: () => "continue",
684
+ });
685
+
686
+ const stateManager = createAgentStateManager({
687
+ initialState: { systemPrompt: "test" },
688
+ });
689
+
690
+ const result = await session.runSession({ stateManager });
691
+
692
+ expect(result.exitReason).toBe("completed");
693
+ const forkedThreadId = result.threadId;
694
+ expect(forkedThreadId).not.toBe("parent-thread");
695
+
696
+ // Order matters: fork must happen before truncate, otherwise the
697
+ // truncate would no-op against an empty thread.
698
+ const forkIdx = log.findIndex((l) => l.op === "forkThread");
699
+ const truncIdx = log.findIndex((l) => l.op === "truncateThread");
700
+ expect(forkIdx).toBeGreaterThanOrEqual(0);
701
+ expect(truncIdx).toBeGreaterThan(forkIdx);
702
+
703
+ const truncOp = log[truncIdx];
704
+ if (!truncOp) throw new Error("expected truncate op");
705
+ expect(truncOp.args[0]).toBe(forkedThreadId);
706
+ expect(truncOp.args[1]).toBe("parent-asst-msg-1");
663
707
  });
664
708
 
665
709
  // --- maxTurns of 1 ---
@@ -196,6 +196,7 @@ export async function createSession<
196
196
  appendSystemMessage,
197
197
  appendAgentMessage,
198
198
  forkThread,
199
+ truncateThread,
199
200
  loadThreadState,
200
201
  saveThreadState,
201
202
  hydrateThread,
@@ -399,6 +400,17 @@ export async function createSession<
399
400
  // is already hot or when no cold tier is wired.
400
401
  await hydrateThread(sourceThreadId, threadKey);
401
402
  await forkThread(sourceThreadId, threadId, threadKey);
403
+ // If the caller asked to drop the tail of the forked thread
404
+ // (e.g. subagent forking its parent mid-tool-call needs to
405
+ // strip the orphan assistant `tool_use`), do it now — before
406
+ // any rehydration / state load so the truncated thread is
407
+ // what subsequent reads see.
408
+ const truncate = (
409
+ threadInit as { mode: "fork"; truncateAfterFork?: { fromMessageId: string } }
410
+ ).truncateAfterFork;
411
+ if (truncate?.fromMessageId) {
412
+ await truncateThread(threadId, truncate.fromMessageId, threadKey);
413
+ }
402
414
  const forkedSlice = await loadThreadState(threadId, threadKey);
403
415
  if (forkedSlice) rehydrateFromSlice(forkedSlice);
404
416
  } else if (threadMode === "continue") {
@@ -570,6 +582,20 @@ export async function createSession<
570
582
  {
571
583
  turn: currentTurn,
572
584
  ...(sandboxId !== undefined && { sandboxId }),
585
+ ...(assistantId !== undefined && {
586
+ assistantMessageId: assistantId,
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
+ ),
573
599
  }
574
600
  );
575
601
 
@@ -244,18 +244,44 @@ export function createSubagentHandler<
244
244
  // `thread: "fork" | "continue"` — `thread: "new"` always starts
245
245
  // fresh regardless of the source.
246
246
  const newThreadSource = config.newThreadSource ?? "new";
247
+ const usingParentFallback =
248
+ allowsContinuation &&
249
+ !args.threadId &&
250
+ newThreadSource === "from-parent";
247
251
  const continuationThreadId = !allowsContinuation
248
252
  ? undefined
249
- : (args.threadId ??
250
- (newThreadSource === "from-parent" ? context.threadId : undefined));
253
+ : (args.threadId ?? (usingParentFallback ? context.threadId : undefined));
251
254
 
252
255
  // --- Build thread init ---
253
256
  let thread: ThreadInit | undefined;
254
257
  if (continuationThreadId) {
255
- thread = {
256
- mode: threadMode as "fork" | "continue",
257
- threadId: continuationThreadId,
258
- };
258
+ if (threadMode === "fork") {
259
+ // When falling back to the parent's thread for a fork, the
260
+ // parent is mid-tool-call: its assistant message containing the
261
+ // `Subagent` tool_use has already been persisted, but its
262
+ // matching tool_result hasn't. Forking verbatim would leave an
263
+ // orphan tool_use at the tail of the child thread, which most
264
+ // model APIs reject on the very next call. Have the child's
265
+ // session truncate that assistant message (and anything after)
266
+ // immediately after the fork so the first model call sees a
267
+ // well-formed history.
268
+ thread = {
269
+ mode: "fork",
270
+ threadId: continuationThreadId,
271
+ ...(usingParentFallback && context.assistantMessageId
272
+ ? {
273
+ truncateAfterFork: {
274
+ fromMessageId: context.assistantMessageId,
275
+ },
276
+ }
277
+ : {}),
278
+ };
279
+ } else {
280
+ thread = {
281
+ mode: "continue",
282
+ threadId: continuationThreadId,
283
+ };
284
+ }
259
285
  }
260
286
 
261
287
  // --- Build sandbox init ---
@@ -413,6 +439,29 @@ export function createSubagentHandler<
413
439
  snapshotBaseCreatorAgent.set(childWorkflowId, config.agentName);
414
440
  }
415
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
+
416
465
  log.info("subagent spawned", {
417
466
  subagent: config.agentName,
418
467
  childWorkflowId,
@@ -653,6 +653,40 @@ describe("createSubagentHandler", () => {
653
653
 
654
654
  await handler(
655
655
  { subagent: "parent-fork", description: "test", prompt: "test" },
656
+ {
657
+ threadId: "parent-t",
658
+ toolCallId: "tc",
659
+ toolName: "Subagent",
660
+ assistantMessageId: "parent-asst-msg-1",
661
+ }
662
+ );
663
+
664
+ const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
665
+ if (!lastCall) throw new Error("expected executeChild call");
666
+ const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
667
+ expect(workflowInput.thread).toEqual({
668
+ mode: "fork",
669
+ threadId: "parent-t",
670
+ truncateAfterFork: { fromMessageId: "parent-asst-msg-1" },
671
+ });
672
+ });
673
+
674
+ it("omits truncateAfterFork on parent-fallback fork when assistantMessageId is absent from context", async () => {
675
+ const { executeChild } = await import("@temporalio/workflow");
676
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
677
+
678
+ const subagent: SubagentConfig = {
679
+ agentName: "parent-fork-no-asst",
680
+ description: "Forks parent thread by default",
681
+ workflow: mockWorkflow(),
682
+ thread: "fork",
683
+ newThreadSource: "from-parent",
684
+ };
685
+
686
+ const { handler } = createSubagentHandler([subagent]);
687
+
688
+ await handler(
689
+ { subagent: "parent-fork-no-asst", description: "test", prompt: "test" },
656
690
  { threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
657
691
  );
658
692
 
@@ -693,7 +727,7 @@ describe("createSubagentHandler", () => {
693
727
  });
694
728
  });
695
729
 
696
- it("prefers args.threadId over the parent source when both are available", async () => {
730
+ it("prefers args.threadId over the parent source when both are available and skips truncateAfterFork", async () => {
697
731
  const { executeChild } = await import("@temporalio/workflow");
698
732
  const execMock = executeChild as ReturnType<typeof vi.fn>;
699
733
 
@@ -714,7 +748,12 @@ describe("createSubagentHandler", () => {
714
748
  prompt: "test",
715
749
  threadId: "explicit-prev",
716
750
  },
717
- { threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
751
+ {
752
+ threadId: "parent-t",
753
+ toolCallId: "tc",
754
+ toolName: "Subagent",
755
+ assistantMessageId: "parent-asst-msg-1",
756
+ }
718
757
  );
719
758
 
720
759
  const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
@@ -774,6 +813,204 @@ describe("createSubagentHandler", () => {
774
813
  expect(workflowInput.thread).toBeUndefined();
775
814
  });
776
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
+
777
1014
  // --- Sandbox continuation ---
778
1015
 
779
1016
  it("does not pass sandbox when thread is fork (own sandbox)", async () => {
@@ -134,6 +134,42 @@ describe("createToolRouter edge cases", () => {
134
134
  expect(appendSpy.calls).toHaveLength(0);
135
135
  });
136
136
 
137
+ // --- assistantMessageId propagation into RouterContext ---
138
+
139
+ it("forwards assistantMessageId from ProcessToolCallsContext into RouterContext", async () => {
140
+ let capturedAssistantMessageId: string | undefined;
141
+ const captureTool = defineTool({
142
+ name: "Capture" as const,
143
+ description: "captures router context",
144
+ schema: z.object({}),
145
+ handler: async (
146
+ _args: Record<string, never>,
147
+ ctx: { assistantMessageId?: string }
148
+ ): Promise<ToolHandlerResponse<null>> => {
149
+ capturedAssistantMessageId = ctx.assistantMessageId;
150
+ return { toolResponse: "ok", data: null };
151
+ },
152
+ });
153
+
154
+ const router = createToolRouter({
155
+ tools: { Capture: captureTool } as const,
156
+ threadId: "t-1",
157
+ appendToolResult: appendSpy.fn,
158
+ });
159
+
160
+ const parsed = router.parseToolCall({
161
+ id: "tc-1",
162
+ name: "Capture",
163
+ args: {},
164
+ });
165
+ await router.processToolCalls([parsed], {
166
+ turn: 1,
167
+ assistantMessageId: "asst-msg-42",
168
+ });
169
+
170
+ expect(capturedAssistantMessageId).toBe("asst-msg-42");
171
+ });
172
+
137
173
  // --- Both global and per-tool pre-hooks run in order ---
138
174
 
139
175
  it("global pre-hook runs before per-tool pre-hook", async () => {
@@ -220,7 +220,9 @@ export function createToolRouter<T extends ToolMap>(
220
220
  toolCall: ParsedToolCallUnion<T>,
221
221
  turn: number,
222
222
  sandboxId?: string,
223
- onRewindRequested?: (signal: RewindSignal) => void
223
+ onRewindRequested?: (signal: RewindSignal) => void,
224
+ assistantMessageId?: string,
225
+ persistThreadState?: () => Promise<void>
224
226
  ): Promise<ProcessedToolCall> {
225
227
  const startTime = Date.now();
226
228
  const tool = toolMap.get(toolCall.name);
@@ -263,6 +265,8 @@ export function createToolRouter<T extends ToolMap>(
263
265
  toolCallId: toolCall.id,
264
266
  toolName: toolCall.name,
265
267
  ...(sandboxId !== undefined && { sandboxId }),
268
+ ...(assistantMessageId !== undefined && { assistantMessageId }),
269
+ ...(persistThreadState !== undefined && { persistThreadState }),
266
270
  };
267
271
  const response = await tool.handler(
268
272
  effectiveArgs as Parameters<typeof tool.handler>[0],
@@ -419,6 +423,8 @@ export function createToolRouter<T extends ToolMap>(
419
423
 
420
424
  const turn = context?.turn ?? 0;
421
425
  const sandboxId = context?.sandboxId;
426
+ const assistantMessageId = context?.assistantMessageId;
427
+ const persistThreadState = context?.persistThreadState;
422
428
 
423
429
  let rewindSignal: RewindSignal | undefined;
424
430
 
@@ -435,7 +441,14 @@ export function createToolRouter<T extends ToolMap>(
435
441
  const outcomes = await scope.run(async () =>
436
442
  Promise.allSettled(
437
443
  toolCalls.map((tc) =>
438
- processToolCall(tc, turn, sandboxId, onRewindRequested)
444
+ processToolCall(
445
+ tc,
446
+ turn,
447
+ sandboxId,
448
+ onRewindRequested,
449
+ assistantMessageId,
450
+ persistThreadState
451
+ )
439
452
  )
440
453
  )
441
454
  );
@@ -457,7 +470,14 @@ export function createToolRouter<T extends ToolMap>(
457
470
 
458
471
  const results: ToolCallResultUnion<TResults>[] = [];
459
472
  for (const toolCall of toolCalls) {
460
- const outcome = await processToolCall(toolCall, turn, sandboxId);
473
+ const outcome = await processToolCall(
474
+ toolCall,
475
+ turn,
476
+ sandboxId,
477
+ undefined,
478
+ assistantMessageId,
479
+ persistThreadState
480
+ );
461
481
  if (outcome.kind === "rewind") {
462
482
  rewindSignal = outcome.signal;
463
483
  break;
@@ -492,6 +512,12 @@ export function createToolRouter<T extends ToolMap>(
492
512
  ...(context?.sandboxId !== undefined && {
493
513
  sandboxId: context.sandboxId,
494
514
  }),
515
+ ...(context?.assistantMessageId !== undefined && {
516
+ assistantMessageId: context.assistantMessageId,
517
+ }),
518
+ ...(context?.persistThreadState !== undefined && {
519
+ persistThreadState: context.persistThreadState,
520
+ }),
495
521
  };
496
522
  const response = await handler(
497
523
  toolCall.args as ToolArgs<T, TName>,
@@ -178,6 +178,32 @@ export interface RouterContext {
178
178
  toolCallId: string;
179
179
  toolName: string;
180
180
  sandboxId?: string;
181
+ /**
182
+ * Id of the assistant message that issued this tool call (the message
183
+ * the session passed as `assistantMessageId` into `runAgent`). Present
184
+ * for any tool call processed through `processToolCalls` from a
185
+ * session; may be absent when the router is driven manually (e.g.
186
+ * tests, custom orchestrators).
187
+ *
188
+ * Subagent handlers that fork the parent's thread mid-call use this
189
+ * to truncate the orphan trailing assistant message from the forked
190
+ * thread so the child's first model call sees a well-formed history.
191
+ */
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>;
181
207
  }
182
208
 
183
209
  /**
@@ -294,6 +320,23 @@ export interface ProcessToolCallsContext {
294
320
  turn?: number;
295
321
  /** Active sandbox ID (when a sandbox is configured for this session) */
296
322
  sandboxId?: string;
323
+ /**
324
+ * Id of the assistant message that produced these tool calls. The
325
+ * router forwards it into every handler's {@link RouterContext} so
326
+ * handlers can reference the message they were issued from (e.g.
327
+ * subagent forks that need to truncate the orphan assistant message
328
+ * out of a parent-forked thread).
329
+ */
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>;
297
340
  }
298
341
 
299
342
  /**