agent-state-machine 2.2.0 → 2.2.1

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.
@@ -1,62 +1,100 @@
1
- # __WORKFLOW_NAME__
1
+ # agent-state-machine
2
2
 
3
- A workflow created with agent-state-machine (native JS format).
3
+ A workflow runner for building **linear, stateful agent workflows** in plain JavaScript.
4
4
 
5
- ## Structure
5
+ You write normal `async/await` code. The runtime handles:
6
+ - **Auto-persisted** `memory` (saved to disk on mutation)
7
+ - **Auto-tracked** `fileTree` (detects file changes made by agents via Git)
8
+ - **Human-in-the-loop** blocking via `askHuman()` or agent-driven interactions
9
+ - Local **JS agents** + **Markdown agents** (LLM-powered)
10
+ - **Agent retries** with history logging for failures
6
11
 
7
- ```
8
- __WORKFLOW_NAME__/
9
- ├── workflow.js # Native JS workflow (async/await)
10
- ├── config.js # Model/API key configuration
11
- ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
12
- ├── interactions/ # Human-in-the-loop inputs (created at runtime)
13
- ├── state/ # Runtime state (current.json, history.jsonl)
14
- └── steering/ # Steering configuration
15
- ```
12
+ ---
16
13
 
17
- ## Usage
14
+ ## Install
18
15
 
19
- Edit `config.js` to set models and API keys for this workflow.
16
+ You need to install the package **globally** to get the CLI, and **locally** in your project so your workflow can import the library.
20
17
 
21
- Run the workflow (or resume if interrupted):
22
- ```bash
23
- state-machine run __WORKFLOW_NAME__
24
- ```
18
+ ### Global CLI
19
+ Provides the `state-machine` command.
25
20
 
26
- Check status:
27
21
  ```bash
28
- state-machine status __WORKFLOW_NAME__
29
- ```
22
+ # npm
23
+ npm i -g agent-state-machine
30
24
 
31
- View history:
32
- ```bash
33
- state-machine history __WORKFLOW_NAME__
25
+ # pnpm
26
+ pnpm add -g agent-state-machine
34
27
  ```
35
28
 
36
- View trace logs in browser with live updates:
29
+ ### Local Library
30
+ Required so your `workflow.js` can `import { agent, memory, fileTree } from 'agent-state-machine'`.
31
+
37
32
  ```bash
38
- state-machine follow __WORKFLOW_NAME__
33
+ # npm
34
+ npm i agent-state-machine
35
+
36
+ # pnpm (for monorepos/turbo, install in root)
37
+ pnpm add agent-state-machine -w
39
38
  ```
40
39
 
41
- Reset state (clears memory/state):
40
+ Requirements: Node.js >= 16.
41
+
42
+ ---
43
+
44
+ ## CLI
45
+
42
46
  ```bash
43
- state-machine reset __WORKFLOW_NAME__
47
+ state-machine --setup <workflow-name>
48
+ state-machine --setup <workflow-name> --template <template-name>
49
+ state-machine run <workflow-name>
50
+ state-machine run <workflow-name> -reset
51
+ state-machine run <workflow-name> -reset-hard
52
+
53
+ state-machine -reset <workflow-name>
54
+ state-machine -reset-hard <workflow-name>
55
+
56
+ state-machine history <workflow-name> [limit]
44
57
  ```
45
58
 
46
- Hard reset (clears everything: history/interactions/memory):
47
- ```bash
48
- state-machine reset-hard __WORKFLOW_NAME__
59
+ Templates live in `templates/` and `starter` is used by default.
60
+
61
+ Workflows live in:
62
+
63
+ ```text
64
+ workflows/<name>/
65
+ ├── workflow.js # Native JS workflow (async/await)
66
+ ├── config.js # Model/API key configuration
67
+ ├── package.json # Sets "type": "module" for this workflow folder
68
+ ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
69
+ ├── interactions/ # Human-in-the-loop files (auto-created)
70
+ ├── state/ # current.json, history.jsonl
71
+ └── steering/ # global.md + config.json
49
72
  ```
50
73
 
51
- ## Writing Workflows
74
+ ---
52
75
 
53
- Edit `workflow.js` - write normal async JavaScript:
76
+ ## Writing workflows (native JS)
77
+
78
+ Edit `config.js` to set models and API keys for the workflow.
54
79
 
55
80
  ```js
81
+ /**
82
+ /**
83
+ * project-builder Workflow
84
+ *
85
+ * Native JavaScript workflow - write normal async/await code!
86
+ *
87
+ * Features:
88
+ * - memory object auto-persists to disk (use memory guards for idempotency)
89
+ * - Use standard JS control flow (if, for, etc.)
90
+ * - Interactive prompts pause and wait for user input
91
+ */
92
+
56
93
  import { agent, memory, askHuman, parallel } from 'agent-state-machine';
94
+ import { notify } from './scripts/mac-notification.js';
57
95
 
58
96
  export default async function() {
59
- console.log('Starting __WORKFLOW_NAME__ workflow...');
97
+ console.log('Starting project-builder workflow...');
60
98
 
61
99
  // Example: Get user input (saved to memory)
62
100
  const userLocation = await askHuman('Where do you live?');
@@ -88,31 +126,242 @@ export default async function() {
88
126
  // console.log('b: ' + JSON.stringify(b))
89
127
  // console.log('c: ' + JSON.stringify(c))
90
128
 
91
- notify(['__WORKFLOW_NAME__', userInfo.name || userInfo + ' has been greeted!']);
129
+ notify(['project-builder', userInfo.name || userInfo + ' has been greeted!']);
92
130
 
93
131
  console.log('Workflow completed!');
94
132
  }
95
133
  ```
96
134
 
97
- ## Creating Agents
135
+ ### Resuming workflows
136
+
137
+ `state-machine run` restarts your workflow from the top, loading the persisted state.
138
+
139
+ If the workflow needs human input, it will **block inline** in the terminal. You can answer in the terminal, edit `interactions/<slug>.md`, or respond in the browser.
140
+
141
+ If the process is interrupted, running `state-machine run <workflow-name>` again will continue execution (assuming your workflow uses `memory` to skip completed steps).
142
+
143
+ ---
144
+
145
+ ## Core API
146
+
147
+ ### `agent(name, params?, options?)`
148
+
149
+ Runs `workflows/<name>/agents/<agent>.(js|mjs|cjs)` or `<agent>.md`.
150
+
151
+ ```js
152
+ const out = await agent('review', { file: 'src/app.js' });
153
+ memory.lastReview = out;
154
+ ```
155
+
156
+ Options:
157
+ - `retry` (number | false): default `2` (3 total attempts). Use `false` to disable retries.
158
+ - `steering` (string | string[]): extra steering files to load from `workflows/<name>/steering/`.
159
+
160
+ Context is explicit: only `params` are provided to agents unless you pass additional data.
161
+
162
+ ### `memory`
163
+
164
+ A persisted object for your workflow.
165
+
166
+ - Mutations auto-save to `workflows/<name>/state/current.json`.
167
+ - Use it as your "long-lived state" between runs.
168
+
169
+ ```js
170
+ memory.count = (memory.count || 0) + 1;
171
+ ```
172
+
173
+ ### `fileTree`
174
+
175
+ Auto-tracked file changes made by agents.
176
+
177
+ - Before each `await agent(...)`, the runtime captures a Git baseline
178
+ - After the agent completes, it detects created/modified/deleted files
179
+ - Changes are stored in `memory.fileTree` and persisted to `current.json`
180
+
181
+ ```js
182
+ // Files are auto-tracked when agents create them
183
+ await agent('code-writer', { task: 'Create auth module' });
184
+
185
+ // Access tracked files
186
+ console.log(memory.fileTree);
187
+ // { "src/auth.js": { status: "created", createdBy: "code-writer", ... } }
188
+
189
+ // Pass file context to other agents
190
+ await agent('code-reviewer', { fileTree: memory.fileTree });
191
+ ```
192
+
193
+ Configuration in `config.js`:
194
+
195
+ ```js
196
+ export const config = {
197
+ // ... models and apiKeys ...
198
+ projectRoot: process.env.PROJECT_ROOT, // defaults to ../.. from workflow
199
+ fileTracking: true, // enable/disable (default: true)
200
+ fileTrackingIgnore: ['node_modules/**', '.git/**', 'dist/**'],
201
+ fileTrackingKeepDeleted: false // keep deleted files in tree
202
+ };
203
+ ```
204
+
205
+ ### `trackFile(path, options?)` / `untrackFile(path)`
206
+
207
+ Manual file tracking utilities:
208
+
209
+ ```js
210
+ import { trackFile, getFileTree, untrackFile } from 'agent-state-machine';
211
+
212
+ trackFile('README.md', { caption: 'Project docs' });
213
+ const tree = getFileTree();
214
+ untrackFile('old-file.js');
215
+ ```
216
+
217
+ ### `askHuman(question, options?)`
218
+
219
+ Gets user input.
220
+
221
+ - In a TTY, it prompts in the terminal (or via the browser when remote follow is enabled).
222
+ - Otherwise it creates `interactions/<slug>.md` and blocks until you confirm in the terminal (or respond in the browser).
223
+
224
+ ```js
225
+ const repo = await askHuman('What repo should I work on?', { slug: 'repo' });
226
+ memory.repo = repo;
227
+ ```
228
+
229
+ ### `parallel([...])` / `parallelLimit([...], limit)`
98
230
 
99
- **JavaScript agent** (`agents/my-agent.js`):
231
+ Run multiple `agent()` calls concurrently:
100
232
 
101
233
  ```js
234
+ import { agent, parallel, parallelLimit } from 'agent-state-machine';
235
+
236
+ const [a, b] = await parallel([
237
+ agent('review', { file: 'src/a.js' }),
238
+ agent('review', { file: 'src/b.js' }),
239
+ ]);
240
+
241
+ const results = await parallelLimit(
242
+ ['a.js', 'b.js', 'c.js'].map(f => agent('review', { file: f })),
243
+ 2
244
+ );
245
+ ```
246
+
247
+ ---
248
+
249
+ ## Agents
250
+
251
+ Agents live in `workflows/<workflow>/agents/`.
252
+
253
+ ### JavaScript agents
254
+
255
+ **ESM (`.js` / `.mjs`)**:
256
+
257
+ ```js
258
+ // workflows/<name>/agents/example.js
102
259
  import { llm } from 'agent-state-machine';
103
260
 
104
261
  export default async function handler(context) {
105
- const response = await llm(context, { model: 'smart', prompt: 'Hello!' });
106
- return { greeting: response.text };
262
+ // context includes:
263
+ // - params passed to agent(name, params)
264
+ // - context._steering (global + optional additional steering content)
265
+ // - context._config (models/apiKeys/workflowDir/projectRoot)
266
+
267
+ // Optionally return _files to annotate tracked files
268
+ return {
269
+ ok: true,
270
+ _files: [{ path: 'src/example.js', caption: 'Example module' }]
271
+ };
272
+ }
273
+ ```
274
+
275
+ **CommonJS (`.cjs`)** (only if you prefer CJS):
276
+
277
+ ```js
278
+ // workflows/<name>/agents/example.cjs
279
+ async function handler(context) {
280
+ return { ok: true };
107
281
  }
282
+
283
+ module.exports = handler;
284
+ module.exports.handler = handler;
108
285
  ```
109
286
 
110
- **Markdown agent** (`agents/greeter.md`):
287
+ If you need to request human input from a JS agent, return an `_interaction` payload:
288
+
289
+ ```js
290
+ return {
291
+ _interaction: {
292
+ slug: 'approval',
293
+ targetKey: 'approval',
294
+ content: 'Please approve this change (yes/no).'
295
+ }
296
+ };
297
+ ```
298
+
299
+ The runtime will block execution and wait for your response in the terminal.
300
+
301
+ ### Markdown agents (`.md`)
302
+
303
+ Markdown agents are LLM-backed prompt templates with optional frontmatter.
304
+ Frontmatter can include `steering` to load additional files from `workflows/<name>/steering/`.
111
305
 
112
306
  ```md
113
307
  ---
114
- model: fast
308
+ model: smart
115
309
  output: greeting
310
+ steering: tone, product
311
+ ---
312
+ Generate a friendly greeting for {{name}}.
313
+ ```
314
+
315
+ Calling it:
316
+
317
+ ```js
318
+ const { greeting } = await agent('greeter', { name: 'Sam' });
319
+ memory.greeting = greeting;
320
+ ```
321
+
116
322
  ---
117
- Generate a greeting for {{name}}.
323
+
324
+ ## Models & LLM execution
325
+
326
+ In your workflow’s `export const config = { models: { ... } }`, each model value can be:
327
+
328
+ ### CLI command
329
+
330
+ ```js
331
+ export const config = {
332
+ models: {
333
+ smart: "claude -m claude-sonnet-4-20250514 -p"
334
+ }
335
+ };
118
336
  ```
337
+
338
+ ### API target
339
+
340
+ Format: `api:<provider>:<model>`
341
+
342
+ ```js
343
+ export const config = {
344
+ models: {
345
+ smart: "api:openai:gpt-4.1-mini"
346
+ },
347
+ apiKeys: {
348
+ openai: process.env.OPENAI_API_KEY
349
+ }
350
+ };
351
+ ```
352
+
353
+ The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates when running with the `--local` flag or via the remote URL. Remote follow links persist across runs (stored in `config.js`) unless you pass `-n`/`--new` to regenerate.
354
+
355
+ ---
356
+
357
+ ## State & persistence
358
+
359
+ Native JS workflows persist to:
360
+
361
+ - `workflows/<name>/state/current.json` — status, memory (includes fileTree), pending interaction
362
+ - `workflows/<name>/state/history.jsonl` — event log (newest entries first, includes agent retry/failure entries)
363
+ - `workflows/<name>/interactions/*.md` — human input files (when paused)
364
+
365
+ ## License
366
+
367
+ MIT
@@ -66,8 +66,8 @@ export default async function handler(req, res) {
66
66
  response,
67
67
  }));
68
68
 
69
- // Set TTL on pending list
70
- await redis.expire(pendingKey, 300); // 5 minutes
69
+ // Set TTL on pending list (24 hours - same as session, allows laptop sleep)
70
+ await redis.expire(pendingKey, 24 * 60 * 60);
71
71
 
72
72
  // Log event to events list (single source of truth for UI)
73
73
  await addEvent(token, {
@@ -21,7 +21,7 @@ import {
21
21
  export default async function handler(req, res) {
22
22
  // Enable CORS
23
23
  res.setHeader('Access-Control-Allow-Origin', '*');
24
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
24
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
25
25
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
26
26
 
27
27
  if (req.method === 'OPTIONS') {
@@ -36,6 +36,10 @@ export default async function handler(req, res) {
36
36
  return handleGet(req, res);
37
37
  }
38
38
 
39
+ if (req.method === 'DELETE') {
40
+ return handleDelete(req, res);
41
+ }
42
+
39
43
  return res.status(405).json({ error: 'Method not allowed' });
40
44
  }
41
45
 
@@ -161,12 +165,22 @@ async function handleGet(req, res) {
161
165
 
162
166
  // Poll every 5 seconds (10 calls per 50s timeout vs 50 calls before)
163
167
  while (Date.now() - startTime < timeoutMs) {
164
- const pending = await redis.lpop(pendingKey);
168
+ // Peek at first item without removing (LINDEX 0)
169
+ // We only remove AFTER CLI confirms receipt via DELETE request
170
+ const pending = await redis.lindex(pendingKey, 0);
165
171
 
166
172
  if (pending) {
167
173
  const data = typeof pending === 'object' ? pending : JSON.parse(pending);
174
+
175
+ // Generate a receipt ID so CLI can confirm
176
+ const receiptId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
177
+
178
+ // DON'T remove yet - CLI will confirm with DELETE request
179
+ // This prevents data loss if response doesn't reach CLI
180
+
168
181
  return res.status(200).json({
169
182
  type: 'interaction_response',
183
+ receiptId,
170
184
  ...data,
171
185
  });
172
186
  }
@@ -182,3 +196,27 @@ async function handleGet(req, res) {
182
196
  return res.status(500).json({ error: err.message });
183
197
  }
184
198
  }
199
+
200
+ /**
201
+ * Handle DELETE requests - CLI confirms receipt of interaction
202
+ * This removes the interaction from the pending queue
203
+ */
204
+ async function handleDelete(req, res) {
205
+ const { token } = req.query;
206
+
207
+ if (!token) {
208
+ return res.status(400).json({ error: 'Missing token parameter' });
209
+ }
210
+
211
+ const channel = KEYS.interactions(token);
212
+ const pendingKey = `${channel}:pending`;
213
+
214
+ try {
215
+ // Remove the first item (the one we just sent)
216
+ await redis.lpop(pendingKey);
217
+ return res.status(200).json({ success: true });
218
+ } catch (err) {
219
+ console.error('Error confirming interaction receipt:', err);
220
+ return res.status(500).json({ error: err.message });
221
+ }
222
+ }
@@ -101,7 +101,7 @@ function sendJson(res, status, data) {
101
101
  res.writeHead(status, {
102
102
  'Content-Type': 'application/json',
103
103
  'Access-Control-Allow-Origin': '*',
104
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
104
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
105
105
  'Access-Control-Allow-Headers': 'Content-Type',
106
106
  });
107
107
  res.end(JSON.stringify(data));
@@ -187,6 +187,7 @@ async function handleCliPost(req, res) {
187
187
 
188
188
  /**
189
189
  * Handle CLI GET (long-poll for interactions)
190
+ * Peeks at first item without removing - CLI confirms via DELETE
190
191
  */
191
192
  async function handleCliGet(req, res, query) {
192
193
  const { token, timeout = '30000' } = query;
@@ -207,7 +208,8 @@ async function handleCliGet(req, res, query) {
207
208
  const checkInterval = setInterval(() => {
208
209
  if (session.pendingInteractions.length > 0) {
209
210
  clearInterval(checkInterval);
210
- const interaction = session.pendingInteractions.shift();
211
+ // Peek at first item WITHOUT removing - CLI will confirm via DELETE
212
+ const interaction = session.pendingInteractions[0];
211
213
  return sendJson(res, 200, {
212
214
  type: 'interaction_response',
213
215
  ...interaction,
@@ -227,6 +229,30 @@ async function handleCliGet(req, res, query) {
227
229
  });
228
230
  }
229
231
 
232
+ /**
233
+ * Handle CLI DELETE (confirm receipt of interaction)
234
+ * Removes the first pending interaction after CLI confirms receipt
235
+ */
236
+ function handleCliDelete(req, res, query) {
237
+ const { token } = query;
238
+
239
+ if (!token) {
240
+ return sendJson(res, 400, { error: 'Missing token' });
241
+ }
242
+
243
+ const session = getSession(token);
244
+ if (!session) {
245
+ return sendJson(res, 404, { error: 'Session not found' });
246
+ }
247
+
248
+ // Remove the first pending interaction (the one we just sent)
249
+ if (session.pendingInteractions.length > 0) {
250
+ session.pendingInteractions.shift();
251
+ }
252
+
253
+ return sendJson(res, 200, { success: true });
254
+ }
255
+
230
256
  /**
231
257
  * Handle SSE events endpoint for browsers
232
258
  */
@@ -446,7 +472,7 @@ async function handleRequest(req, res) {
446
472
  if (req.method === 'OPTIONS') {
447
473
  res.writeHead(200, {
448
474
  'Access-Control-Allow-Origin': '*',
449
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
475
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
450
476
  'Access-Control-Allow-Headers': 'Content-Type',
451
477
  });
452
478
  return res.end();
@@ -460,6 +486,9 @@ async function handleRequest(req, res) {
460
486
  if (req.method === 'GET') {
461
487
  return handleCliGet(req, res, query);
462
488
  }
489
+ if (req.method === 'DELETE') {
490
+ return handleCliDelete(req, res, query);
491
+ }
463
492
  }
464
493
 
465
494
  // Route: Session UI