zeitlich 0.2.8 → 0.2.11

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.8",
3
+ "version": "0.2.11",
4
4
  "description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -69,16 +69,16 @@
69
69
  },
70
70
  "devDependencies": {
71
71
  "@eslint/js": "^10.0.1",
72
- "@temporalio/envconfig": "^1.14.1",
73
- "@temporalio/worker": "^1.14.1",
74
- "@types/node": "^25.3.0",
75
- "eslint": "^10.0.0",
72
+ "@temporalio/envconfig": "^1.15.0",
73
+ "@temporalio/worker": "^1.15.0",
74
+ "@types/node": "^25.3.3",
75
+ "eslint": "^10.0.2",
76
76
  "husky": "^9.1.7",
77
- "prettier": "^3.2.0",
78
- "release-please": "^17.2.0",
79
- "tsup": "^8.0.0",
80
- "typescript": "^5.3.0",
81
- "typescript-eslint": "^8.0.0",
77
+ "prettier": "^3.8.1",
78
+ "release-please": "^17.3.0",
79
+ "tsup": "^8.5.1",
80
+ "typescript": "^5.9.3",
81
+ "typescript-eslint": "^8.56.1",
82
82
  "vitest": "^4.0.18"
83
83
  },
84
84
  "peerDependencies": {
@@ -90,11 +90,11 @@
90
90
  },
91
91
  "homepage": "https://github.com/bead-ai/zeitlich#readme",
92
92
  "dependencies": {
93
- "@langchain/core": "^1.1.18",
94
- "@temporalio/common": "^1.14.1",
95
- "@temporalio/plugin": "^1.14.1",
96
- "@temporalio/workflow": "^1.14.1",
97
- "just-bash": "^2.9.2",
93
+ "@langchain/core": "^1.1.29",
94
+ "@temporalio/common": "^1.15.0",
95
+ "@temporalio/plugin": "^1.15.0",
96
+ "@temporalio/workflow": "^1.15.0",
97
+ "just-bash": "^2.11.6",
98
98
  "zod": "^4.3.6"
99
99
  }
100
100
  }
package/src/index.ts CHANGED
@@ -45,6 +45,9 @@ export { createBashHandler } from "./tools/bash/handler";
45
45
 
46
46
  export { toTree } from "./lib/fs";
47
47
 
48
+ // Skills (activity-side: filesystem provider)
49
+ export { FileSystemSkillProvider } from "./lib/skills/fs-provider";
50
+
48
51
  export { createThreadManager } from "./lib/thread-manager";
49
52
  export type {
50
53
  BaseThreadManager,
@@ -1,6 +1,6 @@
1
1
  import type Redis from "ioredis";
2
2
  import { createThreadManager } from "./thread-manager";
3
- import type { AgentResponse, BaseAgentState } from "./types";
3
+ import { agentQueryName, type AgentResponse, type BaseAgentState } from "./types";
4
4
  import { Context } from "@temporalio/activity";
5
5
  import type { WorkflowClient } from "@temporalio/client";
6
6
  import { mapStoredMessagesToChatMessages } from "@langchain/core/messages";
@@ -48,7 +48,7 @@ export async function invokeModel({
48
48
  const parentRunId = info.workflowExecution.runId;
49
49
 
50
50
  const handle = client.getHandle(parentWorkflowId, parentRunId);
51
- const { tools } = await handle.query<BaseAgentState>(`get${agentName}State`);
51
+ const { tools } = await handle.query<BaseAgentState>(agentQueryName(agentName));
52
52
 
53
53
  const messages = await thread.load();
54
54
  const response = await model.invoke(
@@ -3,6 +3,7 @@ import {
3
3
  condition,
4
4
  defineUpdate,
5
5
  setHandler,
6
+ ApplicationFailure,
6
7
  } from "@temporalio/workflow";
7
8
  import type { ZeitlichSharedActivities } from "../activities";
8
9
  import type {
@@ -44,18 +45,17 @@ export interface SessionLifecycleHooks {
44
45
  export const createSession = async <T extends ToolMap, M = unknown>({
45
46
  threadId,
46
47
  agentName,
47
- description,
48
48
  maxTurns = 50,
49
49
  metadata = {},
50
50
  runAgent,
51
51
  threadOps,
52
52
  buildContextMessage,
53
53
  subagents,
54
+ skills,
54
55
  tools = {} as T,
55
56
  processToolsInParallel = true,
56
57
  hooks = {},
57
58
  appendSystemPrompt = true,
58
- systemPrompt,
59
59
  waitForInputTimeout = "48h",
60
60
  }: SessionConfig<T, M> & AgentConfig): Promise<ZeitlichSession<M>> => {
61
61
  const {
@@ -71,6 +71,7 @@ export const createSession = async <T extends ToolMap, M = unknown>({
71
71
  threadId,
72
72
  hooks,
73
73
  subagents,
74
+ skills,
74
75
  parallel: processToolsInParallel,
75
76
  });
76
77
 
@@ -126,8 +127,16 @@ export const createSession = async <T extends ToolMap, M = unknown>({
126
127
  });
127
128
  }
128
129
 
130
+ const systemPrompt = stateManager.getSystemPrompt();
131
+
129
132
  await initializeThread(threadId);
130
- if (appendSystemPrompt && systemPrompt && systemPrompt.trim() !== "") {
133
+ if (appendSystemPrompt) {
134
+ if (!systemPrompt || systemPrompt.trim() === "") {
135
+ throw ApplicationFailure.create({
136
+ message: "No system prompt in state",
137
+ nonRetryable: true,
138
+ });
139
+ }
131
140
  await appendSystemMessage(threadId, systemPrompt);
132
141
  }
133
142
  await appendHumanMessage(threadId, await buildContextMessage());
@@ -149,8 +158,6 @@ export const createSession = async <T extends ToolMap, M = unknown>({
149
158
  threadId,
150
159
  agentName,
151
160
  metadata,
152
- systemPrompt,
153
- description,
154
161
  });
155
162
 
156
163
  if (usage) {
@@ -219,7 +226,7 @@ export const createSession = async <T extends ToolMap, M = unknown>({
219
226
  }
220
227
  } catch (error) {
221
228
  exitReason = "failed";
222
- throw error;
229
+ throw ApplicationFailure.fromError(error);
223
230
  } finally {
224
231
  // SessionEnd hook - always called
225
232
  await callSessionEnd(exitReason, stateManager.getTurns());
@@ -251,14 +258,13 @@ export function proxyDefaultThreadOps(
251
258
  ): ThreadOps {
252
259
  const activities = proxyActivities<ZeitlichSharedActivities>(
253
260
  options ?? {
254
- startToCloseTimeout: "30m",
261
+ startToCloseTimeout: "10s",
255
262
  retry: {
256
263
  maximumAttempts: 6,
257
264
  initialInterval: "5s",
258
265
  maximumInterval: "15m",
259
266
  backoffCoefficient: 4,
260
267
  },
261
- heartbeatTimeout: "5m",
262
268
  }
263
269
  );
264
270
 
@@ -0,0 +1,84 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Skill, SkillMetadata, SkillProvider } from "./types";
4
+ import { parseSkillFile } from "./parse";
5
+
6
+ /**
7
+ * Loads skills from a filesystem directory following the agentskills.io layout:
8
+ *
9
+ * ```
10
+ * skills/
11
+ * ├── code-review/
12
+ * │ └── SKILL.md
13
+ * ├── pdf-processing/
14
+ * │ └── SKILL.md
15
+ * ```
16
+ *
17
+ * Activity-side only — cannot be used in Temporal workflow code.
18
+ */
19
+ export class FileSystemSkillProvider implements SkillProvider {
20
+ constructor(private readonly baseDir: string) {}
21
+
22
+ async listSkills(): Promise<SkillMetadata[]> {
23
+ const dirs = await this.discoverSkillDirs();
24
+ const skills: SkillMetadata[] = [];
25
+
26
+ for (const dir of dirs) {
27
+ const raw = await readFile(join(this.baseDir, dir, "SKILL.md"), "utf-8");
28
+ const { frontmatter } = parseSkillFile(raw);
29
+ skills.push(frontmatter);
30
+ }
31
+
32
+ return skills;
33
+ }
34
+
35
+ async getSkill(name: string): Promise<Skill> {
36
+ const raw = await readFile(
37
+ join(this.baseDir, name, "SKILL.md"),
38
+ "utf-8"
39
+ );
40
+ const { frontmatter, body } = parseSkillFile(raw);
41
+
42
+ if (frontmatter.name !== name) {
43
+ throw new Error(
44
+ `Skill directory "${name}" contains SKILL.md with mismatched name "${frontmatter.name}"`
45
+ );
46
+ }
47
+
48
+ return { ...frontmatter, instructions: body };
49
+ }
50
+
51
+ /**
52
+ * Convenience method to load all skills with full instructions.
53
+ * Returns `Skill[]` ready to pass into a workflow.
54
+ */
55
+ async loadAll(): Promise<Skill[]> {
56
+ const dirs = await this.discoverSkillDirs();
57
+ const skills: Skill[] = [];
58
+
59
+ for (const dir of dirs) {
60
+ const raw = await readFile(join(this.baseDir, dir, "SKILL.md"), "utf-8");
61
+ const { frontmatter, body } = parseSkillFile(raw);
62
+ skills.push({ ...frontmatter, instructions: body });
63
+ }
64
+
65
+ return skills;
66
+ }
67
+
68
+ private async discoverSkillDirs(): Promise<string[]> {
69
+ const entries = await readdir(this.baseDir, { withFileTypes: true });
70
+ const dirs: string[] = [];
71
+
72
+ for (const entry of entries) {
73
+ if (!entry.isDirectory()) continue;
74
+ try {
75
+ await readFile(join(this.baseDir, entry.name, "SKILL.md"), "utf-8");
76
+ dirs.push(entry.name);
77
+ } catch {
78
+ // No SKILL.md — skip
79
+ }
80
+ }
81
+
82
+ return dirs;
83
+ }
84
+ }
@@ -0,0 +1,3 @@
1
+ export type { Skill, SkillMetadata, SkillProvider } from "./types";
2
+ export { parseSkillFile } from "./parse";
3
+ export { FileSystemSkillProvider } from "./fs-provider";
@@ -0,0 +1,117 @@
1
+ import type { SkillMetadata } from "./types";
2
+
3
+ /**
4
+ * Parse a SKILL.md file into its frontmatter fields and markdown body.
5
+ *
6
+ * Handles the limited YAML subset used by the agentskills.io spec:
7
+ * flat key-value pairs plus one-level nested `metadata` map.
8
+ * No external YAML dependency required.
9
+ */
10
+ export function parseSkillFile(raw: string): {
11
+ frontmatter: SkillMetadata;
12
+ body: string;
13
+ } {
14
+ const trimmed = raw.replace(/^\uFEFF/, ""); // strip BOM
15
+ const match = trimmed.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/);
16
+
17
+ if (!match) {
18
+ throw new Error(
19
+ "SKILL.md must start with YAML frontmatter delimited by ---"
20
+ );
21
+ }
22
+
23
+ const [, yamlBlock, body] = match as [string, string, string];
24
+ const frontmatter = parseSimpleYaml(yamlBlock);
25
+
26
+ if (!frontmatter.name || typeof frontmatter.name !== "string") {
27
+ throw new Error("SKILL.md frontmatter must include a 'name' field");
28
+ }
29
+ if (!frontmatter.description || typeof frontmatter.description !== "string") {
30
+ throw new Error("SKILL.md frontmatter must include a 'description' field");
31
+ }
32
+
33
+ const result: SkillMetadata = {
34
+ name: frontmatter.name,
35
+ description: frontmatter.description,
36
+ };
37
+
38
+ if (frontmatter.license) result.license = String(frontmatter.license);
39
+ if (frontmatter.compatibility)
40
+ result.compatibility = String(frontmatter.compatibility);
41
+ if (frontmatter["allowed-tools"]) {
42
+ result.allowedTools = String(frontmatter["allowed-tools"])
43
+ .split(/\s+/)
44
+ .filter(Boolean);
45
+ }
46
+ if (
47
+ frontmatter.metadata &&
48
+ typeof frontmatter.metadata === "object" &&
49
+ !Array.isArray(frontmatter.metadata)
50
+ ) {
51
+ result.metadata = frontmatter.metadata as Record<string, string>;
52
+ }
53
+
54
+ return { frontmatter: result, body: body.trim() };
55
+ }
56
+
57
+ type YamlValue = string | Record<string, string>;
58
+
59
+ /**
60
+ * Minimal YAML parser for the agentskills.io frontmatter subset.
61
+ * Supports: scalar key-value pairs, one-level nested maps (metadata).
62
+ * Does NOT support arrays, multi-line strings, anchors, etc.
63
+ */
64
+ function parseSimpleYaml(yaml: string): Record<string, YamlValue> {
65
+ const result: Record<string, YamlValue> = {};
66
+ const lines = yaml.split(/\r?\n/);
67
+
68
+ let currentMapKey: string | null = null;
69
+ let currentMap: Record<string, string> | null = null;
70
+
71
+ for (const line of lines) {
72
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
73
+
74
+ const nestedMatch = line.match(/^(\s{2,}|\t+)(\S+)\s*:\s*(.*)$/);
75
+ if (nestedMatch && currentMapKey && currentMap) {
76
+ const [, , key, rawVal] = nestedMatch as [string, string, string, string];
77
+ currentMap[key] = unquote(rawVal.trim());
78
+ continue;
79
+ }
80
+
81
+ // Flush any pending nested map
82
+ if (currentMapKey && currentMap) {
83
+ result[currentMapKey] = currentMap;
84
+ currentMapKey = null;
85
+ currentMap = null;
86
+ }
87
+
88
+ const topMatch = line.match(/^(\S+)\s*:\s*(.*)$/);
89
+ if (!topMatch) continue;
90
+
91
+ const [, key, rawVal] = topMatch as [string, string, string];
92
+ const val = rawVal.trim();
93
+
94
+ if (val === "" || val === "|" || val === ">") {
95
+ currentMapKey = key;
96
+ currentMap = {};
97
+ } else {
98
+ result[key] = unquote(val);
99
+ }
100
+ }
101
+
102
+ if (currentMapKey && currentMap) {
103
+ result[currentMapKey] = currentMap;
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ function unquote(s: string): string {
110
+ if (
111
+ (s.startsWith('"') && s.endsWith('"')) ||
112
+ (s.startsWith("'") && s.endsWith("'"))
113
+ ) {
114
+ return s.slice(1, -1);
115
+ }
116
+ return s;
117
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Skill metadata — the lightweight subset loaded at startup for all skills.
3
+ * Follows the agentskills.io specification frontmatter fields.
4
+ */
5
+ export interface SkillMetadata {
6
+ /** Lowercase alphanumeric + hyphens, max 64 chars, must match directory name */
7
+ name: string;
8
+ /** What the skill does and when to use it (max 1024 chars) */
9
+ description: string;
10
+ /** License name or reference to a bundled license file */
11
+ license?: string;
12
+ /** Environment requirements (intended product, system packages, network access) */
13
+ compatibility?: string;
14
+ /** Arbitrary key-value pairs for additional metadata */
15
+ metadata?: Record<string, string>;
16
+ /** Space-delimited list of pre-approved tools the skill may use */
17
+ allowedTools?: string[];
18
+ }
19
+
20
+ /**
21
+ * A fully-loaded skill including the SKILL.md instruction body.
22
+ * Progressive disclosure: metadata is always available, instructions
23
+ * are loaded on-demand via the ReadSkill tool.
24
+ */
25
+ export interface Skill extends SkillMetadata {
26
+ /** The markdown body of SKILL.md (everything after the frontmatter) */
27
+ instructions: string;
28
+ }
29
+
30
+ /**
31
+ * Abstraction for discovering and loading skills.
32
+ *
33
+ * Implement this interface to provide skills from any source
34
+ * (filesystem, database, API, in-memory, etc.).
35
+ */
36
+ export interface SkillProvider {
37
+ /** Return lightweight metadata for all available skills */
38
+ listSkills(): Promise<SkillMetadata[]>;
39
+ /** Load a single skill with full instructions by name */
40
+ getSkill(name: string): Promise<Skill>;
41
+ }
@@ -3,13 +3,16 @@ import {
3
3
  defineQuery,
4
4
  defineUpdate,
5
5
  setHandler,
6
+ type QueryDefinition,
6
7
  } from "@temporalio/workflow";
8
+ import type { UpdateDefinition } from "@temporalio/common/lib/interfaces";
7
9
  import {
8
- type AgentConfig,
9
10
  type AgentStatus,
10
11
  type BaseAgentState,
11
12
  type WorkflowTask,
12
13
  isTerminalStatus,
14
+ agentQueryName,
15
+ agentStateChangeUpdateName,
13
16
  } from "./types";
14
17
  import type { ToolDefinition } from "./tool-router";
15
18
  import { z } from "zod";
@@ -61,6 +64,11 @@ export type AgentState<TCustom extends JsonSerializable<TCustom>> =
61
64
  * the state and helpers needed for those handlers.
62
65
  */
63
66
  export interface AgentStateManager<TCustom extends JsonSerializable<TCustom>> {
67
+ /** Typed query definition registered for this agent's state */
68
+ readonly stateQuery: QueryDefinition<AgentState<TCustom>>;
69
+ /** Typed update definition registered for waiting on this agent's state change */
70
+ readonly stateChangeUpdate: UpdateDefinition<AgentState<TCustom>, [number]>;
71
+
64
72
  /** Get current status */
65
73
  getStatus(): AgentStatus;
66
74
  /** Check if agent is running */
@@ -90,6 +98,12 @@ export interface AgentStateManager<TCustom extends JsonSerializable<TCustom>> {
90
98
  /** Get current turns */
91
99
  getTurns(): number;
92
100
 
101
+ /** Get the system prompt */
102
+ getSystemPrompt(): string | undefined;
103
+
104
+ /** Set the system prompt */
105
+ setSystemPrompt(newSystemPrompt: string): void;
106
+
93
107
  /** Get a custom state value by key */
94
108
  get<K extends keyof TCustom>(key: K): TCustom[K];
95
109
 
@@ -149,10 +163,10 @@ export function createAgentStateManager<
149
163
  TCustom extends JsonSerializable<TCustom> = Record<string, never>,
150
164
  >({
151
165
  initialState,
152
- agentConfig,
166
+ agentName,
153
167
  }: {
154
168
  initialState?: Partial<BaseAgentState> & TCustom;
155
- agentConfig: AgentConfig;
169
+ agentName: string;
156
170
  }): AgentStateManager<TCustom> {
157
171
  // Default state (BaseAgentState fields)
158
172
  let status: AgentStatus = initialState?.status ?? "RUNNING";
@@ -164,6 +178,7 @@ export function createAgentStateManager<
164
178
  let totalCachedWriteTokens = 0;
165
179
  let totalCachedReadTokens = 0;
166
180
  let totalReasonTokens = 0;
181
+ let systemPrompt = initialState?.systemPrompt;
167
182
 
168
183
  // Tasks state
169
184
  const tasks = new Map<string, WorkflowTask>(initialState?.tasks);
@@ -189,24 +204,26 @@ export function createAgentStateManager<
189
204
  } as AgentState<TCustom>;
190
205
  }
191
206
 
192
- setHandler(defineQuery(`get${agentConfig.agentName}State`), () => {
207
+ const stateQuery = defineQuery<AgentState<TCustom>>(
208
+ agentQueryName(agentName)
209
+ );
210
+ const stateChangeUpdate = defineUpdate<AgentState<TCustom>, [number]>(
211
+ agentStateChangeUpdateName(agentName)
212
+ );
213
+
214
+ setHandler(stateQuery, () => buildState());
215
+ setHandler(stateChangeUpdate, async (lastKnownVersion: number) => {
216
+ await condition(
217
+ () => version > lastKnownVersion || isTerminalStatus(status),
218
+ "55s"
219
+ );
193
220
  return buildState();
194
221
  });
195
222
 
196
- setHandler(
197
- defineUpdate<AgentState<TCustom>, [number]>(
198
- `waitFor${agentConfig.agentName}StateChange`
199
- ),
200
- async (lastKnownVersion: number) => {
201
- await condition(
202
- () => version > lastKnownVersion || isTerminalStatus(status),
203
- "55s"
204
- );
205
- return buildState();
206
- }
207
- );
208
-
209
223
  return {
224
+ stateQuery,
225
+ stateChangeUpdate,
226
+
210
227
  getStatus(): AgentStatus {
211
228
  return status;
212
229
  },
@@ -215,6 +232,10 @@ export function createAgentStateManager<
215
232
  return status === "RUNNING";
216
233
  },
217
234
 
235
+ getSystemPrompt(): string | undefined {
236
+ return systemPrompt;
237
+ },
238
+
218
239
  isTerminal(): boolean {
219
240
  return isTerminalStatus(status);
220
241
  },
@@ -300,6 +321,10 @@ export function createAgentStateManager<
300
321
  }));
301
322
  },
302
323
 
324
+ setSystemPrompt(newSystemPrompt: string): void {
325
+ systemPrompt = newSystemPrompt;
326
+ },
327
+
303
328
  deleteTask(id: string): boolean {
304
329
  const deleted = tasks.delete(id);
305
330
  if (deleted) {
@@ -341,12 +366,3 @@ export function createAgentStateManager<
341
366
  },
342
367
  };
343
368
  }
344
-
345
- /**
346
- * Handler names used across agents
347
- */
348
- export const AGENT_HANDLER_NAMES = {
349
- getAgentState: "getAgentState",
350
- waitForStateChange: "waitForStateChange",
351
- addMessage: "addMessage",
352
- } as const;
@@ -14,6 +14,27 @@ import { v4 as uuidv4 } from "uuid";
14
14
 
15
15
  const THREAD_TTL_SECONDS = 60 * 60 * 24 * 90; // 90 days
16
16
 
17
+ /**
18
+ * Lua script for atomic idempotent append.
19
+ * Checks a dedup key; if it exists the message was already appended and we
20
+ * return 0. Otherwise appends all messages to the list, sets TTL on both
21
+ * the list and the dedup key, and returns 1.
22
+ *
23
+ * KEYS[1] = dedup key, KEYS[2] = list key
24
+ * ARGV[1] = TTL seconds, ARGV[2..N] = serialised messages
25
+ */
26
+ const APPEND_IDEMPOTENT_SCRIPT = `
27
+ if redis.call('EXISTS', KEYS[1]) == 1 then
28
+ return 0
29
+ end
30
+ for i = 2, #ARGV do
31
+ redis.call('RPUSH', KEYS[2], ARGV[i])
32
+ end
33
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[1]))
34
+ redis.call('SET', KEYS[1], '1', 'EX', tonumber(ARGV[1]))
35
+ return 1
36
+ `;
37
+
17
38
  function getThreadKey(threadId: string, key: string): string {
18
39
  return `thread:${threadId}:${key}`;
19
40
  }
@@ -33,6 +54,12 @@ export interface ThreadManagerConfig<T = StoredMessage> {
33
54
  serialize?: (message: T) => string;
34
55
  /** Custom deserializer, defaults to JSON.parse */
35
56
  deserialize?: (raw: string) => T;
57
+ /**
58
+ * Extract a unique id from a message for idempotent appends.
59
+ * When provided, `append` uses an atomic Lua script to skip duplicate writes.
60
+ * Defaults to `StoredMessage.data.id` for the standard ThreadManager.
61
+ */
62
+ idOf?: (message: T) => string;
36
63
  }
37
64
 
38
65
  /** Generic thread manager for any message type */
@@ -41,7 +68,11 @@ export interface BaseThreadManager<T> {
41
68
  initialize(): Promise<void>;
42
69
  /** Load all messages from the thread */
43
70
  load(): Promise<T[]>;
44
- /** Append messages to the thread */
71
+ /**
72
+ * Append messages to the thread.
73
+ * When `idOf` is configured, appends are idempotent — retries with the
74
+ * same message ids are atomically skipped via a Redis Lua script.
75
+ */
45
76
  append(messages: T[]): Promise<void>;
46
77
  /** Delete the thread */
47
78
  delete(): Promise<void>;
@@ -51,6 +82,8 @@ export interface BaseThreadManager<T> {
51
82
  export interface ThreadManager extends BaseThreadManager<StoredMessage> {
52
83
  /** Create a HumanMessage (returns StoredMessage for storage) */
53
84
  createHumanMessage(content: string | MessageContent): StoredMessage;
85
+ /** Create a SystemMessage (returns StoredMessage for storage) */
86
+ createSystemMessage(content: string): StoredMessage;
54
87
  /** Create an AIMessage with optional additional kwargs */
55
88
  createAIMessage(
56
89
  content: string | MessageContent,
@@ -74,6 +107,11 @@ export interface ThreadManager extends BaseThreadManager<StoredMessage> {
74
107
  appendAIMessage(content: string | MessageContent): Promise<void>;
75
108
  }
76
109
 
110
+ /** Default id extractor for StoredMessage */
111
+ function storedMessageId(msg: StoredMessage): string {
112
+ return msg.data.id ?? "";
113
+ }
114
+
77
115
  /**
78
116
  * Creates a thread manager for handling conversation state in Redis.
79
117
  * Without generic args, returns a full ThreadManager with StoredMessage helpers.
@@ -95,6 +133,13 @@ export function createThreadManager<T>(
95
133
  } = config;
96
134
  const redisKey = getThreadKey(threadId, key);
97
135
 
136
+ // Default idOf for StoredMessage when no custom serialization is used
137
+ const idOf =
138
+ config.idOf ??
139
+ (!config.serialize
140
+ ? (storedMessageId as unknown as (m: T) => string)
141
+ : undefined);
142
+
98
143
  const base: BaseThreadManager<T> = {
99
144
  async initialize(): Promise<void> {
100
145
  await redis.del(redisKey);
@@ -106,7 +151,20 @@ export function createThreadManager<T>(
106
151
  },
107
152
 
108
153
  async append(messages: T[]): Promise<void> {
109
- if (messages.length > 0) {
154
+ if (messages.length === 0) return;
155
+
156
+ if (idOf) {
157
+ const dedupId = messages.map(idOf).join(":");
158
+ const dedupKey = getThreadKey(threadId, `dedup:${dedupId}`);
159
+ await redis.eval(
160
+ APPEND_IDEMPOTENT_SCRIPT,
161
+ 2,
162
+ dedupKey,
163
+ redisKey,
164
+ String(THREAD_TTL_SECONDS),
165
+ ...messages.map(serialize)
166
+ );
167
+ } else {
110
168
  await redis.rpush(redisKey, ...messages.map(serialize));
111
169
  await redis.expire(redisKey, THREAD_TTL_SECONDS);
112
170
  }
@@ -156,6 +214,7 @@ export function createThreadManager<T>(
156
214
  toolCallId: string
157
215
  ): StoredMessage {
158
216
  return new ToolMessage({
217
+ id: uuidv4(),
159
218
  content: content as MessageContent,
160
219
  tool_call_id: toolCallId,
161
220
  }).toDict();