zeitlich 0.2.49 → 0.2.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +26 -23
  2. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  3. package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
  4. package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
  5. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  6. package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
  7. package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
  8. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  9. package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
  10. package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
  11. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  12. package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
  13. package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
  14. package/dist/adapters/thread/anthropic/index.cjs +60 -55
  15. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  16. package/dist/adapters/thread/anthropic/index.d.cts +20 -15
  17. package/dist/adapters/thread/anthropic/index.d.ts +20 -15
  18. package/dist/adapters/thread/anthropic/index.js +60 -55
  19. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  20. package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
  21. package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
  22. package/dist/adapters/thread/google-genai/index.cjs +135 -66
  23. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  24. package/dist/adapters/thread/google-genai/index.d.cts +200 -26
  25. package/dist/adapters/thread/google-genai/index.d.ts +200 -26
  26. package/dist/adapters/thread/google-genai/index.js +135 -66
  27. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  28. package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
  29. package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
  30. package/dist/adapters/thread/langchain/index.cjs +67 -55
  31. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  32. package/dist/adapters/thread/langchain/index.d.cts +20 -15
  33. package/dist/adapters/thread/langchain/index.d.ts +20 -15
  34. package/dist/adapters/thread/langchain/index.js +67 -55
  35. package/dist/adapters/thread/langchain/index.js.map +1 -1
  36. package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
  37. package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
  38. package/dist/{cold-store-DKMAO1Dd.d.ts → cold-store-DyHodfAB.d.ts} +1 -1
  39. package/dist/{cold-store-CkWoNtMh.d.cts → cold-store-YOx9nmgR.d.cts} +1 -1
  40. package/dist/index.cjs +15050 -420
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.d.cts +79 -83
  43. package/dist/index.d.ts +79 -83
  44. package/dist/index.js +15051 -417
  45. package/dist/index.js.map +1 -1
  46. package/dist/{proxy-B7CWEV-T.d.cts → proxy-2htgGQrc.d.cts} +1 -1
  47. package/dist/{proxy-ByFHMVRX.d.ts → proxy-CmiTP4pp.d.ts} +1 -1
  48. package/dist/{thread-manager-nK-WcFzM.d.ts → thread-manager-BJ5pz5Cx.d.cts} +6 -7
  49. package/dist/{thread-manager-7AW4rhfu.d.ts → thread-manager-BQAbrYXH.d.cts} +6 -7
  50. package/dist/{thread-manager-Cibe0X5m.d.cts → thread-manager-CcvltOuq.d.ts} +6 -7
  51. package/dist/{thread-manager-B9rtMEVn.d.cts → thread-manager-DHAbncHX.d.ts} +6 -7
  52. package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
  53. package/dist/{types-XUUFvrJ9.d.cts → types-BjdqxKYp.d.cts} +709 -709
  54. package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.cts} +3 -3
  55. package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.ts} +3 -3
  56. package/dist/{types-DO4Tkwxo.d.ts → types-DEbkLA06.d.ts} +3 -3
  57. package/dist/{types-DeVNWqlb.d.ts → types-DiI7mZhI.d.ts} +709 -709
  58. package/dist/{types-BR-k7h0e.d.cts → types-N_LTWe4b.d.cts} +3 -3
  59. package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
  60. package/dist/{workflow-uhOIj9D-.d.ts → workflow-CcgD6EUB.d.cts} +34 -3
  61. package/dist/{workflow-KbGsxpfh.d.cts → workflow-DBjPOKBr.d.ts} +34 -3
  62. package/dist/workflow.cjs +15008 -377
  63. package/dist/workflow.cjs.map +1 -1
  64. package/dist/workflow.d.cts +3 -3
  65. package/dist/workflow.d.ts +3 -3
  66. package/dist/workflow.js +15009 -374
  67. package/dist/workflow.js.map +1 -1
  68. package/package.json +10 -37
  69. package/src/adapters/thread/anthropic/activities.test.ts +115 -0
  70. package/src/adapters/thread/anthropic/activities.ts +11 -19
  71. package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
  72. package/src/adapters/thread/anthropic/model-invoker.test.ts +54 -3
  73. package/src/adapters/thread/anthropic/model-invoker.ts +11 -1
  74. package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
  75. package/src/adapters/thread/anthropic/thread-manager.ts +3 -4
  76. package/src/adapters/thread/google-genai/activities.test.ts +162 -0
  77. package/src/adapters/thread/google-genai/activities.ts +38 -15
  78. package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
  79. package/src/adapters/thread/google-genai/model-invoker.test.ts +386 -0
  80. package/src/adapters/thread/google-genai/model-invoker.ts +118 -23
  81. package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
  82. package/src/adapters/thread/google-genai/thread-manager.ts +3 -4
  83. package/src/adapters/thread/langchain/activities.test.ts +88 -0
  84. package/src/adapters/thread/langchain/activities.ts +15 -12
  85. package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
  86. package/src/adapters/thread/langchain/model-invoker.test.ts +74 -0
  87. package/src/adapters/thread/langchain/model-invoker.ts +16 -3
  88. package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
  89. package/src/adapters/thread/langchain/thread-manager.ts +3 -4
  90. package/src/index.ts +2 -2
  91. package/src/lib/sandbox/capability-types.test.ts +2 -2
  92. package/src/lib/sandbox/manager.ts +2 -6
  93. package/src/lib/sandbox/sandbox.test.ts +1 -1
  94. package/src/lib/sandbox/types.ts +2 -2
  95. package/src/lib/session/session.integration.test.ts +92 -0
  96. package/src/lib/session/session.ts +23 -11
  97. package/src/lib/thread/keys.test.ts +9 -9
  98. package/src/lib/thread/keys.ts +1 -1
  99. package/src/lib/thread/manager.test.ts +24 -14
  100. package/src/lib/thread/manager.ts +19 -23
  101. package/src/lib/thread/snapshot.test.ts +51 -43
  102. package/src/lib/thread/snapshot.ts +54 -32
  103. package/src/lib/thread/test-utils.ts +106 -59
  104. package/src/lib/thread/tiered.test.ts +1 -1
  105. package/src/lib/thread/types.ts +2 -2
  106. package/src/lib/tool-router/router.integration.test.ts +44 -0
  107. package/src/lib/tool-router/router.ts +140 -32
  108. package/src/lib/workflow.ts +49 -0
  109. package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
  110. package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
  111. package/src/tools/bash/bash.test.ts +1 -1
  112. package/src/tools/edit/handler.test.ts +1 -1
  113. package/tsup.config.ts +2 -4
  114. package/dist/activities-7OcT_vdR.d.cts +0 -162
  115. package/dist/activities-zG_FBoY2.d.ts +0 -162
  116. package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
  117. package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
  118. package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
  119. package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
  120. package/dist/adapters/sandbox/inmemory/index.js +0 -211
  121. package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
  122. package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
  123. package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
  124. package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
  125. package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
  126. package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
  127. package/dist/adapters/sandbox/inmemory/workflow.js.map +0 -1
@@ -0,0 +1,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
+ });
@@ -1,4 +1,4 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType as Redis } from "redis";
2
2
  import type { ToolResultConfig } from "../../../lib/types";
3
3
  import type { PersistedThreadState } from "../../../lib/state/types";
4
4
  import type { MessageContent } from "@langchain/core/messages";
@@ -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)
@@ -11,27 +11,27 @@ function createStatefulRedis() {
11
11
  const strings = new Map<string, string>();
12
12
 
13
13
  return {
14
- exists: vi.fn(async (...keys: string[]) =>
15
- keys.reduce(
14
+ exists: vi.fn(async (keys: string | string[]) =>
15
+ (Array.isArray(keys) ? keys : [keys]).reduce(
16
16
  (acc, k) => acc + (lists.has(k) || strings.has(k) ? 1 : 0),
17
17
  0
18
18
  )
19
19
  ),
20
- lrange: vi.fn(async (key: string, start: number, stop: number) => {
20
+ lRange: vi.fn(async (key: string, start: number, stop: number) => {
21
21
  const list = lists.get(key) ?? [];
22
22
  const end = stop === -1 ? list.length : stop + 1;
23
23
  return list.slice(start, end);
24
24
  }),
25
- rpush: vi.fn(async (key: string, ...values: string[]) => {
25
+ rPush: vi.fn(async (key: string, element: string | string[]) => {
26
26
  const list = lists.get(key) ?? [];
27
- list.push(...values);
27
+ list.push(...(Array.isArray(element) ? element : [element]));
28
28
  lists.set(key, list);
29
29
  return list.length;
30
30
  }),
31
- ltrim: vi.fn(async () => "OK"),
32
- del: vi.fn(async (...keys: string[]) => {
31
+ lTrim: vi.fn(async () => "OK"),
32
+ del: vi.fn(async (keys: string | string[]) => {
33
33
  let removed = 0;
34
- for (const k of keys) {
34
+ for (const k of Array.isArray(keys) ? keys : [keys]) {
35
35
  if (lists.delete(k)) removed++;
36
36
  if (strings.delete(k)) removed++;
37
37
  }
@@ -43,10 +43,16 @@ function createStatefulRedis() {
43
43
  }),
44
44
  get: vi.fn(async (key: string) => strings.get(key) ?? null),
45
45
  expire: vi.fn(async () => 1),
46
- llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
46
+ lLen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
47
47
  eval: vi.fn(
48
- async (_script: string, _numKeys: number, ...args: string[]) => {
49
- const [dedupKey, listKey, , ...serialised] = args;
48
+ async (
49
+ _script: string,
50
+ options: { keys?: string[]; arguments?: string[] }
51
+ ) => {
52
+ const keys = options.keys ?? [];
53
+ const argv = options.arguments ?? [];
54
+ const [dedupKey, listKey] = keys;
55
+ const serialised = argv.slice(1);
50
56
  if (!dedupKey || !listKey) return 0;
51
57
  if (strings.has(dedupKey)) return 0;
52
58
  const list = lists.get(listKey) ?? [];
@@ -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
+ });
@@ -1,4 +1,4 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType as Redis } from "redis";
2
2
  import type { AgentResponse, ModelInvokerConfig } from "../../../lib/model";
3
3
  import type { StoredMessage } from "@langchain/core/messages";
4
4
  import { v4 as uuidv4 } from "uuid";
@@ -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
  }
@@ -9,10 +9,10 @@ import { createLangChainThreadManager } from "./thread-manager";
9
9
  function createMockRedis(stored: StoredMessage[]) {
10
10
  return {
11
11
  exists: vi.fn().mockResolvedValue(1),
12
- lrange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
12
+ lRange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
13
13
  del: vi.fn().mockResolvedValue(1),
14
14
  set: vi.fn().mockResolvedValue("OK"),
15
- rpush: vi.fn().mockResolvedValue(1),
15
+ rPush: vi.fn().mockResolvedValue(1),
16
16
  expire: vi.fn().mockResolvedValue(1),
17
17
  eval: vi.fn().mockResolvedValue(1),
18
18
  };
@@ -1,4 +1,4 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType as Redis } from "redis";
2
2
  import type { JsonValue } from "../../../lib/state/types";
3
3
  import {
4
4
  AIMessage,
@@ -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
  }
package/src/index.ts CHANGED
@@ -17,8 +17,8 @@
17
17
  * toTree,
18
18
  * } from 'zeitlich';
19
19
  *
20
- * // In-memory sandbox adapter
21
- * import { InMemorySandboxProvider } from 'zeitlich/adapters/sandbox/inmemory';
20
+ * // Sandbox adapter
21
+ * import { DaytonaSandboxProvider } from 'zeitlich/adapters/sandbox/daytona';
22
22
  *
23
23
  * // LangChain adapter
24
24
  * import { createLangChainAdapter } from 'zeitlich/adapters/thread/langchain';
@@ -27,7 +27,7 @@ import type {
27
27
  SandboxProvider,
28
28
  SandboxSnapshot,
29
29
  } from "./types";
30
- import { InMemorySandboxProvider } from "../../adapters/sandbox/inmemory/index";
30
+ import { InMemorySandboxProvider } from "../../test-utils/in-memory-sandbox";
31
31
  import { DaytonaSandboxProvider } from "../../adapters/sandbox/daytona/index";
32
32
  import type { E2bSandboxProvider } from "../../adapters/sandbox/e2b/index";
33
33
 
@@ -303,7 +303,7 @@ class _ImplWithoutDeclProvider {
303
303
  import type { SubagentSandboxConfig } from "../subagent/types";
304
304
  import { proxyDaytonaSandboxOps } from "../../adapters/sandbox/daytona/proxy";
305
305
  import { proxyE2bSandboxOps } from "../../adapters/sandbox/e2b/proxy";
306
- import { proxyInMemorySandboxOps } from "../../adapters/sandbox/inmemory/proxy";
306
+ import { proxyInMemorySandboxOps } from "../../test-utils/in-memory-sandbox-proxy";
307
307
 
308
308
  // Helper that pins the matrix cell type to `SubagentSandboxConfig` so
309
309
  // `@ts-expect-error` directives consistently land on the call line. The
@@ -102,12 +102,12 @@ export interface SandboxManagerHooks<
102
102
  *
103
103
  * @example
104
104
  * ```typescript
105
- * const manager = new SandboxManager(new InMemorySandboxProvider());
105
+ * const manager = new SandboxManager(new DaytonaSandboxProvider(config));
106
106
  * const activities = {
107
107
  * ...manager.createActivities("CodingAgent"),
108
108
  * bashHandler: withSandbox(manager, bashHandler),
109
109
  * };
110
- * // registers: inMemoryCodingAgentCreateSandbox, …
110
+ * // registers: daytonaCodingAgentCreateSandbox, …
111
111
  * ```
112
112
  *
113
113
  * @example
@@ -336,10 +336,6 @@ export class SandboxManager<
336
336
  *
337
337
  * @example
338
338
  * ```typescript
339
- * const manager = new SandboxManager(new InMemorySandboxProvider());
340
- * manager.createActivities("CodingAgent");
341
- * // registers: inMemoryCodingAgentCreateSandbox, inMemoryCodingAgentDestroySandbox, …
342
- *
343
339
  * const dmgr = new SandboxManager(new DaytonaSandboxProvider(config));
344
340
  * dmgr.createActivities("CodingAgent");
345
341
  * // registers: daytonaCodingAgentCreateSandbox, daytonaCodingAgentDestroySandbox
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, beforeEach } from "vitest";
2
2
  import { SandboxManager } from "./manager";
3
- import { InMemorySandboxProvider } from "../../adapters/sandbox/inmemory/index";
3
+ import { InMemorySandboxProvider } from "../../test-utils/in-memory-sandbox";
4
4
  import {
5
5
  SandboxNotFoundError,
6
6
  type Sandbox,
@@ -342,8 +342,8 @@ export type SandboxOps<
342
342
  *
343
343
  * @example
344
344
  * ```typescript
345
- * type InMemOps = PrefixedSandboxOps<"inMemory">;
346
- * // → { inMemoryCreateSandbox, inMemoryDestroySandbox, inMemorySnapshotSandbox, … }
345
+ * type E2bOps = PrefixedSandboxOps<"e2b">;
346
+ * // → { e2bCreateSandbox, e2bDestroySandbox, e2bSnapshotSandbox, … }
347
347
  * ```
348
348
  */
349
349
  export type PrefixedSandboxOps<
@@ -681,6 +681,98 @@ describe("createSession integration", () => {
681
681
  expect(capturedSandboxId).toBe("my-sandbox");
682
682
  });
683
683
 
684
+ // --- persistThreadState dedupe ---
685
+
686
+ it("memoizes persistThreadState across parallel tool calls in one turn", async () => {
687
+ const { ops, log } = createMockThreadOps();
688
+
689
+ const persistTool = defineTool({
690
+ name: "Persist" as const,
691
+ description: "calls persistThreadState",
692
+ schema: z.object({}),
693
+ handler: async (_args: Record<string, never>, ctx: RouterContext) => {
694
+ await ctx.persistThreadState?.();
695
+ return { toolResponse: "ok", data: null };
696
+ },
697
+ });
698
+
699
+ const session = await createSession({
700
+ agentName: "TestAgent",
701
+ thread: { mode: "new", threadId: "thread-1" },
702
+ runAgent: createScriptedRunAgent([
703
+ {
704
+ message: "fan out",
705
+ toolCalls: [
706
+ { id: "tc-1", name: "Persist", args: {} },
707
+ { id: "tc-2", name: "Persist", args: {} },
708
+ { id: "tc-3", name: "Persist", args: {} },
709
+ ],
710
+ },
711
+ { message: "done", toolCalls: [] },
712
+ ]),
713
+ threadOps: ops,
714
+ tools: { Persist: persistTool },
715
+ buildContextMessage: () => "go",
716
+ });
717
+
718
+ const stateManager = createAgentStateManager({
719
+ initialState: { systemPrompt: "test" },
720
+ });
721
+
722
+ await session.runSession({ stateManager });
723
+
724
+ const saves = log.filter((l) => l.op === "saveThreadState");
725
+ expect(saves).toHaveLength(2);
726
+ });
727
+
728
+ it("re-persists across separate turns even when handlers call persistThreadState", async () => {
729
+ const { ops, log } = createMockThreadOps();
730
+
731
+ const persistTool = defineTool({
732
+ name: "Persist" as const,
733
+ description: "calls persistThreadState",
734
+ schema: z.object({}),
735
+ handler: async (_args: Record<string, never>, ctx: RouterContext) => {
736
+ await ctx.persistThreadState?.();
737
+ return { toolResponse: "ok", data: null };
738
+ },
739
+ });
740
+
741
+ const session = await createSession({
742
+ agentName: "TestAgent",
743
+ thread: { mode: "new", threadId: "thread-1" },
744
+ runAgent: createScriptedRunAgent([
745
+ {
746
+ message: "turn 1",
747
+ toolCalls: [
748
+ { id: "tc-1", name: "Persist", args: {} },
749
+ { id: "tc-2", name: "Persist", args: {} },
750
+ ],
751
+ },
752
+ {
753
+ message: "turn 2",
754
+ toolCalls: [
755
+ { id: "tc-3", name: "Persist", args: {} },
756
+ { id: "tc-4", name: "Persist", args: {} },
757
+ ],
758
+ },
759
+ { message: "done", toolCalls: [] },
760
+ ]),
761
+ threadOps: ops,
762
+ tools: { Persist: persistTool },
763
+ buildContextMessage: () => "go",
764
+ });
765
+
766
+ const stateManager = createAgentStateManager({
767
+ initialState: { systemPrompt: "test" },
768
+ });
769
+
770
+ await session.runSession({ stateManager });
771
+
772
+ const saves = log.filter((l) => l.op === "saveThreadState");
773
+ expect(saves).toHaveLength(3);
774
+ });
775
+
684
776
  // --- Error propagation ---
685
777
 
686
778
  it("propagates runAgent errors and calls onSessionEnd with failed reason", async () => {
@@ -577,6 +577,28 @@ export async function createSession<
577
577
  }
578
578
  }
579
579
 
580
+ // Hand handlers a way to persist the parent's slice mid-loop
581
+ // (subagents that fork or continue the parent's thread need
582
+ // this — otherwise the child loads a stale snapshot from the
583
+ // prior session, since `saveThreadState` would otherwise only
584
+ // run in the `finally` below).
585
+ //
586
+ // Memoized per-batch so a single assistant message that emits
587
+ // N parallel subagent calls only writes the slice once.
588
+ // Persisting again later in the same turn is a no-op anyway
589
+ // (the slice doesn't mutate between handler dispatch and the
590
+ // batch's last `executeChild`), and Redis/cold-store writes
591
+ // aren't free.
592
+ let persistInflight: Promise<void> | undefined;
593
+ const persistThreadStateOnce = (): Promise<void> => {
594
+ persistInflight ??= saveThreadState(
595
+ threadId,
596
+ stateManager.getPersistedSlice(),
597
+ threadKey
598
+ );
599
+ return persistInflight;
600
+ };
601
+
580
602
  const toolCallResults = await toolRouter.processToolCalls(
581
603
  parsedToolCalls,
582
604
  {
@@ -585,17 +607,7 @@ export async function createSession<
585
607
  ...(assistantId !== undefined && {
586
608
  assistantMessageId: assistantId,
587
609
  }),
588
- // Hand handlers a way to persist the parent's slice
589
- // mid-loop (subagents that fork or continue the parent's
590
- // thread need this — otherwise the child loads a stale
591
- // snapshot from the prior session, since `saveThreadState`
592
- // would otherwise only run in the `finally` below).
593
- persistThreadState: () =>
594
- saveThreadState(
595
- threadId,
596
- stateManager.getPersistedSlice(),
597
- threadKey
598
- ),
610
+ persistThreadState: persistThreadStateOnce,
599
611
  }
600
612
  );
601
613
 
@@ -35,29 +35,29 @@ describe("createThreadManager ↔ public key helpers round-trip", () => {
35
35
  const redis = {
36
36
  exists: vi.fn(async (k: string) => (meta.has(k) ? 1 : 0)),
37
37
  set: vi.fn(
38
- async (k: string, v: string, _ex: string, ttl: number) => {
38
+ async (k: string, v: string, options?: { EX?: number }) => {
39
39
  meta.set(k, v);
40
- writtenMetaExpires.set(k, ttl);
40
+ if (options?.EX !== undefined) writtenMetaExpires.set(k, options.EX);
41
41
  return "OK";
42
42
  }
43
43
  ),
44
- del: vi.fn(async (...keys: string[]) => {
44
+ del: vi.fn(async (keys: string | string[]) => {
45
45
  let n = 0;
46
- for (const k of keys) {
46
+ for (const k of Array.isArray(keys) ? keys : [keys]) {
47
47
  if (store.delete(k)) n++;
48
48
  if (meta.delete(k)) n++;
49
49
  }
50
50
  return n;
51
51
  }),
52
- rpush: vi.fn(async (k: string, ...values: string[]) => {
52
+ rPush: vi.fn(async (k: string, element: string | string[]) => {
53
53
  const list = store.get(k) ?? [];
54
- list.push(...values);
54
+ list.push(...(Array.isArray(element) ? element : [element]));
55
55
  store.set(k, list);
56
56
  return list.length;
57
57
  }),
58
- lrange: vi.fn(async (k: string) => store.get(k) ?? []),
59
- llen: vi.fn(async (k: string) => (store.get(k) ?? []).length),
60
- ltrim: vi.fn(async () => "OK"),
58
+ lRange: vi.fn(async (k: string) => store.get(k) ?? []),
59
+ lLen: vi.fn(async (k: string) => (store.get(k) ?? []).length),
60
+ lTrim: vi.fn(async () => "OK"),
61
61
  expire: vi.fn(async (k: string, ttl: number) => {
62
62
  if (store.has(k)) writtenListExpires.set(k, ttl);
63
63
  if (meta.has(k)) writtenMetaExpires.set(k, ttl);
@@ -38,7 +38,7 @@ export const THREAD_TTL_SECONDS = 60 * 60 * 24 * 90;
38
38
  * Build the Redis list key that holds a thread's serialized messages.
39
39
  *
40
40
  * Mirrors the exact key used internally by zeitlich's thread manager,
41
- * so a consumer calling `redis.lrange(getThreadListKey(key, id), 0, -1)`
41
+ * so a consumer calling `redis.lRange(getThreadListKey(key, id), 0, -1)`
42
42
  * sees the same data the writer wrote.
43
43
  *
44
44
  * @param threadKey - Thread key (defaults to `"messages"` inside the