agent-state-machine 2.0.4 → 2.0.6

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.
@@ -164,6 +164,18 @@ export class RemoteClient {
164
164
  });
165
165
  }
166
166
 
167
+ /**
168
+ * Sync history to remote (for manual edits to history.jsonl)
169
+ * @param {Array} history - Array of history entries
170
+ */
171
+ async sendHistorySync(history = []) {
172
+ await this.send({
173
+ type: 'history_sync',
174
+ sessionToken: this.sessionToken,
175
+ history,
176
+ });
177
+ }
178
+
167
179
  /**
168
180
  * Send an event to the server
169
181
  * @param {object} event - Event object with timestamp, event type, etc.
@@ -26,26 +26,24 @@ export async function agent(name, params = {}) {
26
26
  }
27
27
 
28
28
  console.log(` [Agent: ${name}] Starting...`);
29
- runtime.prependHistory({
30
- event: 'AGENT_STARTED',
31
- agent: name
32
- });
33
29
 
34
30
  try {
35
31
  const result = await executeAgent(runtime, name, params);
36
32
 
37
- let prompt = undefined;
38
33
  if (result && typeof result === 'object' && result._debug_prompt) {
39
- prompt = result._debug_prompt;
40
34
  delete result._debug_prompt;
41
35
  }
42
36
 
43
37
  console.log(` [Agent: ${name}] Completed`);
38
+ if (runtime._agentSuppressCompletion?.has(name)) {
39
+ runtime._agentSuppressCompletion.delete(name);
40
+ return result;
41
+ }
42
+
44
43
  runtime.prependHistory({
45
44
  event: 'AGENT_COMPLETED',
46
45
  agent: name,
47
- output: result,
48
- prompt: prompt
46
+ output: result
49
47
  });
50
48
 
51
49
  return result;
@@ -119,6 +117,8 @@ async function executeJSAgent(runtime, agentPath, name, params) {
119
117
  throw new Error(`Agent ${name} does not export a function`);
120
118
  }
121
119
 
120
+ logAgentStart(runtime, name);
121
+
122
122
  // Build context
123
123
  const context = {
124
124
  ...runtime._rawMemory,
@@ -135,7 +135,7 @@ async function executeJSAgent(runtime, agentPath, name, params) {
135
135
 
136
136
  // Handle interaction response from JS agent
137
137
  if (result && result._interaction) {
138
- const interactionResponse = await handleInteraction(runtime, result._interaction);
138
+ const interactionResponse = await handleInteraction(runtime, result._interaction, name);
139
139
 
140
140
  // Use the interaction response as the primary output if it exists
141
141
  // This allows the workflow to receive the user's input directly
@@ -176,7 +176,7 @@ async function executeJSAgent(runtime, agentPath, name, params) {
176
176
  * Execute a Markdown agent (prompt-based)
177
177
  */
178
178
  async function executeMDAgent(runtime, agentPath, name, params) {
179
- const { llm, parseJSON, parseInteractionRequest } = await import('../llm.js');
179
+ const { llm, buildPrompt, parseJSON, parseInteractionRequest } = await import('../llm.js');
180
180
 
181
181
  const content = fs.readFileSync(agentPath, 'utf-8');
182
182
  const { config, prompt } = parseMarkdownAgent(content);
@@ -201,6 +201,14 @@ async function executeMDAgent(runtime, agentPath, name, params) {
201
201
 
202
202
  const model = config.model || 'fast';
203
203
 
204
+ const fullPrompt = buildPrompt(context, {
205
+ model,
206
+ prompt: interpolatedPrompt,
207
+ includeContext: config.includeContext !== 'false'
208
+ });
209
+
210
+ logAgentStart(runtime, name, fullPrompt);
211
+
204
212
  console.log(` Using model: ${model}`);
205
213
 
206
214
  const response = await llm(context, {
@@ -247,7 +255,7 @@ async function executeMDAgent(runtime, agentPath, name, params) {
247
255
  slug,
248
256
  targetKey,
249
257
  content: interactionContent
250
- });
258
+ }, name);
251
259
 
252
260
  // Return the user's response as the agent result
253
261
  return { [outputKey]: userResponse, _debug_prompt: response.fullPrompt };
@@ -334,7 +342,9 @@ function sanitizeSlug(input) {
334
342
  /**
335
343
  * Handle interaction (create file, wait for user, return response)
336
344
  */
337
- async function handleInteraction(runtime, interaction) {
345
+ async function handleInteraction(runtime, interaction, agentName) {
346
+ const effectiveAgentName = typeof agentName === 'string' ? agentName : null;
347
+
338
348
  const slug = sanitizeSlug(interaction.slug);
339
349
  const targetKey = String(interaction.targetKey || slug);
340
350
  const content = String(interaction.content || '').trim();
@@ -357,8 +367,35 @@ ${content}
357
367
  question: content
358
368
  });
359
369
 
370
+ if (effectiveAgentName) {
371
+ runtime._agentSuppressCompletion?.add(effectiveAgentName);
372
+ runtime._agentResumeFlags?.add(effectiveAgentName);
373
+ }
374
+
360
375
  // Block and wait for user input (instead of throwing)
361
376
  const response = await runtime.waitForInteraction(filePath, slug, targetKey);
362
377
 
363
378
  return response;
364
379
  }
380
+
381
+ function logAgentStart(runtime, name, prompt) {
382
+ if (runtime._agentResumeFlags?.has(name)) {
383
+ runtime._agentResumeFlags.delete(name);
384
+ runtime.prependHistory({
385
+ event: 'AGENT_RESUMED',
386
+ agent: name
387
+ });
388
+ return;
389
+ }
390
+
391
+ const entry = {
392
+ event: 'AGENT_STARTED',
393
+ agent: name
394
+ };
395
+
396
+ if (prompt) {
397
+ entry.prompt = prompt;
398
+ }
399
+
400
+ runtime.prependHistory(entry);
401
+ }
@@ -84,6 +84,10 @@ export class WorkflowRuntime {
84
84
  this.remoteEnabled = false;
85
85
  this.remoteUrl = null;
86
86
  this.pendingRemoteInteraction = null; // { slug, targetKey, resolve, reject }
87
+
88
+ // Agent interaction tracking for history logging
89
+ this._agentResumeFlags = new Set();
90
+ this._agentSuppressCompletion = new Set();
87
91
  }
88
92
 
89
93
  ensureDirectories() {
@@ -164,6 +168,9 @@ export class WorkflowRuntime {
164
168
 
165
169
  const line = JSON.stringify(entry) + '\n';
166
170
 
171
+ // Track when we're writing to avoid triggering the file watcher
172
+ this._lastHistoryWrite = Date.now();
173
+
167
174
  // Prepend to file (read existing, write new + existing)
168
175
  let existing = '';
169
176
  if (fs.existsSync(this.historyFile)) {
@@ -539,16 +546,63 @@ export class WorkflowRuntime {
539
546
  this.remoteEnabled = true;
540
547
  this.remoteUrl = this.remoteClient.getRemoteUrl();
541
548
 
549
+ // Watch history.jsonl for manual edits and sync to remote
550
+ this.startHistoryWatcher();
551
+
542
552
  console.log(`\n${C.cyan}${C.bold}Remote follow enabled${C.reset}`);
543
553
  console.log(` ${C.dim}URL:${C.reset} ${this.remoteUrl}\n`);
544
554
 
545
555
  return this.remoteUrl;
546
556
  }
547
557
 
558
+ /**
559
+ * Start watching history.jsonl for manual edits
560
+ */
561
+ startHistoryWatcher() {
562
+ if (this.historyWatcher) return;
563
+
564
+ // Debounce to avoid multiple syncs for rapid changes
565
+ let debounceTimer = null;
566
+
567
+ try {
568
+ this.historyWatcher = fs.watch(this.historyFile, (eventType) => {
569
+ if (eventType !== 'change') return;
570
+
571
+ // Debounce: wait 300ms after last change before syncing
572
+ if (debounceTimer) clearTimeout(debounceTimer);
573
+
574
+ debounceTimer = setTimeout(async () => {
575
+ // Don't sync if we just wrote to the file ourselves (within 500ms)
576
+ if (this._lastHistoryWrite && Date.now() - this._lastHistoryWrite < 500) return;
577
+
578
+ if (this.remoteClient && this.remoteEnabled) {
579
+ const history = this.loadHistory();
580
+ await this.remoteClient.sendHistorySync(history);
581
+ console.log(`${C.dim}Remote: History synced from file${C.reset}`);
582
+ }
583
+ }, 300);
584
+ });
585
+ } catch (err) {
586
+ // File might not exist yet, that's ok
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Stop watching history.jsonl
592
+ */
593
+ stopHistoryWatcher() {
594
+ if (this.historyWatcher) {
595
+ this.historyWatcher.close();
596
+ this.historyWatcher = null;
597
+ }
598
+ }
599
+
548
600
  /**
549
601
  * Disable remote follow mode and disconnect
550
602
  */
551
603
  async disableRemote() {
604
+ this.stopHistoryWatcher();
605
+
552
606
  if (this.remoteClient) {
553
607
  await this.remoteClient.disconnect();
554
608
  this.remoteClient = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "type": "module",
5
5
  "description": "A workflow orchestrator for running agents and scripts in sequence with state management",
6
6
  "main": "lib/index.js",
@@ -2,14 +2,14 @@
2
2
  * File: /vercel-server/api/events/[token].js
3
3
  *
4
4
  * SSE endpoint for browser connections
5
- * Streams history and real-time events to connected browsers
5
+ * Streams events to connected browsers from a single source of truth (events list)
6
6
  */
7
7
 
8
8
  import {
9
9
  getSession,
10
- getHistory,
11
- redis,
12
- KEYS,
10
+ getEvents,
11
+ getEventsLength,
12
+ getEventsRange,
13
13
  refreshSession,
14
14
  } from '../../lib/redis.js';
15
15
 
@@ -48,32 +48,15 @@ export default async function handler(req, res) {
48
48
  workflowName: session.workflowName,
49
49
  })}\n\n`);
50
50
 
51
- // Send existing history
52
- const history = await getHistory(token);
51
+ // Send all existing events (single source of truth)
52
+ const events = await getEvents(token);
53
53
  res.write(`data: ${JSON.stringify({
54
54
  type: 'history',
55
- entries: history,
55
+ entries: events,
56
56
  })}\n\n`);
57
57
 
58
- // Poll for new events
59
- const eventsListKey = `${KEYS.events(token)}:list`;
60
- let lastEventIndex = 0;
61
-
62
- // If history is empty, seed from the event list to catch early events.
63
- const currentLength = await redis.llen(eventsListKey);
64
- // if (history.length === 0 && currentLength > 0) {
65
- // const existingEvents = await redis.lrange(eventsListKey, 0, -1);
66
- // const entries = existingEvents
67
- // .map((event) => (typeof event === 'object' ? event : JSON.parse(event)))
68
- // .filter(Boolean);
69
- // res.write(`data: ${JSON.stringify({
70
- // type: 'history',
71
- // entries,
72
- // })}\n\n`);
73
- // lastEventIndex = currentLength;
74
- // } else {
75
- // lastEventIndex = currentLength;
76
- // }
58
+ // Track current position for polling new events
59
+ let lastEventIndex = await getEventsLength(token);
77
60
 
78
61
  const pollInterval = setInterval(async () => {
79
62
  try {
@@ -81,15 +64,19 @@ export default async function handler(req, res) {
81
64
  await refreshSession(token);
82
65
 
83
66
  // Check for new events
84
- const newLength = await redis.llen(eventsListKey);
67
+ const newLength = await getEventsLength(token);
85
68
 
86
69
  if (newLength > lastEventIndex) {
87
- // Get new events (newest first)
88
- const newEvents = await redis.lrange(eventsListKey, 0, newLength - lastEventIndex - 1);
89
-
90
- for (const event of newEvents.reverse()) {
91
- const eventData = typeof event === 'object' ? event : JSON.parse(event);
92
- res.write(`data: ${JSON.stringify(eventData)}\n\n`);
70
+ // Get new events (they're prepended, so newest are at the start)
71
+ const newCount = newLength - lastEventIndex;
72
+ const newEvents = await getEventsRange(token, 0, newCount - 1);
73
+
74
+ // Send in chronological order (oldest first of the new batch)
75
+ for (const eventData of newEvents.reverse()) {
76
+ res.write(`data: ${JSON.stringify({
77
+ type: 'event',
78
+ ...eventData,
79
+ })}\n\n`);
93
80
  }
94
81
 
95
82
  lastEventIndex = newLength;
@@ -106,14 +93,13 @@ export default async function handler(req, res) {
106
93
  } catch (err) {
107
94
  console.error('Error polling events:', err);
108
95
  }
109
- }, 2000);
96
+ }, 1000); // Poll every 1 second for faster updates
110
97
 
111
98
  // Clean up on client disconnect
112
99
  req.on('close', () => {
113
100
  clearInterval(pollInterval);
114
101
  });
115
102
 
116
- // For Vercel, we need to keep the connection alive but also respect function timeout
117
103
  // Send keepalive pings
118
104
  const keepaliveInterval = setInterval(() => {
119
105
  res.write(': keepalive\n\n');
@@ -6,8 +6,7 @@
6
6
 
7
7
  import {
8
8
  getSession,
9
- addHistoryEvent,
10
- publishEvent,
9
+ addEvent,
11
10
  redis,
12
11
  KEYS,
13
12
  } from '../../lib/redis.js';
@@ -68,22 +67,14 @@ export default async function handler(req, res) {
68
67
  // Set TTL on pending list
69
68
  await redis.expire(pendingKey, 300); // 5 minutes
70
69
 
71
- // Log event to history (include answer preview)
72
- const event = {
70
+ // Log event to events list (single source of truth for UI)
71
+ await addEvent(token, {
73
72
  timestamp: new Date().toISOString(),
74
73
  event: 'INTERACTION_SUBMITTED',
75
74
  slug,
76
75
  targetKey: targetKey || `_interaction_${slug}`,
77
76
  answer: response.substring(0, 200) + (response.length > 200 ? '...' : ''),
78
77
  source: 'remote',
79
- };
80
-
81
- await addHistoryEvent(token, event);
82
-
83
- // Notify other browsers
84
- await publishEvent(token, {
85
- type: 'event',
86
- ...event,
87
78
  });
88
79
 
89
80
  return res.status(200).json({ success: true });
@@ -12,9 +12,8 @@ import {
12
12
  getSession,
13
13
  updateSession,
14
14
  setCLIConnected,
15
- addHistoryEvent,
16
- addHistoryEvents,
17
- publishEvent,
15
+ addEvent,
16
+ setEvents,
18
17
  redis,
19
18
  KEYS,
20
19
  } from '../../lib/redis.js';
@@ -59,19 +58,8 @@ async function handlePost(req, res) {
59
58
  // Create session
60
59
  await createSession(sessionToken, { workflowName, cliConnected: true });
61
60
 
62
- // Replace any existing history and event stream with the provided snapshot
63
- await redis.del(KEYS.history(sessionToken), `${KEYS.events(sessionToken)}:list`);
64
-
65
- // Store initial history
66
- if (history && history.length > 0) {
67
- await addHistoryEvents(sessionToken, history);
68
- }
69
-
70
- // Notify browsers
71
- await publishEvent(sessionToken, {
72
- type: 'cli_connected',
73
- workflowName,
74
- });
61
+ // Replace events with the provided history snapshot (single source of truth)
62
+ await setEvents(sessionToken, history || []);
75
63
 
76
64
  return res.status(200).json({ success: true });
77
65
  }
@@ -82,9 +70,10 @@ async function handlePost(req, res) {
82
70
  // Update session as connected
83
71
  await setCLIConnected(sessionToken, true);
84
72
 
85
- // Notify browsers
86
- await publishEvent(sessionToken, {
87
- type: 'cli_reconnected',
73
+ // Add reconnect event to events list
74
+ await addEvent(sessionToken, {
75
+ timestamp: new Date().toISOString(),
76
+ event: 'CLI_RECONNECTED',
88
77
  workflowName,
89
78
  });
90
79
 
@@ -104,14 +93,8 @@ async function handlePost(req, res) {
104
93
  delete historyEvent.sessionToken;
105
94
  delete historyEvent.type;
106
95
 
107
- // Add to history
108
- await addHistoryEvent(sessionToken, historyEvent);
109
-
110
- // Notify browsers
111
- await publishEvent(sessionToken, {
112
- type: 'event',
113
- ...historyEvent,
114
- });
96
+ // Add to events list (single source of truth)
97
+ await addEvent(sessionToken, historyEvent);
115
98
 
116
99
  return res.status(200).json({ success: true });
117
100
  }
@@ -122,15 +105,25 @@ async function handlePost(req, res) {
122
105
  // Mark CLI as disconnected
123
106
  await setCLIConnected(sessionToken, false);
124
107
 
125
- // Notify browsers
126
- await publishEvent(sessionToken, {
127
- type: 'cli_disconnected',
108
+ // Add disconnect event to events list
109
+ await addEvent(sessionToken, {
110
+ timestamp: new Date().toISOString(),
111
+ event: 'CLI_DISCONNECTED',
128
112
  reason,
129
113
  });
130
114
 
131
115
  return res.status(200).json({ success: true });
132
116
  }
133
117
 
118
+ case 'history_sync': {
119
+ const { history } = body;
120
+
121
+ // Replace events with synced history (for manual edits to history.jsonl)
122
+ await setEvents(sessionToken, history || []);
123
+
124
+ return res.status(200).json({ success: true });
125
+ }
126
+
134
127
  default:
135
128
  return res.status(400).json({ error: `Unknown message type: ${type}` });
136
129
  }
@@ -674,11 +674,54 @@
674
674
 
675
675
  // CENTERED inside card
676
676
  if (item.event === "AGENT_STARTED") {
677
+ if (item.prompt) {
678
+ return wrapIO(
679
+ "left",
680
+ <section className="hairline rounded-2xl rounded-tl-none overflow-hidden io-in">
681
+ <div className="rtl-safe px-5 py-4 divider flex items-center justify-between">
682
+ <div>
683
+ <div className="text-[11px] tracking-[0.22em] font-semibold" style={{ color: "var(--muted)" }}>
684
+ AGENT STARTED
685
+ </div>
686
+ <div className="mt-1 text-[11px] tracking-[0.14em]" style={{ color: "var(--muted)" }}>
687
+ {(item.agent || "").toString()} • {time}
688
+ </div>
689
+ </div>
690
+ <CopyButton text={item.prompt} />
691
+ </div>
692
+ <div className="px-5 py-4">
693
+ <div className="markdown-body text-[13px] leading-relaxed overflow-x-auto">{item.prompt}</div>
694
+ </div>
695
+ </section>,
696
+ idx
697
+ );
698
+ }
699
+
700
+ return wrapIO(
701
+ "left",
702
+ <section className="hairline rounded-2xl rounded-tl-none px-6 py-5 io-in">
703
+ <div className="rtl-safe flex items-center justify-between gap-4">
704
+ <div className="text-[11px] tracking-[0.22em] font-semibold" style={{ color: "var(--muted)" }}>
705
+ AGENT STARTED
706
+ </div>
707
+ <span className="text-[11px] tracking-[0.14em]" style={{ color: "var(--muted)" }}>
708
+ {time}
709
+ </span>
710
+ </div>
711
+ <div className="mt-3 text-[13px] leading-relaxed">
712
+ <span className="font-semibold">{item.agent}</span>
713
+ </div>
714
+ </section>,
715
+ idx
716
+ );
717
+ }
718
+
719
+ if (item.event === "AGENT_RESUMED") {
677
720
  return wrapIO(
678
721
  "center",
679
722
  <section className="meta-center">
680
723
  <div className="text-[11px] tracking-[0.22em] font-semibold" style={{ color: "var(--muted)" }}>
681
- AGENT STARTED
724
+ AGENT RESUMED
682
725
  </div>
683
726
  <div className="mt-2 text-[13px] leading-relaxed">
684
727
  <span className="font-semibold">{item.agent}</span>
@@ -714,8 +757,8 @@
714
757
 
715
758
  if (item.event === "INTERACTION_REQUESTED" || item.event === "PROMPT_REQUESTED") {
716
759
  return wrapIO(
717
- "left",
718
- <section className="hairline rounded-2xl px-6 py-5 io-in">
760
+ "center",
761
+ <section className="hairline rounded-2xl px-6 py-5">
719
762
  <div className="rtl-safe flex items-center justify-between gap-4">
720
763
  <div className="text-[11px] tracking-[0.22em] font-semibold" style={{ color: "var(--muted)" }}>
721
764
  INTERVENTION REQUIRED
@@ -735,8 +778,8 @@
735
778
  if (item.event === "PROMPT_ANSWERED" || item.event === "INTERACTION_SUBMITTED") {
736
779
  const isManual = item.source === "remote";
737
780
  return wrapIO(
738
- "left",
739
- <section className="hairline rounded-2xl px-6 py-5 io-in">
781
+ "center",
782
+ <section className="hairline rounded-2xl px-6 py-5">
740
783
  <div className="rtl-safe flex items-center justify-between gap-4">
741
784
  <div className="text-[11px] tracking-[0.22em] font-semibold" style={{ color: "var(--muted)" }}>
742
785
  {isManual ? "RESOLVED VIA BROWSER" : "USER ANSWERED"}
@@ -752,31 +795,37 @@
752
795
  }
753
796
 
754
797
  if (item.event === "AGENT_COMPLETED" || item.event === "INTERACTION_RESOLVED") {
755
- return (
756
- <React.Fragment key={idx}>
757
- {wrapIO(
758
- "center",
759
- <section className="meta-center">
798
+ if (item.event === "AGENT_COMPLETED") {
799
+ return wrapIO(
800
+ "right",
801
+ <section className="io-out">
802
+ <section className="meta-center mb-4">
760
803
  <div className="text-[11px] tracking-[0.22em] font-semibold" style={{ color: "var(--muted)" }}>
761
804
  DONE
762
805
  </div>
763
806
  <div className="mt-1 text-[11px] tracking-[0.14em]" style={{ color: "var(--muted)" }}>
764
- {(item.agent || item.slug || "").toString()} • {time}
807
+ {(item.agent || "").toString()} • {time}
765
808
  </div>
766
- </section>,
767
- `done-meta-${idx}`
768
- )}
769
-
770
- {(item.output || item.result) && wrapIO(
771
- "right",
772
- <section className="io-out">
809
+ </section>
810
+ {(item.output || item.result) && (
773
811
  <JsonView data={item.output || item.result} label="OUTPUT / RESPONSE" align="right" />
774
- </section>,
775
- `done-io-${idx}`
776
- )}
812
+ )}
813
+ </section>,
814
+ idx
815
+ );
816
+ }
777
817
 
778
- {item.prompt ? renderPromptInputCard(item.prompt, `prompt-${idx}`) : null}
779
- </React.Fragment>
818
+ return wrapIO(
819
+ "center",
820
+ <section className="meta-center">
821
+ <div className="text-[11px] tracking-[0.22em] font-semibold" style={{ color: "var(--muted)" }}>
822
+ DONE
823
+ </div>
824
+ <div className="mt-1 text-[11px] tracking-[0.14em]" style={{ color: "var(--muted)" }}>
825
+ {(item.slug || "").toString()} • {time}
826
+ </div>
827
+ </section>,
828
+ idx
780
829
  );
781
830
  }
782
831
 
@@ -936,4 +985,4 @@
936
985
  </script>
937
986
  </body>
938
987
 
939
- </html>
988
+ </html>