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.
- package/README.md +26 -23
- package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
- package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
- package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
- package/dist/adapters/sandbox/daytona/index.js.map +1 -1
- package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
- package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
- package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
- package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
- package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
- package/dist/adapters/sandbox/e2b/index.js.map +1 -1
- package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
- package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
- package/dist/adapters/thread/anthropic/index.cjs +60 -55
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +20 -15
- package/dist/adapters/thread/anthropic/index.d.ts +20 -15
- package/dist/adapters/thread/anthropic/index.js +60 -55
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
- package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
- package/dist/adapters/thread/google-genai/index.cjs +135 -66
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +200 -26
- package/dist/adapters/thread/google-genai/index.d.ts +200 -26
- package/dist/adapters/thread/google-genai/index.js +135 -66
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
- package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
- package/dist/adapters/thread/langchain/index.cjs +67 -55
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +20 -15
- package/dist/adapters/thread/langchain/index.d.ts +20 -15
- package/dist/adapters/thread/langchain/index.js +67 -55
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
- package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
- package/dist/{cold-store-DKMAO1Dd.d.ts → cold-store-DyHodfAB.d.ts} +1 -1
- package/dist/{cold-store-CkWoNtMh.d.cts → cold-store-YOx9nmgR.d.cts} +1 -1
- package/dist/index.cjs +15050 -420
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -83
- package/dist/index.d.ts +79 -83
- package/dist/index.js +15051 -417
- package/dist/index.js.map +1 -1
- package/dist/{proxy-B7CWEV-T.d.cts → proxy-2htgGQrc.d.cts} +1 -1
- package/dist/{proxy-ByFHMVRX.d.ts → proxy-CmiTP4pp.d.ts} +1 -1
- package/dist/{thread-manager-nK-WcFzM.d.ts → thread-manager-BJ5pz5Cx.d.cts} +6 -7
- package/dist/{thread-manager-7AW4rhfu.d.ts → thread-manager-BQAbrYXH.d.cts} +6 -7
- package/dist/{thread-manager-Cibe0X5m.d.cts → thread-manager-CcvltOuq.d.ts} +6 -7
- package/dist/{thread-manager-B9rtMEVn.d.cts → thread-manager-DHAbncHX.d.ts} +6 -7
- package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
- package/dist/{types-XUUFvrJ9.d.cts → types-BjdqxKYp.d.cts} +709 -709
- package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.cts} +3 -3
- package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.ts} +3 -3
- package/dist/{types-DO4Tkwxo.d.ts → types-DEbkLA06.d.ts} +3 -3
- package/dist/{types-DeVNWqlb.d.ts → types-DiI7mZhI.d.ts} +709 -709
- package/dist/{types-BR-k7h0e.d.cts → types-N_LTWe4b.d.cts} +3 -3
- package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
- package/dist/{workflow-uhOIj9D-.d.ts → workflow-CcgD6EUB.d.cts} +34 -3
- package/dist/{workflow-KbGsxpfh.d.cts → workflow-DBjPOKBr.d.ts} +34 -3
- package/dist/workflow.cjs +15008 -377
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +3 -3
- package/dist/workflow.d.ts +3 -3
- package/dist/workflow.js +15009 -374
- package/dist/workflow.js.map +1 -1
- package/package.json +10 -37
- package/src/adapters/thread/anthropic/activities.test.ts +115 -0
- package/src/adapters/thread/anthropic/activities.ts +11 -19
- package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
- package/src/adapters/thread/anthropic/model-invoker.test.ts +54 -3
- package/src/adapters/thread/anthropic/model-invoker.ts +11 -1
- package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
- package/src/adapters/thread/anthropic/thread-manager.ts +3 -4
- package/src/adapters/thread/google-genai/activities.test.ts +162 -0
- package/src/adapters/thread/google-genai/activities.ts +38 -15
- package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
- package/src/adapters/thread/google-genai/model-invoker.test.ts +386 -0
- package/src/adapters/thread/google-genai/model-invoker.ts +118 -23
- package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
- package/src/adapters/thread/google-genai/thread-manager.ts +3 -4
- package/src/adapters/thread/langchain/activities.test.ts +88 -0
- package/src/adapters/thread/langchain/activities.ts +15 -12
- package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
- package/src/adapters/thread/langchain/model-invoker.test.ts +74 -0
- package/src/adapters/thread/langchain/model-invoker.ts +16 -3
- package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
- package/src/adapters/thread/langchain/thread-manager.ts +3 -4
- package/src/index.ts +2 -2
- package/src/lib/sandbox/capability-types.test.ts +2 -2
- package/src/lib/sandbox/manager.ts +2 -6
- package/src/lib/sandbox/sandbox.test.ts +1 -1
- package/src/lib/sandbox/types.ts +2 -2
- package/src/lib/session/session.integration.test.ts +92 -0
- package/src/lib/session/session.ts +23 -11
- package/src/lib/thread/keys.test.ts +9 -9
- package/src/lib/thread/keys.ts +1 -1
- package/src/lib/thread/manager.test.ts +24 -14
- package/src/lib/thread/manager.ts +19 -23
- package/src/lib/thread/snapshot.test.ts +51 -43
- package/src/lib/thread/snapshot.ts +54 -32
- package/src/lib/thread/test-utils.ts +106 -59
- package/src/lib/thread/tiered.test.ts +1 -1
- package/src/lib/thread/types.ts +2 -2
- package/src/lib/tool-router/router.integration.test.ts +44 -0
- package/src/lib/tool-router/router.ts +140 -32
- package/src/lib/workflow.ts +49 -0
- package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
- package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
- package/src/tools/bash/bash.test.ts +1 -1
- package/src/tools/edit/handler.test.ts +1 -1
- package/tsup.config.ts +2 -4
- package/dist/activities-7OcT_vdR.d.cts +0 -162
- package/dist/activities-zG_FBoY2.d.ts +0 -162
- package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
- package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
- package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
- package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
- package/dist/adapters/sandbox/inmemory/index.js +0 -211
- package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
- package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
- package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
- package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
- package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
- package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
- 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 "
|
|
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
|
-
*
|
|
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
|
-
...
|
|
93
|
-
...(
|
|
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:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
}
|