zeitlich 0.2.49 → 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 (123) hide show
  1. package/README.md +26 -23
  2. package/dist/{activities-zG_FBoY2.d.ts → activities-IuOIvPHO.d.ts} +6 -6
  3. package/dist/{activities-7OcT_vdR.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-CkWoNtMh.d.cts → cold-store-C0uvYTSi.d.cts} +1 -1
  41. package/dist/{cold-store-DKMAO1Dd.d.ts → cold-store-CCnZYWjx.d.ts} +1 -1
  42. package/dist/index.cjs +15050 -420
  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 +15051 -417
  47. package/dist/index.js.map +1 -1
  48. package/dist/{proxy-B7CWEV-T.d.cts → proxy-BVznA2_p.d.cts} +1 -1
  49. package/dist/{proxy-ByFHMVRX.d.ts → proxy-C4J1pNUk.d.ts} +1 -1
  50. package/dist/{thread-manager-Cibe0X5m.d.cts → thread-manager-BqjzWsP7.d.ts} +4 -4
  51. package/dist/{thread-manager-B9rtMEVn.d.cts → thread-manager-CzIs47uG.d.cts} +4 -4
  52. package/dist/{thread-manager-nK-WcFzM.d.ts → thread-manager-Dzl1fHhV.d.cts} +4 -4
  53. package/dist/{thread-manager-7AW4rhfu.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-DO4Tkwxo.d.ts → types-CbPnU4RM.d.ts} +3 -3
  56. package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.cts} +3 -3
  57. package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.ts} +3 -3
  58. package/dist/{types-DeVNWqlb.d.ts → types-DZnUqCAP.d.cts} +709 -709
  59. package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
  60. package/dist/{types-XUUFvrJ9.d.cts → types-YNesmGKV.d.ts} +709 -709
  61. package/dist/{types-BR-k7h0e.d.cts → types-d2RvEP6v.d.cts} +3 -3
  62. package/dist/{workflow-uhOIj9D-.d.ts → workflow-B3oTe2_D.d.cts} +34 -3
  63. package/dist/{workflow-KbGsxpfh.d.cts → workflow-Bkzg0cjB.d.ts} +34 -3
  64. package/dist/workflow.cjs +15008 -377
  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 +15009 -374
  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 -11
  95. package/src/lib/thread/keys.test.ts +9 -9
  96. package/src/lib/thread/keys.ts +1 -1
  97. package/src/lib/thread/manager.test.ts +24 -14
  98. package/src/lib/thread/manager.ts +19 -23
  99. package/src/lib/thread/snapshot.test.ts +51 -43
  100. package/src/lib/thread/snapshot.ts +54 -32
  101. package/src/lib/thread/test-utils.ts +106 -59
  102. package/src/lib/thread/tiered.test.ts +1 -1
  103. package/src/lib/thread/types.ts +2 -2
  104. package/src/lib/tool-router/router.integration.test.ts +44 -0
  105. package/src/lib/tool-router/router.ts +140 -32
  106. package/src/lib/workflow.ts +49 -0
  107. package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
  108. package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
  109. package/src/tools/bash/bash.test.ts +1 -1
  110. package/src/tools/edit/handler.test.ts +1 -1
  111. package/tsup.config.ts +2 -4
  112. package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
  113. package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
  114. package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
  115. package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
  116. package/dist/adapters/sandbox/inmemory/index.js +0 -211
  117. package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
  118. package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
  119. package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
  120. package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
  121. package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
  122. package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
  123. 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,17 +607,7 @@ export async function createSession<
585
607
  ...(assistantId !== undefined && {
586
608
  assistantMessageId: assistantId,
587
609
  }),
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
- ),
610
+ persistThreadState: persistThreadStateOnce,
599
611
  }
600
612
  );
601
613
 
@@ -35,29 +35,29 @@ describe("createThreadManager ↔ public key helpers round-trip", () => {
35
35
  const redis = {
36
36
  exists: vi.fn(async (k: string) => (meta.has(k) ? 1 : 0)),
37
37
  set: vi.fn(
38
- async (k: string, v: string, _ex: string, ttl: number) => {
38
+ async (k: string, v: string, options?: { EX?: number }) => {
39
39
  meta.set(k, v);
40
- writtenMetaExpires.set(k, ttl);
40
+ if (options?.EX !== undefined) writtenMetaExpires.set(k, options.EX);
41
41
  return "OK";
42
42
  }
43
43
  ),
44
- del: vi.fn(async (...keys: string[]) => {
44
+ del: vi.fn(async (keys: string | string[]) => {
45
45
  let n = 0;
46
- for (const k of keys) {
46
+ for (const k of Array.isArray(keys) ? keys : [keys]) {
47
47
  if (store.delete(k)) n++;
48
48
  if (meta.delete(k)) n++;
49
49
  }
50
50
  return n;
51
51
  }),
52
- rpush: vi.fn(async (k: string, ...values: string[]) => {
52
+ rPush: vi.fn(async (k: string, element: string | string[]) => {
53
53
  const list = store.get(k) ?? [];
54
- list.push(...values);
54
+ list.push(...(Array.isArray(element) ? element : [element]));
55
55
  store.set(k, list);
56
56
  return list.length;
57
57
  }),
58
- lrange: vi.fn(async (k: string) => store.get(k) ?? []),
59
- llen: vi.fn(async (k: string) => (store.get(k) ?? []).length),
60
- ltrim: vi.fn(async () => "OK"),
58
+ lRange: vi.fn(async (k: string) => store.get(k) ?? []),
59
+ lLen: vi.fn(async (k: string) => (store.get(k) ?? []).length),
60
+ lTrim: vi.fn(async () => "OK"),
61
61
  expire: vi.fn(async (k: string, ttl: number) => {
62
62
  if (store.has(k)) writtenListExpires.set(k, ttl);
63
63
  if (meta.has(k)) writtenMetaExpires.set(k, ttl);
@@ -38,7 +38,7 @@ export const THREAD_TTL_SECONDS = 60 * 60 * 24 * 90;
38
38
  * Build the Redis list key that holds a thread's serialized messages.
39
39
  *
40
40
  * Mirrors the exact key used internally by zeitlich's thread manager,
41
- * so a consumer calling `redis.lrange(getThreadListKey(key, id), 0, -1)`
41
+ * so a consumer calling `redis.lRange(getThreadListKey(key, id), 0, -1)`
42
42
  * sees the same data the writer wrote.
43
43
  *
44
44
  * @param threadKey - Thread key (defaults to `"messages"` inside the