zeitlich 0.2.48 → 0.2.50

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 (126) hide show
  1. package/README.md +26 -23
  2. package/dist/{activities-DCaIPQBT.d.ts → activities-IuOIvPHO.d.ts} +6 -6
  3. package/dist/{activities-BlQR5gX4.d.cts → activities-cIlq1y1y.d.cts} +6 -6
  4. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  5. package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
  6. package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
  7. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  8. package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
  9. package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
  10. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  11. package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
  12. package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
  13. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  14. package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
  15. package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
  16. package/dist/adapters/thread/anthropic/index.cjs +45 -42
  17. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  18. package/dist/adapters/thread/anthropic/index.d.cts +10 -10
  19. package/dist/adapters/thread/anthropic/index.d.ts +10 -10
  20. package/dist/adapters/thread/anthropic/index.js +45 -42
  21. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  22. package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
  23. package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
  24. package/dist/adapters/thread/google-genai/index.cjs +117 -54
  25. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  26. package/dist/adapters/thread/google-genai/index.d.cts +27 -23
  27. package/dist/adapters/thread/google-genai/index.d.ts +27 -23
  28. package/dist/adapters/thread/google-genai/index.js +117 -54
  29. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  30. package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
  31. package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
  32. package/dist/adapters/thread/langchain/index.cjs +45 -42
  33. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  34. package/dist/adapters/thread/langchain/index.d.cts +10 -10
  35. package/dist/adapters/thread/langchain/index.d.ts +10 -10
  36. package/dist/adapters/thread/langchain/index.js +45 -42
  37. package/dist/adapters/thread/langchain/index.js.map +1 -1
  38. package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
  39. package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
  40. package/dist/{cold-store-UL13Sstw.d.cts → cold-store-C0uvYTSi.d.cts} +1 -1
  41. package/dist/{cold-store-aD4TSKlU.d.ts → cold-store-CCnZYWjx.d.ts} +1 -1
  42. package/dist/index.cjs +15063 -405
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.d.cts +79 -83
  45. package/dist/index.d.ts +79 -83
  46. package/dist/index.js +15064 -402
  47. package/dist/index.js.map +1 -1
  48. package/dist/{proxy-BAty3CWM.d.cts → proxy-BVznA2_p.d.cts} +1 -1
  49. package/dist/{proxy-mbnwBhHw.d.ts → proxy-C4J1pNUk.d.ts} +1 -1
  50. package/dist/{thread-manager-CICj68PI.d.ts → thread-manager-BqjzWsP7.d.ts} +4 -4
  51. package/dist/{thread-manager-R6c3lnJy.d.cts → thread-manager-CzIs47uG.d.cts} +4 -4
  52. package/dist/{thread-manager-DsXvJ5cJ.d.cts → thread-manager-Dzl1fHhV.d.cts} +4 -4
  53. package/dist/{thread-manager-DtEtbUkp.d.ts → thread-manager-SkSWRPRc.d.ts} +4 -4
  54. package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
  55. package/dist/{types-DF4wzWQG.d.ts → types-CbPnU4RM.d.ts} +3 -3
  56. package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.cts} +3 -3
  57. package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.ts} +3 -3
  58. package/dist/{types-DwBYd0ij.d.ts → types-DZnUqCAP.d.cts} +709 -686
  59. package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
  60. package/dist/{types-DWeyCTYK.d.cts → types-YNesmGKV.d.ts} +709 -686
  61. package/dist/{types-DDLPnxBh.d.cts → types-d2RvEP6v.d.cts} +3 -3
  62. package/dist/{workflow-DdaU7_j4.d.ts → workflow-B3oTe2_D.d.cts} +34 -3
  63. package/dist/{workflow-DVNPR7eX.d.cts → workflow-Bkzg0cjB.d.ts} +34 -3
  64. package/dist/workflow.cjs +15021 -362
  65. package/dist/workflow.cjs.map +1 -1
  66. package/dist/workflow.d.cts +3 -3
  67. package/dist/workflow.d.ts +3 -3
  68. package/dist/workflow.js +15022 -359
  69. package/dist/workflow.js.map +1 -1
  70. package/package.json +10 -37
  71. package/src/adapters/thread/anthropic/activities.ts +1 -1
  72. package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
  73. package/src/adapters/thread/anthropic/model-invoker.test.ts +4 -3
  74. package/src/adapters/thread/anthropic/model-invoker.ts +1 -1
  75. package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
  76. package/src/adapters/thread/anthropic/thread-manager.ts +1 -1
  77. package/src/adapters/thread/google-genai/activities.ts +1 -1
  78. package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
  79. package/src/adapters/thread/google-genai/model-invoker.test.ts +337 -0
  80. package/src/adapters/thread/google-genai/model-invoker.ts +107 -23
  81. package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
  82. package/src/adapters/thread/google-genai/thread-manager.ts +1 -1
  83. package/src/adapters/thread/langchain/activities.ts +1 -1
  84. package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
  85. package/src/adapters/thread/langchain/model-invoker.ts +1 -1
  86. package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
  87. package/src/adapters/thread/langchain/thread-manager.ts +1 -1
  88. package/src/index.ts +2 -2
  89. package/src/lib/sandbox/capability-types.test.ts +2 -2
  90. package/src/lib/sandbox/manager.ts +2 -6
  91. package/src/lib/sandbox/sandbox.test.ts +1 -1
  92. package/src/lib/sandbox/types.ts +2 -2
  93. package/src/lib/session/session.integration.test.ts +92 -0
  94. package/src/lib/session/session.ts +23 -0
  95. package/src/lib/subagent/handler.ts +23 -0
  96. package/src/lib/subagent/subagent.integration.test.ts +198 -0
  97. package/src/lib/thread/keys.test.ts +9 -9
  98. package/src/lib/thread/keys.ts +1 -1
  99. package/src/lib/thread/manager.test.ts +24 -14
  100. package/src/lib/thread/manager.ts +19 -23
  101. package/src/lib/thread/snapshot.test.ts +51 -43
  102. package/src/lib/thread/snapshot.ts +54 -32
  103. package/src/lib/thread/test-utils.ts +106 -59
  104. package/src/lib/thread/tiered.test.ts +1 -1
  105. package/src/lib/thread/types.ts +2 -2
  106. package/src/lib/tool-router/router.integration.test.ts +44 -0
  107. package/src/lib/tool-router/router.ts +149 -33
  108. package/src/lib/tool-router/types.ts +23 -0
  109. package/src/lib/workflow.ts +49 -0
  110. package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
  111. package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
  112. package/src/tools/bash/bash.test.ts +1 -1
  113. package/src/tools/edit/handler.test.ts +1 -1
  114. package/tsup.config.ts +2 -4
  115. package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
  116. package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
  117. package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
  118. package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
  119. package/dist/adapters/sandbox/inmemory/index.js +0 -211
  120. package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
  121. package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
  122. package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
  123. package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
  124. package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
  125. package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
  126. package/dist/adapters/sandbox/inmemory/workflow.js.map +0 -1
@@ -1,8 +1,10 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType as Redis } from "redis";
2
+ import { randomBytes } from "node:crypto";
2
3
  import type {
3
4
  GoogleGenAI,
4
5
  Content,
5
6
  FunctionDeclaration,
7
+ GenerateContentConfig,
6
8
  Part,
7
9
  GenerateContentResponse,
8
10
  } from "@google/genai";
@@ -19,6 +21,18 @@ export interface GoogleGenAIModelInvokerConfig {
19
21
  client: GoogleGenAI;
20
22
  model: string;
21
23
  hooks?: GoogleGenAIThreadManagerHooks;
24
+ /** Passed through to `generateContentStream().config`.
25
+ * `systemInstruction`, `tools`, and `abortSignal` are managed by the
26
+ * invoker and will override any values set here. */
27
+ config?: GenerateContentConfig;
28
+ /** Caches the first `splitIndex` messages server-side (with
29
+ * `systemInstruction`, `tools`, and `toolConfig`). Skipped when
30
+ * `contents.length <= splitIndex`. */
31
+ cache?: {
32
+ splitIndex: number;
33
+ /** Default: 300. */
34
+ ttlSeconds?: number;
35
+ };
22
36
  }
23
37
 
24
38
  function toFunctionDeclarations(
@@ -32,12 +46,7 @@ function toFunctionDeclarations(
32
46
  }
33
47
 
34
48
  /**
35
- * Creates a Google GenAI model invoker that satisfies the generic
36
- * `ModelInvoker<Content>` contract.
37
- *
38
- * Internally streams the response and emits Temporal heartbeats on each
39
- * chunk so that long-running LLM calls remain visible to the scheduler.
40
- * The caller is responsible for appending the response to the thread.
49
+ * The caller is responsible for appending the returned response to the thread.
41
50
  *
42
51
  * @example
43
52
  * ```typescript
@@ -60,6 +69,8 @@ export function createGoogleGenAIModelInvoker({
60
69
  client,
61
70
  model,
62
71
  hooks,
72
+ config: generationConfig,
73
+ cache: cacheConfig,
63
74
  }: GoogleGenAIModelInvokerConfig) {
64
75
  return async function invokeGoogleGenAIModel(
65
76
  config: ModelInvokerConfig
@@ -78,19 +89,77 @@ export function createGoogleGenAIModelInvoker({
78
89
  // retry / Temporal reset it wipes the prior attempt's assistant
79
90
  // + tool results so the LLM sees the original pre-call state.
80
91
  await thread.truncateFromId(assistantMessageId);
81
- const { contents, systemInstruction } =
82
- await thread.prepareForInvocation();
92
+ const { contents, systemInstruction } = await thread.prepareForInvocation();
83
93
 
84
94
  const functionDeclarations = toFunctionDeclarations(state.tools);
85
95
  const tools =
86
96
  functionDeclarations.length > 0 ? [{ functionDeclarations }] : undefined;
87
97
 
98
+ const {
99
+ systemInstruction: _si,
100
+ tools: _t,
101
+ abortSignal: _as,
102
+ cachedContent: callerCachedContent,
103
+ toolConfig: callerToolConfig,
104
+ ...callerConfig
105
+ } = generationConfig ?? {};
106
+
107
+ let liveContents = contents;
108
+ let cachedContentName: string | undefined;
109
+ let cachedWriteTokens: number | undefined;
110
+
111
+ if (
112
+ cacheConfig &&
113
+ cacheConfig.splitIndex > 0 &&
114
+ contents.length > cacheConfig.splitIndex
115
+ ) {
116
+ liveContents = contents.slice(cacheConfig.splitIndex);
117
+ const ttl = cacheConfig.ttlSeconds ?? 300;
118
+ const cacheRedisKey = `${threadKey ?? "messages"}:gemini-cache:${model}:${cacheConfig.splitIndex}:thread:${threadId}`;
119
+
120
+ cachedContentName = (await redis.get(cacheRedisKey)) ?? undefined;
121
+
122
+ if (!cachedContentName) {
123
+ const cacheInstance = await client.caches.create({
124
+ model,
125
+ config: {
126
+ contents: contents.slice(0, cacheConfig.splitIndex),
127
+ ...(systemInstruction ? { systemInstruction } : {}),
128
+ ...(tools ? { tools } : {}),
129
+ ...(callerToolConfig ? { toolConfig: callerToolConfig } : {}),
130
+ ttl: `${ttl}s`,
131
+ abortSignal: signal,
132
+ },
133
+ });
134
+ if (!cacheInstance?.name) {
135
+ throw new Error("Gemini cache creation did not return a cache name");
136
+ }
137
+ cachedContentName = cacheInstance.name;
138
+ cachedWriteTokens =
139
+ cacheInstance.usageMetadata?.totalTokenCount ?? undefined;
140
+ const redisTtl = ttl - 5;
141
+ if (redisTtl > 0) {
142
+ await redis.set(cacheRedisKey, cachedContentName, { EX: redisTtl });
143
+ }
144
+ }
145
+ }
146
+
88
147
  const stream = await client.models.generateContentStream({
89
148
  model,
90
- contents,
149
+ contents: liveContents,
91
150
  config: {
92
- ...(systemInstruction ? { systemInstruction } : {}),
93
- ...(tools ? { tools } : {}),
151
+ ...callerConfig,
152
+ ...(cachedContentName
153
+ ? { cachedContent: cachedContentName }
154
+ : {
155
+ ...(callerCachedContent
156
+ ? { cachedContent: callerCachedContent }
157
+ : {
158
+ ...(systemInstruction ? { systemInstruction } : {}),
159
+ ...(tools ? { tools } : {}),
160
+ }),
161
+ ...(callerToolConfig ? { toolConfig: callerToolConfig } : {}),
162
+ }),
94
163
  abortSignal: signal,
95
164
  },
96
165
  });
@@ -107,48 +176,63 @@ export function createGoogleGenAIModelInvoker({
107
176
  throw new Error("Google GenAI stream ended without producing any chunks");
108
177
  }
109
178
 
179
+ for (const part of allParts) {
180
+ if (part.functionCall && !part.functionCall.id) {
181
+ part.functionCall.id = randomBytes(8).toString("hex");
182
+ }
183
+ }
184
+
110
185
  const modelContent: Content = { role: "model", parts: allParts };
111
- const functionCalls = lastChunk.functionCalls ?? [];
112
186
 
113
187
  return {
114
188
  message: modelContent,
115
- rawToolCalls: functionCalls.map((fc) => ({
116
- id: fc.id,
117
- name: fc.name ?? "",
118
- args: fc.args ?? {},
119
- })),
189
+ rawToolCalls: allParts
190
+ .filter(
191
+ (
192
+ p
193
+ ): p is Part & { functionCall: NonNullable<Part["functionCall"]> } =>
194
+ !!p.functionCall
195
+ )
196
+ .map((p) => ({
197
+ id: p.functionCall.id,
198
+ name: p.functionCall.name ?? "",
199
+ args: p.functionCall.args ?? {},
200
+ })),
120
201
  usage: {
121
202
  inputTokens: lastChunk.usageMetadata?.promptTokenCount,
122
203
  outputTokens: lastChunk.usageMetadata?.candidatesTokenCount,
204
+ cachedWriteTokens,
123
205
  cachedReadTokens: lastChunk.usageMetadata?.cachedContentTokenCount,
206
+ reasonTokens: lastChunk.usageMetadata?.thoughtsTokenCount,
124
207
  },
125
208
  };
126
209
  };
127
210
  }
128
211
 
129
- /**
130
- * Standalone function for one-shot Google GenAI model invocation.
131
- * Convenience wrapper around createGoogleGenAIModelInvoker for cases
132
- * where you don't need to reuse the invoker.
133
- */
134
212
  export async function invokeGoogleGenAIModel({
135
213
  redis,
136
214
  client,
137
215
  model,
138
216
  hooks,
139
217
  config,
218
+ generationConfig,
219
+ cache,
140
220
  }: {
141
221
  redis: Redis;
142
222
  client: GoogleGenAI;
143
223
  model: string;
144
224
  hooks?: GoogleGenAIThreadManagerHooks;
145
225
  config: ModelInvokerConfig;
226
+ generationConfig?: GenerateContentConfig;
227
+ cache?: GoogleGenAIModelInvokerConfig["cache"];
146
228
  }): Promise<AgentResponse<Content>> {
147
229
  const invoker = createGoogleGenAIModelInvoker({
148
230
  redis,
149
231
  client,
150
232
  model,
151
233
  hooks,
234
+ config: generationConfig,
235
+ cache,
152
236
  });
153
237
  return invoker(config);
154
238
  }
@@ -6,10 +6,10 @@ import { createGoogleGenAIThreadManager } from "./thread-manager";
6
6
  function createMockRedis(stored: StoredContent[]) {
7
7
  return {
8
8
  exists: vi.fn().mockResolvedValue(1),
9
- lrange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
9
+ lRange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
10
10
  del: vi.fn().mockResolvedValue(1),
11
11
  set: vi.fn().mockResolvedValue("OK"),
12
- rpush: vi.fn().mockResolvedValue(1),
12
+ rPush: vi.fn().mockResolvedValue(1),
13
13
  expire: vi.fn().mockResolvedValue(1),
14
14
  eval: vi.fn().mockResolvedValue(1),
15
15
  };
@@ -1,4 +1,4 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType as Redis } from "redis";
2
2
  import type { Content, Part } from "@google/genai";
3
3
  import { createThreadManager } from "../../../lib/thread/manager";
4
4
  import type {
@@ -1,4 +1,4 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType as Redis } from "redis";
2
2
  import type { ToolResultConfig } from "../../../lib/types";
3
3
  import type { PersistedThreadState } from "../../../lib/state/types";
4
4
  import type { MessageContent } from "@langchain/core/messages";
@@ -11,27 +11,27 @@ function createStatefulRedis() {
11
11
  const strings = new Map<string, string>();
12
12
 
13
13
  return {
14
- exists: vi.fn(async (...keys: string[]) =>
15
- keys.reduce(
14
+ exists: vi.fn(async (keys: string | string[]) =>
15
+ (Array.isArray(keys) ? keys : [keys]).reduce(
16
16
  (acc, k) => acc + (lists.has(k) || strings.has(k) ? 1 : 0),
17
17
  0
18
18
  )
19
19
  ),
20
- lrange: vi.fn(async (key: string, start: number, stop: number) => {
20
+ lRange: vi.fn(async (key: string, start: number, stop: number) => {
21
21
  const list = lists.get(key) ?? [];
22
22
  const end = stop === -1 ? list.length : stop + 1;
23
23
  return list.slice(start, end);
24
24
  }),
25
- rpush: vi.fn(async (key: string, ...values: string[]) => {
25
+ rPush: vi.fn(async (key: string, element: string | string[]) => {
26
26
  const list = lists.get(key) ?? [];
27
- list.push(...values);
27
+ list.push(...(Array.isArray(element) ? element : [element]));
28
28
  lists.set(key, list);
29
29
  return list.length;
30
30
  }),
31
- ltrim: vi.fn(async () => "OK"),
32
- del: vi.fn(async (...keys: string[]) => {
31
+ lTrim: vi.fn(async () => "OK"),
32
+ del: vi.fn(async (keys: string | string[]) => {
33
33
  let removed = 0;
34
- for (const k of keys) {
34
+ for (const k of Array.isArray(keys) ? keys : [keys]) {
35
35
  if (lists.delete(k)) removed++;
36
36
  if (strings.delete(k)) removed++;
37
37
  }
@@ -43,10 +43,16 @@ function createStatefulRedis() {
43
43
  }),
44
44
  get: vi.fn(async (key: string) => strings.get(key) ?? null),
45
45
  expire: vi.fn(async () => 1),
46
- llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
46
+ lLen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
47
47
  eval: vi.fn(
48
- async (_script: string, _numKeys: number, ...args: string[]) => {
49
- const [dedupKey, listKey, , ...serialised] = args;
48
+ async (
49
+ _script: string,
50
+ options: { keys?: string[]; arguments?: string[] }
51
+ ) => {
52
+ const keys = options.keys ?? [];
53
+ const argv = options.arguments ?? [];
54
+ const [dedupKey, listKey] = keys;
55
+ const serialised = argv.slice(1);
50
56
  if (!dedupKey || !listKey) return 0;
51
57
  if (strings.has(dedupKey)) return 0;
52
58
  const list = lists.get(listKey) ?? [];
@@ -1,4 +1,4 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType as Redis } from "redis";
2
2
  import type { AgentResponse, ModelInvokerConfig } from "../../../lib/model";
3
3
  import type { StoredMessage } from "@langchain/core/messages";
4
4
  import { v4 as uuidv4 } from "uuid";
@@ -9,10 +9,10 @@ import { createLangChainThreadManager } from "./thread-manager";
9
9
  function createMockRedis(stored: StoredMessage[]) {
10
10
  return {
11
11
  exists: vi.fn().mockResolvedValue(1),
12
- lrange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
12
+ lRange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
13
13
  del: vi.fn().mockResolvedValue(1),
14
14
  set: vi.fn().mockResolvedValue("OK"),
15
- rpush: vi.fn().mockResolvedValue(1),
15
+ rPush: vi.fn().mockResolvedValue(1),
16
16
  expire: vi.fn().mockResolvedValue(1),
17
17
  eval: vi.fn().mockResolvedValue(1),
18
18
  };
@@ -1,4 +1,4 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType as Redis } from "redis";
2
2
  import type { JsonValue } from "../../../lib/state/types";
3
3
  import {
4
4
  AIMessage,
package/src/index.ts CHANGED
@@ -17,8 +17,8 @@
17
17
  * toTree,
18
18
  * } from 'zeitlich';
19
19
  *
20
- * // In-memory sandbox adapter
21
- * import { InMemorySandboxProvider } from 'zeitlich/adapters/sandbox/inmemory';
20
+ * // Sandbox adapter
21
+ * import { DaytonaSandboxProvider } from 'zeitlich/adapters/sandbox/daytona';
22
22
  *
23
23
  * // LangChain adapter
24
24
  * import { createLangChainAdapter } from 'zeitlich/adapters/thread/langchain';
@@ -27,7 +27,7 @@ import type {
27
27
  SandboxProvider,
28
28
  SandboxSnapshot,
29
29
  } from "./types";
30
- import { InMemorySandboxProvider } from "../../adapters/sandbox/inmemory/index";
30
+ import { InMemorySandboxProvider } from "../../test-utils/in-memory-sandbox";
31
31
  import { DaytonaSandboxProvider } from "../../adapters/sandbox/daytona/index";
32
32
  import type { E2bSandboxProvider } from "../../adapters/sandbox/e2b/index";
33
33
 
@@ -303,7 +303,7 @@ class _ImplWithoutDeclProvider {
303
303
  import type { SubagentSandboxConfig } from "../subagent/types";
304
304
  import { proxyDaytonaSandboxOps } from "../../adapters/sandbox/daytona/proxy";
305
305
  import { proxyE2bSandboxOps } from "../../adapters/sandbox/e2b/proxy";
306
- import { proxyInMemorySandboxOps } from "../../adapters/sandbox/inmemory/proxy";
306
+ import { proxyInMemorySandboxOps } from "../../test-utils/in-memory-sandbox-proxy";
307
307
 
308
308
  // Helper that pins the matrix cell type to `SubagentSandboxConfig` so
309
309
  // `@ts-expect-error` directives consistently land on the call line. The
@@ -102,12 +102,12 @@ export interface SandboxManagerHooks<
102
102
  *
103
103
  * @example
104
104
  * ```typescript
105
- * const manager = new SandboxManager(new InMemorySandboxProvider());
105
+ * const manager = new SandboxManager(new DaytonaSandboxProvider(config));
106
106
  * const activities = {
107
107
  * ...manager.createActivities("CodingAgent"),
108
108
  * bashHandler: withSandbox(manager, bashHandler),
109
109
  * };
110
- * // registers: inMemoryCodingAgentCreateSandbox, …
110
+ * // registers: daytonaCodingAgentCreateSandbox, …
111
111
  * ```
112
112
  *
113
113
  * @example
@@ -336,10 +336,6 @@ export class SandboxManager<
336
336
  *
337
337
  * @example
338
338
  * ```typescript
339
- * const manager = new SandboxManager(new InMemorySandboxProvider());
340
- * manager.createActivities("CodingAgent");
341
- * // registers: inMemoryCodingAgentCreateSandbox, inMemoryCodingAgentDestroySandbox, …
342
- *
343
339
  * const dmgr = new SandboxManager(new DaytonaSandboxProvider(config));
344
340
  * dmgr.createActivities("CodingAgent");
345
341
  * // registers: daytonaCodingAgentCreateSandbox, daytonaCodingAgentDestroySandbox
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, beforeEach } from "vitest";
2
2
  import { SandboxManager } from "./manager";
3
- import { InMemorySandboxProvider } from "../../adapters/sandbox/inmemory/index";
3
+ import { InMemorySandboxProvider } from "../../test-utils/in-memory-sandbox";
4
4
  import {
5
5
  SandboxNotFoundError,
6
6
  type Sandbox,
@@ -342,8 +342,8 @@ export type SandboxOps<
342
342
  *
343
343
  * @example
344
344
  * ```typescript
345
- * type InMemOps = PrefixedSandboxOps<"inMemory">;
346
- * // → { inMemoryCreateSandbox, inMemoryDestroySandbox, inMemorySnapshotSandbox, … }
345
+ * type E2bOps = PrefixedSandboxOps<"e2b">;
346
+ * // → { e2bCreateSandbox, e2bDestroySandbox, e2bSnapshotSandbox, … }
347
347
  * ```
348
348
  */
349
349
  export type PrefixedSandboxOps<
@@ -681,6 +681,98 @@ describe("createSession integration", () => {
681
681
  expect(capturedSandboxId).toBe("my-sandbox");
682
682
  });
683
683
 
684
+ // --- persistThreadState dedupe ---
685
+
686
+ it("memoizes persistThreadState across parallel tool calls in one turn", async () => {
687
+ const { ops, log } = createMockThreadOps();
688
+
689
+ const persistTool = defineTool({
690
+ name: "Persist" as const,
691
+ description: "calls persistThreadState",
692
+ schema: z.object({}),
693
+ handler: async (_args: Record<string, never>, ctx: RouterContext) => {
694
+ await ctx.persistThreadState?.();
695
+ return { toolResponse: "ok", data: null };
696
+ },
697
+ });
698
+
699
+ const session = await createSession({
700
+ agentName: "TestAgent",
701
+ thread: { mode: "new", threadId: "thread-1" },
702
+ runAgent: createScriptedRunAgent([
703
+ {
704
+ message: "fan out",
705
+ toolCalls: [
706
+ { id: "tc-1", name: "Persist", args: {} },
707
+ { id: "tc-2", name: "Persist", args: {} },
708
+ { id: "tc-3", name: "Persist", args: {} },
709
+ ],
710
+ },
711
+ { message: "done", toolCalls: [] },
712
+ ]),
713
+ threadOps: ops,
714
+ tools: { Persist: persistTool },
715
+ buildContextMessage: () => "go",
716
+ });
717
+
718
+ const stateManager = createAgentStateManager({
719
+ initialState: { systemPrompt: "test" },
720
+ });
721
+
722
+ await session.runSession({ stateManager });
723
+
724
+ const saves = log.filter((l) => l.op === "saveThreadState");
725
+ expect(saves).toHaveLength(2);
726
+ });
727
+
728
+ it("re-persists across separate turns even when handlers call persistThreadState", async () => {
729
+ const { ops, log } = createMockThreadOps();
730
+
731
+ const persistTool = defineTool({
732
+ name: "Persist" as const,
733
+ description: "calls persistThreadState",
734
+ schema: z.object({}),
735
+ handler: async (_args: Record<string, never>, ctx: RouterContext) => {
736
+ await ctx.persistThreadState?.();
737
+ return { toolResponse: "ok", data: null };
738
+ },
739
+ });
740
+
741
+ const session = await createSession({
742
+ agentName: "TestAgent",
743
+ thread: { mode: "new", threadId: "thread-1" },
744
+ runAgent: createScriptedRunAgent([
745
+ {
746
+ message: "turn 1",
747
+ toolCalls: [
748
+ { id: "tc-1", name: "Persist", args: {} },
749
+ { id: "tc-2", name: "Persist", args: {} },
750
+ ],
751
+ },
752
+ {
753
+ message: "turn 2",
754
+ toolCalls: [
755
+ { id: "tc-3", name: "Persist", args: {} },
756
+ { id: "tc-4", name: "Persist", args: {} },
757
+ ],
758
+ },
759
+ { message: "done", toolCalls: [] },
760
+ ]),
761
+ threadOps: ops,
762
+ tools: { Persist: persistTool },
763
+ buildContextMessage: () => "go",
764
+ });
765
+
766
+ const stateManager = createAgentStateManager({
767
+ initialState: { systemPrompt: "test" },
768
+ });
769
+
770
+ await session.runSession({ stateManager });
771
+
772
+ const saves = log.filter((l) => l.op === "saveThreadState");
773
+ expect(saves).toHaveLength(3);
774
+ });
775
+
684
776
  // --- Error propagation ---
685
777
 
686
778
  it("propagates runAgent errors and calls onSessionEnd with failed reason", async () => {
@@ -577,6 +577,28 @@ export async function createSession<
577
577
  }
578
578
  }
579
579
 
580
+ // Hand handlers a way to persist the parent's slice mid-loop
581
+ // (subagents that fork or continue the parent's thread need
582
+ // this — otherwise the child loads a stale snapshot from the
583
+ // prior session, since `saveThreadState` would otherwise only
584
+ // run in the `finally` below).
585
+ //
586
+ // Memoized per-batch so a single assistant message that emits
587
+ // N parallel subagent calls only writes the slice once.
588
+ // Persisting again later in the same turn is a no-op anyway
589
+ // (the slice doesn't mutate between handler dispatch and the
590
+ // batch's last `executeChild`), and Redis/cold-store writes
591
+ // aren't free.
592
+ let persistInflight: Promise<void> | undefined;
593
+ const persistThreadStateOnce = (): Promise<void> => {
594
+ persistInflight ??= saveThreadState(
595
+ threadId,
596
+ stateManager.getPersistedSlice(),
597
+ threadKey
598
+ );
599
+ return persistInflight;
600
+ };
601
+
580
602
  const toolCallResults = await toolRouter.processToolCalls(
581
603
  parsedToolCalls,
582
604
  {
@@ -585,6 +607,7 @@ export async function createSession<
585
607
  ...(assistantId !== undefined && {
586
608
  assistantMessageId: assistantId,
587
609
  }),
610
+ persistThreadState: persistThreadStateOnce,
588
611
  }
589
612
  );
590
613
 
@@ -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,