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/dist/index.cjs +366 -136
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -3
- package/dist/index.d.ts +30 -3
- package/dist/index.js +338 -112
- package/dist/index.js.map +1 -1
- package/dist/{workflow-BdAuMMjY.d.cts → workflow-BhjsEQc1.d.cts} +120 -26
- package/dist/{workflow-BdAuMMjY.d.ts → workflow-BhjsEQc1.d.ts} +120 -26
- package/dist/workflow.cjs +267 -126
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +2 -1
- package/dist/workflow.d.ts +2 -1
- package/dist/workflow.js +239 -102
- package/dist/workflow.js.map +1 -1
- package/package.json +15 -15
- package/src/index.ts +3 -0
- package/src/lib/model-invoker.ts +2 -2
- package/src/lib/session.ts +14 -8
- package/src/lib/skills/fs-provider.ts +84 -0
- package/src/lib/skills/index.ts +3 -0
- package/src/lib/skills/parse.ts +117 -0
- package/src/lib/skills/types.ts +41 -0
- package/src/lib/state-manager.ts +42 -26
- package/src/lib/thread-manager.ts +61 -2
- package/src/lib/tool-router.ts +49 -16
- package/src/lib/types.ts +23 -6
- package/src/tools/ask-user-question/handler.ts +3 -3
- package/src/tools/read-file/tool.ts +2 -2
- package/src/tools/read-skill/handler.ts +31 -0
- package/src/tools/read-skill/tool.ts +47 -0
- package/src/tools/subagent/handler.ts +21 -4
- package/src/tools/subagent/tool.ts +7 -22
- package/src/tools/task-create/tool.ts +1 -1
- package/src/tools/write-file/tool.ts +4 -5
- package/src/workflow.ts +16 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zeitlich",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
73
|
-
"@temporalio/worker": "^1.
|
|
74
|
-
"@types/node": "^25.3.
|
|
75
|
-
"eslint": "^10.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.
|
|
78
|
-
"release-please": "^17.
|
|
79
|
-
"tsup": "^8.
|
|
80
|
-
"typescript": "^5.3
|
|
81
|
-
"typescript-eslint": "^8.
|
|
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.
|
|
94
|
-
"@temporalio/common": "^1.
|
|
95
|
-
"@temporalio/plugin": "^1.
|
|
96
|
-
"@temporalio/workflow": "^1.
|
|
97
|
-
"just-bash": "^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,
|
package/src/lib/model-invoker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type Redis from "ioredis";
|
|
2
2
|
import { createThreadManager } from "./thread-manager";
|
|
3
|
-
import type
|
|
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>(
|
|
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(
|
package/src/lib/session.ts
CHANGED
|
@@ -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
|
|
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: "
|
|
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,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
|
+
}
|
package/src/lib/state-manager.ts
CHANGED
|
@@ -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
|
-
|
|
166
|
+
agentName,
|
|
153
167
|
}: {
|
|
154
168
|
initialState?: Partial<BaseAgentState> & TCustom;
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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();
|