zeitlich 0.2.49 → 0.2.51

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 (127) hide show
  1. package/README.md +26 -23
  2. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  3. package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
  4. package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
  5. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  6. package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
  7. package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
  8. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  9. package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
  10. package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
  11. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  12. package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
  13. package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
  14. package/dist/adapters/thread/anthropic/index.cjs +60 -55
  15. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  16. package/dist/adapters/thread/anthropic/index.d.cts +20 -15
  17. package/dist/adapters/thread/anthropic/index.d.ts +20 -15
  18. package/dist/adapters/thread/anthropic/index.js +60 -55
  19. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  20. package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
  21. package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
  22. package/dist/adapters/thread/google-genai/index.cjs +135 -66
  23. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  24. package/dist/adapters/thread/google-genai/index.d.cts +200 -26
  25. package/dist/adapters/thread/google-genai/index.d.ts +200 -26
  26. package/dist/adapters/thread/google-genai/index.js +135 -66
  27. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  28. package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
  29. package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
  30. package/dist/adapters/thread/langchain/index.cjs +67 -55
  31. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  32. package/dist/adapters/thread/langchain/index.d.cts +20 -15
  33. package/dist/adapters/thread/langchain/index.d.ts +20 -15
  34. package/dist/adapters/thread/langchain/index.js +67 -55
  35. package/dist/adapters/thread/langchain/index.js.map +1 -1
  36. package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
  37. package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
  38. package/dist/{cold-store-DKMAO1Dd.d.ts → cold-store-DyHodfAB.d.ts} +1 -1
  39. package/dist/{cold-store-CkWoNtMh.d.cts → cold-store-YOx9nmgR.d.cts} +1 -1
  40. package/dist/index.cjs +15050 -420
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.d.cts +79 -83
  43. package/dist/index.d.ts +79 -83
  44. package/dist/index.js +15051 -417
  45. package/dist/index.js.map +1 -1
  46. package/dist/{proxy-B7CWEV-T.d.cts → proxy-2htgGQrc.d.cts} +1 -1
  47. package/dist/{proxy-ByFHMVRX.d.ts → proxy-CmiTP4pp.d.ts} +1 -1
  48. package/dist/{thread-manager-nK-WcFzM.d.ts → thread-manager-BJ5pz5Cx.d.cts} +6 -7
  49. package/dist/{thread-manager-7AW4rhfu.d.ts → thread-manager-BQAbrYXH.d.cts} +6 -7
  50. package/dist/{thread-manager-Cibe0X5m.d.cts → thread-manager-CcvltOuq.d.ts} +6 -7
  51. package/dist/{thread-manager-B9rtMEVn.d.cts → thread-manager-DHAbncHX.d.ts} +6 -7
  52. package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
  53. package/dist/{types-XUUFvrJ9.d.cts → types-BjdqxKYp.d.cts} +709 -709
  54. package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.cts} +3 -3
  55. package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.ts} +3 -3
  56. package/dist/{types-DO4Tkwxo.d.ts → types-DEbkLA06.d.ts} +3 -3
  57. package/dist/{types-DeVNWqlb.d.ts → types-DiI7mZhI.d.ts} +709 -709
  58. package/dist/{types-BR-k7h0e.d.cts → types-N_LTWe4b.d.cts} +3 -3
  59. package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
  60. package/dist/{workflow-uhOIj9D-.d.ts → workflow-CcgD6EUB.d.cts} +34 -3
  61. package/dist/{workflow-KbGsxpfh.d.cts → workflow-DBjPOKBr.d.ts} +34 -3
  62. package/dist/workflow.cjs +15008 -377
  63. package/dist/workflow.cjs.map +1 -1
  64. package/dist/workflow.d.cts +3 -3
  65. package/dist/workflow.d.ts +3 -3
  66. package/dist/workflow.js +15009 -374
  67. package/dist/workflow.js.map +1 -1
  68. package/package.json +10 -37
  69. package/src/adapters/thread/anthropic/activities.test.ts +115 -0
  70. package/src/adapters/thread/anthropic/activities.ts +11 -19
  71. package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
  72. package/src/adapters/thread/anthropic/model-invoker.test.ts +54 -3
  73. package/src/adapters/thread/anthropic/model-invoker.ts +11 -1
  74. package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
  75. package/src/adapters/thread/anthropic/thread-manager.ts +3 -4
  76. package/src/adapters/thread/google-genai/activities.test.ts +162 -0
  77. package/src/adapters/thread/google-genai/activities.ts +38 -15
  78. package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
  79. package/src/adapters/thread/google-genai/model-invoker.test.ts +386 -0
  80. package/src/adapters/thread/google-genai/model-invoker.ts +118 -23
  81. package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
  82. package/src/adapters/thread/google-genai/thread-manager.ts +3 -4
  83. package/src/adapters/thread/langchain/activities.test.ts +88 -0
  84. package/src/adapters/thread/langchain/activities.ts +15 -12
  85. package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
  86. package/src/adapters/thread/langchain/model-invoker.test.ts +74 -0
  87. package/src/adapters/thread/langchain/model-invoker.ts +16 -3
  88. package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
  89. package/src/adapters/thread/langchain/thread-manager.ts +3 -4
  90. package/src/index.ts +2 -2
  91. package/src/lib/sandbox/capability-types.test.ts +2 -2
  92. package/src/lib/sandbox/manager.ts +2 -6
  93. package/src/lib/sandbox/sandbox.test.ts +1 -1
  94. package/src/lib/sandbox/types.ts +2 -2
  95. package/src/lib/session/session.integration.test.ts +92 -0
  96. package/src/lib/session/session.ts +23 -11
  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 +140 -32
  108. package/src/lib/workflow.ts +49 -0
  109. package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
  110. package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
  111. package/src/tools/bash/bash.test.ts +1 -1
  112. package/src/tools/edit/handler.test.ts +1 -1
  113. package/tsup.config.ts +2 -4
  114. package/dist/activities-7OcT_vdR.d.cts +0 -162
  115. package/dist/activities-zG_FBoY2.d.ts +0 -162
  116. package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
  117. package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
  118. package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
  119. package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
  120. package/dist/adapters/sandbox/inmemory/index.js +0 -211
  121. package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
  122. package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
  123. package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
  124. package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
  125. package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
  126. package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
  127. package/dist/adapters/sandbox/inmemory/workflow.js.map +0 -1
@@ -0,0 +1,386 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ FunctionCallingConfigMode,
4
+ type Content,
5
+ type GenerateContentResponse,
6
+ type Part,
7
+ } from "@google/genai";
8
+ import { createGoogleGenAIModelInvoker } from "./model-invoker";
9
+ import type { StoredContent } from "./thread-manager";
10
+ import type { AgentResponse } from "../../../lib/model";
11
+ import { THREAD_TTL_SECONDS } from "../../../lib/thread/keys";
12
+
13
+ const textReply: Part[] = [{ text: "ok" }];
14
+
15
+ function createMockRedis(
16
+ stored: StoredContent[],
17
+ extra?: Record<string, string>
18
+ ) {
19
+ return {
20
+ exists: vi.fn().mockResolvedValue(1),
21
+ lRange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
22
+ lTrim: vi.fn().mockResolvedValue("OK"),
23
+ get: vi
24
+ .fn()
25
+ .mockImplementation((key: string) =>
26
+ Promise.resolve(extra?.[key] ?? null)
27
+ ),
28
+ del: vi.fn().mockResolvedValue(1),
29
+ set: vi.fn().mockResolvedValue("OK"),
30
+ rPush: vi.fn().mockResolvedValue(1),
31
+ expire: vi.fn().mockResolvedValue(1),
32
+ eval: vi.fn().mockResolvedValue(1),
33
+ };
34
+ }
35
+
36
+ function createMockClient(parts: Part[] = textReply) {
37
+ const chunk: Partial<GenerateContentResponse> = {
38
+ candidates: [{ content: { role: "model", parts } }],
39
+ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 },
40
+ };
41
+ return {
42
+ models: {
43
+ generateContentStream: vi.fn().mockResolvedValue({
44
+ async *[Symbol.asyncIterator]() {
45
+ yield chunk;
46
+ },
47
+ }),
48
+ },
49
+ caches: {
50
+ create: vi.fn().mockResolvedValue({ name: "cached-content-ref" }),
51
+ },
52
+ };
53
+ }
54
+
55
+ const defaultStored: StoredContent[] = [
56
+ {
57
+ id: "msg-1",
58
+ content: { role: "user", parts: [{ text: "classify these files" }] },
59
+ },
60
+ ];
61
+
62
+ const invokerConfig = {
63
+ threadId: "thread-1",
64
+ assistantMessageId: "assistant-1",
65
+ state: { tools: [] } as never,
66
+ agentName: "TestAgent",
67
+ };
68
+
69
+ function invoke(parts: Part[]): Promise<AgentResponse<Content>> {
70
+ const redis = createMockRedis(defaultStored);
71
+ const client = createMockClient(parts);
72
+
73
+ const invoker = createGoogleGenAIModelInvoker({
74
+ redis: redis as never,
75
+ client: client as never,
76
+ model: "gemini-2.5-flash",
77
+ });
78
+
79
+ return invoker(invokerConfig);
80
+ }
81
+
82
+ describe("Google GenAI model invoker — function call IDs", () => {
83
+ it("assigns synthetic IDs when Gemini omits them", async () => {
84
+ const result = await invoke([
85
+ { functionCall: { name: "classifyFile", args: { index: 0 } } },
86
+ { functionCall: { name: "classifyFile", args: { index: 1 } } },
87
+ ]);
88
+
89
+ expect(result.rawToolCalls).toHaveLength(2);
90
+ for (const tc of result.rawToolCalls) {
91
+ expect(tc.id).toBeDefined();
92
+ expect(tc.id).not.toBe("");
93
+ }
94
+ });
95
+
96
+ it("preserves existing IDs from Gemini when present", async () => {
97
+ const result = await invoke([
98
+ {
99
+ functionCall: {
100
+ id: "gemini-abc123",
101
+ name: "lookupFile",
102
+ args: { path: "/a" },
103
+ },
104
+ },
105
+ ]);
106
+
107
+ expect(result.rawToolCalls[0]?.id).toBe("gemini-abc123");
108
+ });
109
+
110
+ it("generates unique IDs across multiple function calls", async () => {
111
+ const parts: Part[] = Array.from({ length: 5 }, (_, i) => ({
112
+ functionCall: { name: "inspect", args: { index: i } },
113
+ }));
114
+
115
+ const result = await invoke(parts);
116
+
117
+ const ids = result.rawToolCalls.map((tc) => tc.id);
118
+ expect(new Set(ids).size).toBe(5);
119
+ });
120
+
121
+ it("matches IDs between message parts and rawToolCalls", async () => {
122
+ const result = await invoke([
123
+ { functionCall: { name: "toolA", args: {} } },
124
+ { functionCall: { name: "toolB", args: {} } },
125
+ ]);
126
+
127
+ const partIds = result.message.parts
128
+ ?.filter((p) => p.functionCall)
129
+ .map((p) => p.functionCall?.id);
130
+ const rawIds = result.rawToolCalls.map((tc) => tc.id);
131
+
132
+ expect(partIds).toEqual(rawIds);
133
+ });
134
+
135
+ it("handles a mix of parts with and without existing IDs", async () => {
136
+ const result = await invoke([
137
+ { functionCall: { id: "existing-id", name: "toolA", args: {} } },
138
+ { functionCall: { name: "toolB", args: {} } },
139
+ { text: "some reasoning text" },
140
+ ]);
141
+
142
+ expect(result.rawToolCalls).toHaveLength(2);
143
+ expect(result.rawToolCalls[0]?.id).toBe("existing-id");
144
+ expect(result.rawToolCalls[1]?.id).toBeDefined();
145
+ expect(result.rawToolCalls[1]?.id).not.toBe("");
146
+ expect(result.rawToolCalls[1]?.id).not.toBe("existing-id");
147
+ });
148
+ });
149
+
150
+ describe("Google GenAI model invoker — context caching", () => {
151
+ const multiMessageThread: StoredContent[] = [
152
+ {
153
+ id: "msg-1",
154
+ content: {
155
+ role: "user",
156
+ parts: [{ inlineData: { data: "base64img", mimeType: "image/png" } }],
157
+ },
158
+ },
159
+ {
160
+ id: "msg-2",
161
+ content: { role: "model", parts: [{ text: "I see the image" }] },
162
+ },
163
+ {
164
+ id: "msg-3",
165
+ content: { role: "user", parts: [{ text: "classify it" }] },
166
+ },
167
+ ];
168
+
169
+ it("creates a cache and sends only live contents when contents exceed splitIndex", async () => {
170
+ const redis = createMockRedis(multiMessageThread);
171
+ const client = createMockClient();
172
+
173
+ const invoker = createGoogleGenAIModelInvoker({
174
+ redis: redis as never,
175
+ client: client as never,
176
+ model: "gemini-2.5-flash",
177
+ cache: { splitIndex: 1 },
178
+ });
179
+
180
+ await invoker(invokerConfig);
181
+
182
+ expect(client.caches.create).toHaveBeenCalledOnce();
183
+ const cacheCall = client.caches.create.mock.calls[0]?.[0];
184
+ expect(cacheCall.model).toBe("gemini-2.5-flash");
185
+ expect(cacheCall.config.contents).toHaveLength(1);
186
+ expect(cacheCall.config.ttl).toBe("300s");
187
+
188
+ const streamCall = client.models.generateContentStream.mock.calls[0]?.[0];
189
+ expect(streamCall.contents).toHaveLength(2);
190
+ expect(streamCall.config.cachedContent).toBe("cached-content-ref");
191
+ expect(streamCall.config.systemInstruction).toBeUndefined();
192
+ expect(streamCall.config.tools).toBeUndefined();
193
+ });
194
+
195
+ it("skips caching when contents.length <= splitIndex", async () => {
196
+ const redis = createMockRedis(defaultStored);
197
+ const client = createMockClient();
198
+
199
+ const invoker = createGoogleGenAIModelInvoker({
200
+ redis: redis as never,
201
+ client: client as never,
202
+ model: "gemini-2.5-flash",
203
+ cache: { splitIndex: 1 },
204
+ });
205
+
206
+ await invoker(invokerConfig);
207
+
208
+ expect(client.caches.create).not.toHaveBeenCalled();
209
+ const streamCall = client.models.generateContentStream.mock.calls[0]?.[0];
210
+ expect(streamCall.contents).toHaveLength(1);
211
+ expect(streamCall.config.cachedContent).toBeUndefined();
212
+ });
213
+
214
+ it("uses custom TTL", async () => {
215
+ const redis = createMockRedis(multiMessageThread);
216
+ const client = createMockClient();
217
+
218
+ const invoker = createGoogleGenAIModelInvoker({
219
+ redis: redis as never,
220
+ client: client as never,
221
+ model: "gemini-2.5-flash",
222
+ cache: { splitIndex: 1, ttlSeconds: 600 },
223
+ });
224
+
225
+ await invoker(invokerConfig);
226
+
227
+ const cacheCall = client.caches.create.mock.calls[0]?.[0];
228
+ expect(cacheCall.config.ttl).toBe("600s");
229
+ });
230
+
231
+ it("moves toolConfig into cache and clears it from live request", async () => {
232
+ const redis = createMockRedis(multiMessageThread);
233
+ const client = createMockClient();
234
+
235
+ const toolConfig = {
236
+ functionCallingConfig: { mode: FunctionCallingConfigMode.ANY },
237
+ };
238
+
239
+ const invoker = createGoogleGenAIModelInvoker({
240
+ redis: redis as never,
241
+ client: client as never,
242
+ model: "gemini-2.5-flash",
243
+ cache: { splitIndex: 1 },
244
+ config: { toolConfig },
245
+ });
246
+
247
+ await invoker(invokerConfig);
248
+
249
+ const cacheCall = client.caches.create.mock.calls[0]?.[0];
250
+ expect(cacheCall.config.toolConfig).toEqual(toolConfig);
251
+
252
+ const streamCall = client.models.generateContentStream.mock.calls[0]?.[0];
253
+ expect(streamCall.config.toolConfig).toBeUndefined();
254
+ });
255
+
256
+ it("skips caching when splitIndex is 0", async () => {
257
+ const redis = createMockRedis(multiMessageThread);
258
+ const client = createMockClient();
259
+
260
+ const invoker = createGoogleGenAIModelInvoker({
261
+ redis: redis as never,
262
+ client: client as never,
263
+ model: "gemini-2.5-flash",
264
+ cache: { splitIndex: 0 },
265
+ });
266
+
267
+ await invoker(invokerConfig);
268
+
269
+ expect(client.caches.create).not.toHaveBeenCalled();
270
+ const streamCall = client.models.generateContentStream.mock.calls[0]?.[0];
271
+ expect(streamCall.config.cachedContent).toBeUndefined();
272
+ });
273
+
274
+ it("reuses cached content name from Redis instead of creating a new cache", async () => {
275
+ const redis = createMockRedis(multiMessageThread, {
276
+ "messages:gemini-cache:gemini-2.5-flash:1:thread:thread-1":
277
+ "cachedContents/existing",
278
+ });
279
+ const client = createMockClient();
280
+
281
+ const invoker = createGoogleGenAIModelInvoker({
282
+ redis: redis as never,
283
+ client: client as never,
284
+ model: "gemini-2.5-flash",
285
+ cache: { splitIndex: 1 },
286
+ });
287
+
288
+ await invoker(invokerConfig);
289
+
290
+ expect(client.caches.create).not.toHaveBeenCalled();
291
+ const streamCall = client.models.generateContentStream.mock.calls[0]?.[0];
292
+ expect(streamCall.config.cachedContent).toBe("cachedContents/existing");
293
+ expect(streamCall.contents).toHaveLength(2);
294
+ });
295
+
296
+ it("stores cache name in Redis after creation", async () => {
297
+ const redis = createMockRedis(multiMessageThread);
298
+ const client = createMockClient();
299
+
300
+ const invoker = createGoogleGenAIModelInvoker({
301
+ redis: redis as never,
302
+ client: client as never,
303
+ model: "gemini-2.5-flash",
304
+ cache: { splitIndex: 1, ttlSeconds: 600 },
305
+ });
306
+
307
+ await invoker(invokerConfig);
308
+
309
+ expect(client.caches.create).toHaveBeenCalledOnce();
310
+ const setCall = redis.set.mock.calls.find(
311
+ (c: string[]) =>
312
+ c[0] === "messages:gemini-cache:gemini-2.5-flash:1:thread:thread-1"
313
+ );
314
+ expect(setCall).toBeDefined();
315
+ expect(setCall?.[1]).toBe("cached-content-ref");
316
+ expect(setCall?.[2]).toEqual({ EX: 595 });
317
+ });
318
+
319
+ it("reports cachedWriteTokens from cache creation", async () => {
320
+ const redis = createMockRedis(multiMessageThread);
321
+ const client = createMockClient();
322
+ client.caches.create.mockResolvedValue({
323
+ name: "cached-content-ref",
324
+ usageMetadata: { totalTokenCount: 4200 },
325
+ });
326
+
327
+ const invoker = createGoogleGenAIModelInvoker({
328
+ redis: redis as never,
329
+ client: client as never,
330
+ model: "gemini-2.5-flash",
331
+ cache: { splitIndex: 1 },
332
+ });
333
+
334
+ const result = await invoker(invokerConfig);
335
+
336
+ expect(result.usage?.cachedWriteTokens).toBe(4200);
337
+ });
338
+ });
339
+
340
+ describe("Google GenAI model invoker — thread TTL", () => {
341
+ // A thread whose tail is a prior attempt's assistant message stored
342
+ // under `assistant-1`, so the invoker's `truncateFromId(assistant-1)`
343
+ // trims it and re-stamps the surviving list key's TTL.
344
+ const retriedThread: StoredContent[] = [
345
+ { id: "msg-1", content: { role: "user", parts: [{ text: "hi" }] } },
346
+ {
347
+ id: "assistant-1",
348
+ content: { role: "model", parts: [{ text: "prior attempt" }] },
349
+ },
350
+ ];
351
+ const listKey = "messages:thread:thread-1";
352
+
353
+ it("re-stamps trimmed hot keys at the configured ttlSeconds", async () => {
354
+ const redis = createMockRedis(retriedThread);
355
+ const client = createMockClient();
356
+
357
+ const invoker = createGoogleGenAIModelInvoker({
358
+ redis: redis as never,
359
+ client: client as never,
360
+ model: "gemini-2.5-flash",
361
+ ttlSeconds: 3600,
362
+ });
363
+
364
+ await invoker(invokerConfig);
365
+
366
+ expect(redis.lTrim).toHaveBeenCalledWith(listKey, 0, 0);
367
+ expect(redis.expire).toHaveBeenCalledWith(listKey, 3600);
368
+ expect(redis.expire).not.toHaveBeenCalledWith(listKey, THREAD_TTL_SECONDS);
369
+ });
370
+
371
+ it("defaults to THREAD_TTL_SECONDS when ttlSeconds is omitted", async () => {
372
+ const redis = createMockRedis(retriedThread);
373
+ const client = createMockClient();
374
+
375
+ const invoker = createGoogleGenAIModelInvoker({
376
+ redis: redis as never,
377
+ client: client as never,
378
+ model: "gemini-2.5-flash",
379
+ });
380
+
381
+ await invoker(invokerConfig);
382
+
383
+ expect(redis.lTrim).toHaveBeenCalledWith(listKey, 0, 0);
384
+ expect(redis.expire).toHaveBeenCalledWith(listKey, THREAD_TTL_SECONDS);
385
+ });
386
+ });
@@ -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,24 @@ export interface GoogleGenAIModelInvokerConfig {
19
21
  client: GoogleGenAI;
20
22
  model: string;
21
23
  hooks?: GoogleGenAIThreadManagerHooks;
24
+ /**
25
+ * Redis TTL for the thread's keys; defaults to 90 days. Use a shorter
26
+ * value (hours) with a cold tier. Distinct from `cache.ttlSeconds`
27
+ * (server-side context caching).
28
+ */
29
+ ttlSeconds?: number;
30
+ /** Passed through to `generateContentStream().config`.
31
+ * `systemInstruction`, `tools`, and `abortSignal` are managed by the
32
+ * invoker and will override any values set here. */
33
+ config?: GenerateContentConfig;
34
+ /** Caches the first `splitIndex` messages server-side (with
35
+ * `systemInstruction`, `tools`, and `toolConfig`). Skipped when
36
+ * `contents.length <= splitIndex`. */
37
+ cache?: {
38
+ splitIndex: number;
39
+ /** Default: 300. */
40
+ ttlSeconds?: number;
41
+ };
22
42
  }
23
43
 
24
44
  function toFunctionDeclarations(
@@ -32,12 +52,7 @@ function toFunctionDeclarations(
32
52
  }
33
53
 
34
54
  /**
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.
55
+ * The caller is responsible for appending the returned response to the thread.
41
56
  *
42
57
  * @example
43
58
  * ```typescript
@@ -60,6 +75,9 @@ export function createGoogleGenAIModelInvoker({
60
75
  client,
61
76
  model,
62
77
  hooks,
78
+ ttlSeconds,
79
+ config: generationConfig,
80
+ cache: cacheConfig,
63
81
  }: GoogleGenAIModelInvokerConfig) {
64
82
  return async function invokeGoogleGenAIModel(
65
83
  config: ModelInvokerConfig
@@ -72,25 +90,84 @@ export function createGoogleGenAIModelInvoker({
72
90
  threadId,
73
91
  key: threadKey,
74
92
  hooks,
93
+ ...(ttlSeconds !== undefined && { ttlSeconds }),
75
94
  });
76
95
  // Truncate the thread starting at the id the assistant message
77
96
  // will be stored under. No-op on the first attempt; on rewind
78
97
  // retry / Temporal reset it wipes the prior attempt's assistant
79
98
  // + tool results so the LLM sees the original pre-call state.
80
99
  await thread.truncateFromId(assistantMessageId);
81
- const { contents, systemInstruction } =
82
- await thread.prepareForInvocation();
100
+ const { contents, systemInstruction } = await thread.prepareForInvocation();
83
101
 
84
102
  const functionDeclarations = toFunctionDeclarations(state.tools);
85
103
  const tools =
86
104
  functionDeclarations.length > 0 ? [{ functionDeclarations }] : undefined;
87
105
 
106
+ const {
107
+ systemInstruction: _si,
108
+ tools: _t,
109
+ abortSignal: _as,
110
+ cachedContent: callerCachedContent,
111
+ toolConfig: callerToolConfig,
112
+ ...callerConfig
113
+ } = generationConfig ?? {};
114
+
115
+ let liveContents = contents;
116
+ let cachedContentName: string | undefined;
117
+ let cachedWriteTokens: number | undefined;
118
+
119
+ if (
120
+ cacheConfig &&
121
+ cacheConfig.splitIndex > 0 &&
122
+ contents.length > cacheConfig.splitIndex
123
+ ) {
124
+ liveContents = contents.slice(cacheConfig.splitIndex);
125
+ const ttl = cacheConfig.ttlSeconds ?? 300;
126
+ const cacheRedisKey = `${threadKey ?? "messages"}:gemini-cache:${model}:${cacheConfig.splitIndex}:thread:${threadId}`;
127
+
128
+ cachedContentName = (await redis.get(cacheRedisKey)) ?? undefined;
129
+
130
+ if (!cachedContentName) {
131
+ const cacheInstance = await client.caches.create({
132
+ model,
133
+ config: {
134
+ contents: contents.slice(0, cacheConfig.splitIndex),
135
+ ...(systemInstruction ? { systemInstruction } : {}),
136
+ ...(tools ? { tools } : {}),
137
+ ...(callerToolConfig ? { toolConfig: callerToolConfig } : {}),
138
+ ttl: `${ttl}s`,
139
+ abortSignal: signal,
140
+ },
141
+ });
142
+ if (!cacheInstance?.name) {
143
+ throw new Error("Gemini cache creation did not return a cache name");
144
+ }
145
+ cachedContentName = cacheInstance.name;
146
+ cachedWriteTokens =
147
+ cacheInstance.usageMetadata?.totalTokenCount ?? undefined;
148
+ const redisTtl = ttl - 5;
149
+ if (redisTtl > 0) {
150
+ await redis.set(cacheRedisKey, cachedContentName, { EX: redisTtl });
151
+ }
152
+ }
153
+ }
154
+
88
155
  const stream = await client.models.generateContentStream({
89
156
  model,
90
- contents,
157
+ contents: liveContents,
91
158
  config: {
92
- ...(systemInstruction ? { systemInstruction } : {}),
93
- ...(tools ? { tools } : {}),
159
+ ...callerConfig,
160
+ ...(cachedContentName
161
+ ? { cachedContent: cachedContentName }
162
+ : {
163
+ ...(callerCachedContent
164
+ ? { cachedContent: callerCachedContent }
165
+ : {
166
+ ...(systemInstruction ? { systemInstruction } : {}),
167
+ ...(tools ? { tools } : {}),
168
+ }),
169
+ ...(callerToolConfig ? { toolConfig: callerToolConfig } : {}),
170
+ }),
94
171
  abortSignal: signal,
95
172
  },
96
173
  });
@@ -107,48 +184,66 @@ export function createGoogleGenAIModelInvoker({
107
184
  throw new Error("Google GenAI stream ended without producing any chunks");
108
185
  }
109
186
 
187
+ for (const part of allParts) {
188
+ if (part.functionCall && !part.functionCall.id) {
189
+ part.functionCall.id = randomBytes(8).toString("hex");
190
+ }
191
+ }
192
+
110
193
  const modelContent: Content = { role: "model", parts: allParts };
111
- const functionCalls = lastChunk.functionCalls ?? [];
112
194
 
113
195
  return {
114
196
  message: modelContent,
115
- rawToolCalls: functionCalls.map((fc) => ({
116
- id: fc.id,
117
- name: fc.name ?? "",
118
- args: fc.args ?? {},
119
- })),
197
+ rawToolCalls: allParts
198
+ .filter(
199
+ (
200
+ p
201
+ ): p is Part & { functionCall: NonNullable<Part["functionCall"]> } =>
202
+ !!p.functionCall
203
+ )
204
+ .map((p) => ({
205
+ id: p.functionCall.id,
206
+ name: p.functionCall.name ?? "",
207
+ args: p.functionCall.args ?? {},
208
+ })),
120
209
  usage: {
121
210
  inputTokens: lastChunk.usageMetadata?.promptTokenCount,
122
211
  outputTokens: lastChunk.usageMetadata?.candidatesTokenCount,
212
+ cachedWriteTokens,
123
213
  cachedReadTokens: lastChunk.usageMetadata?.cachedContentTokenCount,
214
+ reasonTokens: lastChunk.usageMetadata?.thoughtsTokenCount,
124
215
  },
125
216
  };
126
217
  };
127
218
  }
128
219
 
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
220
  export async function invokeGoogleGenAIModel({
135
221
  redis,
136
222
  client,
137
223
  model,
138
224
  hooks,
225
+ ttlSeconds,
139
226
  config,
227
+ generationConfig,
228
+ cache,
140
229
  }: {
141
230
  redis: Redis;
142
231
  client: GoogleGenAI;
143
232
  model: string;
144
233
  hooks?: GoogleGenAIThreadManagerHooks;
234
+ ttlSeconds?: number;
145
235
  config: ModelInvokerConfig;
236
+ generationConfig?: GenerateContentConfig;
237
+ cache?: GoogleGenAIModelInvokerConfig["cache"];
146
238
  }): Promise<AgentResponse<Content>> {
147
239
  const invoker = createGoogleGenAIModelInvoker({
148
240
  redis,
149
241
  client,
150
242
  model,
151
243
  hooks,
244
+ ...(ttlSeconds !== undefined && { ttlSeconds }),
245
+ config: generationConfig,
246
+ cache,
152
247
  });
153
248
  return invoker(config);
154
249
  }
@@ -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 {
@@ -32,9 +32,8 @@ export interface GoogleGenAIThreadManagerConfig {
32
32
  key?: string;
33
33
  hooks?: GoogleGenAIThreadManagerHooks;
34
34
  /**
35
- * Override the default thread TTL (90 days). When pairing the
36
- * adapter with a durable cold tier, a shorter TTL (hours) is
37
- * typically more appropriate.
35
+ * Redis TTL for the thread's keys; defaults to 90 days. Use a shorter
36
+ * value (hours) with a cold tier.
38
37
  */
39
38
  ttlSeconds?: number;
40
39
  }