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
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
SubagentSessionInput,
|
|
13
13
|
} from "./types";
|
|
14
14
|
import type { SubagentSandboxShutdown } from "../lifecycle";
|
|
15
|
+
import type { SandboxSnapshot } from "../sandbox/types";
|
|
15
16
|
import { childSandboxReadySignal } from "./signals";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -50,6 +51,8 @@ import { childSandboxReadySignal } from "./signals";
|
|
|
50
51
|
* });
|
|
51
52
|
*
|
|
52
53
|
* const { finalMessage, threadId } = await session.runSession({ stateManager });
|
|
54
|
+
* // `sandboxId`, `snapshot`, and `baseSnapshot` are auto-forwarded
|
|
55
|
+
* // from the session — no need to thread them through manually.
|
|
53
56
|
* return { toolResponse: finalMessage ?? "No response", data: null, threadId };
|
|
54
57
|
* },
|
|
55
58
|
* );
|
|
@@ -125,23 +128,47 @@ export function defineSubagentWorkflow(
|
|
|
125
128
|
}
|
|
126
129
|
const parentHandle = getExternalWorkflowHandle(parent.workflowId);
|
|
127
130
|
|
|
131
|
+
let capturedSandboxId: string | undefined;
|
|
132
|
+
let capturedSnapshot: SandboxSnapshot | undefined;
|
|
133
|
+
let capturedBaseSnapshot: SandboxSnapshot | undefined;
|
|
134
|
+
let capturedThreadId: string | undefined;
|
|
128
135
|
const sessionInput: SubagentSessionInput = {
|
|
129
136
|
agentName: config.name,
|
|
130
137
|
sandboxShutdown: effectiveShutdown,
|
|
131
138
|
...(workflowInput.thread && { thread: workflowInput.thread }),
|
|
132
139
|
...(workflowInput.sandbox && { sandbox: workflowInput.sandbox }),
|
|
133
|
-
onSandboxReady: (sandboxId
|
|
140
|
+
onSandboxReady: ({ sandboxId, baseSnapshot }) => {
|
|
141
|
+
capturedBaseSnapshot = baseSnapshot;
|
|
134
142
|
const isReuse = workflowInput.sandbox?.mode === "continue";
|
|
135
143
|
if (!isReuse) {
|
|
136
144
|
void parentHandle.signal(childSandboxReadySignal, {
|
|
137
145
|
childWorkflowId: workflowInfo().workflowId,
|
|
138
146
|
sandboxId,
|
|
147
|
+
...(baseSnapshot && { baseSnapshot }),
|
|
139
148
|
});
|
|
140
149
|
}
|
|
141
150
|
},
|
|
151
|
+
onSessionExit: ({ sandboxId, snapshot, threadId }) => {
|
|
152
|
+
capturedSandboxId = sandboxId;
|
|
153
|
+
capturedSnapshot = snapshot;
|
|
154
|
+
capturedThreadId = threadId;
|
|
155
|
+
},
|
|
142
156
|
};
|
|
143
157
|
|
|
144
|
-
|
|
158
|
+
const result = await fn(prompt, sessionInput, context ?? {});
|
|
159
|
+
|
|
160
|
+
// Auto-forward sandbox outputs captured from the session so user code
|
|
161
|
+
// never has to thread them through manually. Explicit values on the fn
|
|
162
|
+
// result take precedence.
|
|
163
|
+
return {
|
|
164
|
+
...result,
|
|
165
|
+
...(capturedThreadId !== undefined && { threadId: capturedThreadId }),
|
|
166
|
+
...(capturedSandboxId !== undefined && { sandboxId: capturedSandboxId }),
|
|
167
|
+
...(capturedSnapshot !== undefined && { snapshot: capturedSnapshot }),
|
|
168
|
+
...(capturedBaseSnapshot !== undefined && {
|
|
169
|
+
baseSnapshot: capturedBaseSnapshot,
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
145
172
|
};
|
|
146
173
|
|
|
147
174
|
// for temporal workflow name
|
package/src/lib/thread/index.ts
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getThreadListKey,
|
|
4
|
+
getThreadMetaKey,
|
|
5
|
+
THREAD_TTL_SECONDS,
|
|
6
|
+
} from "./keys";
|
|
7
|
+
import { createThreadManager } from "./manager";
|
|
8
|
+
|
|
9
|
+
describe("thread keys (public helpers)", () => {
|
|
10
|
+
it("getThreadListKey matches the internal list-key format", () => {
|
|
11
|
+
expect(getThreadListKey("messages", "abc")).toBe("messages:thread:abc");
|
|
12
|
+
expect(getThreadListKey("myScope", "xyz")).toBe("myScope:thread:xyz");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("getThreadMetaKey matches the internal meta-key format", () => {
|
|
16
|
+
expect(getThreadMetaKey("messages", "abc")).toBe(
|
|
17
|
+
"messages:meta:thread:abc"
|
|
18
|
+
);
|
|
19
|
+
expect(getThreadMetaKey("myScope", "xyz")).toBe("myScope:meta:thread:xyz");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("THREAD_TTL_SECONDS is 90 days", () => {
|
|
23
|
+
expect(THREAD_TTL_SECONDS).toBe(60 * 60 * 24 * 90);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("createThreadManager ↔ public key helpers round-trip", () => {
|
|
28
|
+
it("writes and reads via the exact keys returned by the public helpers", async () => {
|
|
29
|
+
const store = new Map<string, string[]>();
|
|
30
|
+
const meta = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
const writtenListExpires = new Map<string, number>();
|
|
33
|
+
const writtenMetaExpires = new Map<string, number>();
|
|
34
|
+
|
|
35
|
+
const redis = {
|
|
36
|
+
exists: vi.fn(async (k: string) => (meta.has(k) ? 1 : 0)),
|
|
37
|
+
set: vi.fn(
|
|
38
|
+
async (k: string, v: string, _ex: string, ttl: number) => {
|
|
39
|
+
meta.set(k, v);
|
|
40
|
+
writtenMetaExpires.set(k, ttl);
|
|
41
|
+
return "OK";
|
|
42
|
+
}
|
|
43
|
+
),
|
|
44
|
+
del: vi.fn(async (...keys: string[]) => {
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (const k of keys) {
|
|
47
|
+
if (store.delete(k)) n++;
|
|
48
|
+
if (meta.delete(k)) n++;
|
|
49
|
+
}
|
|
50
|
+
return n;
|
|
51
|
+
}),
|
|
52
|
+
rpush: vi.fn(async (k: string, ...values: string[]) => {
|
|
53
|
+
const list = store.get(k) ?? [];
|
|
54
|
+
list.push(...values);
|
|
55
|
+
store.set(k, list);
|
|
56
|
+
return list.length;
|
|
57
|
+
}),
|
|
58
|
+
lrange: vi.fn(async (k: string) => store.get(k) ?? []),
|
|
59
|
+
llen: vi.fn(async (k: string) => (store.get(k) ?? []).length),
|
|
60
|
+
ltrim: vi.fn(async () => "OK"),
|
|
61
|
+
expire: vi.fn(async (k: string, ttl: number) => {
|
|
62
|
+
if (store.has(k)) writtenListExpires.set(k, ttl);
|
|
63
|
+
if (meta.has(k)) writtenMetaExpires.set(k, ttl);
|
|
64
|
+
return 1;
|
|
65
|
+
}),
|
|
66
|
+
eval: vi.fn(async () => 1),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const threadKey = "messages";
|
|
70
|
+
const threadId = "t1";
|
|
71
|
+
const tm = createThreadManager<{ id: string }>({
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
+
redis: redis as any,
|
|
74
|
+
threadId,
|
|
75
|
+
key: threadKey,
|
|
76
|
+
idOf: (m) => m.id,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await tm.initialize();
|
|
80
|
+
|
|
81
|
+
const expectedList = getThreadListKey(threadKey, threadId);
|
|
82
|
+
const expectedMeta = getThreadMetaKey(threadKey, threadId);
|
|
83
|
+
|
|
84
|
+
expect(meta.has(expectedMeta)).toBe(true);
|
|
85
|
+
expect(writtenMetaExpires.get(expectedMeta)).toBe(THREAD_TTL_SECONDS);
|
|
86
|
+
|
|
87
|
+
// Append uses rpush (idOf set → eval path; bypass eval for this check by
|
|
88
|
+
// calling rpush path via no-idOf manager)
|
|
89
|
+
const tm2 = createThreadManager<{ id: string }>({
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
redis: redis as any,
|
|
92
|
+
threadId,
|
|
93
|
+
key: threadKey,
|
|
94
|
+
});
|
|
95
|
+
await tm2.append([{ id: "m1" }]);
|
|
96
|
+
|
|
97
|
+
expect(store.has(expectedList)).toBe(true);
|
|
98
|
+
expect(store.get(expectedList)).toEqual([JSON.stringify({ id: "m1" })]);
|
|
99
|
+
expect(writtenListExpires.get(expectedList)).toBe(THREAD_TTL_SECONDS);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public helpers for zeitlich's Redis thread storage layout.
|
|
3
|
+
*
|
|
4
|
+
* These are the exact string-building primitives zeitlich's internal thread
|
|
5
|
+
* manager uses for every adapter. Downstream consumers that need to read a
|
|
6
|
+
* persisted thread (for evaluation, observability, admin tooling, etc.)
|
|
7
|
+
* should use these helpers rather than reconstructing the key layout by
|
|
8
|
+
* hand — the layout is versioned with this module, so upgrading zeitlich
|
|
9
|
+
* keeps the consumer in sync.
|
|
10
|
+
*
|
|
11
|
+
* The layout is adapter-agnostic: every thread adapter stores messages the
|
|
12
|
+
* same way.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import {
|
|
17
|
+
* getThreadListKey,
|
|
18
|
+
* getThreadMetaKey,
|
|
19
|
+
* THREAD_TTL_SECONDS,
|
|
20
|
+
* } from 'zeitlich';
|
|
21
|
+
*
|
|
22
|
+
* const listKey = getThreadListKey('messages', threadId);
|
|
23
|
+
* const metaKey = getThreadMetaKey('messages', threadId);
|
|
24
|
+
* const ttl = await redis.ttl(listKey); // <= THREAD_TTL_SECONDS
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* TTL (in seconds) applied to every thread list and thread meta key that
|
|
30
|
+
* zeitlich's {@link createThreadManager} writes. Exposed so downstream
|
|
31
|
+
* consumers can size their Redis retention / query windows to match.
|
|
32
|
+
*
|
|
33
|
+
* Current value: 90 days.
|
|
34
|
+
*/
|
|
35
|
+
export const THREAD_TTL_SECONDS = 60 * 60 * 24 * 90;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the Redis list key that holds a thread's serialized messages.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors the exact key used internally by zeitlich's thread manager,
|
|
41
|
+
* so a consumer calling `redis.lrange(getThreadListKey(key, id), 0, -1)`
|
|
42
|
+
* sees the same data the writer wrote.
|
|
43
|
+
*
|
|
44
|
+
* @param threadKey - Thread key (defaults to `"messages"` inside the
|
|
45
|
+
* thread manager, but downstream adapters may pass
|
|
46
|
+
* their own value).
|
|
47
|
+
* @param threadId - Thread id as provided to the thread manager.
|
|
48
|
+
*/
|
|
49
|
+
export function getThreadListKey(
|
|
50
|
+
threadKey: string,
|
|
51
|
+
threadId: string
|
|
52
|
+
): string {
|
|
53
|
+
return `${threadKey}:thread:${threadId}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the Redis key that stores a thread's existence marker / metadata.
|
|
58
|
+
*
|
|
59
|
+
* Zeitlich treats the presence of this key as "thread has been
|
|
60
|
+
* initialized"; append/load/fork/truncate operations fail when it is
|
|
61
|
+
* missing. Consumers can use it as a cheap existence probe without
|
|
62
|
+
* scanning the message list.
|
|
63
|
+
*
|
|
64
|
+
* @param threadKey - Thread key (defaults to `"messages"` inside the
|
|
65
|
+
* thread manager, but downstream adapters may pass
|
|
66
|
+
* their own value).
|
|
67
|
+
* @param threadId - Thread id as provided to the thread manager.
|
|
68
|
+
*/
|
|
69
|
+
export function getThreadMetaKey(
|
|
70
|
+
threadKey: string,
|
|
71
|
+
threadId: string
|
|
72
|
+
): string {
|
|
73
|
+
return `${threadKey}:meta:thread:${threadId}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the Redis key that stores a thread's persisted state slice
|
|
78
|
+
* (tasks + custom state) written by zeitlich's session loop on every
|
|
79
|
+
* exit path.
|
|
80
|
+
*
|
|
81
|
+
* Consumers can read this key with `redis.get(getThreadStateKey(key, id))`
|
|
82
|
+
* and `JSON.parse` the result into a {@link PersistedThreadState}.
|
|
83
|
+
*
|
|
84
|
+
* @param threadKey - Thread key (defaults to `"messages"` inside the
|
|
85
|
+
* thread manager, but downstream adapters may pass
|
|
86
|
+
* their own value).
|
|
87
|
+
* @param threadId - Thread id as provided to the thread manager.
|
|
88
|
+
*/
|
|
89
|
+
export function getThreadStateKey(
|
|
90
|
+
threadKey: string,
|
|
91
|
+
threadId: string
|
|
92
|
+
): string {
|
|
93
|
+
return `${threadKey}:state:thread:${threadId}`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import type Redis from "ioredis";
|
|
3
|
+
import { createThreadManager } from "./manager";
|
|
4
|
+
import type { PersistedThreadState } from "../state/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal in-memory Redis stub exposing just the commands used by
|
|
8
|
+
* `createThreadManager`'s state methods (get/set/del/exists/expire) plus
|
|
9
|
+
* the list helpers needed for `initialize`.
|
|
10
|
+
*/
|
|
11
|
+
function createFakeRedis(): Redis {
|
|
12
|
+
const store = new Map<string, string>();
|
|
13
|
+
|
|
14
|
+
const redis = {
|
|
15
|
+
async get(key: string): Promise<string | null> {
|
|
16
|
+
return store.has(key) ? (store.get(key) as string) : null;
|
|
17
|
+
},
|
|
18
|
+
async set(key: string, value: string): Promise<"OK"> {
|
|
19
|
+
store.set(key, String(value));
|
|
20
|
+
return "OK";
|
|
21
|
+
},
|
|
22
|
+
async del(...keys: string[]): Promise<number> {
|
|
23
|
+
let removed = 0;
|
|
24
|
+
for (const k of keys) {
|
|
25
|
+
if (store.delete(k)) removed++;
|
|
26
|
+
}
|
|
27
|
+
return removed;
|
|
28
|
+
},
|
|
29
|
+
async exists(...keys: string[]): Promise<number> {
|
|
30
|
+
return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
|
|
31
|
+
},
|
|
32
|
+
async expire(_key: string, _ttl: number): Promise<number> {
|
|
33
|
+
return 1;
|
|
34
|
+
},
|
|
35
|
+
async lrange(): Promise<string[]> {
|
|
36
|
+
return [];
|
|
37
|
+
},
|
|
38
|
+
async rpush(): Promise<number> {
|
|
39
|
+
return 0;
|
|
40
|
+
},
|
|
41
|
+
_store: store,
|
|
42
|
+
} as unknown as Redis & { _store: Map<string, string> };
|
|
43
|
+
|
|
44
|
+
return redis;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const baseSlice: PersistedThreadState = {
|
|
48
|
+
tasks: [
|
|
49
|
+
[
|
|
50
|
+
"t1",
|
|
51
|
+
{
|
|
52
|
+
id: "t1",
|
|
53
|
+
subject: "do a thing",
|
|
54
|
+
description: "do it",
|
|
55
|
+
activeForm: "doing a thing",
|
|
56
|
+
status: "pending",
|
|
57
|
+
metadata: {},
|
|
58
|
+
blockedBy: [],
|
|
59
|
+
blocks: [],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
],
|
|
63
|
+
custom: { counter: 7, label: "hello" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe("createThreadManager state persistence", () => {
|
|
67
|
+
let redis: Redis & { _store: Map<string, string> };
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
redis = createFakeRedis() as Redis & { _store: Map<string, string> };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function initThread(threadId: string): Promise<void> {
|
|
74
|
+
const tm = createThreadManager({ redis, threadId });
|
|
75
|
+
await tm.initialize();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
it("loadState returns null when nothing has been saved", async () => {
|
|
79
|
+
await initThread("thread-1");
|
|
80
|
+
const tm = createThreadManager({ redis, threadId: "thread-1" });
|
|
81
|
+
expect(await tm.loadState()).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("saveState writes a round-trippable JSON slice", async () => {
|
|
85
|
+
await initThread("thread-1");
|
|
86
|
+
const tm = createThreadManager({ redis, threadId: "thread-1" });
|
|
87
|
+
await tm.saveState(baseSlice);
|
|
88
|
+
|
|
89
|
+
const loaded = await tm.loadState();
|
|
90
|
+
expect(loaded).toEqual(baseSlice);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("saveState throws if the thread was never initialized", async () => {
|
|
94
|
+
const tm = createThreadManager({ redis, threadId: "missing" });
|
|
95
|
+
await expect(tm.saveState(baseSlice)).rejects.toThrow(/does not exist/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("fork copies the persisted state slice to the new thread", async () => {
|
|
99
|
+
await initThread("source");
|
|
100
|
+
const src = createThreadManager({ redis, threadId: "source" });
|
|
101
|
+
await src.saveState(baseSlice);
|
|
102
|
+
|
|
103
|
+
const dst = await src.fork("target");
|
|
104
|
+
|
|
105
|
+
expect(await dst.loadState()).toEqual(baseSlice);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("fork leaves the new thread's slice null when source has none", async () => {
|
|
109
|
+
await initThread("source");
|
|
110
|
+
const src = createThreadManager({ redis, threadId: "source" });
|
|
111
|
+
|
|
112
|
+
const dst = await src.fork("target");
|
|
113
|
+
|
|
114
|
+
expect(await dst.loadState()).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("deleteState removes only the state key", async () => {
|
|
118
|
+
await initThread("thread-1");
|
|
119
|
+
const tm = createThreadManager({ redis, threadId: "thread-1" });
|
|
120
|
+
await tm.saveState(baseSlice);
|
|
121
|
+
|
|
122
|
+
await tm.deleteState();
|
|
123
|
+
|
|
124
|
+
expect(await tm.loadState()).toBeNull();
|
|
125
|
+
const keys = Array.from(redis._store.keys());
|
|
126
|
+
expect(keys.some((k) => k.includes(":meta:"))).toBe(true);
|
|
127
|
+
expect(keys.some((k) => k.includes(":state"))).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("delete removes messages + meta + state together", async () => {
|
|
131
|
+
await initThread("thread-1");
|
|
132
|
+
const tm = createThreadManager({ redis, threadId: "thread-1" });
|
|
133
|
+
await tm.saveState(baseSlice);
|
|
134
|
+
|
|
135
|
+
await tm.delete();
|
|
136
|
+
|
|
137
|
+
expect(redis._store.size).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import type { PersistedThreadState } from "../state/types";
|
|
1
2
|
import type { ThreadManagerConfig, BaseThreadManager } from "./types";
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
import {
|
|
4
|
+
THREAD_TTL_SECONDS,
|
|
5
|
+
getThreadListKey,
|
|
6
|
+
getThreadMetaKey,
|
|
7
|
+
getThreadStateKey,
|
|
8
|
+
} from "./keys";
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Lua script for atomic idempotent append.
|
|
@@ -23,8 +28,8 @@ redis.call('SET', KEYS[1], '1', 'EX', tonumber(ARGV[1]))
|
|
|
23
28
|
return 1
|
|
24
29
|
`;
|
|
25
30
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
31
|
+
function getDedupKey(threadId: string, id: string): string {
|
|
32
|
+
return `dedup:${id}:thread:${threadId}`;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -42,8 +47,9 @@ export function createThreadManager<T>(
|
|
|
42
47
|
deserialize = (raw: string): T => JSON.parse(raw) as T,
|
|
43
48
|
idOf,
|
|
44
49
|
} = config;
|
|
45
|
-
const redisKey =
|
|
46
|
-
const metaKey =
|
|
50
|
+
const redisKey = getThreadListKey(key, threadId);
|
|
51
|
+
const metaKey = getThreadMetaKey(key, threadId);
|
|
52
|
+
const stateKey = getThreadStateKey(key, threadId);
|
|
47
53
|
|
|
48
54
|
async function assertThreadExists(): Promise<void> {
|
|
49
55
|
const exists = await redis.exists(metaKey);
|
|
@@ -70,7 +76,7 @@ export function createThreadManager<T>(
|
|
|
70
76
|
|
|
71
77
|
if (idOf) {
|
|
72
78
|
const dedupId = messages.map(idOf).join(":");
|
|
73
|
-
const dedupKey =
|
|
79
|
+
const dedupKey = getDedupKey(threadId, dedupId);
|
|
74
80
|
await redis.eval(
|
|
75
81
|
APPEND_IDEMPOTENT_SCRIPT,
|
|
76
82
|
2,
|
|
@@ -88,21 +94,70 @@ export function createThreadManager<T>(
|
|
|
88
94
|
async fork(newThreadId: string): Promise<BaseThreadManager<T>> {
|
|
89
95
|
await assertThreadExists();
|
|
90
96
|
const data = await redis.lrange(redisKey, 0, -1);
|
|
97
|
+
const stateRaw = await redis.get(stateKey);
|
|
91
98
|
const forked = createThreadManager({
|
|
92
99
|
...config,
|
|
93
100
|
threadId: newThreadId,
|
|
94
101
|
});
|
|
95
102
|
await forked.initialize();
|
|
96
103
|
if (data.length > 0) {
|
|
97
|
-
const newKey =
|
|
104
|
+
const newKey = getThreadListKey(key, newThreadId);
|
|
98
105
|
await redis.rpush(newKey, ...data);
|
|
99
106
|
await redis.expire(newKey, THREAD_TTL_SECONDS);
|
|
100
107
|
}
|
|
108
|
+
if (stateRaw != null) {
|
|
109
|
+
const newStateKey = getThreadStateKey(key, newThreadId);
|
|
110
|
+
await redis.set(newStateKey, stateRaw, "EX", THREAD_TTL_SECONDS);
|
|
111
|
+
}
|
|
101
112
|
return forked;
|
|
102
113
|
},
|
|
103
114
|
|
|
115
|
+
async replaceAll(messages: T[]): Promise<void> {
|
|
116
|
+
await assertThreadExists();
|
|
117
|
+
if (!idOf) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"replaceAll requires the thread manager to be configured with `idOf`"
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const existing = await redis.lrange(redisKey, 0, -1);
|
|
123
|
+
const existingIds = existing
|
|
124
|
+
.map((raw) => idOf(deserialize(raw)))
|
|
125
|
+
.filter((id): id is string => typeof id === "string");
|
|
126
|
+
await redis.del(redisKey);
|
|
127
|
+
if (existingIds.length > 0) {
|
|
128
|
+
await redis.del(
|
|
129
|
+
...existingIds.map((id) => getDedupKey(threadId, id))
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (messages.length > 0) {
|
|
133
|
+
await redis.rpush(redisKey, ...messages.map(serialize));
|
|
134
|
+
await redis.expire(redisKey, THREAD_TTL_SECONDS);
|
|
135
|
+
}
|
|
136
|
+
await redis.expire(metaKey, THREAD_TTL_SECONDS);
|
|
137
|
+
},
|
|
138
|
+
|
|
104
139
|
async delete(): Promise<void> {
|
|
105
|
-
await redis.del(redisKey, metaKey);
|
|
140
|
+
await redis.del(redisKey, metaKey, stateKey);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async loadState(): Promise<PersistedThreadState | null> {
|
|
144
|
+
const raw = await redis.get(stateKey);
|
|
145
|
+
if (raw == null) return null;
|
|
146
|
+
return JSON.parse(raw) as PersistedThreadState;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async saveState(state: PersistedThreadState): Promise<void> {
|
|
150
|
+
await assertThreadExists();
|
|
151
|
+
await redis.set(
|
|
152
|
+
stateKey,
|
|
153
|
+
JSON.stringify(state),
|
|
154
|
+
"EX",
|
|
155
|
+
THREAD_TTL_SECONDS
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async deleteState(): Promise<void> {
|
|
160
|
+
await redis.del(stateKey);
|
|
106
161
|
},
|
|
107
162
|
|
|
108
163
|
async length(): Promise<number> {
|
|
@@ -110,17 +165,40 @@ export function createThreadManager<T>(
|
|
|
110
165
|
return redis.llen(redisKey);
|
|
111
166
|
},
|
|
112
167
|
|
|
113
|
-
async
|
|
168
|
+
async truncateFromId(messageId: string): Promise<void> {
|
|
114
169
|
await assertThreadExists();
|
|
115
|
-
if (
|
|
170
|
+
if (!idOf) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"truncateFromId requires the thread manager to be configured with `idOf`"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const data = await redis.lrange(redisKey, 0, -1);
|
|
176
|
+
let idx = -1;
|
|
177
|
+
const removedIds: string[] = [];
|
|
178
|
+
for (let i = 0; i < data.length; i++) {
|
|
179
|
+
const raw = data[i];
|
|
180
|
+
if (raw === undefined) continue;
|
|
181
|
+
const id = idOf(deserialize(raw));
|
|
182
|
+
if (idx === -1 && id === messageId) idx = i;
|
|
183
|
+
if (idx !== -1) removedIds.push(id);
|
|
184
|
+
}
|
|
185
|
+
if (idx === -1) return;
|
|
186
|
+
if (idx === 0) {
|
|
116
187
|
await redis.del(redisKey);
|
|
117
188
|
await redis.expire(metaKey, THREAD_TTL_SECONDS);
|
|
118
189
|
} else {
|
|
119
|
-
await redis.ltrim(redisKey, 0,
|
|
190
|
+
await redis.ltrim(redisKey, 0, idx - 1);
|
|
120
191
|
await redis.expire(redisKey, THREAD_TTL_SECONDS);
|
|
121
192
|
}
|
|
122
|
-
//
|
|
123
|
-
//
|
|
193
|
+
// Clear dedup markers for the removed messages so that a rewind
|
|
194
|
+
// retry which reuses the same ids (e.g. the same assistantId) can
|
|
195
|
+
// re-append without the idempotent-append Lua script treating it
|
|
196
|
+
// as a duplicate.
|
|
197
|
+
if (removedIds.length > 0) {
|
|
198
|
+
await redis.del(
|
|
199
|
+
...removedIds.map((id) => getDedupKey(threadId, id))
|
|
200
|
+
);
|
|
201
|
+
}
|
|
124
202
|
},
|
|
125
203
|
};
|
|
126
204
|
}
|
package/src/lib/thread/proxy.ts
CHANGED
|
@@ -54,5 +54,7 @@ export function createThreadOpsProxy(
|
|
|
54
54
|
appendSystemMessage: acts[p("appendSystemMessage")],
|
|
55
55
|
forkThread: acts[p("forkThread")],
|
|
56
56
|
truncateThread: acts[p("truncateThread")],
|
|
57
|
+
loadThreadState: acts[p("loadThreadState")],
|
|
58
|
+
saveThreadState: acts[p("saveThreadState")],
|
|
57
59
|
} as ActivityInterfaceFor<ThreadOps>;
|
|
58
60
|
}
|
package/src/lib/thread/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type Redis from "ioredis";
|
|
2
|
-
import type { JsonValue } from "../state/types";
|
|
2
|
+
import type { JsonValue, PersistedThreadState } from "../state/types";
|
|
3
3
|
export interface ThreadManagerConfig<T> {
|
|
4
4
|
redis: Redis;
|
|
5
5
|
threadId: string;
|
|
@@ -34,17 +34,50 @@ export interface BaseThreadManager<T> {
|
|
|
34
34
|
* forks — each call creates an independent copy.
|
|
35
35
|
*/
|
|
36
36
|
fork(newThreadId: string): Promise<BaseThreadManager<T>>;
|
|
37
|
+
/**
|
|
38
|
+
* Atomically replace the entire contents of the thread with `messages`.
|
|
39
|
+
* The existing list is cleared, the new messages are appended in order,
|
|
40
|
+
* and dedup markers from prior appends are cleared so future idempotent
|
|
41
|
+
* appends with ids that were removed aren't silently skipped.
|
|
42
|
+
*
|
|
43
|
+
* Requires the thread manager to be configured with `idOf`.
|
|
44
|
+
*/
|
|
45
|
+
replaceAll(messages: T[]): Promise<void>;
|
|
37
46
|
/** Delete the thread */
|
|
38
47
|
delete(): Promise<void>;
|
|
39
48
|
/** Get the number of stored messages currently in the thread */
|
|
40
49
|
length(): Promise<number>;
|
|
41
50
|
/**
|
|
42
|
-
* Truncate the thread
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
51
|
+
* Truncate the thread starting at the message with id `messageId`.
|
|
52
|
+
* That message and every message after it are removed. If `messageId`
|
|
53
|
+
* is not present in the thread this is a no-op — useful as the
|
|
54
|
+
* "truncate on entry" step of the `runAgent` activity, which becomes a
|
|
55
|
+
* no-op on the first attempt and a cleanup on Temporal workflow reset
|
|
56
|
+
* or in-workflow rewind retries.
|
|
57
|
+
*
|
|
58
|
+
* Dedup markers for removed single-message appends are also cleared so
|
|
59
|
+
* that appending the same id again (e.g. the same assistant message id
|
|
60
|
+
* on a rewind retry) is not silently skipped.
|
|
61
|
+
*
|
|
62
|
+
* Requires the thread manager to be configured with `idOf`.
|
|
63
|
+
*/
|
|
64
|
+
truncateFromId(messageId: string): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Load the persisted state slice associated with this thread, or
|
|
67
|
+
* `null` if none has been saved yet. Safe to call on any thread —
|
|
68
|
+
* treats a missing slice as a non-error.
|
|
46
69
|
*/
|
|
47
|
-
|
|
70
|
+
loadState(): Promise<PersistedThreadState | null>;
|
|
71
|
+
/**
|
|
72
|
+
* Overwrite the persisted state slice for this thread. The thread
|
|
73
|
+
* itself must already exist (same TTL as the message list).
|
|
74
|
+
*
|
|
75
|
+
* Note: {@link BaseThreadManager.fork} already copies the slice to
|
|
76
|
+
* the new thread, so there's no separate `forkState` method.
|
|
77
|
+
*/
|
|
78
|
+
saveState(state: PersistedThreadState): Promise<void>;
|
|
79
|
+
/** Delete just the persisted state slice, leaving messages intact. */
|
|
80
|
+
deleteState(): Promise<void>;
|
|
48
81
|
}
|
|
49
82
|
|
|
50
83
|
/**
|
|
@@ -81,6 +114,27 @@ export interface ThreadManagerHooks<TStored, TPrepared = TStored> {
|
|
|
81
114
|
index: number,
|
|
82
115
|
messages: readonly TPrepared[]
|
|
83
116
|
) => TPrepared;
|
|
117
|
+
/**
|
|
118
|
+
* One-shot list-level pre-pass applied once when a thread is forked with
|
|
119
|
+
* `transform: true`. Runs before {@link onForkTransform}. May filter,
|
|
120
|
+
* compact, prepend, or otherwise rewrite the whole forked thread — so the
|
|
121
|
+
* returned length need not match the input length. Async, so implementations
|
|
122
|
+
* may call an LLM or other I/O.
|
|
123
|
+
*/
|
|
124
|
+
onForkPrepareThread?: (
|
|
125
|
+
messages: readonly TStored[]
|
|
126
|
+
) => TStored[] | Promise<TStored[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Per-message final pass applied once when a thread is forked with
|
|
129
|
+
* `transform: true`. Runs after {@link onForkPrepareThread}. Pure 1:1 map —
|
|
130
|
+
* must return a value for every input message; length cannot change. Same
|
|
131
|
+
* shape as {@link onPreparedMessage}.
|
|
132
|
+
*/
|
|
133
|
+
onForkTransform?: (
|
|
134
|
+
message: TStored,
|
|
135
|
+
index: number,
|
|
136
|
+
messages: readonly TStored[]
|
|
137
|
+
) => TStored;
|
|
84
138
|
}
|
|
85
139
|
|
|
86
140
|
export interface ProviderThreadManager<
|