zeitlich 0.2.44 → 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-CPIB2v2C.d.ts → activities-Bm4TLTid.d.ts} +24 -4
- package/dist/{activities-DnmNOnq4.d.cts → activities-CyeiqK_f.d.cts} +24 -4
- package/dist/adapters/sandbox/daytona/index.d.cts +2 -2
- package/dist/adapters/sandbox/daytona/index.d.ts +2 -2
- package/dist/adapters/sandbox/daytona/workflow.d.cts +1 -1
- package/dist/adapters/sandbox/daytona/workflow.d.ts +1 -1
- package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
- package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
- 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 +181 -77
- 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 +182 -74
- 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 +252 -53
- 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 +247 -54
- package/dist/index.js.map +1 -1
- package/dist/{proxy-DTnc5rqT.d.cts → proxy-BxFyd6cg.d.cts} +1 -1
- package/dist/{proxy-B7Xi1znZ.d.ts → proxy-Cskmj4Yx.d.ts} +1 -1
- package/dist/{thread-manager-BlX2TwRN.d.cts → thread-manager-9tezUcLW.d.cts} +9 -3
- package/dist/{thread-manager-BAv340mi.d.ts → thread-manager-B-zy3xrs.d.ts} +9 -3
- package/dist/{thread-manager-D2xorI-J.d.ts → thread-manager-D33SUmZa.d.cts} +10 -4
- package/dist/{thread-manager-BWv6ZXI3.d.cts → thread-manager-DduoSkvJ.d.ts} +10 -4
- package/dist/{types-C90VoEpt.d.cts → types-CjY93AWZ.d.cts} +1 -1
- package/dist/{types-4Wmk-wRq.d.cts → types-CnuN9T6t.d.cts} +23 -1
- package/dist/{types-DKsCdAtQ.d.ts → types-CwN6_tAL.d.ts} +23 -1
- package/dist/{types-Clhqautb.d.ts → types-L5bvbF-n.d.ts} +17 -1
- package/dist/{types-DpFD8ofR.d.ts → types-gVa5XCWD.d.ts} +1 -1
- package/dist/{types-DRJt1TMi.d.cts → types-oxt8GN97.d.cts} +17 -1
- package/dist/{workflow-D32TRMr-.d.ts → workflow-B1TOcHbt.d.ts} +33 -2
- package/dist/{workflow-XVt0ww8K.d.cts → workflow-DIaIV7L2.d.cts} +33 -2
- package/dist/workflow.cjs +29 -19
- 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 +29 -19
- 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 +47 -22
- 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/src/lib/.env +0 -1
- package/src/tools/bash/.env +0 -1
package/src/index.ts
CHANGED
|
@@ -33,11 +33,30 @@ export * from "./workflow";
|
|
|
33
33
|
export { FileSystemSkillProvider } from "./lib/skills/fs-provider";
|
|
34
34
|
|
|
35
35
|
// Thread manager (generic, framework-agnostic)
|
|
36
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
createThreadManager,
|
|
38
|
+
createTieredThreadManager,
|
|
39
|
+
createS3ColdStore,
|
|
40
|
+
encodeSnapshot,
|
|
41
|
+
applySnapshot,
|
|
42
|
+
clearHotTier,
|
|
43
|
+
getThreadStateKey,
|
|
44
|
+
getThreadDedupKey,
|
|
45
|
+
} from "./lib/thread";
|
|
37
46
|
export type {
|
|
38
47
|
BaseThreadManager,
|
|
39
48
|
ProviderThreadManager,
|
|
40
49
|
ThreadManagerConfig,
|
|
50
|
+
TieredThreadManager,
|
|
51
|
+
TieredThreadManagerConfig,
|
|
52
|
+
FlushOptions,
|
|
53
|
+
ColdThreadStore,
|
|
54
|
+
ThreadSnapshot,
|
|
55
|
+
S3LikeClient,
|
|
56
|
+
S3ColdStoreConfig,
|
|
57
|
+
EncodeSnapshotConfig,
|
|
58
|
+
ApplySnapshotConfig,
|
|
59
|
+
ClearHotTierConfig,
|
|
41
60
|
} from "./lib/thread";
|
|
42
61
|
|
|
43
62
|
// Model invoker contract (framework-agnostic)
|
|
@@ -129,6 +129,12 @@ function createMockThreadOps() {
|
|
|
129
129
|
log.push({ op: "saveThreadState", args: [threadId, state] });
|
|
130
130
|
stateStore.set(threadId, state);
|
|
131
131
|
},
|
|
132
|
+
hydrateThread: async (threadId) => {
|
|
133
|
+
log.push({ op: "hydrateThread", args: [threadId] });
|
|
134
|
+
},
|
|
135
|
+
flushThread: async (threadId) => {
|
|
136
|
+
log.push({ op: "flushThread", args: [threadId] });
|
|
137
|
+
},
|
|
132
138
|
});
|
|
133
139
|
return { ops, log, stateStore };
|
|
134
140
|
}
|
|
@@ -824,6 +830,12 @@ describe("createSession edge cases", () => {
|
|
|
824
830
|
saveThreadState: async (threadId, state) => {
|
|
825
831
|
log.push({ op: "saveThreadState", args: [threadId, state] });
|
|
826
832
|
},
|
|
833
|
+
hydrateThread: async (threadId) => {
|
|
834
|
+
log.push({ op: "hydrateThread", args: [threadId] });
|
|
835
|
+
},
|
|
836
|
+
flushThread: async (threadId) => {
|
|
837
|
+
log.push({ op: "flushThread", args: [threadId] });
|
|
838
|
+
},
|
|
827
839
|
});
|
|
828
840
|
|
|
829
841
|
const session = await createSession<
|
|
@@ -132,6 +132,12 @@ function createMockThreadOps() {
|
|
|
132
132
|
log.push({ op: "saveThreadState", args: [threadId, state] });
|
|
133
133
|
stateStore.set(threadId, state);
|
|
134
134
|
},
|
|
135
|
+
hydrateThread: async (threadId) => {
|
|
136
|
+
log.push({ op: "hydrateThread", args: [threadId] });
|
|
137
|
+
},
|
|
138
|
+
flushThread: async (threadId) => {
|
|
139
|
+
log.push({ op: "flushThread", args: [threadId] });
|
|
140
|
+
},
|
|
135
141
|
});
|
|
136
142
|
|
|
137
143
|
return { ops, log, stateStore };
|
|
@@ -1336,4 +1342,136 @@ describe("createSession integration", () => {
|
|
|
1336
1342
|
const newThreadSlice = stateStore.get(result.threadId);
|
|
1337
1343
|
expect(newThreadSlice?.tasks).toHaveLength(1);
|
|
1338
1344
|
});
|
|
1345
|
+
|
|
1346
|
+
// --- Cold-tier lifecycle (hydrateThread + flushThread) ---
|
|
1347
|
+
|
|
1348
|
+
it("skips hydrateThread for mode:'new' threads but still flushes on exit", async () => {
|
|
1349
|
+
const { ops, log } = createMockThreadOps();
|
|
1350
|
+
|
|
1351
|
+
const session = await createSession({
|
|
1352
|
+
agentName: "TestAgent",
|
|
1353
|
+
thread: { mode: "new", threadId: "fresh-thread" },
|
|
1354
|
+
runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
|
|
1355
|
+
threadOps: ops,
|
|
1356
|
+
buildContextMessage: () => "hi",
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
const stateManager = createAgentStateManager({
|
|
1360
|
+
initialState: { systemPrompt: "system" },
|
|
1361
|
+
});
|
|
1362
|
+
await session.runSession({ stateManager });
|
|
1363
|
+
|
|
1364
|
+
expect(log.filter((l) => l.op === "hydrateThread")).toHaveLength(0);
|
|
1365
|
+
const flushes = log.filter((l) => l.op === "flushThread");
|
|
1366
|
+
expect(flushes).toHaveLength(1);
|
|
1367
|
+
expect(at(flushes, 0).args[0]).toBe("fresh-thread");
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it("hydrates the target thread before continuing it", async () => {
|
|
1371
|
+
const { ops, log, stateStore } = createMockThreadOps();
|
|
1372
|
+
stateStore.set("existing-thread", {
|
|
1373
|
+
tasks: [],
|
|
1374
|
+
custom: { previous: true },
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
const session = await createSession({
|
|
1378
|
+
agentName: "TestAgent",
|
|
1379
|
+
thread: { mode: "continue", threadId: "existing-thread" },
|
|
1380
|
+
runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
|
|
1381
|
+
threadOps: ops,
|
|
1382
|
+
buildContextMessage: () => "follow-up",
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
const stateManager = createAgentStateManager({
|
|
1386
|
+
initialState: { systemPrompt: "system" },
|
|
1387
|
+
});
|
|
1388
|
+
await session.runSession({ stateManager });
|
|
1389
|
+
|
|
1390
|
+
const hydrates = log.filter((l) => l.op === "hydrateThread");
|
|
1391
|
+
expect(hydrates).toHaveLength(1);
|
|
1392
|
+
expect(at(hydrates, 0).args[0]).toBe("existing-thread");
|
|
1393
|
+
|
|
1394
|
+
// hydrate runs strictly before loadThreadState so the load sees
|
|
1395
|
+
// fresh data when a cold tier is wired.
|
|
1396
|
+
const hydrateIdx = log.findIndex((l) => l.op === "hydrateThread");
|
|
1397
|
+
const loadIdx = log.findIndex((l) => l.op === "loadThreadState");
|
|
1398
|
+
expect(hydrateIdx).toBeLessThan(loadIdx);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("hydrates the *source* thread (not the new one) on mode:'fork'", async () => {
|
|
1402
|
+
const { ops, log, stateStore } = createMockThreadOps();
|
|
1403
|
+
stateStore.set("src-thread", { tasks: [], custom: {} });
|
|
1404
|
+
|
|
1405
|
+
const session = await createSession({
|
|
1406
|
+
agentName: "TestAgent",
|
|
1407
|
+
thread: { mode: "fork", threadId: "src-thread" },
|
|
1408
|
+
runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
|
|
1409
|
+
threadOps: ops,
|
|
1410
|
+
buildContextMessage: () => "fork me",
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
const stateManager = createAgentStateManager({
|
|
1414
|
+
initialState: { systemPrompt: "system" },
|
|
1415
|
+
});
|
|
1416
|
+
const result = await session.runSession({ stateManager });
|
|
1417
|
+
|
|
1418
|
+
const hydrates = log.filter((l) => l.op === "hydrateThread");
|
|
1419
|
+
expect(hydrates).toHaveLength(1);
|
|
1420
|
+
expect(at(hydrates, 0).args[0]).toBe("src-thread");
|
|
1421
|
+
|
|
1422
|
+
// hydrate runs before forkThread so the source is hot before
|
|
1423
|
+
// the in-Redis fork copy.
|
|
1424
|
+
const hydrateIdx = log.findIndex((l) => l.op === "hydrateThread");
|
|
1425
|
+
const forkIdx = log.findIndex((l) => l.op === "forkThread");
|
|
1426
|
+
expect(hydrateIdx).toBeLessThan(forkIdx);
|
|
1427
|
+
|
|
1428
|
+
// Flush still targets the *new* thread.
|
|
1429
|
+
const flushes = log.filter((l) => l.op === "flushThread");
|
|
1430
|
+
expect(flushes).toHaveLength(1);
|
|
1431
|
+
expect(at(flushes, 0).args[0]).toBe(result.threadId);
|
|
1432
|
+
expect(at(flushes, 0).args[0]).not.toBe("src-thread");
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it("flushThread runs even when the session fails", async () => {
|
|
1436
|
+
const { ops, log } = createMockThreadOps();
|
|
1437
|
+
|
|
1438
|
+
const session = await createSession({
|
|
1439
|
+
agentName: "TestAgent",
|
|
1440
|
+
thread: { mode: "new", threadId: "fail-thread" },
|
|
1441
|
+
runAgent: async () => {
|
|
1442
|
+
throw new Error("boom");
|
|
1443
|
+
},
|
|
1444
|
+
threadOps: ops,
|
|
1445
|
+
buildContextMessage: () => "hi",
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
const stateManager = createAgentStateManager({
|
|
1449
|
+
initialState: { systemPrompt: "system" },
|
|
1450
|
+
});
|
|
1451
|
+
await expect(session.runSession({ stateManager })).rejects.toThrow(/boom/);
|
|
1452
|
+
|
|
1453
|
+
const flushes = log.filter((l) => l.op === "flushThread");
|
|
1454
|
+
expect(flushes).toHaveLength(1);
|
|
1455
|
+
expect(at(flushes, 0).args[0]).toBe("fail-thread");
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("flushThread runs after saveThreadState", async () => {
|
|
1459
|
+
const { ops, log } = createMockThreadOps();
|
|
1460
|
+
const session = await createSession({
|
|
1461
|
+
agentName: "TestAgent",
|
|
1462
|
+
thread: { mode: "new", threadId: "ordered-thread" },
|
|
1463
|
+
runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
|
|
1464
|
+
threadOps: ops,
|
|
1465
|
+
buildContextMessage: () => "hi",
|
|
1466
|
+
});
|
|
1467
|
+
const stateManager = createAgentStateManager({
|
|
1468
|
+
initialState: { systemPrompt: "system" },
|
|
1469
|
+
});
|
|
1470
|
+
await session.runSession({ stateManager });
|
|
1471
|
+
|
|
1472
|
+
const saveIdx = log.findIndex((l) => l.op === "saveThreadState");
|
|
1473
|
+
const flushIdx = log.findIndex((l) => l.op === "flushThread");
|
|
1474
|
+
expect(saveIdx).toBeGreaterThanOrEqual(0);
|
|
1475
|
+
expect(flushIdx).toBeGreaterThan(saveIdx);
|
|
1476
|
+
});
|
|
1339
1477
|
});
|
|
@@ -163,8 +163,7 @@ export async function createSession<
|
|
|
163
163
|
unknown,
|
|
164
164
|
SandboxCapability
|
|
165
165
|
>;
|
|
166
|
-
const wideOps = (): WideSandboxOps =>
|
|
167
|
-
sandboxOps as unknown as WideSandboxOps;
|
|
166
|
+
const wideOps = (): WideSandboxOps => sandboxOps as unknown as WideSandboxOps;
|
|
168
167
|
// ---------------------------------------------------------------------------
|
|
169
168
|
// Thread resolution
|
|
170
169
|
// ---------------------------------------------------------------------------
|
|
@@ -199,6 +198,8 @@ export async function createSession<
|
|
|
199
198
|
forkThread,
|
|
200
199
|
loadThreadState,
|
|
201
200
|
saveThreadState,
|
|
201
|
+
hydrateThread,
|
|
202
|
+
flushThread,
|
|
202
203
|
} = threadOps;
|
|
203
204
|
|
|
204
205
|
const plugins: ToolMap[string][] = [];
|
|
@@ -276,10 +277,7 @@ export async function createSession<
|
|
|
276
277
|
// defaults are. Both surfaces consult `resolveSessionLifecycle`
|
|
277
278
|
// (or its type-level equivalent) before checking individual
|
|
278
279
|
// mode/shutdown values.
|
|
279
|
-
const lifecycle = resolveSessionLifecycle(
|
|
280
|
-
sandboxInit,
|
|
281
|
-
sandboxShutdown
|
|
282
|
-
);
|
|
280
|
+
const lifecycle = resolveSessionLifecycle(sandboxInit, sandboxShutdown);
|
|
283
281
|
const sandboxMode: SandboxInit["mode"] | undefined = lifecycle.mode;
|
|
284
282
|
const resolvedShutdown: SubagentSandboxShutdown = lifecycle.shutdown;
|
|
285
283
|
let sandboxId: string | undefined;
|
|
@@ -383,22 +381,6 @@ export async function createSession<
|
|
|
383
381
|
});
|
|
384
382
|
}
|
|
385
383
|
|
|
386
|
-
if (hooks.onSessionStart) {
|
|
387
|
-
await hooks.onSessionStart({
|
|
388
|
-
threadId,
|
|
389
|
-
agentName,
|
|
390
|
-
metadata,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
log.info("session started", {
|
|
395
|
-
agentName,
|
|
396
|
-
threadId,
|
|
397
|
-
threadMode,
|
|
398
|
-
maxTurns,
|
|
399
|
-
...(sandboxId && { sandboxId }),
|
|
400
|
-
});
|
|
401
|
-
|
|
402
384
|
const sessionStartMs = Date.now();
|
|
403
385
|
const systemPrompt = stateManager.getSystemPrompt();
|
|
404
386
|
|
|
@@ -411,10 +393,19 @@ export async function createSession<
|
|
|
411
393
|
};
|
|
412
394
|
|
|
413
395
|
if (threadMode === "fork" && sourceThreadId) {
|
|
396
|
+
// Pull the source thread from the cold tier into Redis (if a
|
|
397
|
+
// cold store is configured for this adapter) before the
|
|
398
|
+
// in-Redis fork copy runs. Hydrate is a no-op when the source
|
|
399
|
+
// is already hot or when no cold tier is wired.
|
|
400
|
+
await hydrateThread(sourceThreadId, threadKey);
|
|
414
401
|
await forkThread(sourceThreadId, threadId, threadKey);
|
|
415
402
|
const forkedSlice = await loadThreadState(threadId, threadKey);
|
|
416
403
|
if (forkedSlice) rehydrateFromSlice(forkedSlice);
|
|
417
404
|
} else if (threadMode === "continue") {
|
|
405
|
+
// Pull the thread from the cold tier into Redis before any
|
|
406
|
+
// appends. No-op when the thread is already hot or when no
|
|
407
|
+
// cold tier is wired.
|
|
408
|
+
await hydrateThread(threadId, threadKey);
|
|
418
409
|
// "continue" — thread already exists, just append the new message
|
|
419
410
|
const continuedSlice = await loadThreadState(threadId, threadKey);
|
|
420
411
|
if (continuedSlice) rehydrateFromSlice(continuedSlice);
|
|
@@ -490,6 +481,22 @@ export async function createSession<
|
|
|
490
481
|
let exitReason: SessionExitReason = "completed";
|
|
491
482
|
let finalMessage: M | null = null;
|
|
492
483
|
|
|
484
|
+
if (hooks.onSessionStart) {
|
|
485
|
+
await hooks.onSessionStart({
|
|
486
|
+
threadId,
|
|
487
|
+
agentName,
|
|
488
|
+
metadata,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
log.info("session started", {
|
|
493
|
+
agentName,
|
|
494
|
+
threadId,
|
|
495
|
+
threadMode,
|
|
496
|
+
maxTurns,
|
|
497
|
+
...(sandboxId && { sandboxId }),
|
|
498
|
+
});
|
|
499
|
+
|
|
493
500
|
try {
|
|
494
501
|
// Per-turn assistant message id. Pre-generated in the workflow
|
|
495
502
|
// so the runAgent activity can truncate the thread from this id
|
|
@@ -644,6 +651,24 @@ export async function createSession<
|
|
|
644
651
|
});
|
|
645
652
|
}
|
|
646
653
|
|
|
654
|
+
// Archive the thread to the durable cold tier (S3 / R2 / etc.)
|
|
655
|
+
// after the state slice has been written. No-op when no cold
|
|
656
|
+
// store is wired into the adapter. Best-effort — failures
|
|
657
|
+
// must not mask the original exit reason and the thread is
|
|
658
|
+
// still readable from Redis via its TTL window.
|
|
659
|
+
try {
|
|
660
|
+
await flushThread(threadId, threadKey);
|
|
661
|
+
} catch (flushError) {
|
|
662
|
+
log.warn("failed to flush thread to cold tier", {
|
|
663
|
+
agentName,
|
|
664
|
+
threadId,
|
|
665
|
+
error:
|
|
666
|
+
flushError instanceof Error
|
|
667
|
+
? flushError.message
|
|
668
|
+
: String(flushError),
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
647
672
|
await callSessionEnd(exitReason, stateManager.getTurns());
|
|
648
673
|
|
|
649
674
|
if (sandboxOwned && sandboxId && sandboxOps) {
|
package/src/lib/session/types.ts
CHANGED
|
@@ -109,6 +109,28 @@ export interface ThreadOps<TContent = string> {
|
|
|
109
109
|
state: PersistedThreadState,
|
|
110
110
|
threadKey?: string
|
|
111
111
|
): Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* Restore the thread's contents from the durable cold tier (if any)
|
|
114
|
+
* into Redis. Called once on session entry for `mode: "continue"`
|
|
115
|
+
* and `mode: "fork"` so the rest of the loop reads the freshest data.
|
|
116
|
+
*
|
|
117
|
+
* Adapters configured without a cold-store implementation treat this
|
|
118
|
+
* as a no-op. Implementations must be **idempotent** — Temporal
|
|
119
|
+
* retries the activity on transient failures, and a thread that is
|
|
120
|
+
* already hot must not be wiped.
|
|
121
|
+
*/
|
|
122
|
+
hydrateThread(threadId: string, threadKey?: string): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Archive the thread's contents to the durable cold tier and
|
|
125
|
+
* (optionally) drop the hot-tier Redis keys. Called once in the
|
|
126
|
+
* session's `finally` block on every exit path, after
|
|
127
|
+
* `saveThreadState`.
|
|
128
|
+
*
|
|
129
|
+
* Adapters configured without a cold-store implementation treat this
|
|
130
|
+
* as a no-op. Implementations must be **idempotent** — the cold
|
|
131
|
+
* tier is last-writer-wins and a retried flush must converge.
|
|
132
|
+
*/
|
|
133
|
+
flushThread(threadId: string, threadKey?: string): Promise<void>;
|
|
112
134
|
}
|
|
113
135
|
|
|
114
136
|
/**
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
3
|
+
import { createS3ColdStore, type S3LikeClient } from "./cold-store";
|
|
4
|
+
import type { ThreadSnapshot } from "./cold-store";
|
|
5
|
+
|
|
6
|
+
interface StoredObject {
|
|
7
|
+
body: Buffer;
|
|
8
|
+
contentType?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CommandInput {
|
|
12
|
+
Bucket: string;
|
|
13
|
+
Key: string;
|
|
14
|
+
Body?: Buffer | string;
|
|
15
|
+
ContentType?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minimal in-memory S3-shaped client. The `send()` method dispatches
|
|
20
|
+
* by the command's constructor name, mirroring the contract that
|
|
21
|
+
* `@aws-sdk/client-s3` exposes.
|
|
22
|
+
*/
|
|
23
|
+
function createFakeS3(): {
|
|
24
|
+
s3: S3LikeClient;
|
|
25
|
+
store: Map<string, StoredObject>;
|
|
26
|
+
calls: { get: number; put: number; delete: number };
|
|
27
|
+
} {
|
|
28
|
+
const store = new Map<string, StoredObject>();
|
|
29
|
+
const calls = { get: 0, put: 0, delete: 0 };
|
|
30
|
+
|
|
31
|
+
const compositeKey = (bucket: string, key: string): string =>
|
|
32
|
+
`${bucket}/${key}`;
|
|
33
|
+
|
|
34
|
+
const s3: S3LikeClient = {
|
|
35
|
+
async send<TInput, TOutput>(
|
|
36
|
+
command: { input: TInput } & object
|
|
37
|
+
): Promise<TOutput> {
|
|
38
|
+
const name = (command as { constructor: { name: string } }).constructor
|
|
39
|
+
.name;
|
|
40
|
+
const input = command.input as unknown as CommandInput;
|
|
41
|
+
const fullKey = compositeKey(input.Bucket, input.Key);
|
|
42
|
+
if (name === "GetObjectCommand") {
|
|
43
|
+
calls.get++;
|
|
44
|
+
const obj = store.get(fullKey);
|
|
45
|
+
if (!obj) {
|
|
46
|
+
const err = new Error("NoSuchKey") as Error & { name: string };
|
|
47
|
+
err.name = "NoSuchKey";
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
return { Body: obj.body } as unknown as TOutput;
|
|
51
|
+
}
|
|
52
|
+
if (name === "PutObjectCommand") {
|
|
53
|
+
calls.put++;
|
|
54
|
+
const body =
|
|
55
|
+
typeof input.Body === "string"
|
|
56
|
+
? Buffer.from(input.Body, "utf8")
|
|
57
|
+
: (input.Body as Buffer);
|
|
58
|
+
store.set(fullKey, {
|
|
59
|
+
body,
|
|
60
|
+
...(input.ContentType && { contentType: input.ContentType }),
|
|
61
|
+
});
|
|
62
|
+
return {} as TOutput;
|
|
63
|
+
}
|
|
64
|
+
if (name === "DeleteObjectCommand") {
|
|
65
|
+
calls.delete++;
|
|
66
|
+
store.delete(fullKey);
|
|
67
|
+
return {} as TOutput;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`unknown command: ${name}`);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return { s3, store, calls };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sampleSnapshot: ThreadSnapshot = {
|
|
77
|
+
v: 1,
|
|
78
|
+
messages: [JSON.stringify({ id: "m1", text: "hi" })],
|
|
79
|
+
state: null,
|
|
80
|
+
dedupIds: ["m1"],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
describe("createS3ColdStore", () => {
|
|
84
|
+
let fake: ReturnType<typeof createFakeS3>;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
fake = createFakeS3();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("read returns null when no object exists for the thread", async () => {
|
|
91
|
+
const cold = createS3ColdStore({
|
|
92
|
+
s3: fake.s3,
|
|
93
|
+
bucket: "test-bucket",
|
|
94
|
+
});
|
|
95
|
+
expect(await cold.read("messages", "t-1")).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("write then read round-trips a snapshot (gzip default)", async () => {
|
|
99
|
+
const cold = createS3ColdStore({
|
|
100
|
+
s3: fake.s3,
|
|
101
|
+
bucket: "test-bucket",
|
|
102
|
+
prefix: "prod/threads",
|
|
103
|
+
});
|
|
104
|
+
await cold.write("messages", "t-1", sampleSnapshot);
|
|
105
|
+
expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
|
|
106
|
+
|
|
107
|
+
// Verify the on-disk format is gzip + JSON.
|
|
108
|
+
const stored = fake.store.get("test-bucket/prod/threads/messages/t-1.json.gz");
|
|
109
|
+
expect(stored).toBeDefined();
|
|
110
|
+
if (!stored) throw new Error("expected stored object");
|
|
111
|
+
expect(stored.contentType).toBe("application/gzip");
|
|
112
|
+
const decoded = JSON.parse(gunzipSync(stored.body).toString("utf8"));
|
|
113
|
+
expect(decoded).toEqual(sampleSnapshot);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("supports plain JSON when gzip is disabled", async () => {
|
|
117
|
+
const cold = createS3ColdStore({
|
|
118
|
+
s3: fake.s3,
|
|
119
|
+
bucket: "test-bucket",
|
|
120
|
+
gzip: false,
|
|
121
|
+
});
|
|
122
|
+
await cold.write("messages", "t-1", sampleSnapshot);
|
|
123
|
+
|
|
124
|
+
const stored = fake.store.get("test-bucket/threads/messages/t-1.json");
|
|
125
|
+
expect(stored).toBeDefined();
|
|
126
|
+
if (!stored) throw new Error("expected stored object");
|
|
127
|
+
expect(stored.contentType).toBe("application/json");
|
|
128
|
+
expect(JSON.parse(stored.body.toString("utf8"))).toEqual(sampleSnapshot);
|
|
129
|
+
|
|
130
|
+
expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("uses 'threads' as the default prefix", async () => {
|
|
134
|
+
const cold = createS3ColdStore({
|
|
135
|
+
s3: fake.s3,
|
|
136
|
+
bucket: "test-bucket",
|
|
137
|
+
});
|
|
138
|
+
await cold.write("messages", "abc", sampleSnapshot);
|
|
139
|
+
expect(fake.store.has("test-bucket/threads/messages/abc.json.gz")).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("delete removes the underlying object", async () => {
|
|
143
|
+
const cold = createS3ColdStore({
|
|
144
|
+
s3: fake.s3,
|
|
145
|
+
bucket: "test-bucket",
|
|
146
|
+
});
|
|
147
|
+
await cold.write("messages", "t-1", sampleSnapshot);
|
|
148
|
+
await cold.delete("messages", "t-1");
|
|
149
|
+
expect(await cold.read("messages", "t-1")).toBeNull();
|
|
150
|
+
expect(fake.store.size).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("read treats 404 errors as null (not throw)", async () => {
|
|
154
|
+
const cold = createS3ColdStore({
|
|
155
|
+
s3: {
|
|
156
|
+
async send() {
|
|
157
|
+
const err = {
|
|
158
|
+
name: "NotFound",
|
|
159
|
+
$metadata: { httpStatusCode: 404 },
|
|
160
|
+
};
|
|
161
|
+
throw err;
|
|
162
|
+
},
|
|
163
|
+
} as unknown as S3LikeClient,
|
|
164
|
+
bucket: "test-bucket",
|
|
165
|
+
});
|
|
166
|
+
expect(await cold.read("messages", "t-1")).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("read rethrows non-404 errors", async () => {
|
|
170
|
+
const cold = createS3ColdStore({
|
|
171
|
+
s3: {
|
|
172
|
+
async send() {
|
|
173
|
+
throw new Error("AccessDenied");
|
|
174
|
+
},
|
|
175
|
+
} as unknown as S3LikeClient,
|
|
176
|
+
bucket: "test-bucket",
|
|
177
|
+
});
|
|
178
|
+
await expect(cold.read("messages", "t-1")).rejects.toThrow("AccessDenied");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("falls back to gunzip when the cold object is gzip-encoded but read with gzip:true", async () => {
|
|
182
|
+
// Pre-seed the fake S3 with a gzip-encoded payload, then read.
|
|
183
|
+
const compressed = gzipSync(Buffer.from(JSON.stringify(sampleSnapshot)));
|
|
184
|
+
fake.store.set("test-bucket/threads/messages/t-1.json.gz", {
|
|
185
|
+
body: compressed,
|
|
186
|
+
});
|
|
187
|
+
const cold = createS3ColdStore({
|
|
188
|
+
s3: fake.s3,
|
|
189
|
+
bucket: "test-bucket",
|
|
190
|
+
});
|
|
191
|
+
expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
|
|
192
|
+
});
|
|
193
|
+
});
|