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.
Files changed (74) hide show
  1. package/dist/adapters/thread/anthropic/index.cjs +15 -13
  2. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  3. package/dist/adapters/thread/anthropic/index.d.cts +15 -10
  4. package/dist/adapters/thread/anthropic/index.d.ts +15 -10
  5. package/dist/adapters/thread/anthropic/index.js +15 -13
  6. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  7. package/dist/adapters/thread/anthropic/workflow.d.cts +5 -5
  8. package/dist/adapters/thread/anthropic/workflow.d.ts +5 -5
  9. package/dist/adapters/thread/google-genai/index.cjs +18 -12
  10. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  11. package/dist/adapters/thread/google-genai/index.d.cts +181 -11
  12. package/dist/adapters/thread/google-genai/index.d.ts +181 -11
  13. package/dist/adapters/thread/google-genai/index.js +18 -12
  14. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  15. package/dist/adapters/thread/google-genai/workflow.d.cts +6 -6
  16. package/dist/adapters/thread/google-genai/workflow.d.ts +6 -6
  17. package/dist/adapters/thread/langchain/index.cjs +22 -13
  18. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  19. package/dist/adapters/thread/langchain/index.d.cts +15 -10
  20. package/dist/adapters/thread/langchain/index.d.ts +15 -10
  21. package/dist/adapters/thread/langchain/index.js +22 -13
  22. package/dist/adapters/thread/langchain/index.js.map +1 -1
  23. package/dist/adapters/thread/langchain/workflow.d.cts +5 -5
  24. package/dist/adapters/thread/langchain/workflow.d.ts +5 -5
  25. package/dist/{cold-store-CCnZYWjx.d.ts → cold-store-BbvJLhXJ.d.ts} +1 -1
  26. package/dist/{cold-store-C0uvYTSi.d.cts → cold-store-Ki_U0jyd.d.cts} +1 -1
  27. package/dist/index.cjs +38 -3
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +8 -8
  30. package/dist/index.d.ts +8 -8
  31. package/dist/index.js +38 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/{proxy-C4J1pNUk.d.ts → proxy-CwniAm8W.d.ts} +1 -1
  34. package/dist/{proxy-BVznA2_p.d.cts → proxy-wsNrEh2u.d.cts} +1 -1
  35. package/dist/{thread-manager-BqjzWsP7.d.ts → thread-manager-D1zfZnxi.d.ts} +4 -5
  36. package/dist/{thread-manager-SkSWRPRc.d.ts → thread-manager-DCXkMqHH.d.ts} +4 -5
  37. package/dist/{thread-manager-CzIs47uG.d.cts → thread-manager-DW7FqMdN.d.cts} +4 -5
  38. package/dist/{thread-manager-Dzl1fHhV.d.cts → thread-manager-DhvA5oDL.d.cts} +4 -5
  39. package/dist/{types-YNesmGKV.d.ts → types-DQQKF5FQ.d.ts} +24 -2
  40. package/dist/{types-DZnUqCAP.d.cts → types-DpHBKA8c.d.cts} +24 -2
  41. package/dist/{types-d2RvEP6v.d.cts → types-tJ9Or7u_.d.cts} +1 -1
  42. package/dist/{types-CbPnU4RM.d.ts → types-ziu6HZPh.d.ts} +1 -1
  43. package/dist/{workflow-Bkzg0cjB.d.ts → workflow-BeMiPEq4.d.ts} +2 -1
  44. package/dist/{workflow-B3oTe2_D.d.cts → workflow-CNTNwEnj.d.cts} +2 -1
  45. package/dist/workflow.cjs +38 -3
  46. package/dist/workflow.cjs.map +1 -1
  47. package/dist/workflow.d.cts +2 -2
  48. package/dist/workflow.d.ts +2 -2
  49. package/dist/workflow.js +38 -3
  50. package/dist/workflow.js.map +1 -1
  51. package/package.json +2 -2
  52. package/src/adapters/thread/anthropic/activities.test.ts +115 -0
  53. package/src/adapters/thread/anthropic/activities.ts +10 -18
  54. package/src/adapters/thread/anthropic/model-invoker.test.ts +50 -0
  55. package/src/adapters/thread/anthropic/model-invoker.ts +10 -0
  56. package/src/adapters/thread/anthropic/thread-manager.ts +2 -3
  57. package/src/adapters/thread/google-genai/activities.test.ts +162 -0
  58. package/src/adapters/thread/google-genai/activities.ts +37 -14
  59. package/src/adapters/thread/google-genai/model-invoker.test.ts +53 -4
  60. package/src/adapters/thread/google-genai/model-invoker.ts +11 -0
  61. package/src/adapters/thread/google-genai/thread-manager.ts +2 -3
  62. package/src/adapters/thread/langchain/activities.test.ts +88 -0
  63. package/src/adapters/thread/langchain/activities.ts +14 -11
  64. package/src/adapters/thread/langchain/model-invoker.test.ts +74 -0
  65. package/src/adapters/thread/langchain/model-invoker.ts +15 -2
  66. package/src/adapters/thread/langchain/thread-manager.ts +2 -3
  67. package/src/lib/hooks/index.ts +2 -0
  68. package/src/lib/hooks/types.ts +26 -1
  69. package/src/lib/observability/hooks.ts +17 -2
  70. package/src/lib/session/session.ts +31 -3
  71. package/src/lib/state/types.ts +9 -11
  72. package/src/workflow.ts +2 -0
  73. package/dist/activities-IuOIvPHO.d.ts +0 -162
  74. 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
- lrange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
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
- rpush: vi.fn().mockResolvedValue(1),
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]).toBe("EX");
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
- * 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
  }
@@ -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
- * Override the default Redis TTL (90 days). When pairing the
50
- * adapter with a `coldStore`, a shorter TTL (hours) is typically
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
- const baseExtras = {
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
- redis,
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
- redis,
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
- redis,
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({ redis, model, hooks: config.hooks });
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({ redis, model, hooks });
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
- * Override the default thread TTL (90 days). When pairing the
39
- * adapter with a durable cold tier, a shorter TTL (hours) is
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
  }
@@ -3,6 +3,8 @@ export type {
3
3
  SessionStartHook,
4
4
  SessionEndHookContext,
5
5
  SessionEndHook,
6
+ TurnCompleteHookContext,
7
+ TurnCompleteHook,
6
8
  PreHumanMessageAppendHookContext,
7
9
  PreHumanMessageAppendHook,
8
10
  PostHumanMessageAppendHookContext,
@@ -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 { SessionStartHook, SessionEndHook } from "../hooks/types";
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
- await callSessionEnd(exitReason, stateManager.getTurns());
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) {
@@ -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] extends JsonValue
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
@@ -116,6 +116,8 @@ export type {
116
116
  SessionStartHookContext,
117
117
  SessionEndHook,
118
118
  SessionEndHookContext,
119
+ TurnCompleteHook,
120
+ TurnCompleteHookContext,
119
121
  PreHumanMessageAppendHook,
120
122
  PreHumanMessageAppendHookContext,
121
123
  PostHumanMessageAppendHook,