zeitlich 0.2.50 → 0.2.53
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/dist/adapters/thread/anthropic/index.cjs +15 -13
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +15 -10
- package/dist/adapters/thread/anthropic/index.d.ts +15 -10
- package/dist/adapters/thread/anthropic/index.js +15 -13
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +5 -5
- package/dist/adapters/thread/anthropic/workflow.d.ts +5 -5
- package/dist/adapters/thread/google-genai/index.cjs +18 -12
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +181 -11
- package/dist/adapters/thread/google-genai/index.d.ts +181 -11
- package/dist/adapters/thread/google-genai/index.js +18 -12
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +6 -6
- package/dist/adapters/thread/google-genai/workflow.d.ts +6 -6
- package/dist/adapters/thread/langchain/index.cjs +22 -13
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +15 -10
- package/dist/adapters/thread/langchain/index.d.ts +15 -10
- package/dist/adapters/thread/langchain/index.js +22 -13
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +5 -5
- package/dist/adapters/thread/langchain/workflow.d.ts +5 -5
- package/dist/{cold-store-CCnZYWjx.d.ts → cold-store-BbvJLhXJ.d.ts} +1 -1
- package/dist/{cold-store-C0uvYTSi.d.cts → cold-store-Ki_U0jyd.d.cts} +1 -1
- package/dist/index.cjs +38 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +38 -3
- package/dist/index.js.map +1 -1
- package/dist/{proxy-C4J1pNUk.d.ts → proxy-CwniAm8W.d.ts} +1 -1
- package/dist/{proxy-BVznA2_p.d.cts → proxy-wsNrEh2u.d.cts} +1 -1
- package/dist/{thread-manager-BqjzWsP7.d.ts → thread-manager-D1zfZnxi.d.ts} +4 -5
- package/dist/{thread-manager-SkSWRPRc.d.ts → thread-manager-DCXkMqHH.d.ts} +4 -5
- package/dist/{thread-manager-CzIs47uG.d.cts → thread-manager-DW7FqMdN.d.cts} +4 -5
- package/dist/{thread-manager-Dzl1fHhV.d.cts → thread-manager-DhvA5oDL.d.cts} +4 -5
- package/dist/{types-YNesmGKV.d.ts → types-DQQKF5FQ.d.ts} +24 -2
- package/dist/{types-DZnUqCAP.d.cts → types-DpHBKA8c.d.cts} +24 -2
- package/dist/{types-d2RvEP6v.d.cts → types-tJ9Or7u_.d.cts} +1 -1
- package/dist/{types-CbPnU4RM.d.ts → types-ziu6HZPh.d.ts} +1 -1
- package/dist/{workflow-Bkzg0cjB.d.ts → workflow-BeMiPEq4.d.ts} +2 -1
- package/dist/{workflow-B3oTe2_D.d.cts → workflow-CNTNwEnj.d.cts} +2 -1
- package/dist/workflow.cjs +38 -3
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +2 -2
- package/dist/workflow.d.ts +2 -2
- package/dist/workflow.js +38 -3
- package/dist/workflow.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/thread/anthropic/activities.test.ts +115 -0
- package/src/adapters/thread/anthropic/activities.ts +10 -18
- package/src/adapters/thread/anthropic/model-invoker.test.ts +50 -0
- package/src/adapters/thread/anthropic/model-invoker.ts +10 -0
- package/src/adapters/thread/anthropic/thread-manager.ts +2 -3
- package/src/adapters/thread/google-genai/activities.test.ts +162 -0
- package/src/adapters/thread/google-genai/activities.ts +37 -14
- package/src/adapters/thread/google-genai/model-invoker.test.ts +53 -4
- package/src/adapters/thread/google-genai/model-invoker.ts +11 -0
- package/src/adapters/thread/google-genai/thread-manager.ts +2 -3
- package/src/adapters/thread/langchain/activities.test.ts +88 -0
- package/src/adapters/thread/langchain/activities.ts +14 -11
- package/src/adapters/thread/langchain/model-invoker.test.ts +74 -0
- package/src/adapters/thread/langchain/model-invoker.ts +15 -2
- package/src/adapters/thread/langchain/thread-manager.ts +2 -3
- package/src/lib/hooks/index.ts +2 -0
- package/src/lib/hooks/types.ts +26 -1
- package/src/lib/observability/hooks.ts +17 -2
- package/src/lib/session/session.ts +31 -3
- package/src/lib/state/types.ts +9 -11
- package/src/workflow.ts +2 -0
- package/dist/activities-IuOIvPHO.d.ts +0 -162
- package/dist/activities-cIlq1y1y.d.cts +0 -162
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { createGoogleGenAIModelInvoker } from "./model-invoker";
|
|
9
9
|
import type { StoredContent } from "./thread-manager";
|
|
10
10
|
import type { AgentResponse } from "../../../lib/model";
|
|
11
|
+
import { THREAD_TTL_SECONDS } from "../../../lib/thread/keys";
|
|
11
12
|
|
|
12
13
|
const textReply: Part[] = [{ text: "ok" }];
|
|
13
14
|
|
|
@@ -17,7 +18,8 @@ function createMockRedis(
|
|
|
17
18
|
) {
|
|
18
19
|
return {
|
|
19
20
|
exists: vi.fn().mockResolvedValue(1),
|
|
20
|
-
|
|
21
|
+
lRange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
|
|
22
|
+
lTrim: vi.fn().mockResolvedValue("OK"),
|
|
21
23
|
get: vi
|
|
22
24
|
.fn()
|
|
23
25
|
.mockImplementation((key: string) =>
|
|
@@ -25,7 +27,7 @@ function createMockRedis(
|
|
|
25
27
|
),
|
|
26
28
|
del: vi.fn().mockResolvedValue(1),
|
|
27
29
|
set: vi.fn().mockResolvedValue("OK"),
|
|
28
|
-
|
|
30
|
+
rPush: vi.fn().mockResolvedValue(1),
|
|
29
31
|
expire: vi.fn().mockResolvedValue(1),
|
|
30
32
|
eval: vi.fn().mockResolvedValue(1),
|
|
31
33
|
};
|
|
@@ -311,8 +313,7 @@ describe("Google GenAI model invoker — context caching", () => {
|
|
|
311
313
|
);
|
|
312
314
|
expect(setCall).toBeDefined();
|
|
313
315
|
expect(setCall?.[1]).toBe("cached-content-ref");
|
|
314
|
-
expect(setCall?.[2]).
|
|
315
|
-
expect(setCall?.[3]).toBe(595);
|
|
316
|
+
expect(setCall?.[2]).toEqual({ EX: 595 });
|
|
316
317
|
});
|
|
317
318
|
|
|
318
319
|
it("reports cachedWriteTokens from cache creation", async () => {
|
|
@@ -335,3 +336,51 @@ describe("Google GenAI model invoker — context caching", () => {
|
|
|
335
336
|
expect(result.usage?.cachedWriteTokens).toBe(4200);
|
|
336
337
|
});
|
|
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
|
+
});
|
|
@@ -21,6 +21,12 @@ export interface GoogleGenAIModelInvokerConfig {
|
|
|
21
21
|
client: GoogleGenAI;
|
|
22
22
|
model: string;
|
|
23
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;
|
|
24
30
|
/** Passed through to `generateContentStream().config`.
|
|
25
31
|
* `systemInstruction`, `tools`, and `abortSignal` are managed by the
|
|
26
32
|
* invoker and will override any values set here. */
|
|
@@ -69,6 +75,7 @@ export function createGoogleGenAIModelInvoker({
|
|
|
69
75
|
client,
|
|
70
76
|
model,
|
|
71
77
|
hooks,
|
|
78
|
+
ttlSeconds,
|
|
72
79
|
config: generationConfig,
|
|
73
80
|
cache: cacheConfig,
|
|
74
81
|
}: GoogleGenAIModelInvokerConfig) {
|
|
@@ -83,6 +90,7 @@ export function createGoogleGenAIModelInvoker({
|
|
|
83
90
|
threadId,
|
|
84
91
|
key: threadKey,
|
|
85
92
|
hooks,
|
|
93
|
+
...(ttlSeconds !== undefined && { ttlSeconds }),
|
|
86
94
|
});
|
|
87
95
|
// Truncate the thread starting at the id the assistant message
|
|
88
96
|
// will be stored under. No-op on the first attempt; on rewind
|
|
@@ -214,6 +222,7 @@ export async function invokeGoogleGenAIModel({
|
|
|
214
222
|
client,
|
|
215
223
|
model,
|
|
216
224
|
hooks,
|
|
225
|
+
ttlSeconds,
|
|
217
226
|
config,
|
|
218
227
|
generationConfig,
|
|
219
228
|
cache,
|
|
@@ -222,6 +231,7 @@ export async function invokeGoogleGenAIModel({
|
|
|
222
231
|
client: GoogleGenAI;
|
|
223
232
|
model: string;
|
|
224
233
|
hooks?: GoogleGenAIThreadManagerHooks;
|
|
234
|
+
ttlSeconds?: number;
|
|
225
235
|
config: ModelInvokerConfig;
|
|
226
236
|
generationConfig?: GenerateContentConfig;
|
|
227
237
|
cache?: GoogleGenAIModelInvokerConfig["cache"];
|
|
@@ -231,6 +241,7 @@ export async function invokeGoogleGenAIModel({
|
|
|
231
241
|
client,
|
|
232
242
|
model,
|
|
233
243
|
hooks,
|
|
244
|
+
...(ttlSeconds !== undefined && { ttlSeconds }),
|
|
234
245
|
config: generationConfig,
|
|
235
246
|
cache,
|
|
236
247
|
});
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AIMessage, HumanMessage } from "@langchain/core/messages";
|
|
3
|
+
import { createLangChainAdapter } from "./activities";
|
|
4
|
+
import { THREAD_TTL_SECONDS } from "../../../lib/thread/keys";
|
|
5
|
+
|
|
6
|
+
function createMockRedis(stored: unknown[]) {
|
|
7
|
+
return {
|
|
8
|
+
exists: vi.fn().mockResolvedValue(1),
|
|
9
|
+
lRange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
|
|
10
|
+
lTrim: vi.fn().mockResolvedValue("OK"),
|
|
11
|
+
del: vi.fn().mockResolvedValue(1),
|
|
12
|
+
set: vi.fn().mockResolvedValue("OK"),
|
|
13
|
+
rPush: vi.fn().mockResolvedValue(1),
|
|
14
|
+
expire: vi.fn().mockResolvedValue(1),
|
|
15
|
+
eval: vi.fn().mockResolvedValue(1),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createMockModel() {
|
|
20
|
+
const response = {
|
|
21
|
+
tool_calls: [],
|
|
22
|
+
response_metadata: {},
|
|
23
|
+
usage_metadata: { input_tokens: 1, output_tokens: 1 },
|
|
24
|
+
toDict: () => ({ type: "ai", data: { content: "ok" } }),
|
|
25
|
+
};
|
|
26
|
+
return { invoke: vi.fn().mockResolvedValue(response) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Tail stored under the `assistantMessageId`, so the invoker's
|
|
30
|
+
// `truncateFromId` trims it and re-stamps the surviving list key's TTL.
|
|
31
|
+
const retriedThread = [
|
|
32
|
+
new HumanMessage({ id: "msg-1", content: "hi" }).toDict(),
|
|
33
|
+
new AIMessage({ id: "assistant-1", content: "prior" }).toDict(),
|
|
34
|
+
];
|
|
35
|
+
const listKey = "messages:thread:thread-1";
|
|
36
|
+
const metaKey = "messages:meta:thread:thread-1";
|
|
37
|
+
const invokerCall = {
|
|
38
|
+
threadId: "thread-1",
|
|
39
|
+
assistantMessageId: "assistant-1",
|
|
40
|
+
state: { tools: [] } as never,
|
|
41
|
+
agentName: "TestAgent",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("createLangChainAdapter — TTL propagation", () => {
|
|
45
|
+
it("forwards adapter ttlSeconds to a created invoker's writes", async () => {
|
|
46
|
+
const redis = createMockRedis(retriedThread);
|
|
47
|
+
const model = createMockModel();
|
|
48
|
+
const adapter = createLangChainAdapter({
|
|
49
|
+
redis: redis as never,
|
|
50
|
+
ttlSeconds: 3600,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await adapter.createModelInvoker(model as never)(invokerCall);
|
|
54
|
+
|
|
55
|
+
expect(redis.expire).toHaveBeenCalledWith(listKey, 3600);
|
|
56
|
+
expect(redis.expire).not.toHaveBeenCalledWith(listKey, THREAD_TTL_SECONDS);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("forwards adapter ttlSeconds to thread-op writes", async () => {
|
|
60
|
+
const redis = createMockRedis([]);
|
|
61
|
+
const adapter = createLangChainAdapter({
|
|
62
|
+
redis: redis as never,
|
|
63
|
+
ttlSeconds: 3600,
|
|
64
|
+
});
|
|
65
|
+
const acts = adapter.createActivities() as unknown as Record<
|
|
66
|
+
string,
|
|
67
|
+
(threadId: string, threadKey?: string) => Promise<void>
|
|
68
|
+
>;
|
|
69
|
+
const initialize = Object.entries(acts).find(([k]) =>
|
|
70
|
+
k.endsWith("InitializeThread")
|
|
71
|
+
)?.[1];
|
|
72
|
+
if (!initialize) throw new Error("initializeThread activity not found");
|
|
73
|
+
|
|
74
|
+
await initialize("thread-1");
|
|
75
|
+
|
|
76
|
+
expect(redis.set).toHaveBeenCalledWith(metaKey, "1", { EX: 3600 });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("defaults to THREAD_TTL_SECONDS when adapter ttlSeconds is omitted", async () => {
|
|
80
|
+
const redis = createMockRedis(retriedThread);
|
|
81
|
+
const model = createMockModel();
|
|
82
|
+
const adapter = createLangChainAdapter({ redis: redis as never });
|
|
83
|
+
|
|
84
|
+
await adapter.createModelInvoker(model as never)(invokerCall);
|
|
85
|
+
|
|
86
|
+
expect(redis.expire).toHaveBeenCalledWith(listKey, THREAD_TTL_SECONDS);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -46,9 +46,8 @@ export interface LangChainAdapterConfig {
|
|
|
46
46
|
*/
|
|
47
47
|
coldStore?: ColdThreadStore;
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* more appropriate.
|
|
49
|
+
* Redis TTL for the thread's keys; defaults to 90 days. Use a shorter
|
|
50
|
+
* value (hours) with a cold tier.
|
|
52
51
|
*/
|
|
53
52
|
ttlSeconds?: number;
|
|
54
53
|
}
|
|
@@ -133,25 +132,26 @@ export function createLangChainAdapter(
|
|
|
133
132
|
): LangChainAdapter {
|
|
134
133
|
const { redis } = config;
|
|
135
134
|
|
|
136
|
-
|
|
135
|
+
// Single source for the adapter's `redis` handle and configured TTL, spread
|
|
136
|
+
// into every internal thread manager so all of them share one configuration.
|
|
137
|
+
const base = {
|
|
138
|
+
redis,
|
|
137
139
|
...(config.ttlSeconds !== undefined && { ttlSeconds: config.ttlSeconds }),
|
|
138
140
|
};
|
|
139
141
|
|
|
140
142
|
const makeProviderThread = (threadId: string, threadKey?: string) =>
|
|
141
143
|
createLangChainThreadManager({
|
|
142
|
-
|
|
144
|
+
...base,
|
|
143
145
|
threadId,
|
|
144
146
|
key: threadKey,
|
|
145
|
-
...baseExtras,
|
|
146
147
|
});
|
|
147
148
|
|
|
148
149
|
const makeTieredBase = (threadId: string, threadKey?: string) =>
|
|
149
150
|
createTieredThreadManager<StoredMessage>({
|
|
150
|
-
|
|
151
|
+
...base,
|
|
151
152
|
threadId,
|
|
152
153
|
key: threadKey,
|
|
153
154
|
idOf: storedMessageId,
|
|
154
|
-
...baseExtras,
|
|
155
155
|
...(config.coldStore && { coldStore: config.coldStore }),
|
|
156
156
|
});
|
|
157
157
|
|
|
@@ -207,11 +207,10 @@ export function createLangChainAdapter(
|
|
|
207
207
|
threadKey?: string
|
|
208
208
|
): Promise<void> {
|
|
209
209
|
const thread = createLangChainThreadManager({
|
|
210
|
-
|
|
210
|
+
...base,
|
|
211
211
|
threadId: sourceThreadId,
|
|
212
212
|
key: threadKey,
|
|
213
213
|
hooks: config.hooks,
|
|
214
|
-
...baseExtras,
|
|
215
214
|
});
|
|
216
215
|
await thread.fork(targetThreadId);
|
|
217
216
|
},
|
|
@@ -275,7 +274,11 @@ export function createLangChainAdapter(
|
|
|
275
274
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
276
275
|
model: BaseChatModel<any>
|
|
277
276
|
): ModelInvoker<StoredMessage> =>
|
|
278
|
-
createLangChainModelInvoker({
|
|
277
|
+
createLangChainModelInvoker({
|
|
278
|
+
...base,
|
|
279
|
+
model,
|
|
280
|
+
hooks: config.hooks,
|
|
281
|
+
});
|
|
279
282
|
|
|
280
283
|
const invoker: ModelInvoker<StoredMessage> = config.model
|
|
281
284
|
? makeInvoker(config.model)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AIMessage, HumanMessage } from "@langchain/core/messages";
|
|
3
|
+
import { createLangChainModelInvoker } from "./model-invoker";
|
|
4
|
+
import { THREAD_TTL_SECONDS } from "../../../lib/thread/keys";
|
|
5
|
+
|
|
6
|
+
function createMockRedis(stored: unknown[]) {
|
|
7
|
+
return {
|
|
8
|
+
exists: vi.fn().mockResolvedValue(1),
|
|
9
|
+
lRange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
|
|
10
|
+
lTrim: vi.fn().mockResolvedValue("OK"),
|
|
11
|
+
del: vi.fn().mockResolvedValue(1),
|
|
12
|
+
set: vi.fn().mockResolvedValue("OK"),
|
|
13
|
+
rPush: vi.fn().mockResolvedValue(1),
|
|
14
|
+
expire: vi.fn().mockResolvedValue(1),
|
|
15
|
+
eval: vi.fn().mockResolvedValue(1),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createMockModel() {
|
|
20
|
+
const response = {
|
|
21
|
+
tool_calls: [],
|
|
22
|
+
response_metadata: {},
|
|
23
|
+
usage_metadata: { input_tokens: 1, output_tokens: 1 },
|
|
24
|
+
toDict: () => ({ type: "ai", data: { content: "ok" } }),
|
|
25
|
+
};
|
|
26
|
+
return { invoke: vi.fn().mockResolvedValue(response) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("createLangChainModelInvoker thread TTL", () => {
|
|
30
|
+
// The tail message is stored under `assistant-1`, so the invoker's
|
|
31
|
+
// `truncateFromId(assistant-1)` trims it and re-stamps the surviving
|
|
32
|
+
// list key's TTL.
|
|
33
|
+
const retriedThread = [
|
|
34
|
+
new HumanMessage({ id: "msg-1", content: "hi" }).toDict(),
|
|
35
|
+
new AIMessage({ id: "assistant-1", content: "prior" }).toDict(),
|
|
36
|
+
];
|
|
37
|
+
const listKey = "messages:thread:thread-1";
|
|
38
|
+
const invokerConfig = {
|
|
39
|
+
threadId: "thread-1",
|
|
40
|
+
assistantMessageId: "assistant-1",
|
|
41
|
+
state: { tools: [] } as never,
|
|
42
|
+
agentName: "Agent",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
it("re-stamps trimmed hot keys at the configured ttlSeconds", async () => {
|
|
46
|
+
const redis = createMockRedis(retriedThread);
|
|
47
|
+
const model = createMockModel();
|
|
48
|
+
const invoker = createLangChainModelInvoker({
|
|
49
|
+
redis: redis as never,
|
|
50
|
+
model: model as never,
|
|
51
|
+
ttlSeconds: 3600,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await invoker(invokerConfig);
|
|
55
|
+
|
|
56
|
+
expect(redis.lTrim).toHaveBeenCalledWith(listKey, 0, 0);
|
|
57
|
+
expect(redis.expire).toHaveBeenCalledWith(listKey, 3600);
|
|
58
|
+
expect(redis.expire).not.toHaveBeenCalledWith(listKey, THREAD_TTL_SECONDS);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("defaults to THREAD_TTL_SECONDS when ttlSeconds is omitted", async () => {
|
|
62
|
+
const redis = createMockRedis(retriedThread);
|
|
63
|
+
const model = createMockModel();
|
|
64
|
+
const invoker = createLangChainModelInvoker({
|
|
65
|
+
redis: redis as never,
|
|
66
|
+
model: model as never,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await invoker(invokerConfig);
|
|
70
|
+
|
|
71
|
+
expect(redis.lTrim).toHaveBeenCalledWith(listKey, 0, 0);
|
|
72
|
+
expect(redis.expire).toHaveBeenCalledWith(listKey, THREAD_TTL_SECONDS);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -16,6 +16,11 @@ export interface LangChainModelInvokerConfig<
|
|
|
16
16
|
redis: Redis;
|
|
17
17
|
model: TModel;
|
|
18
18
|
hooks?: LangChainThreadManagerHooks;
|
|
19
|
+
/**
|
|
20
|
+
* Redis TTL for the thread's keys; defaults to 90 days. Use a shorter
|
|
21
|
+
* value (hours) with a cold tier.
|
|
22
|
+
*/
|
|
23
|
+
ttlSeconds?: number;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
/**
|
|
@@ -43,7 +48,7 @@ export interface LangChainModelInvokerConfig<
|
|
|
43
48
|
|
|
44
49
|
export function createLangChainModelInvoker<
|
|
45
50
|
TModel extends BaseChatModel<any> = BaseChatModel<any>,
|
|
46
|
-
>({ redis, model, hooks }: LangChainModelInvokerConfig<TModel>) {
|
|
51
|
+
>({ redis, model, hooks, ttlSeconds }: LangChainModelInvokerConfig<TModel>) {
|
|
47
52
|
return async function invokeLangChainModel(
|
|
48
53
|
config: ModelInvokerConfig
|
|
49
54
|
): Promise<AgentResponse<StoredMessage>> {
|
|
@@ -56,6 +61,7 @@ export function createLangChainModelInvoker<
|
|
|
56
61
|
threadId,
|
|
57
62
|
key: threadKey,
|
|
58
63
|
hooks,
|
|
64
|
+
...(ttlSeconds !== undefined && { ttlSeconds }),
|
|
59
65
|
});
|
|
60
66
|
const runId = uuidv4();
|
|
61
67
|
|
|
@@ -122,13 +128,20 @@ export async function invokeLangChainModel<
|
|
|
122
128
|
redis,
|
|
123
129
|
model,
|
|
124
130
|
hooks,
|
|
131
|
+
ttlSeconds,
|
|
125
132
|
config,
|
|
126
133
|
}: {
|
|
127
134
|
redis: Redis;
|
|
128
135
|
config: ModelInvokerConfig;
|
|
129
136
|
model: TModel;
|
|
130
137
|
hooks?: LangChainThreadManagerHooks;
|
|
138
|
+
ttlSeconds?: number;
|
|
131
139
|
}): Promise<AgentResponse<StoredMessage>> {
|
|
132
|
-
const invoker = createLangChainModelInvoker({
|
|
140
|
+
const invoker = createLangChainModelInvoker({
|
|
141
|
+
redis,
|
|
142
|
+
model,
|
|
143
|
+
hooks,
|
|
144
|
+
...(ttlSeconds !== undefined && { ttlSeconds }),
|
|
145
|
+
});
|
|
133
146
|
return invoker(config);
|
|
134
147
|
}
|
|
@@ -35,9 +35,8 @@ export interface LangChainThreadManagerConfig {
|
|
|
35
35
|
key?: string;
|
|
36
36
|
hooks?: LangChainThreadManagerHooks;
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* typically more appropriate.
|
|
38
|
+
* Redis TTL for the thread's keys; defaults to 90 days. Use a shorter
|
|
39
|
+
* value (hours) with a cold tier.
|
|
41
40
|
*/
|
|
42
41
|
ttlSeconds?: number;
|
|
43
42
|
}
|
package/src/lib/hooks/index.ts
CHANGED
package/src/lib/hooks/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SessionExitReason } from "../types";
|
|
1
|
+
import type { SessionExitReason, TokenUsage } from "../types";
|
|
2
2
|
import type { ToolMap, ToolRouterHooks } from "../tool-router/types";
|
|
3
3
|
|
|
4
4
|
// ============================================================================
|
|
@@ -29,6 +29,7 @@ export interface SessionEndHookContext {
|
|
|
29
29
|
agentName: string;
|
|
30
30
|
exitReason: SessionExitReason;
|
|
31
31
|
turns: number;
|
|
32
|
+
usage: TokenUsage;
|
|
32
33
|
metadata: Record<string, unknown>;
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -39,6 +40,28 @@ export type SessionEndHook = (
|
|
|
39
40
|
ctx: SessionEndHookContext
|
|
40
41
|
) => void | Promise<void>;
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Context for TurnComplete hook - called after each agent turn commits
|
|
45
|
+
* (i.e. once per model invocation, excluding rewound turns)
|
|
46
|
+
*/
|
|
47
|
+
export interface TurnCompleteHookContext {
|
|
48
|
+
threadId: string;
|
|
49
|
+
agentName: string;
|
|
50
|
+
/** 1-based turn number that just completed */
|
|
51
|
+
turn: number;
|
|
52
|
+
/** Number of tool calls the model requested this turn */
|
|
53
|
+
toolCallCount: number;
|
|
54
|
+
/** Token usage reported by the model for this turn, if available */
|
|
55
|
+
usage?: TokenUsage;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* TurnComplete hook - called after each agent turn commits
|
|
60
|
+
*/
|
|
61
|
+
export type TurnCompleteHook = (
|
|
62
|
+
ctx: TurnCompleteHookContext
|
|
63
|
+
) => void | Promise<void>;
|
|
64
|
+
|
|
42
65
|
// ============================================================================
|
|
43
66
|
// Message Lifecycle Hooks
|
|
44
67
|
// ============================================================================
|
|
@@ -95,4 +118,6 @@ export interface Hooks<
|
|
|
95
118
|
onSessionStart?: SessionStartHook;
|
|
96
119
|
/** Called when session ends */
|
|
97
120
|
onSessionEnd?: SessionEndHook;
|
|
121
|
+
/** Called after each agent turn commits (excludes rewound turns) */
|
|
122
|
+
onTurnComplete?: TurnCompleteHook;
|
|
98
123
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { proxySinks } from "@temporalio/workflow";
|
|
2
2
|
import type { ZeitlichObservabilitySinks } from "./sinks";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
SessionStartHook,
|
|
5
|
+
SessionEndHook,
|
|
6
|
+
TurnCompleteHook,
|
|
7
|
+
} from "../hooks/types";
|
|
4
8
|
import type {
|
|
5
9
|
PostToolUseHook,
|
|
6
10
|
PostToolUseFailureHook,
|
|
@@ -9,6 +13,7 @@ import type {
|
|
|
9
13
|
export interface ObservabilityHooks {
|
|
10
14
|
onSessionStart: SessionStartHook;
|
|
11
15
|
onSessionEnd: SessionEndHook;
|
|
16
|
+
onTurnComplete: TurnCompleteHook;
|
|
12
17
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
18
|
onPostToolUse: PostToolUseHook<any, any>;
|
|
14
19
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -58,11 +63,21 @@ export function createObservabilityHooks(
|
|
|
58
63
|
threadId: ctx.threadId,
|
|
59
64
|
exitReason: ctx.exitReason,
|
|
60
65
|
turns: ctx.turns,
|
|
61
|
-
usage:
|
|
66
|
+
usage: ctx.usage,
|
|
62
67
|
durationMs: Date.now() - sessionStartMs,
|
|
63
68
|
});
|
|
64
69
|
},
|
|
65
70
|
|
|
71
|
+
onTurnComplete: (ctx) => {
|
|
72
|
+
zeitlichMetrics.turnCompleted({
|
|
73
|
+
agentName,
|
|
74
|
+
threadId: ctx.threadId,
|
|
75
|
+
turn: ctx.turn,
|
|
76
|
+
toolCallCount: ctx.toolCallCount,
|
|
77
|
+
...(ctx.usage && { usage: ctx.usage }),
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
|
|
66
81
|
onPostToolUse: (ctx) => {
|
|
67
82
|
zeitlichMetrics.toolExecuted({
|
|
68
83
|
agentName,
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
ApplicationFailure,
|
|
5
5
|
log,
|
|
6
6
|
} from "@temporalio/workflow";
|
|
7
|
-
import type { SessionExitReason } from "../types";
|
|
7
|
+
import type { SessionExitReason, TokenUsage } from "../types";
|
|
8
8
|
import type { SessionConfig, ZeitlichSession } from "./types";
|
|
9
9
|
import { resolveSessionLifecycle } from "./types";
|
|
10
10
|
import type {
|
|
@@ -232,7 +232,8 @@ export async function createSession<
|
|
|
232
232
|
|
|
233
233
|
const callSessionEnd = async (
|
|
234
234
|
exitReason: SessionExitReason,
|
|
235
|
-
turns: number
|
|
235
|
+
turns: number,
|
|
236
|
+
usage: TokenUsage
|
|
236
237
|
): Promise<void> => {
|
|
237
238
|
if (hooks.onSessionEnd) {
|
|
238
239
|
await hooks.onSessionEnd({
|
|
@@ -240,6 +241,7 @@ export async function createSession<
|
|
|
240
241
|
agentName,
|
|
241
242
|
exitReason,
|
|
242
243
|
turns,
|
|
244
|
+
usage,
|
|
243
245
|
metadata,
|
|
244
246
|
});
|
|
245
247
|
}
|
|
@@ -554,6 +556,15 @@ export async function createSession<
|
|
|
554
556
|
});
|
|
555
557
|
|
|
556
558
|
if (!toolRouter.hasTools() || rawToolCalls.length === 0) {
|
|
559
|
+
if (hooks.onTurnComplete) {
|
|
560
|
+
await hooks.onTurnComplete({
|
|
561
|
+
threadId,
|
|
562
|
+
agentName,
|
|
563
|
+
turn: currentTurn,
|
|
564
|
+
toolCallCount: rawToolCalls.length,
|
|
565
|
+
...(usage && { usage }),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
557
568
|
stateManager.complete();
|
|
558
569
|
exitReason = "completed";
|
|
559
570
|
finalMessage = message;
|
|
@@ -638,6 +649,16 @@ export async function createSession<
|
|
|
638
649
|
|
|
639
650
|
// Turn committed: fresh id for the next turn.
|
|
640
651
|
assistantId = undefined;
|
|
652
|
+
|
|
653
|
+
if (hooks.onTurnComplete) {
|
|
654
|
+
await hooks.onTurnComplete({
|
|
655
|
+
threadId,
|
|
656
|
+
agentName,
|
|
657
|
+
turn: currentTurn,
|
|
658
|
+
toolCallCount: rawToolCalls.length,
|
|
659
|
+
...(usage && { usage }),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
641
662
|
}
|
|
642
663
|
|
|
643
664
|
if (stateManager.getTurns() >= maxTurns && stateManager.isRunning()) {
|
|
@@ -707,7 +728,14 @@ export async function createSession<
|
|
|
707
728
|
});
|
|
708
729
|
}
|
|
709
730
|
|
|
710
|
-
|
|
731
|
+
const totals = stateManager.getTotalUsage();
|
|
732
|
+
await callSessionEnd(exitReason, totals.turns, {
|
|
733
|
+
inputTokens: totals.totalInputTokens,
|
|
734
|
+
outputTokens: totals.totalOutputTokens,
|
|
735
|
+
cachedWriteTokens: totals.totalCachedWriteTokens,
|
|
736
|
+
cachedReadTokens: totals.totalCachedReadTokens,
|
|
737
|
+
reasonTokens: totals.totalReasonTokens,
|
|
738
|
+
});
|
|
711
739
|
|
|
712
740
|
if (sandboxOwned && sandboxId && sandboxOps) {
|
|
713
741
|
switch (resolvedShutdown) {
|
package/src/lib/state/types.ts
CHANGED
|
@@ -29,19 +29,17 @@ export type JsonValue =
|
|
|
29
29
|
* Rejects: functions, symbols, undefined, class instances with methods
|
|
30
30
|
*/
|
|
31
31
|
export type JsonSerializable<T> = {
|
|
32
|
-
[K in keyof T]: T[K]
|
|
33
|
-
? T[K]
|
|
34
|
-
: T[K] extends JsonPrimitive
|
|
35
|
-
? T[K]
|
|
36
|
-
: T[K] extends (infer U)[]
|
|
37
|
-
? U extends JsonValue
|
|
38
|
-
? T[K]
|
|
39
|
-
: JsonSerializable<U>[]
|
|
40
|
-
: T[K] extends object
|
|
41
|
-
? JsonSerializable<T[K]>
|
|
42
|
-
: never;
|
|
32
|
+
[K in keyof T]: JsonSerializableValue<T[K]>;
|
|
43
33
|
};
|
|
44
34
|
|
|
35
|
+
type JsonSerializableValue<V> = V extends JsonValue
|
|
36
|
+
? V
|
|
37
|
+
: V extends (infer U)[]
|
|
38
|
+
? JsonSerializableValue<U>[]
|
|
39
|
+
: V extends object
|
|
40
|
+
? JsonSerializable<V>
|
|
41
|
+
: never;
|
|
42
|
+
|
|
45
43
|
/**
|
|
46
44
|
* Full state type combining base state with custom state
|
|
47
45
|
*/
|
package/src/workflow.ts
CHANGED