zeitlich 0.2.47 → 0.2.48

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-BlQR5gX4.d.cts} +3 -3
  3. package/dist/{activities-DlaBxNID.d.ts → activities-DCaIPQBT.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-UL13Sstw.d.cts} +1 -1
  17. package/dist/{cold-store-BDgJpwLI.d.ts → cold-store-aD4TSKlU.d.ts} +1 -1
  18. package/dist/index.cjs +48 -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 +48 -10
  23. package/dist/index.js.map +1 -1
  24. package/dist/{proxy-CDh3Rsa7.d.cts → proxy-BAty3CWM.d.cts} +1 -1
  25. package/dist/{proxy-Du8ggERu.d.ts → proxy-mbnwBhHw.d.ts} +1 -1
  26. package/dist/{thread-manager-Dw96FKH1.d.ts → thread-manager-CICj68PI.d.ts} +2 -2
  27. package/dist/{thread-manager-BjoYYXgd.d.cts → thread-manager-DsXvJ5cJ.d.cts} +2 -2
  28. package/dist/{thread-manager-DtHYws2F.d.ts → thread-manager-DtEtbUkp.d.ts} +2 -2
  29. package/dist/{thread-manager-D8zKNFZ9.d.cts → thread-manager-R6c3lnJy.d.cts} +2 -2
  30. package/dist/{types-BMJrsHo0.d.cts → types-DDLPnxBh.d.cts} +1 -1
  31. package/dist/{types-CtdOquo3.d.ts → types-DF4wzWQG.d.ts} +1 -1
  32. package/dist/{types-DNEl5uxQ.d.cts → types-DWeyCTYK.d.cts} +31 -0
  33. package/dist/{types-qQVZfhoT.d.ts → types-DwBYd0ij.d.ts} +31 -0
  34. package/dist/{workflow-BH9ImDGq.d.cts → workflow-DVNPR7eX.d.cts} +1 -1
  35. package/dist/{workflow-Cdw3-RNB.d.ts → workflow-DdaU7_j4.d.ts} +1 -1
  36. package/dist/workflow.cjs +48 -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 +48 -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 +15 -0
  46. package/src/lib/subagent/handler.ts +32 -6
  47. package/src/lib/subagent/subagent.integration.test.ts +41 -2
  48. package/src/lib/tool-router/router-edge-cases.integration.test.ts +36 -0
  49. package/src/lib/tool-router/router.ts +21 -3
  50. package/src/lib/tool-router/types.ts +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeitlich",
3
- "version": "0.2.47",
3
+ "version": "0.2.48",
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,9 @@ export async function createSession<
570
582
  {
571
583
  turn: currentTurn,
572
584
  ...(sandboxId !== undefined && { sandboxId }),
585
+ ...(assistantId !== undefined && {
586
+ assistantMessageId: assistantId,
587
+ }),
573
588
  }
574
589
  );
575
590
 
@@ -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 ---
@@ -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];
@@ -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,8 @@ 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
224
225
  ): Promise<ProcessedToolCall> {
225
226
  const startTime = Date.now();
226
227
  const tool = toolMap.get(toolCall.name);
@@ -263,6 +264,7 @@ export function createToolRouter<T extends ToolMap>(
263
264
  toolCallId: toolCall.id,
264
265
  toolName: toolCall.name,
265
266
  ...(sandboxId !== undefined && { sandboxId }),
267
+ ...(assistantMessageId !== undefined && { assistantMessageId }),
266
268
  };
267
269
  const response = await tool.handler(
268
270
  effectiveArgs as Parameters<typeof tool.handler>[0],
@@ -419,6 +421,7 @@ export function createToolRouter<T extends ToolMap>(
419
421
 
420
422
  const turn = context?.turn ?? 0;
421
423
  const sandboxId = context?.sandboxId;
424
+ const assistantMessageId = context?.assistantMessageId;
422
425
 
423
426
  let rewindSignal: RewindSignal | undefined;
424
427
 
@@ -435,7 +438,13 @@ export function createToolRouter<T extends ToolMap>(
435
438
  const outcomes = await scope.run(async () =>
436
439
  Promise.allSettled(
437
440
  toolCalls.map((tc) =>
438
- processToolCall(tc, turn, sandboxId, onRewindRequested)
441
+ processToolCall(
442
+ tc,
443
+ turn,
444
+ sandboxId,
445
+ onRewindRequested,
446
+ assistantMessageId
447
+ )
439
448
  )
440
449
  )
441
450
  );
@@ -457,7 +466,13 @@ export function createToolRouter<T extends ToolMap>(
457
466
 
458
467
  const results: ToolCallResultUnion<TResults>[] = [];
459
468
  for (const toolCall of toolCalls) {
460
- const outcome = await processToolCall(toolCall, turn, sandboxId);
469
+ const outcome = await processToolCall(
470
+ toolCall,
471
+ turn,
472
+ sandboxId,
473
+ undefined,
474
+ assistantMessageId
475
+ );
461
476
  if (outcome.kind === "rewind") {
462
477
  rewindSignal = outcome.signal;
463
478
  break;
@@ -492,6 +507,9 @@ export function createToolRouter<T extends ToolMap>(
492
507
  ...(context?.sandboxId !== undefined && {
493
508
  sandboxId: context.sandboxId,
494
509
  }),
510
+ ...(context?.assistantMessageId !== undefined && {
511
+ assistantMessageId: context.assistantMessageId,
512
+ }),
495
513
  };
496
514
  const response = await handler(
497
515
  toolCall.args as ToolArgs<T, TName>,
@@ -178,6 +178,18 @@ 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;
181
193
  }
182
194
 
183
195
  /**
@@ -294,6 +306,14 @@ export interface ProcessToolCallsContext {
294
306
  turn?: number;
295
307
  /** Active sandbox ID (when a sandbox is configured for this session) */
296
308
  sandboxId?: string;
309
+ /**
310
+ * Id of the assistant message that produced these tool calls. The
311
+ * router forwards it into every handler's {@link RouterContext} so
312
+ * handlers can reference the message they were issued from (e.g.
313
+ * subagent forks that need to truncate the orphan assistant message
314
+ * out of a parent-forked thread).
315
+ */
316
+ assistantMessageId?: string;
297
317
  }
298
318
 
299
319
  /**