zeitlich 0.2.43 → 0.2.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeitlich",
3
- "version": "0.2.43",
3
+ "version": "0.2.45",
4
4
  "description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -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
  // ---------------------------------------------------------------------------
@@ -276,10 +275,7 @@ export async function createSession<
276
275
  // defaults are. Both surfaces consult `resolveSessionLifecycle`
277
276
  // (or its type-level equivalent) before checking individual
278
277
  // mode/shutdown values.
279
- const lifecycle = resolveSessionLifecycle(
280
- sandboxInit,
281
- sandboxShutdown
282
- );
278
+ const lifecycle = resolveSessionLifecycle(sandboxInit, sandboxShutdown);
283
279
  const sandboxMode: SandboxInit["mode"] | undefined = lifecycle.mode;
284
280
  const resolvedShutdown: SubagentSandboxShutdown = lifecycle.shutdown;
285
281
  let sandboxId: string | undefined;
@@ -383,7 +379,48 @@ export async function createSession<
383
379
  });
384
380
  }
385
381
 
382
+ const sessionStartMs = Date.now();
383
+ const systemPrompt = stateManager.getSystemPrompt();
384
+
385
+ // --- Thread lifecycle: new, continue, or fork ----------------------
386
+ const rehydrateFromSlice = (slice: PersistedThreadState): void => {
387
+ stateManager.mergeUpdate({
388
+ tasks: new Map(slice.tasks),
389
+ ...slice.custom,
390
+ } as Partial<AgentState<TState>>);
391
+ };
392
+
393
+ if (threadMode === "fork" && sourceThreadId) {
394
+ await forkThread(sourceThreadId, threadId, threadKey);
395
+ const forkedSlice = await loadThreadState(threadId, threadKey);
396
+ if (forkedSlice) rehydrateFromSlice(forkedSlice);
397
+ } else if (threadMode === "continue") {
398
+ // "continue" — thread already exists, just append the new message
399
+ const continuedSlice = await loadThreadState(threadId, threadKey);
400
+ if (continuedSlice) rehydrateFromSlice(continuedSlice);
401
+ } else {
402
+ if (appendSystemPrompt) {
403
+ if (
404
+ systemPrompt == null ||
405
+ (typeof systemPrompt === "string" && systemPrompt.trim() === "")
406
+ ) {
407
+ throw ApplicationFailure.create({
408
+ message: "No system prompt in state",
409
+ nonRetryable: true,
410
+ });
411
+ }
412
+ await appendSystemMessage(threadId, uuid4(), systemPrompt, threadKey);
413
+ } else {
414
+ await initializeThread(threadId, threadKey);
415
+ }
416
+ }
417
+
386
418
  // --- Virtual filesystem init (independent of sandbox) ----------------
419
+ // Runs AFTER thread rehydration so the freshly resolved tree is
420
+ // authoritative. Otherwise a stale `fileTree` carried in a persisted
421
+ // slice (from a run on older code that didn't strip it) could
422
+ // overwrite entries that no longer exist in the backing store and
423
+ // cause subsequent FileWrite calls to fail with "not_found".
387
424
  if (virtualFsConfig) {
388
425
  if (!virtualFsOps) {
389
426
  throw ApplicationFailure.create({
@@ -423,6 +460,16 @@ export async function createSession<
423
460
  } as Partial<AgentState<TState>>);
424
461
  }
425
462
 
463
+ await appendHumanMessage(
464
+ threadId,
465
+ uuid4(),
466
+ await buildContextMessage(),
467
+ threadKey
468
+ );
469
+
470
+ let exitReason: SessionExitReason = "completed";
471
+ let finalMessage: M | null = null;
472
+
426
473
  if (hooks.onSessionStart) {
427
474
  await hooks.onSessionStart({
428
475
  threadId,
@@ -439,51 +486,6 @@ export async function createSession<
439
486
  ...(sandboxId && { sandboxId }),
440
487
  });
441
488
 
442
- const sessionStartMs = Date.now();
443
- const systemPrompt = stateManager.getSystemPrompt();
444
-
445
- // --- Thread lifecycle: new, continue, or fork ----------------------
446
- const rehydrateFromSlice = (slice: PersistedThreadState): void => {
447
- stateManager.mergeUpdate({
448
- tasks: new Map(slice.tasks),
449
- ...slice.custom,
450
- } as Partial<AgentState<TState>>);
451
- };
452
-
453
- if (threadMode === "fork" && sourceThreadId) {
454
- await forkThread(sourceThreadId, threadId, threadKey);
455
- const forkedSlice = await loadThreadState(threadId, threadKey);
456
- if (forkedSlice) rehydrateFromSlice(forkedSlice);
457
- } else if (threadMode === "continue") {
458
- // "continue" — thread already exists, just append the new message
459
- const continuedSlice = await loadThreadState(threadId, threadKey);
460
- if (continuedSlice) rehydrateFromSlice(continuedSlice);
461
- } else {
462
- if (appendSystemPrompt) {
463
- if (
464
- systemPrompt == null ||
465
- (typeof systemPrompt === "string" && systemPrompt.trim() === "")
466
- ) {
467
- throw ApplicationFailure.create({
468
- message: "No system prompt in state",
469
- nonRetryable: true,
470
- });
471
- }
472
- await appendSystemMessage(threadId, uuid4(), systemPrompt, threadKey);
473
- } else {
474
- await initializeThread(threadId, threadKey);
475
- }
476
- }
477
- await appendHumanMessage(
478
- threadId,
479
- uuid4(),
480
- await buildContextMessage(),
481
- threadKey
482
- );
483
-
484
- let exitReason: SessionExitReason = "completed";
485
- let finalMessage: M | null = null;
486
-
487
489
  try {
488
490
  // Per-turn assistant message id. Pre-generated in the workflow
489
491
  // so the runAgent activity can truncate the thread from this id
@@ -385,6 +385,33 @@ describe("createAgentStateManager integration", () => {
385
385
  expect("tools" in slice.custom).toBe(false);
386
386
  });
387
387
 
388
+ it("getPersistedSlice strips runtime fields injected via mergeUpdate", () => {
389
+ const sm = createAgentStateManager<{ label: string }>({
390
+ initialState: { systemPrompt: "test", label: "original" },
391
+ });
392
+
393
+ sm.mergeUpdate({
394
+ label: "updated",
395
+ fileTree: [
396
+ {
397
+ id: "f1",
398
+ path: "/a.txt",
399
+ size: 1,
400
+ mtime: "2026-01-01T00:00:00Z",
401
+ metadata: {},
402
+ },
403
+ ],
404
+ virtualFsCtx: { workspaceBase: "/tmp" },
405
+ inlineFiles: { "/a.txt": "hi" },
406
+ } as Parameters<typeof sm.mergeUpdate>[0]);
407
+
408
+ const slice = sm.getPersistedSlice();
409
+ expect(slice.custom).toEqual({ label: "updated" });
410
+ expect("fileTree" in slice.custom).toBe(false);
411
+ expect("virtualFsCtx" in slice.custom).toBe(false);
412
+ expect("inlineFiles" in slice.custom).toBe(false);
413
+ });
414
+
388
415
  it("mergeUpdate replaces the task map when given a tasks field", () => {
389
416
  const sm = createAgentStateManager<{ label: string; extra?: string }>({
390
417
  initialState: {
@@ -20,6 +20,32 @@ import type {
20
20
  } from "./types";
21
21
  import { z } from "zod";
22
22
 
23
+ /**
24
+ * Fields that live on `AgentState` at runtime but must NOT be persisted into
25
+ * `PersistedThreadState`. They're either managed by the state manager itself
26
+ * (status/version/turns/tasks/tools/usage), kept in their own slot
27
+ * (systemPrompt), or rebuilt on each run (the virtual-fs trio).
28
+ *
29
+ * Centralizing the list keeps the constructor's destructure-omit and
30
+ * `getPersistedSlice` in lockstep.
31
+ */
32
+ const RESERVED_STATE_KEYS = [
33
+ "status",
34
+ "version",
35
+ "turns",
36
+ "tasks",
37
+ "tools",
38
+ "systemPrompt",
39
+ "fileTree",
40
+ "inlineFiles",
41
+ "virtualFsCtx",
42
+ "totalInputTokens",
43
+ "totalOutputTokens",
44
+ "totalReasonTokens",
45
+ "cachedWriteTokens",
46
+ "cachedReadTokens",
47
+ ] as const;
48
+
23
49
  /**
24
50
  * Creates an agent state manager for tracking workflow state.
25
51
  * Automatically registers Temporal query and update handlers for the agent.
@@ -238,9 +264,21 @@ export function createAgentStateManager<
238
264
  },
239
265
 
240
266
  getPersistedSlice(): PersistedThreadState {
267
+ // `customState` can pick up reserved/runtime fields via `mergeUpdate`
268
+ // (e.g. `fileTree`, `virtualFsCtx`, `inlineFiles` written by the
269
+ // virtual-fs bootstrap on every run). Those are rebuilt per run and
270
+ // must never round-trip through the thread store, so strip them here
271
+ // rather than relying on callers to remember.
272
+ const source = customState as unknown as Record<string, JsonValue>;
273
+ const custom: Record<string, JsonValue> = {};
274
+ const reserved = new Set<string>(RESERVED_STATE_KEYS);
275
+ for (const [key, value] of Object.entries(source)) {
276
+ if (reserved.has(key)) continue;
277
+ custom[key] = value;
278
+ }
241
279
  return {
242
280
  tasks: Array.from(tasks.entries()),
243
- custom: { ...(customState as unknown as Record<string, JsonValue>) },
281
+ custom,
244
282
  };
245
283
  },
246
284