zeitlich 0.2.38 → 0.2.40
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-CULxRzJ1.d.ts} +4 -6
- package/dist/{activities-CDcwkRZs.d.cts → activities-CvUrG3YG.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 +267 -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 +264 -49
- package/dist/index.js.map +1 -1
- package/dist/{proxy-D_3x7RN4.d.cts → proxy-5EbwzaY4.d.cts} +1 -1
- package/dist/{proxy-CUlKSvZS.d.ts → proxy-wZufFfBh.d.ts} +1 -1
- package/dist/{thread-manager-CVu7o2cs.d.ts → thread-manager-BNiIt5r8.d.ts} +2 -4
- package/dist/{thread-manager-c1gPopAG.d.ts → thread-manager-BoN5DOvG.d.cts} +2 -4
- package/dist/{thread-manager-wGi-LqIP.d.cts → thread-manager-BqBAIsED.d.ts} +2 -4
- package/dist/{thread-manager-HSwyh28L.d.cts → thread-manager-DF8WuCRs.d.cts} +2 -4
- package/dist/{types-BH_IRryz.d.ts → types-C7OoY7h8.d.ts} +54 -6
- package/dist/{types-C06FwR96.d.cts → types-Cn2r3ol3.d.cts} +163 -44
- package/dist/{types-BaOw4hKI.d.cts → types-CuISs0Ub.d.cts} +54 -6
- package/dist/{types-DNr31FzL.d.ts → types-DeQH84C_.d.ts} +163 -44
- package/dist/{workflow-CSCkpwAL.d.ts → workflow-C2MZZj5K.d.ts} +82 -2
- package/dist/{workflow-DuvMZ8Vm.d.cts → workflow-DhplIN65.d.cts} +82 -2
- package/dist/workflow.cjs +189 -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 +186 -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 +69 -28
- package/src/lib/session/types.ts +61 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zeitlich",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.40",
|
|
4
4
|
"description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -27,6 +27,16 @@
|
|
|
27
27
|
"default": "./dist/workflow.js"
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
|
+
"./adapters/thread": {
|
|
31
|
+
"import": {
|
|
32
|
+
"types": "./dist/adapters/thread/index.d.ts",
|
|
33
|
+
"default": "./dist/adapters/thread/index.js"
|
|
34
|
+
},
|
|
35
|
+
"require": {
|
|
36
|
+
"types": "./dist/adapters/thread/index.d.ts",
|
|
37
|
+
"default": "./dist/adapters/thread/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
30
40
|
"./adapters/thread/langchain": {
|
|
31
41
|
"import": {
|
|
32
42
|
"types": "./dist/adapters/thread/langchain/index.d.ts",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, expectTypeOf } from "vitest";
|
|
2
|
+
import { ADAPTER_ID as LANGCHAIN } from "./langchain/adapter-id";
|
|
3
|
+
import { ADAPTER_ID as GOOGLE_GENAI } from "./google-genai/adapter-id";
|
|
4
|
+
import { ADAPTER_ID as ANTHROPIC } from "./anthropic/adapter-id";
|
|
5
|
+
import {
|
|
6
|
+
LANGCHAIN_ADAPTER_ID,
|
|
7
|
+
GOOGLE_GENAI_ADAPTER_ID,
|
|
8
|
+
ANTHROPIC_ADAPTER_ID,
|
|
9
|
+
type ThreadAdapterId,
|
|
10
|
+
} from "./index";
|
|
11
|
+
|
|
12
|
+
describe("thread adapter identity", () => {
|
|
13
|
+
it("langchain ADAPTER_ID is the wire-format string", () => {
|
|
14
|
+
expect(LANGCHAIN).toBe("langChain");
|
|
15
|
+
expect(LANGCHAIN_ADAPTER_ID).toBe("langChain");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("google-genai ADAPTER_ID is the wire-format string", () => {
|
|
19
|
+
expect(GOOGLE_GENAI).toBe("googleGenAI");
|
|
20
|
+
expect(GOOGLE_GENAI_ADAPTER_ID).toBe("googleGenAI");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("anthropic ADAPTER_ID is the wire-format string", () => {
|
|
24
|
+
expect(ANTHROPIC).toBe("anthropic");
|
|
25
|
+
expect(ANTHROPIC_ADAPTER_ID).toBe("anthropic");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("ADAPTER_ID values narrow to string literals, not `string`", () => {
|
|
29
|
+
expectTypeOf(LANGCHAIN).toEqualTypeOf<"langChain">();
|
|
30
|
+
expectTypeOf(GOOGLE_GENAI).toEqualTypeOf<"googleGenAI">();
|
|
31
|
+
expectTypeOf(ANTHROPIC).toEqualTypeOf<"anthropic">();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("ThreadAdapterId is the discriminated union of every built-in id", () => {
|
|
35
|
+
const allow = (_id: ThreadAdapterId): void => undefined;
|
|
36
|
+
allow(LANGCHAIN);
|
|
37
|
+
allow(GOOGLE_GENAI);
|
|
38
|
+
allow(ANTHROPIC);
|
|
39
|
+
// @ts-expect-error — arbitrary strings aren't members of the union
|
|
40
|
+
allow("someOtherAdapter");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type Redis from "ioredis";
|
|
2
2
|
import type Anthropic from "@anthropic-ai/sdk";
|
|
3
3
|
import type { ToolResultConfig } from "../../../lib/types";
|
|
4
|
+
import type { PersistedThreadState } from "../../../lib/state/types";
|
|
4
5
|
import type {
|
|
5
6
|
ActivityToolHandler,
|
|
6
7
|
RouterContext,
|
|
@@ -22,11 +23,10 @@ import {
|
|
|
22
23
|
createAnthropicModelInvoker,
|
|
23
24
|
type AnthropicModelInvokerConfig,
|
|
24
25
|
} from "./model-invoker";
|
|
25
|
-
|
|
26
|
-
const ADAPTER_PREFIX = "anthropic" as const;
|
|
26
|
+
import { ADAPTER_ID } from "./adapter-id";
|
|
27
27
|
|
|
28
28
|
export type AnthropicThreadOps<TScope extends string = ""> = PrefixedThreadOps<
|
|
29
|
-
ScopedPrefix<TScope, typeof
|
|
29
|
+
ScopedPrefix<TScope, typeof ADAPTER_ID>,
|
|
30
30
|
AnthropicContent
|
|
31
31
|
>;
|
|
32
32
|
|
|
@@ -209,17 +209,43 @@ export function createAnthropicAdapter(
|
|
|
209
209
|
redis,
|
|
210
210
|
threadId: sourceThreadId,
|
|
211
211
|
key: threadKey,
|
|
212
|
+
hooks: config.hooks,
|
|
212
213
|
});
|
|
213
214
|
await thread.fork(targetThreadId);
|
|
214
215
|
},
|
|
215
216
|
|
|
216
217
|
async truncateThread(
|
|
217
218
|
threadId: string,
|
|
218
|
-
|
|
219
|
+
messageId: string,
|
|
219
220
|
threadKey?: string,
|
|
220
221
|
): Promise<void> {
|
|
221
222
|
const thread = createAnthropicThreadManager({ redis, threadId, key: threadKey });
|
|
222
|
-
await thread.
|
|
223
|
+
await thread.truncateFromId(messageId);
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async loadThreadState(
|
|
227
|
+
threadId: string,
|
|
228
|
+
threadKey?: string
|
|
229
|
+
): Promise<PersistedThreadState | null> {
|
|
230
|
+
const thread = createAnthropicThreadManager({
|
|
231
|
+
redis,
|
|
232
|
+
threadId,
|
|
233
|
+
key: threadKey,
|
|
234
|
+
});
|
|
235
|
+
return thread.loadState();
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
async saveThreadState(
|
|
239
|
+
threadId: string,
|
|
240
|
+
state: PersistedThreadState,
|
|
241
|
+
threadKey?: string
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
const thread = createAnthropicThreadManager({
|
|
244
|
+
redis,
|
|
245
|
+
threadId,
|
|
246
|
+
key: threadKey,
|
|
247
|
+
});
|
|
248
|
+
await thread.saveState(state);
|
|
223
249
|
},
|
|
224
250
|
};
|
|
225
251
|
|
|
@@ -227,8 +253,8 @@ export function createAnthropicAdapter(
|
|
|
227
253
|
scope?: S
|
|
228
254
|
): AnthropicThreadOps<S> {
|
|
229
255
|
const prefix = scope
|
|
230
|
-
? `${
|
|
231
|
-
:
|
|
256
|
+
? `${ADAPTER_ID}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
|
|
257
|
+
: ADAPTER_ID;
|
|
232
258
|
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
|
233
259
|
return Object.fromEntries(
|
|
234
260
|
Object.entries(threadOps).map(([k, v]) => [`${prefix}${cap(k)}`, v])
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public adapter identity for the Anthropic thread adapter.
|
|
3
|
+
*
|
|
4
|
+
* This value is wire format — it appears as the prefix for Temporal
|
|
5
|
+
* activity names (e.g. `anthropicCodingAgentInitializeThread`) 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/anthropic` so downstream
|
|
10
|
+
* consumers can use the exact same literal the adapter uses internally,
|
|
11
|
+
* typed as the narrow string literal `"anthropic"`.
|
|
12
|
+
*/
|
|
13
|
+
export const ADAPTER_ID = "anthropic" as const;
|
|
14
|
+
|
|
15
|
+
/** Narrow string-literal type for {@link ADAPTER_ID}. */
|
|
16
|
+
export type AdapterId = typeof ADAPTER_ID;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { StoredMessage } from "./thread-manager";
|
|
3
|
+
import { createAnthropicThreadManager } from "./thread-manager";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Stateful in-memory Redis mock sufficient for fork / replaceAll flows.
|
|
7
|
+
// Only the commands used by createThreadManager are implemented.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function createStatefulRedis() {
|
|
11
|
+
const lists = new Map<string, string[]>();
|
|
12
|
+
const strings = new Map<string, string>();
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
exists: vi.fn(async (...keys: string[]) =>
|
|
16
|
+
keys.reduce(
|
|
17
|
+
(acc, k) => acc + (lists.has(k) || strings.has(k) ? 1 : 0),
|
|
18
|
+
0
|
|
19
|
+
)
|
|
20
|
+
),
|
|
21
|
+
lrange: vi.fn(async (key: string, start: number, stop: number) => {
|
|
22
|
+
const list = lists.get(key) ?? [];
|
|
23
|
+
const end = stop === -1 ? list.length : stop + 1;
|
|
24
|
+
return list.slice(start, end);
|
|
25
|
+
}),
|
|
26
|
+
rpush: vi.fn(async (key: string, ...values: string[]) => {
|
|
27
|
+
const list = lists.get(key) ?? [];
|
|
28
|
+
list.push(...values);
|
|
29
|
+
lists.set(key, list);
|
|
30
|
+
return list.length;
|
|
31
|
+
}),
|
|
32
|
+
ltrim: vi.fn(async (key: string, start: number, stop: number) => {
|
|
33
|
+
const list = lists.get(key) ?? [];
|
|
34
|
+
const end = stop === -1 ? list.length : stop + 1;
|
|
35
|
+
lists.set(key, list.slice(start, end));
|
|
36
|
+
return "OK";
|
|
37
|
+
}),
|
|
38
|
+
del: vi.fn(async (...keys: string[]) => {
|
|
39
|
+
let removed = 0;
|
|
40
|
+
for (const k of keys) {
|
|
41
|
+
if (lists.delete(k)) removed++;
|
|
42
|
+
if (strings.delete(k)) removed++;
|
|
43
|
+
}
|
|
44
|
+
return removed;
|
|
45
|
+
}),
|
|
46
|
+
set: vi.fn(async (key: string, value: string) => {
|
|
47
|
+
strings.set(key, value);
|
|
48
|
+
return "OK";
|
|
49
|
+
}),
|
|
50
|
+
get: vi.fn(async (key: string) => strings.get(key) ?? null),
|
|
51
|
+
expire: vi.fn(async (_key: string, _ttl: number) => 1),
|
|
52
|
+
llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
|
|
53
|
+
eval: vi.fn(
|
|
54
|
+
async (_script: string, _numKeys: number, ...args: string[]) => {
|
|
55
|
+
const [dedupKey, listKey, , ...serialised] = args;
|
|
56
|
+
if (!dedupKey || !listKey) return 0;
|
|
57
|
+
if (strings.has(dedupKey)) return 0;
|
|
58
|
+
const list = lists.get(listKey) ?? [];
|
|
59
|
+
list.push(...serialised);
|
|
60
|
+
lists.set(listKey, list);
|
|
61
|
+
strings.set(dedupKey, "1");
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
),
|
|
65
|
+
__peek: {
|
|
66
|
+
list: (key: string): string[] => [...(lists.get(key) ?? [])],
|
|
67
|
+
strings,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const userMsg: StoredMessage = {
|
|
73
|
+
id: "msg-1",
|
|
74
|
+
message: { role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const assistantMsg: StoredMessage = {
|
|
78
|
+
id: "msg-2",
|
|
79
|
+
message: {
|
|
80
|
+
role: "assistant",
|
|
81
|
+
content: [{ type: "text", text: "Hi there!" }],
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const userMsg2: StoredMessage = {
|
|
86
|
+
id: "msg-3",
|
|
87
|
+
message: { role: "user", content: [{ type: "text", text: "Again please" }] },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
async function seedSource(
|
|
91
|
+
redis: ReturnType<typeof createStatefulRedis>,
|
|
92
|
+
threadId: string,
|
|
93
|
+
messages: StoredMessage[]
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const tm = createAnthropicThreadManager({
|
|
96
|
+
redis: redis as never,
|
|
97
|
+
threadId,
|
|
98
|
+
});
|
|
99
|
+
await tm.initialize();
|
|
100
|
+
await tm.append(messages);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("Anthropic fork + transform hooks", () => {
|
|
104
|
+
it("behaves like fork when neither onFork hook is configured", async () => {
|
|
105
|
+
const redis = createStatefulRedis();
|
|
106
|
+
await seedSource(redis, "src", [userMsg, assistantMsg]);
|
|
107
|
+
|
|
108
|
+
const tm = createAnthropicThreadManager({
|
|
109
|
+
redis: redis as never,
|
|
110
|
+
threadId: "src",
|
|
111
|
+
});
|
|
112
|
+
const forked = await tm.fork("dst");
|
|
113
|
+
const loaded = await forked.load();
|
|
114
|
+
|
|
115
|
+
expect(loaded).toEqual([userMsg, assistantMsg]);
|
|
116
|
+
|
|
117
|
+
// Source is untouched
|
|
118
|
+
const srcLoaded = await tm.load();
|
|
119
|
+
expect(srcLoaded).toEqual([userMsg, assistantMsg]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("applies onForkTransform alone as a per-message map", async () => {
|
|
123
|
+
const redis = createStatefulRedis();
|
|
124
|
+
await seedSource(redis, "src", [userMsg, assistantMsg, userMsg2]);
|
|
125
|
+
|
|
126
|
+
const calls: Array<{
|
|
127
|
+
idx: number;
|
|
128
|
+
id: string;
|
|
129
|
+
total: number;
|
|
130
|
+
}> = [];
|
|
131
|
+
const onForkTransform = vi.fn(
|
|
132
|
+
(msg: StoredMessage, index: number, messages: readonly StoredMessage[]) => {
|
|
133
|
+
calls.push({ idx: index, id: msg.id, total: messages.length });
|
|
134
|
+
const firstBlock = (msg.message.content as Array<{ text?: string }>)[0];
|
|
135
|
+
return {
|
|
136
|
+
...msg,
|
|
137
|
+
message: {
|
|
138
|
+
...msg.message,
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text" as const,
|
|
142
|
+
text: `[T${index}] ${firstBlock?.text ?? ""}`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const tm = createAnthropicThreadManager({
|
|
151
|
+
redis: redis as never,
|
|
152
|
+
threadId: "src",
|
|
153
|
+
hooks: { onForkTransform },
|
|
154
|
+
});
|
|
155
|
+
const forked = await tm.fork("dst");
|
|
156
|
+
const loaded = await forked.load();
|
|
157
|
+
|
|
158
|
+
expect(onForkTransform).toHaveBeenCalledTimes(3);
|
|
159
|
+
expect(calls).toEqual([
|
|
160
|
+
{ idx: 0, id: "msg-1", total: 3 },
|
|
161
|
+
{ idx: 1, id: "msg-2", total: 3 },
|
|
162
|
+
{ idx: 2, id: "msg-3", total: 3 },
|
|
163
|
+
]);
|
|
164
|
+
expect(loaded).toHaveLength(3);
|
|
165
|
+
expect(loaded[0]?.message.content).toEqual([
|
|
166
|
+
{ type: "text", text: "[T0] Hello" },
|
|
167
|
+
]);
|
|
168
|
+
expect(loaded[1]?.message.content).toEqual([
|
|
169
|
+
{ type: "text", text: "[T1] Hi there!" },
|
|
170
|
+
]);
|
|
171
|
+
expect(loaded[2]?.message.content).toEqual([
|
|
172
|
+
{ type: "text", text: "[T2] Again please" },
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
// Source is unchanged.
|
|
176
|
+
const srcLoaded = await tm.load();
|
|
177
|
+
expect(srcLoaded.map((m) => m.id)).toEqual(["msg-1", "msg-2", "msg-3"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("applies onForkPrepareThread alone and may change list length", async () => {
|
|
181
|
+
const redis = createStatefulRedis();
|
|
182
|
+
await seedSource(redis, "src", [userMsg, assistantMsg, userMsg2]);
|
|
183
|
+
|
|
184
|
+
const onForkPrepareThread = vi.fn(
|
|
185
|
+
async (messages: readonly StoredMessage[]) =>
|
|
186
|
+
// Drop first message and prepend a summary.
|
|
187
|
+
[
|
|
188
|
+
{
|
|
189
|
+
id: "summary-1",
|
|
190
|
+
message: {
|
|
191
|
+
role: "user" as const,
|
|
192
|
+
content: [{ type: "text" as const, text: "[summary]" }],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
...messages.slice(1),
|
|
196
|
+
]
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const tm = createAnthropicThreadManager({
|
|
200
|
+
redis: redis as never,
|
|
201
|
+
threadId: "src",
|
|
202
|
+
hooks: { onForkPrepareThread },
|
|
203
|
+
});
|
|
204
|
+
const forked = await tm.fork("dst");
|
|
205
|
+
const loaded = await forked.load();
|
|
206
|
+
|
|
207
|
+
expect(onForkPrepareThread).toHaveBeenCalledTimes(1);
|
|
208
|
+
expect(loaded.map((m) => m.id)).toEqual(["summary-1", "msg-2", "msg-3"]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("runs onForkPrepareThread before onForkTransform and passes prepared list as messages", async () => {
|
|
212
|
+
const redis = createStatefulRedis();
|
|
213
|
+
await seedSource(redis, "src", [userMsg, assistantMsg, userMsg2]);
|
|
214
|
+
|
|
215
|
+
const order: string[] = [];
|
|
216
|
+
const indicesSeen: Array<{ idx: number; total: number; id: string }> = [];
|
|
217
|
+
|
|
218
|
+
const onForkPrepareThread = vi.fn(
|
|
219
|
+
async (messages: readonly StoredMessage[]) => {
|
|
220
|
+
order.push("prepare");
|
|
221
|
+
// Drop the last message (length changes).
|
|
222
|
+
return messages.slice(0, -1);
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const onForkTransform = vi.fn(
|
|
227
|
+
(
|
|
228
|
+
msg: StoredMessage,
|
|
229
|
+
index: number,
|
|
230
|
+
messages: readonly StoredMessage[]
|
|
231
|
+
) => {
|
|
232
|
+
order.push("transform");
|
|
233
|
+
indicesSeen.push({ idx: index, total: messages.length, id: msg.id });
|
|
234
|
+
return {
|
|
235
|
+
...msg,
|
|
236
|
+
message: {
|
|
237
|
+
...msg.message,
|
|
238
|
+
content: [{ type: "text" as const, text: `[x${index}]` }],
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const tm = createAnthropicThreadManager({
|
|
245
|
+
redis: redis as never,
|
|
246
|
+
threadId: "src",
|
|
247
|
+
hooks: { onForkPrepareThread, onForkTransform },
|
|
248
|
+
});
|
|
249
|
+
const forked = await tm.fork("dst");
|
|
250
|
+
const loaded = await forked.load();
|
|
251
|
+
|
|
252
|
+
// prepare runs once, transform once per survivor.
|
|
253
|
+
expect(order).toEqual(["prepare", "transform", "transform"]);
|
|
254
|
+
expect(indicesSeen).toEqual([
|
|
255
|
+
{ idx: 0, total: 2, id: "msg-1" },
|
|
256
|
+
{ idx: 1, total: 2, id: "msg-2" },
|
|
257
|
+
]);
|
|
258
|
+
expect(loaded).toHaveLength(2);
|
|
259
|
+
expect(loaded[0]?.message.content).toEqual([{ type: "text", text: "[x0]" }]);
|
|
260
|
+
expect(loaded[1]?.message.content).toEqual([{ type: "text", text: "[x1]" }]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("leaves dedup markers cleared so the transformed thread can accept replays", async () => {
|
|
264
|
+
const redis = createStatefulRedis();
|
|
265
|
+
await seedSource(redis, "src", [userMsg, assistantMsg]);
|
|
266
|
+
|
|
267
|
+
const onForkTransform = vi.fn(
|
|
268
|
+
(msg: StoredMessage) => ({
|
|
269
|
+
...msg,
|
|
270
|
+
message: {
|
|
271
|
+
...msg.message,
|
|
272
|
+
content: [{ type: "text" as const, text: "[replaced]" }],
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const tm = createAnthropicThreadManager({
|
|
278
|
+
redis: redis as never,
|
|
279
|
+
threadId: "src",
|
|
280
|
+
hooks: { onForkTransform },
|
|
281
|
+
});
|
|
282
|
+
await tm.fork("dst");
|
|
283
|
+
|
|
284
|
+
// After replaceAll, dedup markers from the pre-replacement writes must be
|
|
285
|
+
// gone — otherwise an append with the same id would be silently skipped.
|
|
286
|
+
const lingering = Array.from(redis.__peek.strings.keys()).filter((k) =>
|
|
287
|
+
k.startsWith("messages:thread:dst:dedup:")
|
|
288
|
+
);
|
|
289
|
+
expect(lingering).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -61,7 +61,7 @@ export function createAnthropicModelInvoker({
|
|
|
61
61
|
return async function invokeAnthropicModel(
|
|
62
62
|
config: ModelInvokerConfig
|
|
63
63
|
): Promise<AgentResponse<Anthropic.Messages.Message>> {
|
|
64
|
-
const { threadId, threadKey, state } = config;
|
|
64
|
+
const { threadId, threadKey, state, assistantMessageId } = config;
|
|
65
65
|
const { heartbeat, signal } = getActivityContext();
|
|
66
66
|
|
|
67
67
|
const thread = createAnthropicThreadManager({
|
|
@@ -70,8 +70,13 @@ export function createAnthropicModelInvoker({
|
|
|
70
70
|
key: threadKey,
|
|
71
71
|
hooks,
|
|
72
72
|
});
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// Truncate the thread starting at the id the assistant message
|
|
74
|
+
// will be stored under. On the happy path this is a no-op; on a
|
|
75
|
+
// rewind retry or a Temporal workflow reset it wipes the prior
|
|
76
|
+
// attempt's assistant + tool results so the LLM sees the same
|
|
77
|
+
// pre-call state that it saw originally.
|
|
78
|
+
await thread.truncateFromId(assistantMessageId);
|
|
79
|
+
const { messages, system } = await thread.prepareForInvocation();
|
|
75
80
|
|
|
76
81
|
const anthropicTools = toAnthropicTools(state.tools);
|
|
77
82
|
const tools = anthropicTools.length > 0 ? anthropicTools : undefined;
|
|
@@ -111,7 +116,6 @@ export function createAnthropicModelInvoker({
|
|
|
111
116
|
response.usage.cache_creation_input_tokens ?? undefined,
|
|
112
117
|
cachedReadTokens: response.usage.cache_read_input_tokens ?? undefined,
|
|
113
118
|
},
|
|
114
|
-
threadLengthAtCall: storedLength,
|
|
115
119
|
};
|
|
116
120
|
};
|
|
117
121
|
}
|
|
@@ -22,15 +22,16 @@ import { type ActivityInterfaceFor } from "@temporalio/workflow";
|
|
|
22
22
|
import type { ThreadOps } from "../../../lib/session/types";
|
|
23
23
|
import type { AnthropicContent } 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 proxyAnthropicThreadOps(
|
|
29
30
|
scope?: string,
|
|
30
31
|
options?: Parameters<typeof createThreadOpsProxy>[2]
|
|
31
32
|
): ActivityInterfaceFor<ThreadOps<AnthropicContent>> {
|
|
32
33
|
return createThreadOpsProxy(
|
|
33
|
-
|
|
34
|
+
ADAPTER_ID,
|
|
34
35
|
scope,
|
|
35
36
|
options
|
|
36
37
|
) as ActivityInterfaceFor<ThreadOps<AnthropicContent>>;
|
|
@@ -41,8 +41,6 @@ export interface AnthropicThreadManagerConfig {
|
|
|
41
41
|
export interface AnthropicInvocationPayload {
|
|
42
42
|
messages: Anthropic.Messages.MessageParam[];
|
|
43
43
|
system?: string | Anthropic.Messages.TextBlockParam[];
|
|
44
|
-
/** Number of stored messages loaded from Redis before preparation. */
|
|
45
|
-
storedLength: number;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
/** Thread manager with Anthropic MessageParam convenience helpers */
|
|
@@ -222,10 +220,35 @@ export function createAnthropicThreadManager(
|
|
|
222
220
|
? messages.map((msg, i) => onPreparedMessage(msg, i, messages))
|
|
223
221
|
: messages,
|
|
224
222
|
...(system ? { system } : {}),
|
|
225
|
-
storedLength: stored.length,
|
|
226
223
|
};
|
|
227
224
|
},
|
|
228
225
|
};
|
|
229
226
|
|
|
230
|
-
|
|
227
|
+
const manager = Object.assign(base, helpers);
|
|
228
|
+
|
|
229
|
+
const originalFork = manager.fork.bind(manager);
|
|
230
|
+
manager.fork = async (
|
|
231
|
+
newThreadId: string
|
|
232
|
+
): Promise<AnthropicThreadManager> => {
|
|
233
|
+
await originalFork(newThreadId);
|
|
234
|
+
const forked = createAnthropicThreadManager({
|
|
235
|
+
...config,
|
|
236
|
+
threadId: newThreadId,
|
|
237
|
+
});
|
|
238
|
+
const { onForkPrepareThread, onForkTransform } = config.hooks ?? {};
|
|
239
|
+
if (!onForkPrepareThread && !onForkTransform) {
|
|
240
|
+
return forked;
|
|
241
|
+
}
|
|
242
|
+
let next = await forked.load();
|
|
243
|
+
if (onForkPrepareThread) {
|
|
244
|
+
next = await onForkPrepareThread(next);
|
|
245
|
+
}
|
|
246
|
+
if (onForkTransform) {
|
|
247
|
+
next = next.map((msg, i) => onForkTransform(msg, i, next));
|
|
248
|
+
}
|
|
249
|
+
await forked.replaceAll(next);
|
|
250
|
+
return forked;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return manager;
|
|
231
254
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type Redis from "ioredis";
|
|
2
2
|
import type { GoogleGenAI, Content, Part } from "@google/genai";
|
|
3
3
|
import type { ToolResultConfig } from "../../../lib/types";
|
|
4
|
+
import type { PersistedThreadState } from "../../../lib/state/types";
|
|
4
5
|
import type {
|
|
5
6
|
ActivityToolHandler,
|
|
6
7
|
RouterContext,
|
|
@@ -19,12 +20,11 @@ import {
|
|
|
19
20
|
type GoogleGenAIThreadManagerHooks,
|
|
20
21
|
} from "./thread-manager";
|
|
21
22
|
import { createGoogleGenAIModelInvoker } from "./model-invoker";
|
|
22
|
-
|
|
23
|
-
const ADAPTER_PREFIX = "googleGenAI" as const;
|
|
23
|
+
import { ADAPTER_ID } from "./adapter-id";
|
|
24
24
|
|
|
25
25
|
export type GoogleGenAIThreadOps<TScope extends string = ""> =
|
|
26
26
|
PrefixedThreadOps<
|
|
27
|
-
ScopedPrefix<TScope, typeof
|
|
27
|
+
ScopedPrefix<TScope, typeof ADAPTER_ID>,
|
|
28
28
|
GoogleGenAIContent
|
|
29
29
|
>;
|
|
30
30
|
|
|
@@ -219,13 +219,14 @@ export function createGoogleGenAIAdapter(
|
|
|
219
219
|
redis,
|
|
220
220
|
threadId: sourceThreadId,
|
|
221
221
|
key: threadKey,
|
|
222
|
+
hooks: config.hooks,
|
|
222
223
|
});
|
|
223
224
|
await thread.fork(targetThreadId);
|
|
224
225
|
},
|
|
225
226
|
|
|
226
227
|
async truncateThread(
|
|
227
228
|
threadId: string,
|
|
228
|
-
|
|
229
|
+
messageId: string,
|
|
229
230
|
threadKey?: string,
|
|
230
231
|
): Promise<void> {
|
|
231
232
|
const thread = createGoogleGenAIThreadManager({
|
|
@@ -233,7 +234,32 @@ export function createGoogleGenAIAdapter(
|
|
|
233
234
|
threadId,
|
|
234
235
|
key: threadKey,
|
|
235
236
|
});
|
|
236
|
-
await thread.
|
|
237
|
+
await thread.truncateFromId(messageId);
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async loadThreadState(
|
|
241
|
+
threadId: string,
|
|
242
|
+
threadKey?: string
|
|
243
|
+
): Promise<PersistedThreadState | null> {
|
|
244
|
+
const thread = createGoogleGenAIThreadManager({
|
|
245
|
+
redis,
|
|
246
|
+
threadId,
|
|
247
|
+
key: threadKey,
|
|
248
|
+
});
|
|
249
|
+
return thread.loadState();
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async saveThreadState(
|
|
253
|
+
threadId: string,
|
|
254
|
+
state: PersistedThreadState,
|
|
255
|
+
threadKey?: string
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const thread = createGoogleGenAIThreadManager({
|
|
258
|
+
redis,
|
|
259
|
+
threadId,
|
|
260
|
+
key: threadKey,
|
|
261
|
+
});
|
|
262
|
+
await thread.saveState(state);
|
|
237
263
|
},
|
|
238
264
|
};
|
|
239
265
|
|
|
@@ -241,8 +267,8 @@ export function createGoogleGenAIAdapter(
|
|
|
241
267
|
scope?: S
|
|
242
268
|
): GoogleGenAIThreadOps<S> {
|
|
243
269
|
const prefix = scope
|
|
244
|
-
? `${
|
|
245
|
-
:
|
|
270
|
+
? `${ADAPTER_ID}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
|
|
271
|
+
: ADAPTER_ID;
|
|
246
272
|
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
|
247
273
|
return Object.fromEntries(
|
|
248
274
|
Object.entries(threadOps).map(([k, v]) => [`${prefix}${cap(k)}`, v])
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public adapter identity for the Google GenAI thread adapter.
|
|
3
|
+
*
|
|
4
|
+
* This value is wire format — it appears as the prefix for Temporal
|
|
5
|
+
* activity names (e.g. `googleGenAICodingAgentInitializeThread`) and
|
|
6
|
+
* must never change, since renaming it would orphan existing persisted
|
|
7
|
+
* threads and break in-flight workflows.
|
|
8
|
+
*
|
|
9
|
+
* Re-exported from `zeitlich/adapters/thread/google-genai` so downstream
|
|
10
|
+
* consumers can use the exact same literal the adapter uses internally,
|
|
11
|
+
* typed as the narrow string literal `"googleGenAI"`.
|
|
12
|
+
*/
|
|
13
|
+
export const ADAPTER_ID = "googleGenAI" as const;
|
|
14
|
+
|
|
15
|
+
/** Narrow string-literal type for {@link ADAPTER_ID}. */
|
|
16
|
+
export type AdapterId = typeof ADAPTER_ID;
|