zeitlich 0.2.45 → 0.2.46
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 +78 -10
- package/dist/{activities-CrN-ghLo.d.ts → activities-Bm4TLTid.d.ts} +22 -2
- package/dist/{activities-Coafq5zr.d.cts → activities-CyeiqK_f.d.cts} +22 -2
- package/dist/adapters/thread/anthropic/index.cjs +171 -65
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +19 -4
- package/dist/adapters/thread/anthropic/index.d.ts +19 -4
- package/dist/adapters/thread/anthropic/index.js +171 -65
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.cjs +3 -1
- package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +4 -4
- package/dist/adapters/thread/anthropic/workflow.d.ts +4 -4
- package/dist/adapters/thread/anthropic/workflow.js +3 -1
- package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.cjs +171 -69
- 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 +171 -69
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.cjs +3 -1
- 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 +3 -1
- package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
- package/dist/adapters/thread/langchain/index.cjs +170 -66
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +18 -4
- package/dist/adapters/thread/langchain/index.d.ts +18 -4
- package/dist/adapters/thread/langchain/index.js +170 -66
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.cjs +3 -1
- package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +4 -4
- package/dist/adapters/thread/langchain/workflow.d.ts +4 -4
- package/dist/adapters/thread/langchain/workflow.js +3 -1
- package/dist/adapters/thread/langchain/workflow.js.map +1 -1
- package/dist/cold-store-BC5L5Z8A.d.cts +117 -0
- package/dist/cold-store-CFHwemBJ.d.ts +117 -0
- package/dist/index.cjs +226 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +138 -8
- package/dist/index.d.ts +138 -8
- package/dist/index.js +220 -28
- package/dist/index.js.map +1 -1
- package/dist/{proxy-Bf7uI-Hw.d.cts → proxy-BxFyd6cg.d.cts} +1 -1
- package/dist/{proxy-COqA95FW.d.ts → proxy-Cskmj4Yx.d.ts} +1 -1
- package/dist/{thread-manager-BsLO3Fgc.d.cts → thread-manager-9tezUcLW.d.cts} +8 -2
- package/dist/{thread-manager-Bi1XlbpJ.d.ts → thread-manager-B-zy3xrs.d.ts} +8 -2
- package/dist/{thread-manager-wRVVBFgj.d.cts → thread-manager-D33SUmZa.d.cts} +8 -2
- package/dist/{thread-manager-BhkOyQ1I.d.ts → thread-manager-DduoSkvJ.d.ts} +8 -2
- package/dist/{types-CdALEF3z.d.cts → types-CnuN9T6t.d.cts} +22 -0
- package/dist/{types-ChAy_jSP.d.ts → types-CwN6_tAL.d.ts} +22 -0
- package/dist/{types-BkX4HLzi.d.ts → types-L5bvbF-n.d.ts} +17 -1
- package/dist/{types-C66-BVBr.d.cts → types-oxt8GN97.d.cts} +17 -1
- package/dist/{workflow-BwT5EybR.d.ts → workflow-B1TOcHbt.d.ts} +33 -2
- package/dist/{workflow-DMmiaw6w.d.cts → workflow-DIaIV7L2.d.cts} +33 -2
- package/dist/workflow.cjs +14 -1
- 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 +14 -1
- package/dist/workflow.js.map +1 -1
- package/package.json +6 -1
- package/src/adapters/thread/anthropic/activities.ts +72 -36
- package/src/adapters/thread/anthropic/thread-manager.ts +9 -1
- package/src/adapters/thread/google-genai/activities.ts +64 -40
- package/src/adapters/thread/google-genai/thread-manager.ts +9 -1
- package/src/adapters/thread/langchain/activities.ts +63 -36
- package/src/adapters/thread/langchain/thread-manager.ts +9 -1
- package/src/index.ts +20 -1
- package/src/lib/session/session-edge-cases.integration.test.ts +12 -0
- package/src/lib/session/session.integration.test.ts +138 -0
- package/src/lib/session/session.ts +29 -0
- package/src/lib/session/types.ts +22 -0
- package/src/lib/thread/cold-store.test.ts +193 -0
- package/src/lib/thread/cold-store.ts +250 -0
- package/src/lib/thread/index.ts +32 -0
- package/src/lib/thread/keys.ts +20 -0
- package/src/lib/thread/manager.ts +16 -27
- package/src/lib/thread/proxy.ts +2 -0
- package/src/lib/thread/snapshot.test.ts +443 -0
- package/src/lib/thread/snapshot.ts +163 -0
- package/src/lib/thread/test-utils.ts +228 -0
- package/src/lib/thread/tiered.test.ts +281 -0
- package/src/lib/thread/tiered.ts +135 -0
- package/src/lib/thread/types.ts +16 -0
package/README.md
CHANGED
|
@@ -103,6 +103,7 @@ npm install zeitlich ioredis \
|
|
|
103
103
|
- `@langchain/core` >= 1.0.0 (optional — only when using the LangChain adapter)
|
|
104
104
|
- `@google/genai` >= 1.0.0 (optional — only when using the Google GenAI adapter)
|
|
105
105
|
- `@aws-sdk/client-bedrock-agentcore` >= 3.900.0 (optional — only when using the Bedrock adapter)
|
|
106
|
+
- `@aws-sdk/client-s3` >= 3.700.0 (optional — only when using the built-in S3 cold thread tier)
|
|
106
107
|
|
|
107
108
|
> **Why peer deps?** Zeitlich's public API surfaces `@temporalio/*` types
|
|
108
109
|
> (`UpdateDefinition`, `ChildWorkflowOptions`, `Duration`, etc.) directly. Peer
|
|
@@ -666,6 +667,68 @@ const continuedSession = await createSession({
|
|
|
666
667
|
|
|
667
668
|
`getShortId()` produces compact, workflow-deterministic IDs (~12 base-62 chars) that are more token-efficient than UUIDs.
|
|
668
669
|
|
|
670
|
+
#### Tiered Thread Storage (Redis hot + S3 cold)
|
|
671
|
+
|
|
672
|
+
By default every thread lives in Redis with a 90-day TTL — both messages and the persisted state slice. For long-lived agents, that ties up hot memory for inactive conversations and ties durability to your Redis retention. Zeitlich's tiered storage moves cold threads to a durable archive (S3, R2, GCS, …) while keeping Redis as the hot tier only for the duration of a workflow run.
|
|
673
|
+
|
|
674
|
+
| Tier | Backend | Lifetime |
|
|
675
|
+
| ----- | ---------------------- | ------------------------------------------------- |
|
|
676
|
+
| Hot | Redis | Only while a workflow run is active (configurable TTL) |
|
|
677
|
+
| Cold | Pluggable `ColdThreadStore` (built-in S3) | Durable across runs |
|
|
678
|
+
|
|
679
|
+
The session wiring is fully automatic:
|
|
680
|
+
|
|
681
|
+
- On entry — `mode: "continue"` and `mode: "fork"` call `hydrateThread`, which restores the latest cold-tier snapshot into Redis if Redis is cold. Idempotent (safe for Temporal activity retries).
|
|
682
|
+
- In `finally{}` — every exit path calls `flushThread` after `saveThreadState`, which writes the current Redis contents to the cold tier and (by default) deletes the Redis keys. A near-immediate `continue` re-hydrates in a single `GetObject`.
|
|
683
|
+
|
|
684
|
+
##### Wiring the built-in S3 cold store
|
|
685
|
+
|
|
686
|
+
```typescript
|
|
687
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
688
|
+
import { createS3ColdStore } from "zeitlich";
|
|
689
|
+
import { createAnthropicAdapter } from "zeitlich/adapters/thread/anthropic";
|
|
690
|
+
|
|
691
|
+
const coldStore = createS3ColdStore({
|
|
692
|
+
s3: new S3Client({ region: "us-east-1" }),
|
|
693
|
+
bucket: "my-threads",
|
|
694
|
+
prefix: "prod/threads",
|
|
695
|
+
// gzip: true (default) — message lists compress well
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const adapter = createAnthropicAdapter({
|
|
699
|
+
redis,
|
|
700
|
+
client: anthropic,
|
|
701
|
+
model: "claude-sonnet-4-20250514",
|
|
702
|
+
coldStore,
|
|
703
|
+
// Recommended: drop the Redis TTL to a small window when cold tiering
|
|
704
|
+
// is enabled. The cold tier is the source of truth.
|
|
705
|
+
ttlSeconds: 60 * 60 * 24, // 24h
|
|
706
|
+
});
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
That's the only change required — `createSession`, all `ThreadInit` modes, and every adapter activity are already wired for the lifecycle. When `coldStore` is omitted, the adapter behaves identically to the Redis-only baseline.
|
|
710
|
+
|
|
711
|
+
##### Custom backends
|
|
712
|
+
|
|
713
|
+
`ColdThreadStore` is intentionally minimal:
|
|
714
|
+
|
|
715
|
+
```typescript
|
|
716
|
+
interface ColdThreadStore {
|
|
717
|
+
read(threadKey: string, threadId: string): Promise<ThreadSnapshot | null>;
|
|
718
|
+
write(threadKey: string, threadId: string, snapshot: ThreadSnapshot): Promise<void>;
|
|
719
|
+
delete(threadKey: string, threadId: string): Promise<void>;
|
|
720
|
+
}
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Any backend that can satisfy these three calls — Cloudflare R2, Google Cloud Storage, Postgres, the local filesystem — can plug into the same tiered manager. `ThreadSnapshot` is one JSON-friendly blob per thread; round-trip it however you like.
|
|
724
|
+
|
|
725
|
+
##### Trade-offs
|
|
726
|
+
|
|
727
|
+
- **Cold writes are session-boundary only.** No per-append S3 traffic. Long-running sessions are durable via Temporal workflow history + Redis TTL.
|
|
728
|
+
- **Single-writer assumed.** Two sessions started simultaneously on the same `threadId` would race on flush. Use distinct `threadId`s or coordinate at a layer above zeitlich.
|
|
729
|
+
- **`deleteHot: true` by default on flush.** Memory drops immediately; the next continue re-hydrates in one `GetObject`. Override per-call via the tiered manager if you want to keep the hot tier warm.
|
|
730
|
+
- **`mode: "new"` overwrites the cold archive for that `threadId`.** A session entered with `mode: "new"` skips `hydrateThread`; on exit `flushThread` writes the fresh snapshot back, silently replacing any prior cold-tier blob at the same `(threadKey, threadId)`. To resume a thread, use `mode: "continue"` or `mode: "fork"` — passing a previously-used `threadId` with `mode: "new"` is destructive by design.
|
|
731
|
+
|
|
669
732
|
#### Sandbox Initialization (`SandboxInit`)
|
|
670
733
|
|
|
671
734
|
The `sandbox` field controls how a sandbox is created or reused:
|
|
@@ -983,16 +1046,21 @@ Safe for use in Temporal workflow files:
|
|
|
983
1046
|
|
|
984
1047
|
Framework-agnostic utilities for activities, worker setup, and Node.js code:
|
|
985
1048
|
|
|
986
|
-
| Export
|
|
987
|
-
|
|
|
988
|
-
| `createRunAgentActivity`
|
|
989
|
-
| `withParentWorkflowState`
|
|
990
|
-
| `createThreadManager`
|
|
991
|
-
| `
|
|
992
|
-
| `
|
|
993
|
-
| `
|
|
994
|
-
| `
|
|
995
|
-
|
|
|
1049
|
+
| Export | Description |
|
|
1050
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
1051
|
+
| `createRunAgentActivity` | Wraps a handler into a scope-prefixed `RunAgentActivity` with auto-fetched parent workflow state |
|
|
1052
|
+
| `withParentWorkflowState` | Wraps a tool handler into an `ActivityToolHandler` with auto-fetched parent workflow state |
|
|
1053
|
+
| `createThreadManager` | Generic Redis-backed thread manager factory |
|
|
1054
|
+
| `createTieredThreadManager` | Redis hot + pluggable cold tier; adds `hydrate()` / `flush()` to `BaseThreadManager<T>` |
|
|
1055
|
+
| `createS3ColdStore` | Built-in `ColdThreadStore` backed by an `@aws-sdk/client-s3` `S3Client` |
|
|
1056
|
+
| `encodeSnapshot` | Low-level helper that builds a `ThreadSnapshot` from the hot-tier Redis state |
|
|
1057
|
+
| `applySnapshot` | Low-level helper that restores a `ThreadSnapshot` into Redis (idempotent) |
|
|
1058
|
+
| `clearHotTier` | Low-level helper that deletes every Redis key the thread manager wrote for a given `(threadKey, threadId)` |
|
|
1059
|
+
| `toTree` | Generate file tree string from an `IFileSystem` instance |
|
|
1060
|
+
| `withSandbox` | Wraps a handler to auto-resolve sandbox from context (pairs with `withAutoAppend`) |
|
|
1061
|
+
| `NodeFsSandboxFileSystem` | `node:fs` adapter for `SandboxFileSystem` — read skills from the worker's local disk |
|
|
1062
|
+
| `FileSystemSkillProvider` | Load skills from a directory following the agentskills.io layout |
|
|
1063
|
+
| Tool handlers | `bashHandler`, `editHandler`, `globHandler`, `readFileHandler`, `writeFileHandler`, `createAskUserQuestionHandler` |
|
|
996
1064
|
|
|
997
1065
|
### Thread Adapter Entry Points
|
|
998
1066
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Redis from 'ioredis';
|
|
2
2
|
import { Part, Content, GoogleGenAI } from '@google/genai';
|
|
3
|
-
import { a as ModelInvoker, b as PrefixedThreadOps, S as ScopedPrefix, R as RouterContext, c as ToolHandlerResponse, d as ActivityToolHandler } from './types-
|
|
4
|
-
import {
|
|
3
|
+
import { a as ModelInvoker, b as PrefixedThreadOps, S as ScopedPrefix, R as RouterContext, c as ToolHandlerResponse, d as ActivityToolHandler } from './types-CwN6_tAL.js';
|
|
4
|
+
import { C as ColdThreadStore } from './cold-store-CFHwemBJ.js';
|
|
5
|
+
import { T as ThreadManagerHooks, P as ProviderThreadManager } from './types-L5bvbF-n.js';
|
|
5
6
|
import { A as ADAPTER_ID } from './adapter-id-BB-mmrts.js';
|
|
6
7
|
|
|
7
8
|
/** SDK-native content type for Google GenAI human messages */
|
|
@@ -20,6 +21,12 @@ interface GoogleGenAIThreadManagerConfig {
|
|
|
20
21
|
/** Thread key, defaults to 'messages' */
|
|
21
22
|
key?: string;
|
|
22
23
|
hooks?: GoogleGenAIThreadManagerHooks;
|
|
24
|
+
/**
|
|
25
|
+
* Override the default thread TTL (90 days). When pairing the
|
|
26
|
+
* adapter with a durable cold tier, a shorter TTL (hours) is
|
|
27
|
+
* typically more appropriate.
|
|
28
|
+
*/
|
|
29
|
+
ttlSeconds?: number;
|
|
23
30
|
}
|
|
24
31
|
/** Prepared payload ready to send to the Google GenAI API */
|
|
25
32
|
interface GoogleGenAIInvocationPayload {
|
|
@@ -45,6 +52,19 @@ interface GoogleGenAIAdapterConfig {
|
|
|
45
52
|
/** Default model name (e.g. 'gemini-2.5-flash'). If omitted, use `createModelInvoker()` */
|
|
46
53
|
model?: string;
|
|
47
54
|
hooks?: GoogleGenAIThreadManagerHooks;
|
|
55
|
+
/**
|
|
56
|
+
* Optional durable cold tier (e.g. S3, R2, GCS). When provided,
|
|
57
|
+
* the session hydrates the thread on entry (`continue`/`fork`) and
|
|
58
|
+
* flushes it on every exit path. When omitted, the adapter is
|
|
59
|
+
* Redis-only and `hydrateThread`/`flushThread` activities are no-ops.
|
|
60
|
+
*/
|
|
61
|
+
coldStore?: ColdThreadStore;
|
|
62
|
+
/**
|
|
63
|
+
* Override the default Redis TTL (90 days). When pairing the
|
|
64
|
+
* adapter with a `coldStore`, a shorter TTL (hours) is typically
|
|
65
|
+
* more appropriate.
|
|
66
|
+
*/
|
|
67
|
+
ttlSeconds?: number;
|
|
48
68
|
}
|
|
49
69
|
/**
|
|
50
70
|
* Tool response type accepted by the Google GenAI adapter.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Redis from 'ioredis';
|
|
2
2
|
import { Part, Content, GoogleGenAI } from '@google/genai';
|
|
3
|
-
import { a as ModelInvoker, b as PrefixedThreadOps, S as ScopedPrefix, R as RouterContext, c as ToolHandlerResponse, d as ActivityToolHandler } from './types-
|
|
4
|
-
import {
|
|
3
|
+
import { a as ModelInvoker, b as PrefixedThreadOps, S as ScopedPrefix, R as RouterContext, c as ToolHandlerResponse, d as ActivityToolHandler } from './types-CnuN9T6t.cjs';
|
|
4
|
+
import { C as ColdThreadStore } from './cold-store-BC5L5Z8A.cjs';
|
|
5
|
+
import { T as ThreadManagerHooks, P as ProviderThreadManager } from './types-oxt8GN97.cjs';
|
|
5
6
|
import { A as ADAPTER_ID } from './adapter-id-BB-mmrts.cjs';
|
|
6
7
|
|
|
7
8
|
/** SDK-native content type for Google GenAI human messages */
|
|
@@ -20,6 +21,12 @@ interface GoogleGenAIThreadManagerConfig {
|
|
|
20
21
|
/** Thread key, defaults to 'messages' */
|
|
21
22
|
key?: string;
|
|
22
23
|
hooks?: GoogleGenAIThreadManagerHooks;
|
|
24
|
+
/**
|
|
25
|
+
* Override the default thread TTL (90 days). When pairing the
|
|
26
|
+
* adapter with a durable cold tier, a shorter TTL (hours) is
|
|
27
|
+
* typically more appropriate.
|
|
28
|
+
*/
|
|
29
|
+
ttlSeconds?: number;
|
|
23
30
|
}
|
|
24
31
|
/** Prepared payload ready to send to the Google GenAI API */
|
|
25
32
|
interface GoogleGenAIInvocationPayload {
|
|
@@ -45,6 +52,19 @@ interface GoogleGenAIAdapterConfig {
|
|
|
45
52
|
/** Default model name (e.g. 'gemini-2.5-flash'). If omitted, use `createModelInvoker()` */
|
|
46
53
|
model?: string;
|
|
47
54
|
hooks?: GoogleGenAIThreadManagerHooks;
|
|
55
|
+
/**
|
|
56
|
+
* Optional durable cold tier (e.g. S3, R2, GCS). When provided,
|
|
57
|
+
* the session hydrates the thread on entry (`continue`/`fork`) and
|
|
58
|
+
* flushes it on every exit path. When omitted, the adapter is
|
|
59
|
+
* Redis-only and `hydrateThread`/`flushThread` activities are no-ops.
|
|
60
|
+
*/
|
|
61
|
+
coldStore?: ColdThreadStore;
|
|
62
|
+
/**
|
|
63
|
+
* Override the default Redis TTL (90 days). When pairing the
|
|
64
|
+
* adapter with a `coldStore`, a shorter TTL (hours) is typically
|
|
65
|
+
* more appropriate.
|
|
66
|
+
*/
|
|
67
|
+
ttlSeconds?: number;
|
|
48
68
|
}
|
|
49
69
|
/**
|
|
50
70
|
* Tool response type accepted by the Google GenAI adapter.
|
|
@@ -16,6 +16,9 @@ function getThreadMetaKey(threadKey, threadId) {
|
|
|
16
16
|
function getThreadStateKey(threadKey, threadId) {
|
|
17
17
|
return `${threadKey}:state:thread:${threadId}`;
|
|
18
18
|
}
|
|
19
|
+
function getThreadDedupKey(threadId, dedupId) {
|
|
20
|
+
return `dedup:${dedupId}:thread:${threadId}`;
|
|
21
|
+
}
|
|
19
22
|
|
|
20
23
|
// src/lib/thread/manager.ts
|
|
21
24
|
var APPEND_IDEMPOTENT_SCRIPT = `
|
|
@@ -29,9 +32,6 @@ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[1]))
|
|
|
29
32
|
redis.call('SET', KEYS[1], '1', 'EX', tonumber(ARGV[1]))
|
|
30
33
|
return 1
|
|
31
34
|
`;
|
|
32
|
-
function getDedupKey(threadId, id) {
|
|
33
|
-
return `dedup:${id}:thread:${threadId}`;
|
|
34
|
-
}
|
|
35
35
|
function createThreadManager(config) {
|
|
36
36
|
const {
|
|
37
37
|
redis,
|
|
@@ -39,11 +39,13 @@ function createThreadManager(config) {
|
|
|
39
39
|
key = "messages",
|
|
40
40
|
serialize = (m) => JSON.stringify(m),
|
|
41
41
|
deserialize = (raw) => JSON.parse(raw),
|
|
42
|
-
idOf
|
|
42
|
+
idOf,
|
|
43
|
+
ttlSeconds = THREAD_TTL_SECONDS
|
|
43
44
|
} = config;
|
|
44
45
|
const redisKey = getThreadListKey(key, threadId);
|
|
45
46
|
const metaKey = getThreadMetaKey(key, threadId);
|
|
46
47
|
const stateKey = getThreadStateKey(key, threadId);
|
|
48
|
+
const dedupKey = (id) => getThreadDedupKey(threadId, id);
|
|
47
49
|
async function assertThreadExists() {
|
|
48
50
|
const exists = await redis.exists(metaKey);
|
|
49
51
|
if (!exists) {
|
|
@@ -53,7 +55,7 @@ function createThreadManager(config) {
|
|
|
53
55
|
return {
|
|
54
56
|
async initialize() {
|
|
55
57
|
await redis.del(redisKey);
|
|
56
|
-
await redis.set(metaKey, "1", "EX",
|
|
58
|
+
await redis.set(metaKey, "1", "EX", ttlSeconds);
|
|
57
59
|
},
|
|
58
60
|
async load() {
|
|
59
61
|
await assertThreadExists();
|
|
@@ -65,18 +67,17 @@ function createThreadManager(config) {
|
|
|
65
67
|
await assertThreadExists();
|
|
66
68
|
if (idOf) {
|
|
67
69
|
const dedupId = messages.map(idOf).join(":");
|
|
68
|
-
const dedupKey = getDedupKey(threadId, dedupId);
|
|
69
70
|
await redis.eval(
|
|
70
71
|
APPEND_IDEMPOTENT_SCRIPT,
|
|
71
72
|
2,
|
|
72
|
-
dedupKey,
|
|
73
|
+
dedupKey(dedupId),
|
|
73
74
|
redisKey,
|
|
74
|
-
String(
|
|
75
|
+
String(ttlSeconds),
|
|
75
76
|
...messages.map(serialize)
|
|
76
77
|
);
|
|
77
78
|
} else {
|
|
78
79
|
await redis.rpush(redisKey, ...messages.map(serialize));
|
|
79
|
-
await redis.expire(redisKey,
|
|
80
|
+
await redis.expire(redisKey, ttlSeconds);
|
|
80
81
|
}
|
|
81
82
|
},
|
|
82
83
|
async fork(newThreadId) {
|
|
@@ -91,11 +92,11 @@ function createThreadManager(config) {
|
|
|
91
92
|
if (data.length > 0) {
|
|
92
93
|
const newKey = getThreadListKey(key, newThreadId);
|
|
93
94
|
await redis.rpush(newKey, ...data);
|
|
94
|
-
await redis.expire(newKey,
|
|
95
|
+
await redis.expire(newKey, ttlSeconds);
|
|
95
96
|
}
|
|
96
97
|
if (stateRaw != null) {
|
|
97
98
|
const newStateKey = getThreadStateKey(key, newThreadId);
|
|
98
|
-
await redis.set(newStateKey, stateRaw, "EX",
|
|
99
|
+
await redis.set(newStateKey, stateRaw, "EX", ttlSeconds);
|
|
99
100
|
}
|
|
100
101
|
return forked;
|
|
101
102
|
},
|
|
@@ -110,15 +111,13 @@ function createThreadManager(config) {
|
|
|
110
111
|
const existingIds = existing.map((raw) => idOf(deserialize(raw))).filter((id) => typeof id === "string");
|
|
111
112
|
await redis.del(redisKey);
|
|
112
113
|
if (existingIds.length > 0) {
|
|
113
|
-
await redis.del(
|
|
114
|
-
...existingIds.map((id) => getDedupKey(threadId, id))
|
|
115
|
-
);
|
|
114
|
+
await redis.del(...existingIds.map(dedupKey));
|
|
116
115
|
}
|
|
117
116
|
if (messages.length > 0) {
|
|
118
117
|
await redis.rpush(redisKey, ...messages.map(serialize));
|
|
119
|
-
await redis.expire(redisKey,
|
|
118
|
+
await redis.expire(redisKey, ttlSeconds);
|
|
120
119
|
}
|
|
121
|
-
await redis.expire(metaKey,
|
|
120
|
+
await redis.expire(metaKey, ttlSeconds);
|
|
122
121
|
},
|
|
123
122
|
async delete() {
|
|
124
123
|
await redis.del(redisKey, metaKey, stateKey);
|
|
@@ -130,12 +129,7 @@ function createThreadManager(config) {
|
|
|
130
129
|
},
|
|
131
130
|
async saveState(state) {
|
|
132
131
|
await assertThreadExists();
|
|
133
|
-
await redis.set(
|
|
134
|
-
stateKey,
|
|
135
|
-
JSON.stringify(state),
|
|
136
|
-
"EX",
|
|
137
|
-
THREAD_TTL_SECONDS
|
|
138
|
-
);
|
|
132
|
+
await redis.set(stateKey, JSON.stringify(state), "EX", ttlSeconds);
|
|
139
133
|
},
|
|
140
134
|
async deleteState() {
|
|
141
135
|
await redis.del(stateKey);
|
|
@@ -164,20 +158,133 @@ function createThreadManager(config) {
|
|
|
164
158
|
if (idx === -1) return;
|
|
165
159
|
if (idx === 0) {
|
|
166
160
|
await redis.del(redisKey);
|
|
167
|
-
await redis.expire(metaKey,
|
|
161
|
+
await redis.expire(metaKey, ttlSeconds);
|
|
168
162
|
} else {
|
|
169
163
|
await redis.ltrim(redisKey, 0, idx - 1);
|
|
170
|
-
await redis.expire(redisKey,
|
|
164
|
+
await redis.expire(redisKey, ttlSeconds);
|
|
171
165
|
}
|
|
172
166
|
if (removedIds.length > 0) {
|
|
173
|
-
await redis.del(
|
|
174
|
-
...removedIds.map((id) => getDedupKey(threadId, id))
|
|
175
|
-
);
|
|
167
|
+
await redis.del(...removedIds.map(dedupKey));
|
|
176
168
|
}
|
|
177
169
|
}
|
|
178
170
|
};
|
|
179
171
|
}
|
|
180
172
|
|
|
173
|
+
// src/lib/thread/snapshot.ts
|
|
174
|
+
async function encodeSnapshot(config) {
|
|
175
|
+
const { redis, threadKey, threadId, idOf } = config;
|
|
176
|
+
const metaKey = getThreadMetaKey(threadKey, threadId);
|
|
177
|
+
if (await redis.exists(metaKey) === 0) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const listKey = getThreadListKey(threadKey, threadId);
|
|
181
|
+
const stateKey = getThreadStateKey(threadKey, threadId);
|
|
182
|
+
const messages = await redis.lrange(listKey, 0, -1);
|
|
183
|
+
const stateRaw = await redis.get(stateKey);
|
|
184
|
+
const state = stateRaw == null ? null : JSON.parse(stateRaw);
|
|
185
|
+
const dedupIds = idOf ? messages.map(idOf) : [];
|
|
186
|
+
return { v: 1, messages, state, dedupIds };
|
|
187
|
+
}
|
|
188
|
+
async function applySnapshot(config) {
|
|
189
|
+
const {
|
|
190
|
+
redis,
|
|
191
|
+
threadKey,
|
|
192
|
+
threadId,
|
|
193
|
+
snapshot,
|
|
194
|
+
ttlSeconds = THREAD_TTL_SECONDS
|
|
195
|
+
} = config;
|
|
196
|
+
const metaKey = getThreadMetaKey(threadKey, threadId);
|
|
197
|
+
if (await redis.exists(metaKey) === 1) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const listKey = getThreadListKey(threadKey, threadId);
|
|
201
|
+
const stateKey = getThreadStateKey(threadKey, threadId);
|
|
202
|
+
await redis.del(listKey, stateKey);
|
|
203
|
+
const pipeline = redis.pipeline();
|
|
204
|
+
if (snapshot.messages.length > 0) {
|
|
205
|
+
pipeline.rpush(listKey, ...snapshot.messages);
|
|
206
|
+
pipeline.expire(listKey, ttlSeconds);
|
|
207
|
+
}
|
|
208
|
+
if (snapshot.state != null) {
|
|
209
|
+
pipeline.set(stateKey, JSON.stringify(snapshot.state), "EX", ttlSeconds);
|
|
210
|
+
}
|
|
211
|
+
for (const id of snapshot.dedupIds) {
|
|
212
|
+
pipeline.set(getThreadDedupKey(threadId, id), "1", "EX", ttlSeconds);
|
|
213
|
+
}
|
|
214
|
+
const results = await pipeline.exec();
|
|
215
|
+
if (results) {
|
|
216
|
+
const firstErr = results.find(([err]) => err)?.[0] ?? null;
|
|
217
|
+
if (firstErr) {
|
|
218
|
+
await redis.del(
|
|
219
|
+
listKey,
|
|
220
|
+
stateKey,
|
|
221
|
+
...snapshot.dedupIds.map((id) => getThreadDedupKey(threadId, id))
|
|
222
|
+
).catch(() => void 0);
|
|
223
|
+
throw firstErr;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
await redis.set(metaKey, "1", "EX", ttlSeconds);
|
|
227
|
+
}
|
|
228
|
+
async function clearHotTier(config) {
|
|
229
|
+
const { redis, threadKey, threadId, dedupIds = [] } = config;
|
|
230
|
+
const keys = [
|
|
231
|
+
getThreadListKey(threadKey, threadId),
|
|
232
|
+
getThreadMetaKey(threadKey, threadId),
|
|
233
|
+
getThreadStateKey(threadKey, threadId),
|
|
234
|
+
...dedupIds.map((id) => getThreadDedupKey(threadId, id))
|
|
235
|
+
];
|
|
236
|
+
await redis.del(...keys);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/lib/thread/tiered.ts
|
|
240
|
+
function createTieredThreadManager(config) {
|
|
241
|
+
const {
|
|
242
|
+
redis,
|
|
243
|
+
threadId,
|
|
244
|
+
key = "messages",
|
|
245
|
+
coldStore,
|
|
246
|
+
idOf,
|
|
247
|
+
deserialize = (raw) => JSON.parse(raw),
|
|
248
|
+
ttlSeconds = THREAD_TTL_SECONDS
|
|
249
|
+
} = config;
|
|
250
|
+
const base = createThreadManager(config);
|
|
251
|
+
const rawIdOf = idOf ? (raw) => idOf(deserialize(raw)) : void 0;
|
|
252
|
+
return Object.assign(base, {
|
|
253
|
+
async hydrate() {
|
|
254
|
+
if (!coldStore) return;
|
|
255
|
+
const snapshot = await coldStore.read(key, threadId);
|
|
256
|
+
if (!snapshot) return;
|
|
257
|
+
await applySnapshot({
|
|
258
|
+
redis,
|
|
259
|
+
threadKey: key,
|
|
260
|
+
threadId,
|
|
261
|
+
snapshot,
|
|
262
|
+
ttlSeconds
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
async flush(opts) {
|
|
266
|
+
if (!coldStore) return;
|
|
267
|
+
const snapshot = await encodeSnapshot({
|
|
268
|
+
redis,
|
|
269
|
+
threadKey: key,
|
|
270
|
+
threadId,
|
|
271
|
+
...rawIdOf ? { idOf: rawIdOf } : {}
|
|
272
|
+
});
|
|
273
|
+
if (!snapshot) return;
|
|
274
|
+
await coldStore.write(key, threadId, snapshot);
|
|
275
|
+
const deleteHot = opts?.deleteHot ?? true;
|
|
276
|
+
if (deleteHot) {
|
|
277
|
+
await clearHotTier({
|
|
278
|
+
redis,
|
|
279
|
+
threadKey: key,
|
|
280
|
+
threadId,
|
|
281
|
+
dedupIds: snapshot.dedupIds
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
181
288
|
// src/adapters/thread/anthropic/thread-manager.ts
|
|
182
289
|
function storedMessageId(msg) {
|
|
183
290
|
return msg.id;
|
|
@@ -210,7 +317,8 @@ function createAnthropicThreadManager(config) {
|
|
|
210
317
|
redis: config.redis,
|
|
211
318
|
threadId: config.threadId,
|
|
212
319
|
key: config.key,
|
|
213
|
-
idOf: storedMessageId
|
|
320
|
+
idOf: storedMessageId,
|
|
321
|
+
...config.ttlSeconds !== void 0 && { ttlSeconds: config.ttlSeconds }
|
|
214
322
|
};
|
|
215
323
|
const base = createThreadManager(baseConfig);
|
|
216
324
|
const helpers = {
|
|
@@ -399,46 +507,43 @@ async function invokeAnthropicModel({
|
|
|
399
507
|
// src/adapters/thread/anthropic/activities.ts
|
|
400
508
|
function createAnthropicAdapter(config) {
|
|
401
509
|
const { redis, client } = config;
|
|
510
|
+
const baseExtras = {
|
|
511
|
+
...config.ttlSeconds !== void 0 && { ttlSeconds: config.ttlSeconds }
|
|
512
|
+
};
|
|
513
|
+
const makeProviderThread = (threadId, threadKey) => createAnthropicThreadManager({
|
|
514
|
+
redis,
|
|
515
|
+
threadId,
|
|
516
|
+
key: threadKey,
|
|
517
|
+
...baseExtras
|
|
518
|
+
});
|
|
519
|
+
const makeTieredBase = (threadId, threadKey) => createTieredThreadManager({
|
|
520
|
+
redis,
|
|
521
|
+
threadId,
|
|
522
|
+
key: threadKey,
|
|
523
|
+
idOf: storedMessageId,
|
|
524
|
+
...baseExtras,
|
|
525
|
+
...config.coldStore && { coldStore: config.coldStore }
|
|
526
|
+
});
|
|
402
527
|
const threadOps = {
|
|
403
528
|
async initializeThread(threadId, threadKey) {
|
|
404
|
-
const thread =
|
|
405
|
-
redis,
|
|
406
|
-
threadId,
|
|
407
|
-
key: threadKey
|
|
408
|
-
});
|
|
529
|
+
const thread = makeProviderThread(threadId, threadKey);
|
|
409
530
|
await thread.initialize();
|
|
410
531
|
},
|
|
411
532
|
async appendHumanMessage(threadId, id, content, threadKey) {
|
|
412
|
-
const thread =
|
|
413
|
-
redis,
|
|
414
|
-
threadId,
|
|
415
|
-
key: threadKey
|
|
416
|
-
});
|
|
533
|
+
const thread = makeProviderThread(threadId, threadKey);
|
|
417
534
|
await thread.appendUserMessage(id, content);
|
|
418
535
|
},
|
|
419
536
|
async appendSystemMessage(threadId, id, content, threadKey) {
|
|
420
|
-
const thread =
|
|
421
|
-
redis,
|
|
422
|
-
threadId,
|
|
423
|
-
key: threadKey
|
|
424
|
-
});
|
|
537
|
+
const thread = makeProviderThread(threadId, threadKey);
|
|
425
538
|
await thread.appendSystemMessage(id, content);
|
|
426
539
|
},
|
|
427
540
|
async appendToolResult(id, cfg) {
|
|
428
541
|
const { threadId, threadKey, toolCallId, toolName, content } = cfg;
|
|
429
|
-
const thread =
|
|
430
|
-
redis,
|
|
431
|
-
threadId,
|
|
432
|
-
key: threadKey
|
|
433
|
-
});
|
|
542
|
+
const thread = makeProviderThread(threadId, threadKey);
|
|
434
543
|
await thread.appendToolResult(id, toolCallId, toolName, content);
|
|
435
544
|
},
|
|
436
545
|
async appendAgentMessage(threadId, id, message, threadKey) {
|
|
437
|
-
const thread =
|
|
438
|
-
redis,
|
|
439
|
-
threadId,
|
|
440
|
-
key: threadKey
|
|
441
|
-
});
|
|
546
|
+
const thread = makeProviderThread(threadId, threadKey);
|
|
442
547
|
await thread.appendAssistantMessage(id, message.content);
|
|
443
548
|
},
|
|
444
549
|
async forkThread(sourceThreadId, targetThreadId, threadKey) {
|
|
@@ -446,29 +551,30 @@ function createAnthropicAdapter(config) {
|
|
|
446
551
|
redis,
|
|
447
552
|
threadId: sourceThreadId,
|
|
448
553
|
key: threadKey,
|
|
449
|
-
hooks: config.hooks
|
|
554
|
+
hooks: config.hooks,
|
|
555
|
+
...baseExtras
|
|
450
556
|
});
|
|
451
557
|
await thread.fork(targetThreadId);
|
|
452
558
|
},
|
|
453
559
|
async truncateThread(threadId, messageId, threadKey) {
|
|
454
|
-
const thread =
|
|
560
|
+
const thread = makeProviderThread(threadId, threadKey);
|
|
455
561
|
await thread.truncateFromId(messageId);
|
|
456
562
|
},
|
|
457
563
|
async loadThreadState(threadId, threadKey) {
|
|
458
|
-
const thread =
|
|
459
|
-
redis,
|
|
460
|
-
threadId,
|
|
461
|
-
key: threadKey
|
|
462
|
-
});
|
|
564
|
+
const thread = makeProviderThread(threadId, threadKey);
|
|
463
565
|
return thread.loadState();
|
|
464
566
|
},
|
|
465
567
|
async saveThreadState(threadId, state, threadKey) {
|
|
466
|
-
const thread =
|
|
467
|
-
redis,
|
|
468
|
-
threadId,
|
|
469
|
-
key: threadKey
|
|
470
|
-
});
|
|
568
|
+
const thread = makeProviderThread(threadId, threadKey);
|
|
471
569
|
await thread.saveState(state);
|
|
570
|
+
},
|
|
571
|
+
async hydrateThread(threadId, threadKey) {
|
|
572
|
+
if (!config.coldStore) return;
|
|
573
|
+
await makeTieredBase(threadId, threadKey).hydrate();
|
|
574
|
+
},
|
|
575
|
+
async flushThread(threadId, threadKey) {
|
|
576
|
+
if (!config.coldStore) return;
|
|
577
|
+
await makeTieredBase(threadId, threadKey).flush();
|
|
472
578
|
}
|
|
473
579
|
};
|
|
474
580
|
function createActivities(scope) {
|