zeitlich 0.2.38 → 0.2.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/{activities-BKhMtKDd.d.ts → activities-Bmu7XnaG.d.ts} +4 -6
- package/dist/{activities-CDcwkRZs.d.cts → activities-ByBFLvm2.d.cts} +4 -6
- package/dist/adapter-id-BB-mmrts.d.cts +17 -0
- package/dist/adapter-id-BB-mmrts.d.ts +17 -0
- package/dist/adapter-id-CMwVrVqv.d.cts +17 -0
- package/dist/adapter-id-CMwVrVqv.d.ts +17 -0
- package/dist/adapter-id-CbY2zeSt.d.cts +17 -0
- package/dist/adapter-id-CbY2zeSt.d.ts +17 -0
- package/dist/adapters/thread/anthropic/index.cjs +140 -23
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +8 -7
- package/dist/adapters/thread/anthropic/index.d.ts +8 -7
- package/dist/adapters/thread/anthropic/index.js +140 -24
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.cjs +8 -3
- package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +5 -4
- package/dist/adapters/thread/anthropic/workflow.d.ts +5 -4
- package/dist/adapters/thread/anthropic/workflow.js +8 -4
- package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.cjs +140 -23
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +5 -4
- package/dist/adapters/thread/google-genai/index.d.ts +5 -4
- package/dist/adapters/thread/google-genai/index.js +140 -24
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.cjs +8 -3
- package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +5 -4
- package/dist/adapters/thread/google-genai/workflow.d.ts +5 -4
- package/dist/adapters/thread/google-genai/workflow.js +8 -4
- package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
- package/dist/adapters/thread/index.cjs +16 -0
- package/dist/adapters/thread/index.cjs.map +1 -0
- package/dist/adapters/thread/index.d.cts +34 -0
- package/dist/adapters/thread/index.d.ts +34 -0
- package/dist/adapters/thread/index.js +12 -0
- package/dist/adapters/thread/index.js.map +1 -0
- package/dist/adapters/thread/langchain/index.cjs +139 -24
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +8 -7
- package/dist/adapters/thread/langchain/index.d.ts +8 -7
- package/dist/adapters/thread/langchain/index.js +139 -25
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.cjs +8 -3
- package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +5 -4
- package/dist/adapters/thread/langchain/workflow.d.ts +5 -4
- package/dist/adapters/thread/langchain/workflow.js +8 -4
- package/dist/adapters/thread/langchain/workflow.js.map +1 -1
- package/dist/index.cjs +266 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +263 -49
- package/dist/index.js.map +1 -1
- package/dist/{proxy-D_3x7RN4.d.cts → proxy-BAKzNGRq.d.cts} +1 -1
- package/dist/{proxy-CUlKSvZS.d.ts → proxy-DO_MXbY4.d.ts} +1 -1
- package/dist/{thread-manager-CVu7o2cs.d.ts → thread-manager-CcRXasqs.d.ts} +2 -4
- package/dist/{thread-manager-HSwyh28L.d.cts → thread-manager-ClwSaUnj.d.cts} +2 -4
- package/dist/{thread-manager-c1gPopAG.d.ts → thread-manager-D-7lp1JK.d.ts} +2 -4
- package/dist/{thread-manager-wGi-LqIP.d.cts → thread-manager-Y8Ucf0Tf.d.cts} +2 -4
- package/dist/{types-C06FwR96.d.cts → types-Bcbiq8iv.d.cts} +162 -44
- package/dist/{types-BH_IRryz.d.ts → types-DpHTX-iO.d.ts} +54 -6
- package/dist/{types-DNr31FzL.d.ts → types-Dt8-HBBT.d.ts} +162 -44
- package/dist/{types-BaOw4hKI.d.cts → types-hFFi-Zd9.d.cts} +54 -6
- package/dist/{workflow-CSCkpwAL.d.ts → workflow-Bmf9EtDW.d.ts} +82 -2
- package/dist/{workflow-DuvMZ8Vm.d.cts → workflow-Bx9utBwb.d.cts} +82 -2
- package/dist/workflow.cjs +188 -37
- 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 +185 -38
- package/dist/workflow.js.map +1 -1
- package/package.json +11 -1
- package/src/adapters/thread/adapter-id.test.ts +42 -0
- package/src/adapters/thread/anthropic/activities.ts +33 -7
- package/src/adapters/thread/anthropic/adapter-id.ts +16 -0
- package/src/adapters/thread/anthropic/fork-transform.test.ts +291 -0
- package/src/adapters/thread/anthropic/index.ts +3 -0
- package/src/adapters/thread/anthropic/model-invoker.ts +8 -4
- package/src/adapters/thread/anthropic/proxy.ts +3 -2
- package/src/adapters/thread/anthropic/thread-manager.ts +27 -4
- package/src/adapters/thread/google-genai/activities.ts +33 -7
- package/src/adapters/thread/google-genai/adapter-id.ts +16 -0
- package/src/adapters/thread/google-genai/fork-transform.test.ts +149 -0
- package/src/adapters/thread/google-genai/index.ts +3 -0
- package/src/adapters/thread/google-genai/model-invoker.ts +7 -3
- package/src/adapters/thread/google-genai/proxy.ts +3 -2
- package/src/adapters/thread/google-genai/thread-manager.ts +27 -4
- package/src/adapters/thread/index.ts +39 -0
- package/src/adapters/thread/langchain/activities.ts +33 -7
- package/src/adapters/thread/langchain/adapter-id.ts +16 -0
- package/src/adapters/thread/langchain/fork-transform.test.ts +142 -0
- package/src/adapters/thread/langchain/index.ts +3 -0
- package/src/adapters/thread/langchain/model-invoker.ts +8 -3
- package/src/adapters/thread/langchain/proxy.ts +3 -2
- package/src/adapters/thread/langchain/thread-manager.ts +27 -4
- package/src/lib/lifecycle.ts +3 -1
- package/src/lib/model/types.ts +7 -10
- package/src/lib/session/session-edge-cases.integration.test.ts +131 -63
- package/src/lib/session/session.integration.test.ts +174 -5
- package/src/lib/session/session.ts +68 -28
- package/src/lib/session/types.ts +60 -9
- package/src/lib/state/index.ts +1 -0
- package/src/lib/state/manager.integration.test.ts +109 -0
- package/src/lib/state/manager.ts +38 -8
- package/src/lib/state/types.ts +25 -0
- package/src/lib/subagent/handler.ts +124 -11
- package/src/lib/subagent/index.ts +5 -1
- package/src/lib/subagent/subagent.integration.test.ts +528 -0
- package/src/lib/subagent/types.ts +63 -14
- package/src/lib/subagent/workflow.ts +29 -2
- package/src/lib/thread/index.ts +5 -0
- package/src/lib/thread/keys.test.ts +101 -0
- package/src/lib/thread/keys.ts +94 -0
- package/src/lib/thread/manager.test.ts +139 -0
- package/src/lib/thread/manager.ts +92 -14
- package/src/lib/thread/proxy.ts +2 -0
- package/src/lib/thread/types.ts +60 -6
- package/src/lib/tool-router/types.ts +16 -8
- package/src/lib/types.ts +12 -0
- package/src/workflow.ts +12 -1
- package/tsup.config.ts +1 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { StoredContent } from "./thread-manager";
|
|
3
|
+
import { createGoogleGenAIThreadManager } from "./thread-manager";
|
|
4
|
+
|
|
5
|
+
function createStatefulRedis() {
|
|
6
|
+
const lists = new Map<string, string[]>();
|
|
7
|
+
const strings = new Map<string, string>();
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
exists: vi.fn(async (...keys: string[]) =>
|
|
11
|
+
keys.reduce(
|
|
12
|
+
(acc, k) => acc + (lists.has(k) || strings.has(k) ? 1 : 0),
|
|
13
|
+
0
|
|
14
|
+
)
|
|
15
|
+
),
|
|
16
|
+
lrange: vi.fn(async (key: string, start: number, stop: number) => {
|
|
17
|
+
const list = lists.get(key) ?? [];
|
|
18
|
+
const end = stop === -1 ? list.length : stop + 1;
|
|
19
|
+
return list.slice(start, end);
|
|
20
|
+
}),
|
|
21
|
+
rpush: vi.fn(async (key: string, ...values: string[]) => {
|
|
22
|
+
const list = lists.get(key) ?? [];
|
|
23
|
+
list.push(...values);
|
|
24
|
+
lists.set(key, list);
|
|
25
|
+
return list.length;
|
|
26
|
+
}),
|
|
27
|
+
ltrim: vi.fn(async () => "OK"),
|
|
28
|
+
del: vi.fn(async (...keys: string[]) => {
|
|
29
|
+
let removed = 0;
|
|
30
|
+
for (const k of keys) {
|
|
31
|
+
if (lists.delete(k)) removed++;
|
|
32
|
+
if (strings.delete(k)) removed++;
|
|
33
|
+
}
|
|
34
|
+
return removed;
|
|
35
|
+
}),
|
|
36
|
+
set: vi.fn(async (key: string, value: string) => {
|
|
37
|
+
strings.set(key, value);
|
|
38
|
+
return "OK";
|
|
39
|
+
}),
|
|
40
|
+
get: vi.fn(async (key: string) => strings.get(key) ?? null),
|
|
41
|
+
expire: vi.fn(async () => 1),
|
|
42
|
+
llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
|
|
43
|
+
eval: vi.fn(
|
|
44
|
+
async (_script: string, _numKeys: number, ...args: string[]) => {
|
|
45
|
+
const [dedupKey, listKey, , ...serialised] = args;
|
|
46
|
+
if (!dedupKey || !listKey) return 0;
|
|
47
|
+
if (strings.has(dedupKey)) return 0;
|
|
48
|
+
const list = lists.get(listKey) ?? [];
|
|
49
|
+
list.push(...serialised);
|
|
50
|
+
lists.set(listKey, list);
|
|
51
|
+
strings.set(dedupKey, "1");
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const userContent: StoredContent = {
|
|
59
|
+
id: "msg-1",
|
|
60
|
+
content: { role: "user", parts: [{ text: "Hello" }] },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const modelContent: StoredContent = {
|
|
64
|
+
id: "msg-2",
|
|
65
|
+
content: { role: "model", parts: [{ text: "Hi there!" }] },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const userContent2: StoredContent = {
|
|
69
|
+
id: "msg-3",
|
|
70
|
+
content: { role: "user", parts: [{ text: "Again please" }] },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
async function seed(
|
|
74
|
+
redis: ReturnType<typeof createStatefulRedis>,
|
|
75
|
+
threadId: string,
|
|
76
|
+
messages: StoredContent[]
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const tm = createGoogleGenAIThreadManager({
|
|
79
|
+
redis: redis as never,
|
|
80
|
+
threadId,
|
|
81
|
+
});
|
|
82
|
+
await tm.initialize();
|
|
83
|
+
await tm.append(messages);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("Google GenAI fork + transform hooks", () => {
|
|
87
|
+
it("falls back to plain fork when no hooks are set", async () => {
|
|
88
|
+
const redis = createStatefulRedis();
|
|
89
|
+
await seed(redis, "src", [userContent, modelContent]);
|
|
90
|
+
|
|
91
|
+
const tm = createGoogleGenAIThreadManager({
|
|
92
|
+
redis: redis as never,
|
|
93
|
+
threadId: "src",
|
|
94
|
+
});
|
|
95
|
+
const forked = await tm.fork("dst");
|
|
96
|
+
expect(await forked.load()).toEqual([userContent, modelContent]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("applies onForkPrepareThread then onForkTransform in order", async () => {
|
|
100
|
+
const redis = createStatefulRedis();
|
|
101
|
+
await seed(redis, "src", [userContent, modelContent, userContent2]);
|
|
102
|
+
|
|
103
|
+
const order: string[] = [];
|
|
104
|
+
const tm = createGoogleGenAIThreadManager({
|
|
105
|
+
redis: redis as never,
|
|
106
|
+
threadId: "src",
|
|
107
|
+
hooks: {
|
|
108
|
+
onForkPrepareThread: async (messages) => {
|
|
109
|
+
order.push("prepare");
|
|
110
|
+
return messages.slice(0, -1);
|
|
111
|
+
},
|
|
112
|
+
onForkTransform: (msg, i) => {
|
|
113
|
+
order.push("transform");
|
|
114
|
+
return {
|
|
115
|
+
...msg,
|
|
116
|
+
content: { ...msg.content, parts: [{ text: `[x${i}]` }] },
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const forked = await tm.fork("dst");
|
|
123
|
+
const loaded = await forked.load();
|
|
124
|
+
|
|
125
|
+
expect(order).toEqual(["prepare", "transform", "transform"]);
|
|
126
|
+
expect(loaded).toHaveLength(2);
|
|
127
|
+
expect(loaded[0]?.content.parts).toEqual([{ text: "[x0]" }]);
|
|
128
|
+
expect(loaded[1]?.content.parts).toEqual([{ text: "[x1]" }]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("keeps the source thread untouched", async () => {
|
|
132
|
+
const redis = createStatefulRedis();
|
|
133
|
+
await seed(redis, "src", [userContent, modelContent]);
|
|
134
|
+
|
|
135
|
+
const tm = createGoogleGenAIThreadManager({
|
|
136
|
+
redis: redis as never,
|
|
137
|
+
threadId: "src",
|
|
138
|
+
hooks: {
|
|
139
|
+
onForkTransform: (msg) => ({
|
|
140
|
+
...msg,
|
|
141
|
+
content: { ...msg.content, parts: [{ text: "mutated" }] },
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await tm.fork("dst");
|
|
147
|
+
expect(await tm.load()).toEqual([userContent, modelContent]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -64,7 +64,7 @@ export function createGoogleGenAIModelInvoker({
|
|
|
64
64
|
return async function invokeGoogleGenAIModel(
|
|
65
65
|
config: ModelInvokerConfig
|
|
66
66
|
): Promise<AgentResponse<Content>> {
|
|
67
|
-
const { threadId, threadKey, state } = config;
|
|
67
|
+
const { threadId, threadKey, state, assistantMessageId } = config;
|
|
68
68
|
const { heartbeat, signal } = getActivityContext();
|
|
69
69
|
|
|
70
70
|
const thread = createGoogleGenAIThreadManager({
|
|
@@ -73,7 +73,12 @@ export function createGoogleGenAIModelInvoker({
|
|
|
73
73
|
key: threadKey,
|
|
74
74
|
hooks,
|
|
75
75
|
});
|
|
76
|
-
|
|
76
|
+
// Truncate the thread starting at the id the assistant message
|
|
77
|
+
// will be stored under. No-op on the first attempt; on rewind
|
|
78
|
+
// retry / Temporal reset it wipes the prior attempt's assistant
|
|
79
|
+
// + tool results so the LLM sees the original pre-call state.
|
|
80
|
+
await thread.truncateFromId(assistantMessageId);
|
|
81
|
+
const { contents, systemInstruction } =
|
|
77
82
|
await thread.prepareForInvocation();
|
|
78
83
|
|
|
79
84
|
const functionDeclarations = toFunctionDeclarations(state.tools);
|
|
@@ -117,7 +122,6 @@ export function createGoogleGenAIModelInvoker({
|
|
|
117
122
|
outputTokens: lastChunk.usageMetadata?.candidatesTokenCount,
|
|
118
123
|
cachedReadTokens: lastChunk.usageMetadata?.cachedContentTokenCount,
|
|
119
124
|
},
|
|
120
|
-
threadLengthAtCall: storedLength,
|
|
121
125
|
};
|
|
122
126
|
};
|
|
123
127
|
}
|
|
@@ -22,15 +22,16 @@ import { type ActivityInterfaceFor } from "@temporalio/workflow";
|
|
|
22
22
|
import type { ThreadOps } from "../../../lib/session/types";
|
|
23
23
|
import type { GoogleGenAIContent } from "./thread-manager";
|
|
24
24
|
import { createThreadOpsProxy } from "../../../lib/thread/proxy";
|
|
25
|
+
import { ADAPTER_ID } from "./adapter-id";
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
export { ADAPTER_ID, type AdapterId } from "./adapter-id";
|
|
27
28
|
|
|
28
29
|
export function proxyGoogleGenAIThreadOps(
|
|
29
30
|
scope?: string,
|
|
30
31
|
options?: Parameters<typeof createThreadOpsProxy>[2]
|
|
31
32
|
): ActivityInterfaceFor<ThreadOps<GoogleGenAIContent>> {
|
|
32
33
|
return createThreadOpsProxy(
|
|
33
|
-
|
|
34
|
+
ADAPTER_ID,
|
|
34
35
|
scope,
|
|
35
36
|
options
|
|
36
37
|
) as ActivityInterfaceFor<ThreadOps<GoogleGenAIContent>>;
|
|
@@ -37,8 +37,6 @@ export interface GoogleGenAIThreadManagerConfig {
|
|
|
37
37
|
export interface GoogleGenAIInvocationPayload {
|
|
38
38
|
contents: Content[];
|
|
39
39
|
systemInstruction?: Part[];
|
|
40
|
-
/** Number of stored messages loaded from Redis before preparation. */
|
|
41
|
-
storedLength: number;
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
/** Thread manager with Google GenAI Content convenience helpers */
|
|
@@ -198,10 +196,35 @@ export function createGoogleGenAIThreadManager(
|
|
|
198
196
|
...(systemInstruction && systemInstruction.length > 0
|
|
199
197
|
? { systemInstruction }
|
|
200
198
|
: {}),
|
|
201
|
-
storedLength: stored.length,
|
|
202
199
|
};
|
|
203
200
|
},
|
|
204
201
|
};
|
|
205
202
|
|
|
206
|
-
|
|
203
|
+
const manager = Object.assign(base, helpers);
|
|
204
|
+
|
|
205
|
+
const originalFork = manager.fork.bind(manager);
|
|
206
|
+
manager.fork = async (
|
|
207
|
+
newThreadId: string
|
|
208
|
+
): Promise<GoogleGenAIThreadManager> => {
|
|
209
|
+
await originalFork(newThreadId);
|
|
210
|
+
const forked = createGoogleGenAIThreadManager({
|
|
211
|
+
...config,
|
|
212
|
+
threadId: newThreadId,
|
|
213
|
+
});
|
|
214
|
+
const { onForkPrepareThread, onForkTransform } = config.hooks ?? {};
|
|
215
|
+
if (!onForkPrepareThread && !onForkTransform) {
|
|
216
|
+
return forked;
|
|
217
|
+
}
|
|
218
|
+
let next = await forked.load();
|
|
219
|
+
if (onForkPrepareThread) {
|
|
220
|
+
next = await onForkPrepareThread(next);
|
|
221
|
+
}
|
|
222
|
+
if (onForkTransform) {
|
|
223
|
+
next = next.map((msg, i) => onForkTransform(msg, i, next));
|
|
224
|
+
}
|
|
225
|
+
await forked.replaceAll(next);
|
|
226
|
+
return forked;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return manager;
|
|
207
230
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel re-exports for every built-in thread adapter's public identity.
|
|
3
|
+
*
|
|
4
|
+
* Downstream consumers reading persisted threads can import a narrow
|
|
5
|
+
* discriminated union of adapter identifiers without pulling the full
|
|
6
|
+
* adapter implementation (Redis, provider SDKs, etc.) as a dependency —
|
|
7
|
+
* each individual re-export resolves to an `adapter-id.ts` module with
|
|
8
|
+
* no runtime dependencies.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import {
|
|
13
|
+
* LANGCHAIN_ADAPTER_ID,
|
|
14
|
+
* GOOGLE_GENAI_ADAPTER_ID,
|
|
15
|
+
* ANTHROPIC_ADAPTER_ID,
|
|
16
|
+
* type ThreadAdapterId,
|
|
17
|
+
* } from 'zeitlich/adapters/thread';
|
|
18
|
+
*
|
|
19
|
+
* interface ThreadIdentity {
|
|
20
|
+
* adapter: ThreadAdapterId;
|
|
21
|
+
* threadKey: string;
|
|
22
|
+
* threadId: string;
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export { ADAPTER_ID as LANGCHAIN_ADAPTER_ID } from "./langchain/adapter-id";
|
|
28
|
+
export { ADAPTER_ID as GOOGLE_GENAI_ADAPTER_ID } from "./google-genai/adapter-id";
|
|
29
|
+
export { ADAPTER_ID as ANTHROPIC_ADAPTER_ID } from "./anthropic/adapter-id";
|
|
30
|
+
|
|
31
|
+
import type { ADAPTER_ID as LANGCHAIN } from "./langchain/adapter-id";
|
|
32
|
+
import type { ADAPTER_ID as GOOGLE_GENAI } from "./google-genai/adapter-id";
|
|
33
|
+
import type { ADAPTER_ID as ANTHROPIC } from "./anthropic/adapter-id";
|
|
34
|
+
|
|
35
|
+
/** Narrow discriminated union of every built-in thread adapter id. */
|
|
36
|
+
export type ThreadAdapterId =
|
|
37
|
+
| typeof LANGCHAIN
|
|
38
|
+
| typeof GOOGLE_GENAI
|
|
39
|
+
| typeof ANTHROPIC;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type Redis from "ioredis";
|
|
2
2
|
import type { ToolResultConfig } from "../../../lib/types";
|
|
3
|
+
import type { PersistedThreadState } from "../../../lib/state/types";
|
|
3
4
|
import type { MessageContent } from "@langchain/core/messages";
|
|
4
5
|
import type {
|
|
5
6
|
ActivityToolHandler,
|
|
@@ -21,11 +22,10 @@ import {
|
|
|
21
22
|
type LangChainThreadManagerHooks,
|
|
22
23
|
} from "./thread-manager";
|
|
23
24
|
import { createLangChainModelInvoker } from "./model-invoker";
|
|
24
|
-
|
|
25
|
-
const ADAPTER_PREFIX = "langChain" as const;
|
|
25
|
+
import { ADAPTER_ID } from "./adapter-id";
|
|
26
26
|
|
|
27
27
|
export type LangChainThreadOps<TScope extends string = ""> = PrefixedThreadOps<
|
|
28
|
-
ScopedPrefix<TScope, typeof
|
|
28
|
+
ScopedPrefix<TScope, typeof ADAPTER_ID>,
|
|
29
29
|
LangChainContent
|
|
30
30
|
>;
|
|
31
31
|
|
|
@@ -192,17 +192,43 @@ export function createLangChainAdapter(
|
|
|
192
192
|
redis,
|
|
193
193
|
threadId: sourceThreadId,
|
|
194
194
|
key: threadKey,
|
|
195
|
+
hooks: config.hooks,
|
|
195
196
|
});
|
|
196
197
|
await thread.fork(targetThreadId);
|
|
197
198
|
},
|
|
198
199
|
|
|
199
200
|
async truncateThread(
|
|
200
201
|
threadId: string,
|
|
201
|
-
|
|
202
|
+
messageId: string,
|
|
202
203
|
threadKey?: string,
|
|
203
204
|
): Promise<void> {
|
|
204
205
|
const thread = createLangChainThreadManager({ redis, threadId, key: threadKey });
|
|
205
|
-
await thread.
|
|
206
|
+
await thread.truncateFromId(messageId);
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async loadThreadState(
|
|
210
|
+
threadId: string,
|
|
211
|
+
threadKey?: string
|
|
212
|
+
): Promise<PersistedThreadState | null> {
|
|
213
|
+
const thread = createLangChainThreadManager({
|
|
214
|
+
redis,
|
|
215
|
+
threadId,
|
|
216
|
+
key: threadKey,
|
|
217
|
+
});
|
|
218
|
+
return thread.loadState();
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async saveThreadState(
|
|
222
|
+
threadId: string,
|
|
223
|
+
state: PersistedThreadState,
|
|
224
|
+
threadKey?: string
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const thread = createLangChainThreadManager({
|
|
227
|
+
redis,
|
|
228
|
+
threadId,
|
|
229
|
+
key: threadKey,
|
|
230
|
+
});
|
|
231
|
+
await thread.saveState(state);
|
|
206
232
|
},
|
|
207
233
|
};
|
|
208
234
|
|
|
@@ -210,8 +236,8 @@ export function createLangChainAdapter(
|
|
|
210
236
|
scope?: S
|
|
211
237
|
): LangChainThreadOps<S> {
|
|
212
238
|
const prefix = scope
|
|
213
|
-
? `${
|
|
214
|
-
:
|
|
239
|
+
? `${ADAPTER_ID}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
|
|
240
|
+
: ADAPTER_ID;
|
|
215
241
|
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
|
216
242
|
return Object.fromEntries(
|
|
217
243
|
Object.entries(threadOps).map(([k, v]) => [`${prefix}${cap(k)}`, v])
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public adapter identity for the LangChain thread adapter.
|
|
3
|
+
*
|
|
4
|
+
* This value is wire format — it appears as the prefix for Temporal
|
|
5
|
+
* activity names (e.g. `langChainCodingAgentInitializeThread`) and must
|
|
6
|
+
* never change, since renaming it would orphan existing persisted
|
|
7
|
+
* threads and break in-flight workflows.
|
|
8
|
+
*
|
|
9
|
+
* Re-exported from `zeitlich/adapters/thread/langchain` so downstream
|
|
10
|
+
* consumers can use the exact same literal the adapter uses internally,
|
|
11
|
+
* typed as the narrow string literal `"langChain"`.
|
|
12
|
+
*/
|
|
13
|
+
export const ADAPTER_ID = "langChain" as const;
|
|
14
|
+
|
|
15
|
+
/** Narrow string-literal type for {@link ADAPTER_ID}. */
|
|
16
|
+
export type AdapterId = typeof ADAPTER_ID;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
HumanMessage,
|
|
4
|
+
AIMessage,
|
|
5
|
+
type StoredMessage,
|
|
6
|
+
} from "@langchain/core/messages";
|
|
7
|
+
import { createLangChainThreadManager } from "./thread-manager";
|
|
8
|
+
|
|
9
|
+
function createStatefulRedis() {
|
|
10
|
+
const lists = new Map<string, string[]>();
|
|
11
|
+
const strings = new Map<string, string>();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
exists: vi.fn(async (...keys: string[]) =>
|
|
15
|
+
keys.reduce(
|
|
16
|
+
(acc, k) => acc + (lists.has(k) || strings.has(k) ? 1 : 0),
|
|
17
|
+
0
|
|
18
|
+
)
|
|
19
|
+
),
|
|
20
|
+
lrange: vi.fn(async (key: string, start: number, stop: number) => {
|
|
21
|
+
const list = lists.get(key) ?? [];
|
|
22
|
+
const end = stop === -1 ? list.length : stop + 1;
|
|
23
|
+
return list.slice(start, end);
|
|
24
|
+
}),
|
|
25
|
+
rpush: vi.fn(async (key: string, ...values: string[]) => {
|
|
26
|
+
const list = lists.get(key) ?? [];
|
|
27
|
+
list.push(...values);
|
|
28
|
+
lists.set(key, list);
|
|
29
|
+
return list.length;
|
|
30
|
+
}),
|
|
31
|
+
ltrim: vi.fn(async () => "OK"),
|
|
32
|
+
del: vi.fn(async (...keys: string[]) => {
|
|
33
|
+
let removed = 0;
|
|
34
|
+
for (const k of keys) {
|
|
35
|
+
if (lists.delete(k)) removed++;
|
|
36
|
+
if (strings.delete(k)) removed++;
|
|
37
|
+
}
|
|
38
|
+
return removed;
|
|
39
|
+
}),
|
|
40
|
+
set: vi.fn(async (key: string, value: string) => {
|
|
41
|
+
strings.set(key, value);
|
|
42
|
+
return "OK";
|
|
43
|
+
}),
|
|
44
|
+
get: vi.fn(async (key: string) => strings.get(key) ?? null),
|
|
45
|
+
expire: vi.fn(async () => 1),
|
|
46
|
+
llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
|
|
47
|
+
eval: vi.fn(
|
|
48
|
+
async (_script: string, _numKeys: number, ...args: string[]) => {
|
|
49
|
+
const [dedupKey, listKey, , ...serialised] = args;
|
|
50
|
+
if (!dedupKey || !listKey) return 0;
|
|
51
|
+
if (strings.has(dedupKey)) return 0;
|
|
52
|
+
const list = lists.get(listKey) ?? [];
|
|
53
|
+
list.push(...serialised);
|
|
54
|
+
lists.set(listKey, list);
|
|
55
|
+
strings.set(dedupKey, "1");
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const humanMsg = new HumanMessage({ id: "msg-1", content: "Hello" }).toDict();
|
|
63
|
+
const aiMsg = new AIMessage({ id: "msg-2", content: "Hi there!" }).toDict();
|
|
64
|
+
const humanMsg2 = new HumanMessage({
|
|
65
|
+
id: "msg-3",
|
|
66
|
+
content: "Again please",
|
|
67
|
+
}).toDict();
|
|
68
|
+
|
|
69
|
+
async function seed(
|
|
70
|
+
redis: ReturnType<typeof createStatefulRedis>,
|
|
71
|
+
threadId: string,
|
|
72
|
+
messages: StoredMessage[]
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const tm = createLangChainThreadManager({
|
|
75
|
+
redis: redis as never,
|
|
76
|
+
threadId,
|
|
77
|
+
});
|
|
78
|
+
await tm.initialize();
|
|
79
|
+
await tm.append(messages);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("LangChain fork + transform hooks", () => {
|
|
83
|
+
it("falls back to plain fork when no hooks are set", async () => {
|
|
84
|
+
const redis = createStatefulRedis();
|
|
85
|
+
await seed(redis, "src", [humanMsg, aiMsg]);
|
|
86
|
+
|
|
87
|
+
const tm = createLangChainThreadManager({
|
|
88
|
+
redis: redis as never,
|
|
89
|
+
threadId: "src",
|
|
90
|
+
});
|
|
91
|
+
const forked = await tm.fork("dst");
|
|
92
|
+
expect(await forked.load()).toEqual([humanMsg, aiMsg]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("applies onForkPrepareThread then onForkTransform in order", async () => {
|
|
96
|
+
const redis = createStatefulRedis();
|
|
97
|
+
await seed(redis, "src", [humanMsg, aiMsg, humanMsg2]);
|
|
98
|
+
|
|
99
|
+
const order: string[] = [];
|
|
100
|
+
const tm = createLangChainThreadManager({
|
|
101
|
+
redis: redis as never,
|
|
102
|
+
threadId: "src",
|
|
103
|
+
hooks: {
|
|
104
|
+
onForkPrepareThread: async (messages) => {
|
|
105
|
+
order.push("prepare");
|
|
106
|
+
return messages.slice(0, -1);
|
|
107
|
+
},
|
|
108
|
+
onForkTransform: (msg, i) => {
|
|
109
|
+
order.push("transform");
|
|
110
|
+
return { ...msg, data: { ...msg.data, content: `[x${i}]` } };
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const forked = await tm.fork("dst");
|
|
116
|
+
const loaded = await forked.load();
|
|
117
|
+
|
|
118
|
+
expect(order).toEqual(["prepare", "transform", "transform"]);
|
|
119
|
+
expect(loaded).toHaveLength(2);
|
|
120
|
+
expect(loaded[0]?.data.content).toBe("[x0]");
|
|
121
|
+
expect(loaded[1]?.data.content).toBe("[x1]");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("keeps the source thread untouched", async () => {
|
|
125
|
+
const redis = createStatefulRedis();
|
|
126
|
+
await seed(redis, "src", [humanMsg, aiMsg]);
|
|
127
|
+
|
|
128
|
+
const tm = createLangChainThreadManager({
|
|
129
|
+
redis: redis as never,
|
|
130
|
+
threadId: "src",
|
|
131
|
+
hooks: {
|
|
132
|
+
onForkTransform: (msg) => ({
|
|
133
|
+
...msg,
|
|
134
|
+
data: { ...msg.data, content: "mutated" },
|
|
135
|
+
}),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await tm.fork("dst");
|
|
140
|
+
expect(await tm.load()).toEqual([humanMsg, aiMsg]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -47,7 +47,8 @@ export function createLangChainModelInvoker<
|
|
|
47
47
|
return async function invokeLangChainModel(
|
|
48
48
|
config: ModelInvokerConfig
|
|
49
49
|
): Promise<AgentResponse<StoredMessage>> {
|
|
50
|
-
const { threadId, threadKey, agentName, state, metadata } =
|
|
50
|
+
const { threadId, threadKey, agentName, state, metadata, assistantMessageId } =
|
|
51
|
+
config;
|
|
51
52
|
const { heartbeat, signal } = getActivityContext();
|
|
52
53
|
|
|
53
54
|
const thread = createLangChainThreadManager({
|
|
@@ -58,7 +59,12 @@ export function createLangChainModelInvoker<
|
|
|
58
59
|
});
|
|
59
60
|
const runId = uuidv4();
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
// Truncate the thread starting at the id the assistant message
|
|
63
|
+
// will be stored under. No-op on the first attempt; on rewind
|
|
64
|
+
// retry / Temporal reset it wipes the prior attempt's assistant
|
|
65
|
+
// + tool results so the LLM sees the original pre-call state.
|
|
66
|
+
await thread.truncateFromId(assistantMessageId);
|
|
67
|
+
const { messages } = await thread.prepareForInvocation();
|
|
62
68
|
|
|
63
69
|
const heartbeatInterval = heartbeat
|
|
64
70
|
? setInterval(() => heartbeat(), 30_000)
|
|
@@ -97,7 +103,6 @@ export function createLangChainModelInvoker<
|
|
|
97
103
|
response.usage_metadata?.input_token_details?.cache_read ||
|
|
98
104
|
(providerUsage.cacheReadInputTokens as number | undefined),
|
|
99
105
|
},
|
|
100
|
-
threadLengthAtCall: storedLength,
|
|
101
106
|
};
|
|
102
107
|
} finally {
|
|
103
108
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
@@ -22,15 +22,16 @@ import { type ActivityInterfaceFor } from "@temporalio/workflow";
|
|
|
22
22
|
import type { ThreadOps } from "../../../lib/session/types";
|
|
23
23
|
import type { LangChainContent } from "./thread-manager";
|
|
24
24
|
import { createThreadOpsProxy } from "../../../lib/thread/proxy";
|
|
25
|
+
import { ADAPTER_ID } from "./adapter-id";
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
export { ADAPTER_ID, type AdapterId } from "./adapter-id";
|
|
27
28
|
|
|
28
29
|
export function proxyLangChainThreadOps(
|
|
29
30
|
scope?: string,
|
|
30
31
|
options?: Parameters<typeof createThreadOpsProxy>[2]
|
|
31
32
|
): ActivityInterfaceFor<ThreadOps<LangChainContent>> {
|
|
32
33
|
return createThreadOpsProxy(
|
|
33
|
-
|
|
34
|
+
ADAPTER_ID,
|
|
34
35
|
scope,
|
|
35
36
|
options
|
|
36
37
|
) as ActivityInterfaceFor<ThreadOps<LangChainContent>>;
|
|
@@ -39,8 +39,6 @@ export interface LangChainThreadManagerConfig {
|
|
|
39
39
|
/** Prepared payload ready to send to a LangChain chat model */
|
|
40
40
|
export interface LangChainInvocationPayload {
|
|
41
41
|
messages: BaseMessage[];
|
|
42
|
-
/** Number of stored messages loaded from Redis before preparation. */
|
|
43
|
-
storedLength: number;
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
/** Thread manager with LangChain StoredMessage convenience helpers */
|
|
@@ -141,10 +139,35 @@ export function createLangChainThreadManager(
|
|
|
141
139
|
messages: onPreparedMessage
|
|
142
140
|
? messages.map((msg, i) => onPreparedMessage(msg, i, messages))
|
|
143
141
|
: messages,
|
|
144
|
-
storedLength: stored.length,
|
|
145
142
|
};
|
|
146
143
|
},
|
|
147
144
|
};
|
|
148
145
|
|
|
149
|
-
|
|
146
|
+
const manager = Object.assign(base, helpers);
|
|
147
|
+
|
|
148
|
+
const originalFork = manager.fork.bind(manager);
|
|
149
|
+
manager.fork = async (
|
|
150
|
+
newThreadId: string
|
|
151
|
+
): Promise<LangChainThreadManager> => {
|
|
152
|
+
await originalFork(newThreadId);
|
|
153
|
+
const forked = createLangChainThreadManager({
|
|
154
|
+
...config,
|
|
155
|
+
threadId: newThreadId,
|
|
156
|
+
});
|
|
157
|
+
const { onForkPrepareThread, onForkTransform } = config.hooks ?? {};
|
|
158
|
+
if (!onForkPrepareThread && !onForkTransform) {
|
|
159
|
+
return forked;
|
|
160
|
+
}
|
|
161
|
+
let next = await forked.load();
|
|
162
|
+
if (onForkPrepareThread) {
|
|
163
|
+
next = await onForkPrepareThread(next);
|
|
164
|
+
}
|
|
165
|
+
if (onForkTransform) {
|
|
166
|
+
next = next.map((msg, i) => onForkTransform(msg, i, next));
|
|
167
|
+
}
|
|
168
|
+
await forked.replaceAll(next);
|
|
169
|
+
return forked;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return manager;
|
|
150
173
|
}
|
package/src/lib/lifecycle.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* - `"new"` — start a fresh thread (optionally specify its ID).
|
|
9
9
|
* - `"continue"` — append directly to an existing thread in-place.
|
|
10
10
|
* - `"fork"` — copy all messages from an existing thread into a new one and
|
|
11
|
-
* continue there.
|
|
11
|
+
* continue there. When the adapter has `onForkPrepareThread` and/or
|
|
12
|
+
* `onForkTransform` hooks configured, they are applied once to the forked
|
|
13
|
+
* thread before the session starts.
|
|
12
14
|
*/
|
|
13
15
|
export type ThreadInit =
|
|
14
16
|
| { mode: "new"; threadId?: string }
|
package/src/lib/model/types.ts
CHANGED
|
@@ -8,16 +8,6 @@ export interface AgentResponse<M = unknown> {
|
|
|
8
8
|
message: M;
|
|
9
9
|
rawToolCalls: RawToolCall[];
|
|
10
10
|
usage?: TokenUsage;
|
|
11
|
-
/**
|
|
12
|
-
* Number of stored messages in the thread at the moment the LLM was
|
|
13
|
-
* invoked — i.e. *before* the assistant message is appended. The
|
|
14
|
-
* session uses this as a rewind snapshot so it can roll the thread
|
|
15
|
-
* back to this exact state if a tool requests a rewind.
|
|
16
|
-
*
|
|
17
|
-
* Adapters compute this for free from the array of stored messages
|
|
18
|
-
* they load when preparing the payload.
|
|
19
|
-
*/
|
|
20
|
-
threadLengthAtCall?: number;
|
|
21
11
|
}
|
|
22
12
|
|
|
23
13
|
/**
|
|
@@ -39,6 +29,13 @@ export interface ModelInvokerConfig {
|
|
|
39
29
|
agentName: string;
|
|
40
30
|
state: BaseAgentState;
|
|
41
31
|
metadata?: Record<string, unknown>;
|
|
32
|
+
/**
|
|
33
|
+
* The id the assistant message produced by this call will be stored
|
|
34
|
+
* under. Invokers truncate the thread from this id on entry so that
|
|
35
|
+
* rewind retries and Temporal workflow resets restore the pre-call
|
|
36
|
+
* state before re-invoking the LLM. See {@link RunAgentConfig}.
|
|
37
|
+
*/
|
|
38
|
+
assistantMessageId: string;
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
/**
|