zeitlich 0.2.26 → 0.2.28

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 (97) hide show
  1. package/dist/{activities-BEJRyDVU.d.cts → activities-3xj_fEJK.d.ts} +7 -4
  2. package/dist/{activities-LVQdLF6I.d.ts → activities-BzYq6jf7.d.cts} +7 -4
  3. package/dist/adapters/sandbox/e2b/index.cjs +230 -0
  4. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -0
  5. package/dist/adapters/sandbox/e2b/index.d.cts +77 -0
  6. package/dist/adapters/sandbox/e2b/index.d.ts +77 -0
  7. package/dist/adapters/sandbox/e2b/index.js +227 -0
  8. package/dist/adapters/sandbox/e2b/index.js.map +1 -0
  9. package/dist/adapters/sandbox/e2b/workflow.cjs +33 -0
  10. package/dist/adapters/sandbox/e2b/workflow.cjs.map +1 -0
  11. package/dist/adapters/sandbox/e2b/workflow.d.cts +27 -0
  12. package/dist/adapters/sandbox/e2b/workflow.d.ts +27 -0
  13. package/dist/adapters/sandbox/e2b/workflow.js +31 -0
  14. package/dist/adapters/sandbox/e2b/workflow.js.map +1 -0
  15. package/dist/adapters/sandbox/virtual/index.d.cts +4 -4
  16. package/dist/adapters/sandbox/virtual/index.d.ts +4 -4
  17. package/dist/adapters/sandbox/virtual/workflow.d.cts +2 -2
  18. package/dist/adapters/sandbox/virtual/workflow.d.ts +2 -2
  19. package/dist/adapters/thread/anthropic/index.cjs +13 -6
  20. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  21. package/dist/adapters/thread/anthropic/index.d.cts +10 -7
  22. package/dist/adapters/thread/anthropic/index.d.ts +10 -7
  23. package/dist/adapters/thread/anthropic/index.js +13 -6
  24. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  25. package/dist/adapters/thread/anthropic/workflow.d.cts +5 -5
  26. package/dist/adapters/thread/anthropic/workflow.d.ts +5 -5
  27. package/dist/adapters/thread/google-genai/index.cjs +11 -6
  28. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  29. package/dist/adapters/thread/google-genai/index.d.cts +9 -6
  30. package/dist/adapters/thread/google-genai/index.d.ts +9 -6
  31. package/dist/adapters/thread/google-genai/index.js +11 -6
  32. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  33. package/dist/adapters/thread/google-genai/workflow.d.cts +5 -5
  34. package/dist/adapters/thread/google-genai/workflow.d.ts +5 -5
  35. package/dist/adapters/thread/langchain/index.cjs +44 -5
  36. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  37. package/dist/adapters/thread/langchain/index.d.cts +25 -9
  38. package/dist/adapters/thread/langchain/index.d.ts +25 -9
  39. package/dist/adapters/thread/langchain/index.js +44 -6
  40. package/dist/adapters/thread/langchain/index.js.map +1 -1
  41. package/dist/adapters/thread/langchain/workflow.d.cts +5 -5
  42. package/dist/adapters/thread/langchain/workflow.d.ts +5 -5
  43. package/dist/index.cjs +27 -16
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.d.cts +8 -8
  46. package/dist/index.d.ts +8 -8
  47. package/dist/index.js +27 -16
  48. package/dist/index.js.map +1 -1
  49. package/dist/{proxy-BK1ydQt0.d.ts → proxy-7e7v8ccg.d.ts} +1 -1
  50. package/dist/{proxy-BMAsMHdp.d.cts → proxy-CsB8r0RR.d.cts} +1 -1
  51. package/dist/{queries-DwnE2bu3.d.cts → queries-DVnukByF.d.cts} +1 -1
  52. package/dist/{queries-BCgJ9Sr5.d.ts → queries-kjlvsUfz.d.ts} +1 -1
  53. package/dist/{thread-manager-DOnQzImf.d.cts → thread-manager-B5qA4v7V.d.ts} +5 -3
  54. package/dist/{thread-manager-b4DML-qu.d.cts → thread-manager-D8C5QvLi.d.ts} +4 -2
  55. package/dist/{thread-manager-CH9krS3h.d.ts → thread-manager-DFJ3sKKU.d.cts} +4 -2
  56. package/dist/{thread-manager-Czhpxbt6.d.ts → thread-manager-DdVFl1IY.d.cts} +5 -3
  57. package/dist/{types-BDRDbm3h.d.cts → types-BZ75HpYd.d.ts} +14 -2
  58. package/dist/{types-mCVxKIZb.d.cts → types-BclYm5Ic.d.cts} +0 -4
  59. package/dist/{types-mCVxKIZb.d.ts → types-BclYm5Ic.d.ts} +0 -4
  60. package/dist/{types-DRnz-OZp.d.cts → types-BgsAwN3L.d.cts} +1 -1
  61. package/dist/{types-CvJyXDYt.d.ts → types-BtqbM1bO.d.ts} +1 -1
  62. package/dist/{types-DSOefLpY.d.cts → types-BuCEZ4dF.d.cts} +1 -1
  63. package/dist/{types-WNSeZbWa.d.ts → types-HbjqzyJH.d.cts} +14 -2
  64. package/dist/{types-DFUNSYbj.d.ts → types-yU5AINiP.d.ts} +1 -1
  65. package/dist/workflow.cjs +27 -16
  66. package/dist/workflow.cjs.map +1 -1
  67. package/dist/workflow.d.cts +7 -7
  68. package/dist/workflow.d.ts +7 -7
  69. package/dist/workflow.js +27 -16
  70. package/dist/workflow.js.map +1 -1
  71. package/package.json +21 -1
  72. package/src/adapters/sandbox/e2b/proxy.ts +56 -0
  73. package/src/adapters/thread/anthropic/activities.ts +3 -0
  74. package/src/adapters/thread/anthropic/model-invoker.ts +7 -2
  75. package/src/adapters/thread/anthropic/thread-manager.test.ts +137 -0
  76. package/src/adapters/thread/anthropic/thread-manager.ts +13 -2
  77. package/src/adapters/thread/google-genai/activities.ts +3 -1
  78. package/src/adapters/thread/google-genai/model-invoker.ts +7 -3
  79. package/src/adapters/thread/google-genai/thread-manager.test.ts +159 -0
  80. package/src/adapters/thread/google-genai/thread-manager.ts +13 -2
  81. package/src/adapters/thread/langchain/activities.ts +3 -1
  82. package/src/adapters/thread/langchain/hooks.test.ts +195 -0
  83. package/src/adapters/thread/langchain/hooks.ts +54 -0
  84. package/src/adapters/thread/langchain/index.ts +3 -0
  85. package/src/adapters/thread/langchain/model-invoker.ts +7 -4
  86. package/src/adapters/thread/langchain/thread-manager.test.ts +144 -0
  87. package/src/adapters/thread/langchain/thread-manager.ts +12 -1
  88. package/src/lib/.env +1 -0
  89. package/src/lib/session/session.ts +16 -5
  90. package/src/lib/subagent/handler.ts +5 -2
  91. package/src/lib/subagent/register.ts +7 -2
  92. package/src/lib/thread/index.ts +1 -0
  93. package/src/lib/thread/types.ts +13 -0
  94. package/src/lib/tool-router/router.ts +2 -5
  95. package/src/lib/tool-router/types.ts +0 -4
  96. package/src/tools/bash/.env +1 -0
  97. package/tsup.config.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeitlich",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -147,6 +147,26 @@
147
147
  "default": "./dist/adapters/sandbox/daytona/workflow.js"
148
148
  }
149
149
  },
150
+ "./adapters/sandbox/e2b": {
151
+ "import": {
152
+ "types": "./dist/adapters/sandbox/e2b/index.d.ts",
153
+ "default": "./dist/adapters/sandbox/e2b/index.js"
154
+ },
155
+ "require": {
156
+ "types": "./dist/adapters/sandbox/e2b/index.d.ts",
157
+ "default": "./dist/adapters/sandbox/e2b/index.js"
158
+ }
159
+ },
160
+ "./adapters/sandbox/e2b/workflow": {
161
+ "import": {
162
+ "types": "./dist/adapters/sandbox/e2b/workflow.d.ts",
163
+ "default": "./dist/adapters/sandbox/e2b/workflow.js"
164
+ },
165
+ "require": {
166
+ "types": "./dist/adapters/sandbox/e2b/workflow.d.ts",
167
+ "default": "./dist/adapters/sandbox/e2b/workflow.js"
168
+ }
169
+ },
150
170
  "./adapters/sandbox/bedrock": {
151
171
  "import": {
152
172
  "types": "./dist/adapters/sandbox/bedrock/index.d.ts",
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Workflow-safe proxy for E2B sandbox operations.
3
+ *
4
+ * Uses longer timeouts than in-memory providers since E2B
5
+ * sandboxes are remote and creation involves provisioning.
6
+ *
7
+ * Import this from `zeitlich/adapters/sandbox/e2b/workflow`
8
+ * in your Temporal workflow files.
9
+ *
10
+ * By default the scope is derived from `workflowInfo().workflowType`,
11
+ * so activities are automatically namespaced per workflow.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { proxyE2bSandboxOps } from 'zeitlich/adapters/sandbox/e2b/workflow';
16
+ *
17
+ * const sandbox = proxyE2bSandboxOps();
18
+ * ```
19
+ */
20
+ import { proxyActivities, workflowInfo } from "@temporalio/workflow";
21
+ import type { SandboxOps } from "../../../lib/sandbox/types";
22
+ import type { E2bSandboxCreateOptions } from "./types";
23
+
24
+ const ADAPTER_PREFIX = "e2b";
25
+
26
+ export function proxyE2bSandboxOps(
27
+ scope?: string,
28
+ options?: Parameters<typeof proxyActivities>[0]
29
+ ): SandboxOps {
30
+ const resolvedScope = scope ?? workflowInfo().workflowType;
31
+
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ const acts = proxyActivities<Record<string, (...args: any[]) => any>>(
34
+ options ?? {
35
+ startToCloseTimeout: "120s",
36
+ retry: {
37
+ maximumAttempts: 3,
38
+ initialInterval: "5s",
39
+ maximumInterval: "60s",
40
+ backoffCoefficient: 3,
41
+ },
42
+ }
43
+ );
44
+
45
+ const prefix = `${ADAPTER_PREFIX}${resolvedScope.charAt(0).toUpperCase()}${resolvedScope.slice(1)}`;
46
+ const p = (key: string): string =>
47
+ `${prefix}${key.charAt(0).toUpperCase()}${key.slice(1)}`;
48
+
49
+ return {
50
+ createSandbox: acts[p("createSandbox")],
51
+ destroySandbox: acts[p("destroySandbox")],
52
+ pauseSandbox: acts[p("pauseSandbox")],
53
+ snapshotSandbox: acts[p("snapshotSandbox")],
54
+ forkSandbox: acts[p("forkSandbox")],
55
+ } as SandboxOps<E2bSandboxCreateOptions>;
56
+ }
@@ -15,6 +15,7 @@ import type { ModelInvoker } from "../../../lib/model";
15
15
  import {
16
16
  createAnthropicThreadManager,
17
17
  type AnthropicContent,
18
+ type AnthropicThreadManagerHooks,
18
19
  } from "./thread-manager";
19
20
  import {
20
21
  createAnthropicModelInvoker,
@@ -33,6 +34,7 @@ export interface AnthropicAdapterConfig {
33
34
  model?: string;
34
35
  /** Maximum tokens to generate. Defaults to 16384. */
35
36
  maxTokens?: number;
37
+ hooks?: AnthropicThreadManagerHooks;
36
38
  }
37
39
 
38
40
  /**
@@ -201,6 +203,7 @@ export function createAnthropicAdapter(
201
203
  ...(config.maxTokens !== undefined && maxTokens === undefined
202
204
  ? { maxTokens: config.maxTokens }
203
205
  : {}),
206
+ hooks: config.hooks,
204
207
  };
205
208
  return createAnthropicModelInvoker(invokerConfig);
206
209
  };
@@ -2,7 +2,7 @@ import type Redis from "ioredis";
2
2
  import type Anthropic from "@anthropic-ai/sdk";
3
3
  import type { SerializableToolDefinition } from "../../../lib/types";
4
4
  import type { AgentResponse, ModelInvokerConfig } from "../../../lib/model";
5
- import { createAnthropicThreadManager } from "./thread-manager";
5
+ import { createAnthropicThreadManager, type AnthropicThreadManagerHooks } from "./thread-manager";
6
6
  import { v4 as uuidv4 } from "uuid";
7
7
 
8
8
  export interface AnthropicModelInvokerConfig {
@@ -11,6 +11,7 @@ export interface AnthropicModelInvokerConfig {
11
11
  model: string;
12
12
  /** Maximum tokens to generate. Defaults to 16384. */
13
13
  maxTokens?: number;
14
+ hooks?: AnthropicThreadManagerHooks;
14
15
  }
15
16
 
16
17
  function toAnthropicTools(
@@ -52,13 +53,14 @@ export function createAnthropicModelInvoker({
52
53
  client,
53
54
  model,
54
55
  maxTokens = 16384,
56
+ hooks,
55
57
  }: AnthropicModelInvokerConfig) {
56
58
  return async function invokeAnthropicModel(
57
59
  config: ModelInvokerConfig,
58
60
  ): Promise<AgentResponse<Anthropic.Messages.Message>> {
59
61
  const { threadId, threadKey, state } = config;
60
62
 
61
- const thread = createAnthropicThreadManager({ redis, threadId, key: threadKey });
63
+ const thread = createAnthropicThreadManager({ redis, threadId, key: threadKey, hooks });
62
64
  const { messages, system } = await thread.prepareForInvocation();
63
65
 
64
66
  const anthropicTools = toAnthropicTools(state.tools);
@@ -106,12 +108,14 @@ export async function invokeAnthropicModel({
106
108
  client,
107
109
  model,
108
110
  maxTokens,
111
+ hooks,
109
112
  config,
110
113
  }: {
111
114
  redis: Redis;
112
115
  client: Anthropic;
113
116
  model: string;
114
117
  maxTokens?: number;
118
+ hooks?: AnthropicThreadManagerHooks;
115
119
  config: ModelInvokerConfig;
116
120
  }): Promise<AgentResponse<Anthropic.Messages.Message>> {
117
121
  const invoker = createAnthropicModelInvoker({
@@ -119,6 +123,7 @@ export async function invokeAnthropicModel({
119
123
  client,
120
124
  model,
121
125
  maxTokens,
126
+ hooks,
122
127
  });
123
128
  return invoker(config);
124
129
  }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { StoredMessage } from "./thread-manager";
3
+ import { createAnthropicThreadManager } from "./thread-manager";
4
+
5
+ function createMockRedis(stored: StoredMessage[]) {
6
+ return {
7
+ exists: vi.fn().mockResolvedValue(1),
8
+ lrange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
9
+ del: vi.fn().mockResolvedValue(1),
10
+ set: vi.fn().mockResolvedValue("OK"),
11
+ rpush: vi.fn().mockResolvedValue(1),
12
+ expire: vi.fn().mockResolvedValue(1),
13
+ eval: vi.fn().mockResolvedValue(1),
14
+ };
15
+ }
16
+
17
+ const systemMsg: StoredMessage = {
18
+ id: "sys-1",
19
+ message: { role: "user", content: "You are helpful." },
20
+ isSystem: true,
21
+ };
22
+
23
+ const userMsg: StoredMessage = {
24
+ id: "msg-1",
25
+ message: { role: "user", content: [{ type: "text", text: "Hello" }] },
26
+ };
27
+
28
+ const assistantMsg: StoredMessage = {
29
+ id: "msg-2",
30
+ message: { role: "assistant", content: [{ type: "text", text: "Hi there!" }] },
31
+ };
32
+
33
+ describe("Anthropic thread manager hooks", () => {
34
+ describe("onPrepareMessage", () => {
35
+ it("transforms stored messages before system extraction and merge", async () => {
36
+ const hook = vi.fn((msg: StoredMessage) => {
37
+ if (msg.isSystem) return msg;
38
+ const firstBlock = (msg.message.content as Array<{ text: string }>)[0];
39
+ return {
40
+ ...msg,
41
+ message: {
42
+ ...msg.message,
43
+ content: [{ type: "text" as const, text: `[modified] ${firstBlock?.text}` }],
44
+ },
45
+ };
46
+ });
47
+
48
+ const redis = createMockRedis([systemMsg, userMsg, assistantMsg]);
49
+ const tm = createAnthropicThreadManager({
50
+ redis: redis as never,
51
+ threadId: "t1",
52
+ hooks: { onPrepareMessage: hook },
53
+ });
54
+
55
+ const { messages, system } = await tm.prepareForInvocation();
56
+
57
+ expect(hook).toHaveBeenCalledTimes(3);
58
+ expect(hook).toHaveBeenCalledWith(systemMsg, 0, [systemMsg, userMsg, assistantMsg]);
59
+ expect(system).toBe("You are helpful.");
60
+ expect(messages[0]?.content).toEqual([{ type: "text", text: "[modified] Hello" }]);
61
+ expect(messages[1]?.content).toEqual([{ type: "text", text: "[modified] Hi there!" }]);
62
+ });
63
+
64
+ it("is not called when not configured", async () => {
65
+ const redis = createMockRedis([userMsg]);
66
+ const tm = createAnthropicThreadManager({
67
+ redis: redis as never,
68
+ threadId: "t1",
69
+ });
70
+
71
+ const { messages } = await tm.prepareForInvocation();
72
+ expect(messages).toHaveLength(1);
73
+ });
74
+ });
75
+
76
+ describe("onPreparedMessage", () => {
77
+ it("transforms SDK-native messages after merge", async () => {
78
+ const hook = vi.fn((msg) => ({
79
+ ...msg,
80
+ content: [{ type: "text" as const, text: "[post] done" }],
81
+ }));
82
+
83
+ const redis = createMockRedis([userMsg, assistantMsg]);
84
+ const tm = createAnthropicThreadManager({
85
+ redis: redis as never,
86
+ threadId: "t1",
87
+ hooks: { onPreparedMessage: hook },
88
+ });
89
+
90
+ const { messages } = await tm.prepareForInvocation();
91
+
92
+ expect(hook).toHaveBeenCalledTimes(2);
93
+ expect(messages[0]?.content).toEqual([{ type: "text", text: "[post] done" }]);
94
+ });
95
+
96
+ it("receives the full prepared messages array", async () => {
97
+ const hook = vi.fn((msg) => msg);
98
+
99
+ const redis = createMockRedis([userMsg, assistantMsg]);
100
+ const tm = createAnthropicThreadManager({
101
+ redis: redis as never,
102
+ threadId: "t1",
103
+ hooks: { onPreparedMessage: hook },
104
+ });
105
+
106
+ await tm.prepareForInvocation();
107
+
108
+ const args = hook.mock.calls[0] as unknown as [unknown, number, unknown[]];
109
+ expect(args[2]).toHaveLength(2);
110
+ });
111
+ });
112
+
113
+ describe("both hooks combined", () => {
114
+ it("runs onPrepareMessage before onPreparedMessage", async () => {
115
+ const order: string[] = [];
116
+
117
+ const redis = createMockRedis([userMsg]);
118
+ const tm = createAnthropicThreadManager({
119
+ redis: redis as never,
120
+ threadId: "t1",
121
+ hooks: {
122
+ onPrepareMessage: (msg) => {
123
+ order.push("pre");
124
+ return msg;
125
+ },
126
+ onPreparedMessage: (msg) => {
127
+ order.push("post");
128
+ return msg;
129
+ },
130
+ },
131
+ });
132
+
133
+ await tm.prepareForInvocation();
134
+ expect(order).toEqual(["pre", "post"]);
135
+ });
136
+ });
137
+ });
@@ -5,6 +5,7 @@ import {
5
5
  createThreadManager,
6
6
  type ProviderThreadManager,
7
7
  type ThreadManagerConfig,
8
+ type ThreadManagerHooks,
8
9
  } from "../../../lib/thread";
9
10
 
10
11
  /** SDK-native content type for Anthropic human messages */
@@ -20,11 +21,14 @@ export interface StoredMessage {
20
21
  isSystem?: boolean;
21
22
  }
22
23
 
24
+ export type AnthropicThreadManagerHooks = ThreadManagerHooks<StoredMessage, Anthropic.Messages.MessageParam>;
25
+
23
26
  export interface AnthropicThreadManagerConfig {
24
27
  redis: Redis;
25
28
  threadId: string;
26
29
  /** Thread key, defaults to 'messages' */
27
30
  key?: string;
31
+ hooks?: AnthropicThreadManagerHooks;
28
32
  }
29
33
 
30
34
  /** Prepared payload ready to send to the Anthropic API */
@@ -165,11 +169,15 @@ export function createAnthropicThreadManager(
165
169
 
166
170
  async prepareForInvocation(): Promise<AnthropicInvocationPayload> {
167
171
  const stored = await base.load();
172
+ const { onPrepareMessage, onPreparedMessage } = config.hooks ?? {};
173
+ const mapped = onPrepareMessage
174
+ ? stored.map((msg, i) => onPrepareMessage(msg, i, stored))
175
+ : stored;
168
176
 
169
177
  let system: string | undefined;
170
178
  const conversationMessages: Anthropic.Messages.MessageParam[] = [];
171
179
 
172
- for (const item of stored) {
180
+ for (const item of mapped) {
173
181
  if (item.isSystem) {
174
182
  system =
175
183
  typeof item.message.content === "string"
@@ -180,8 +188,11 @@ export function createAnthropicThreadManager(
180
188
  }
181
189
  }
182
190
 
191
+ const messages = mergeConsecutiveMessages(conversationMessages);
183
192
  return {
184
- messages: mergeConsecutiveMessages(conversationMessages),
193
+ messages: onPreparedMessage
194
+ ? messages.map((msg, i) => onPreparedMessage(msg, i, messages))
195
+ : messages,
185
196
  ...(system ? { system } : {}),
186
197
  };
187
198
  },
@@ -15,6 +15,7 @@ import type { ModelInvoker } from "../../../lib/model";
15
15
  import {
16
16
  createGoogleGenAIThreadManager,
17
17
  type GoogleGenAIContent,
18
+ type GoogleGenAIThreadManagerHooks,
18
19
  } from "./thread-manager";
19
20
  import { createGoogleGenAIModelInvoker } from "./model-invoker";
20
21
 
@@ -31,6 +32,7 @@ export interface GoogleGenAIAdapterConfig {
31
32
  client?: GoogleGenAI;
32
33
  /** Default model name (e.g. 'gemini-2.5-flash'). If omitted, use `createModelInvoker()` */
33
34
  model?: string;
35
+ hooks?: GoogleGenAIThreadManagerHooks;
34
36
  }
35
37
 
36
38
  /**
@@ -222,7 +224,7 @@ export function createGoogleGenAIAdapter(
222
224
  model: string,
223
225
  client: GoogleGenAI
224
226
  ): ModelInvoker<Content> =>
225
- createGoogleGenAIModelInvoker({ redis, client, model });
227
+ createGoogleGenAIModelInvoker({ redis, client, model, hooks: config.hooks });
226
228
 
227
229
  const invoker: ModelInvoker<Content> =
228
230
  config.model && config.client
@@ -2,13 +2,14 @@ import type Redis from "ioredis";
2
2
  import type { GoogleGenAI, Content, FunctionDeclaration } from "@google/genai";
3
3
  import type { SerializableToolDefinition } from "../../../lib/types";
4
4
  import type { AgentResponse, ModelInvokerConfig } from "../../../lib/model";
5
- import { createGoogleGenAIThreadManager } from "./thread-manager";
5
+ import { createGoogleGenAIThreadManager, type GoogleGenAIThreadManagerHooks } from "./thread-manager";
6
6
  import { v4 as uuidv4 } from "uuid";
7
7
 
8
8
  export interface GoogleGenAIModelInvokerConfig {
9
9
  redis: Redis;
10
10
  client: GoogleGenAI;
11
11
  model: string;
12
+ hooks?: GoogleGenAIThreadManagerHooks;
12
13
  }
13
14
 
14
15
  function toFunctionDeclarations(
@@ -49,13 +50,14 @@ export function createGoogleGenAIModelInvoker({
49
50
  redis,
50
51
  client,
51
52
  model,
53
+ hooks,
52
54
  }: GoogleGenAIModelInvokerConfig) {
53
55
  return async function invokeGoogleGenAIModel(
54
56
  config: ModelInvokerConfig,
55
57
  ): Promise<AgentResponse<Content>> {
56
58
  const { threadId, threadKey, state } = config;
57
59
 
58
- const thread = createGoogleGenAIThreadManager({ redis, threadId, key: threadKey });
60
+ const thread = createGoogleGenAIThreadManager({ redis, threadId, key: threadKey, hooks });
59
61
  const { contents, systemInstruction } =
60
62
  await thread.prepareForInvocation();
61
63
 
@@ -104,13 +106,15 @@ export async function invokeGoogleGenAIModel({
104
106
  redis,
105
107
  client,
106
108
  model,
109
+ hooks,
107
110
  config,
108
111
  }: {
109
112
  redis: Redis;
110
113
  client: GoogleGenAI;
111
114
  model: string;
115
+ hooks?: GoogleGenAIThreadManagerHooks;
112
116
  config: ModelInvokerConfig;
113
117
  }): Promise<AgentResponse<Content>> {
114
- const invoker = createGoogleGenAIModelInvoker({ redis, client, model });
118
+ const invoker = createGoogleGenAIModelInvoker({ redis, client, model, hooks });
115
119
  return invoker(config);
116
120
  }
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { Content } from "@google/genai";
3
+ import type { StoredContent } from "./thread-manager";
4
+ import { createGoogleGenAIThreadManager } from "./thread-manager";
5
+
6
+ function createMockRedis(stored: StoredContent[]) {
7
+ return {
8
+ exists: vi.fn().mockResolvedValue(1),
9
+ lrange: vi.fn().mockResolvedValue(stored.map((m) => JSON.stringify(m))),
10
+ del: vi.fn().mockResolvedValue(1),
11
+ set: vi.fn().mockResolvedValue("OK"),
12
+ rpush: vi.fn().mockResolvedValue(1),
13
+ expire: vi.fn().mockResolvedValue(1),
14
+ eval: vi.fn().mockResolvedValue(1),
15
+ };
16
+ }
17
+
18
+ const systemContent: StoredContent = {
19
+ id: "sys-1",
20
+ content: { role: "system", parts: [{ text: "You are helpful." }] },
21
+ };
22
+
23
+ const userContent: StoredContent = {
24
+ id: "msg-1",
25
+ content: { role: "user", parts: [{ text: "Hello" }] },
26
+ };
27
+
28
+ const modelContent: StoredContent = {
29
+ id: "msg-2",
30
+ content: { role: "model", parts: [{ text: "Hi there!" }] },
31
+ };
32
+
33
+ describe("Google GenAI thread manager hooks", () => {
34
+ describe("onPrepareMessage", () => {
35
+ it("transforms stored messages before system extraction and merge", async () => {
36
+ const hook = vi.fn((msg: StoredContent) => {
37
+ if (msg.content.role === "system") return msg;
38
+ return {
39
+ ...msg,
40
+ content: {
41
+ ...msg.content,
42
+ parts: [{ text: `[modified] ${msg.content.parts?.[0]?.text ?? ""}` }],
43
+ },
44
+ };
45
+ });
46
+
47
+ const redis = createMockRedis([systemContent, userContent, modelContent]);
48
+ const tm = createGoogleGenAIThreadManager({
49
+ redis: redis as never,
50
+ threadId: "t1",
51
+ hooks: { onPrepareMessage: hook },
52
+ });
53
+
54
+ const { contents, systemInstruction } = await tm.prepareForInvocation();
55
+
56
+ expect(hook).toHaveBeenCalledTimes(3);
57
+ expect(hook).toHaveBeenCalledWith(systemContent, 0, [systemContent, userContent, modelContent]);
58
+ expect(systemInstruction).toBe("You are helpful.");
59
+ expect(contents[0]?.parts?.[0]?.text).toBe("[modified] Hello");
60
+ expect(contents[1]?.parts?.[0]?.text).toBe("[modified] Hi there!");
61
+ });
62
+
63
+ it("is not called when not configured", async () => {
64
+ const redis = createMockRedis([userContent]);
65
+ const tm = createGoogleGenAIThreadManager({
66
+ redis: redis as never,
67
+ threadId: "t1",
68
+ });
69
+
70
+ const { contents } = await tm.prepareForInvocation();
71
+ expect(contents).toHaveLength(1);
72
+ expect(contents[0]?.parts?.[0]?.text).toBe("Hello");
73
+ });
74
+ });
75
+
76
+ describe("onPreparedMessage", () => {
77
+ it("transforms SDK-native Content after merge", async () => {
78
+ const hook = vi.fn((msg: Content) => ({
79
+ ...msg,
80
+ parts: [{ text: `[post] ${msg.parts?.[0]?.text ?? ""}` }],
81
+ }));
82
+
83
+ const redis = createMockRedis([userContent, modelContent]);
84
+ const tm = createGoogleGenAIThreadManager({
85
+ redis: redis as never,
86
+ threadId: "t1",
87
+ hooks: { onPreparedMessage: hook },
88
+ });
89
+
90
+ const { contents } = await tm.prepareForInvocation();
91
+
92
+ expect(hook).toHaveBeenCalledTimes(2);
93
+ expect(contents[0]?.parts?.[0]?.text).toBe("[post] Hello");
94
+ expect(contents[1]?.parts?.[0]?.text).toBe("[post] Hi there!");
95
+ });
96
+
97
+ it("receives the full prepared contents array", async () => {
98
+ const hook = vi.fn((msg: Content) => msg);
99
+
100
+ const redis = createMockRedis([userContent, modelContent]);
101
+ const tm = createGoogleGenAIThreadManager({
102
+ redis: redis as never,
103
+ threadId: "t1",
104
+ hooks: { onPreparedMessage: hook },
105
+ });
106
+
107
+ await tm.prepareForInvocation();
108
+
109
+ const args = hook.mock.calls[0] as unknown as [Content, number, Content[]];
110
+ expect(args[2]).toHaveLength(2);
111
+ });
112
+ });
113
+
114
+ describe("both hooks combined", () => {
115
+ it("runs onPrepareMessage before onPreparedMessage", async () => {
116
+ const order: string[] = [];
117
+
118
+ const redis = createMockRedis([userContent]);
119
+ const tm = createGoogleGenAIThreadManager({
120
+ redis: redis as never,
121
+ threadId: "t1",
122
+ hooks: {
123
+ onPrepareMessage: (msg) => {
124
+ order.push("pre");
125
+ return msg;
126
+ },
127
+ onPreparedMessage: (msg) => {
128
+ order.push("post");
129
+ return msg;
130
+ },
131
+ },
132
+ });
133
+
134
+ await tm.prepareForInvocation();
135
+ expect(order).toEqual(["pre", "post"]);
136
+ });
137
+
138
+ it("onPreparedMessage sees results of onPrepareMessage", async () => {
139
+ const redis = createMockRedis([userContent]);
140
+ const tm = createGoogleGenAIThreadManager({
141
+ redis: redis as never,
142
+ threadId: "t1",
143
+ hooks: {
144
+ onPrepareMessage: (msg) => ({
145
+ ...msg,
146
+ content: { ...msg.content, parts: [{ text: "replaced" }] },
147
+ }),
148
+ onPreparedMessage: (msg) => {
149
+ expect(msg.parts?.[0]?.text).toBe("replaced");
150
+ return msg;
151
+ },
152
+ },
153
+ });
154
+
155
+ const { contents } = await tm.prepareForInvocation();
156
+ expect(contents[0]?.parts?.[0]?.text).toBe("replaced");
157
+ });
158
+ });
159
+ });
@@ -4,6 +4,7 @@ import {
4
4
  createThreadManager,
5
5
  type ProviderThreadManager,
6
6
  type ThreadManagerConfig,
7
+ type ThreadManagerHooks,
7
8
  } from "../../../lib/thread";
8
9
  import type { GoogleGenAIToolResponse } from "./activities";
9
10
 
@@ -16,11 +17,14 @@ export interface StoredContent {
16
17
  content: Content;
17
18
  }
18
19
 
20
+ export type GoogleGenAIThreadManagerHooks = ThreadManagerHooks<StoredContent, Content>;
21
+
19
22
  export interface GoogleGenAIThreadManagerConfig {
20
23
  redis: Redis;
21
24
  threadId: string;
22
25
  /** Thread key, defaults to 'messages' */
23
26
  key?: string;
27
+ hooks?: GoogleGenAIThreadManagerHooks;
24
28
  }
25
29
 
26
30
  /** Prepared payload ready to send to the Google GenAI API */
@@ -141,11 +145,15 @@ export function createGoogleGenAIThreadManager(
141
145
 
142
146
  async prepareForInvocation(): Promise<GoogleGenAIInvocationPayload> {
143
147
  const stored = await base.load();
148
+ const { onPrepareMessage, onPreparedMessage } = config.hooks ?? {};
149
+ const mapped = onPrepareMessage
150
+ ? stored.map((msg, i) => onPrepareMessage(msg, i, stored))
151
+ : stored;
144
152
 
145
153
  let systemInstruction: string | undefined;
146
154
  const conversationContents: Content[] = [];
147
155
 
148
- for (const item of stored) {
156
+ for (const item of mapped) {
149
157
  if (item.content.role === "system") {
150
158
  systemInstruction = item.content.parts?.[0]?.text;
151
159
  } else {
@@ -153,8 +161,11 @@ export function createGoogleGenAIThreadManager(
153
161
  }
154
162
  }
155
163
 
164
+ const contents = mergeConsecutiveContents(conversationContents);
156
165
  return {
157
- contents: mergeConsecutiveContents(conversationContents),
166
+ contents: onPreparedMessage
167
+ ? contents.map((msg, i) => onPreparedMessage(msg, i, contents))
168
+ : contents,
158
169
  ...(systemInstruction ? { systemInstruction } : {}),
159
170
  };
160
171
  },
@@ -17,6 +17,7 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
17
17
  import {
18
18
  createLangChainThreadManager,
19
19
  type LangChainContent,
20
+ type LangChainThreadManagerHooks,
20
21
  } from "./thread-manager";
21
22
  import { createLangChainModelInvoker } from "./model-invoker";
22
23
 
@@ -30,6 +31,7 @@ export interface LangChainAdapterConfig {
30
31
  /** Optional default model — if omitted, use `createModelInvoker()` to create invokers later */
31
32
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
33
  model?: BaseChatModel<any>;
34
+ hooks?: LangChainThreadManagerHooks;
33
35
  }
34
36
 
35
37
  /**
@@ -177,7 +179,7 @@ export function createLangChainAdapter(
177
179
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
180
  model: BaseChatModel<any>,
179
181
  ): ModelInvoker<StoredMessage> =>
180
- createLangChainModelInvoker({ redis, model });
182
+ createLangChainModelInvoker({ redis, model, hooks: config.hooks });
181
183
 
182
184
  const invoker: ModelInvoker<StoredMessage> = config.model
183
185
  ? makeInvoker(config.model)