agent-state-machine 1.3.2 → 1.4.0
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/README.md +3 -3
- package/bin/cli.js +32 -9
- package/lib/remote/client.js +252 -0
- package/lib/runtime/agent.js +2 -5
- package/lib/runtime/prompt.js +67 -11
- package/lib/runtime/runtime.js +214 -34
- package/lib/setup.js +7 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,8 +101,8 @@ export default async function() {
|
|
|
101
101
|
console.log('Starting project-builder workflow...');
|
|
102
102
|
|
|
103
103
|
// Example: Get user input (saved to memory)
|
|
104
|
-
const
|
|
105
|
-
console.log('Example prompt answer:',
|
|
104
|
+
const userLocation = await initialPrompt('Where do you live?');
|
|
105
|
+
console.log('Example prompt answer:', userLocation);
|
|
106
106
|
|
|
107
107
|
const userInfo = await agent('yoda-name-collector');
|
|
108
108
|
memory.userInfo = userInfo;
|
|
@@ -113,7 +113,7 @@ export default async function() {
|
|
|
113
113
|
console.log('Example agent memory.userInfo:', memory.userInfo || userInfo);
|
|
114
114
|
|
|
115
115
|
// Context is provided automatically
|
|
116
|
-
const { greeting } = await agent('yoda-greeter');
|
|
116
|
+
const { greeting } = await agent('yoda-greeter', { userLocation });
|
|
117
117
|
console.log('Example agent greeting:', greeting);
|
|
118
118
|
|
|
119
119
|
// Or you can provide context manually
|
package/bin/cli.js
CHANGED
|
@@ -23,13 +23,16 @@ function getVersion() {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Default remote server URL (can be overridden with STATE_MACHINE_REMOTE_URL env var)
|
|
27
|
+
const DEFAULT_REMOTE_URL = process.env.STATE_MACHINE_REMOTE_URL || 'http://localhost:3001';
|
|
28
|
+
|
|
26
29
|
function printHelp() {
|
|
27
30
|
console.log(`
|
|
28
31
|
Agent State Machine CLI (Native JS Workflows Only) v${getVersion()}
|
|
29
32
|
|
|
30
33
|
Usage:
|
|
31
34
|
state-machine --setup <workflow-name> Create a new workflow project
|
|
32
|
-
state-machine run <workflow-name>
|
|
35
|
+
state-machine run <workflow-name> [--remote] Run a workflow (--remote enables remote follow)
|
|
33
36
|
state-machine follow <workflow-name> View prompt trace history in browser with live updates
|
|
34
37
|
state-machine status [workflow-name] Show current state (or list all)
|
|
35
38
|
state-machine history <workflow-name> [limit] Show execution history logs
|
|
@@ -40,9 +43,13 @@ Usage:
|
|
|
40
43
|
|
|
41
44
|
Options:
|
|
42
45
|
--setup, -s Initialize a new workflow with directory structure
|
|
46
|
+
--remote, -r Enable remote follow (generates shareable URL for browser access)
|
|
43
47
|
--help, -h Show help
|
|
44
48
|
--version, -v Show version
|
|
45
49
|
|
|
50
|
+
Environment Variables:
|
|
51
|
+
STATE_MACHINE_REMOTE_URL Override the default remote server URL
|
|
52
|
+
|
|
46
53
|
Workflow Structure:
|
|
47
54
|
workflows/<name>/
|
|
48
55
|
├── workflow.js # Native JS workflow (async/await)
|
|
@@ -136,7 +143,7 @@ function listWorkflows() {
|
|
|
136
143
|
console.log('');
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
async function runOrResume(workflowName) {
|
|
146
|
+
async function runOrResume(workflowName, { remoteEnabled = false } = {}) {
|
|
140
147
|
const workflowDir = resolveWorkflowDir(workflowName);
|
|
141
148
|
|
|
142
149
|
if (!fs.existsSync(workflowDir)) {
|
|
@@ -154,7 +161,20 @@ async function runOrResume(workflowName) {
|
|
|
154
161
|
const runtime = new WorkflowRuntime(workflowDir);
|
|
155
162
|
const workflowUrl = pathToFileURL(entry).href;
|
|
156
163
|
|
|
157
|
-
|
|
164
|
+
// Enable remote follow mode if requested
|
|
165
|
+
if (remoteEnabled) {
|
|
166
|
+
const remoteUrl = process.env.STATE_MACHINE_REMOTE_URL || DEFAULT_REMOTE_URL;
|
|
167
|
+
await runtime.enableRemote(remoteUrl);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await runtime.runWorkflow(workflowUrl);
|
|
172
|
+
} finally {
|
|
173
|
+
// Always disable remote on completion
|
|
174
|
+
if (remoteEnabled) {
|
|
175
|
+
await runtime.disableRemote();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
async function main() {
|
|
@@ -185,14 +205,17 @@ async function main() {
|
|
|
185
205
|
case 'run':
|
|
186
206
|
if (!workflowName) {
|
|
187
207
|
console.error('Error: Workflow name required');
|
|
188
|
-
console.error(`Usage: state-machine ${command} <workflow-name
|
|
208
|
+
console.error(`Usage: state-machine ${command} <workflow-name> [--remote]`);
|
|
189
209
|
process.exit(1);
|
|
190
210
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
211
|
+
{
|
|
212
|
+
const remoteEnabled = args.includes('--remote') || args.includes('-r');
|
|
213
|
+
try {
|
|
214
|
+
await runOrResume(workflowName, { remoteEnabled });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error('Error:', err.message || String(err));
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
196
219
|
}
|
|
197
220
|
break;
|
|
198
221
|
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /lib/remote/client.js
|
|
3
|
+
*
|
|
4
|
+
* RemoteClient - HTTP client for connecting to the remote follow server
|
|
5
|
+
* Uses HTTP POST for sending events and long-polling for receiving interactions
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import http from 'http';
|
|
10
|
+
import https from 'https';
|
|
11
|
+
|
|
12
|
+
// ANSI Colors
|
|
13
|
+
const C = {
|
|
14
|
+
reset: '\x1b[0m',
|
|
15
|
+
bold: '\x1b[1m',
|
|
16
|
+
dim: '\x1b[2m',
|
|
17
|
+
red: '\x1b[31m',
|
|
18
|
+
green: '\x1b[32m',
|
|
19
|
+
yellow: '\x1b[33m',
|
|
20
|
+
cyan: '\x1b[36m'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate a cryptographically secure session token
|
|
25
|
+
* @returns {string} 32-byte base64url-encoded token
|
|
26
|
+
*/
|
|
27
|
+
export function generateSessionToken() {
|
|
28
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Make an HTTP/HTTPS request
|
|
33
|
+
*/
|
|
34
|
+
function makeRequest(url, options, body = null) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const parsedUrl = new URL(url);
|
|
37
|
+
const client = parsedUrl.protocol === 'https:' ? https : http;
|
|
38
|
+
|
|
39
|
+
const req = client.request(parsedUrl, options, (res) => {
|
|
40
|
+
let data = '';
|
|
41
|
+
|
|
42
|
+
res.on('data', (chunk) => {
|
|
43
|
+
data += chunk;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
res.on('end', () => {
|
|
47
|
+
let parsedData = null;
|
|
48
|
+
if (data) {
|
|
49
|
+
try {
|
|
50
|
+
parsedData = JSON.parse(data);
|
|
51
|
+
} catch {
|
|
52
|
+
// Response is not JSON (could be HTML error page)
|
|
53
|
+
parsedData = { error: data.substring(0, 200) };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
resolve({
|
|
57
|
+
status: res.statusCode,
|
|
58
|
+
data: parsedData,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
req.on('error', reject);
|
|
64
|
+
|
|
65
|
+
if (body) {
|
|
66
|
+
req.write(JSON.stringify(body));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
req.end();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* RemoteClient class - manages HTTP connection to remote server
|
|
75
|
+
*/
|
|
76
|
+
export class RemoteClient {
|
|
77
|
+
/**
|
|
78
|
+
* @param {object} options
|
|
79
|
+
* @param {string} options.serverUrl - Base URL of remote server (e.g., https://example.vercel.app)
|
|
80
|
+
* @param {string} options.workflowName - Name of the workflow
|
|
81
|
+
* @param {function} options.onInteractionResponse - Callback when interaction response received
|
|
82
|
+
* @param {function} [options.onStatusChange] - Callback when connection status changes
|
|
83
|
+
*/
|
|
84
|
+
constructor(options) {
|
|
85
|
+
this.serverUrl = options.serverUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
86
|
+
this.workflowName = options.workflowName;
|
|
87
|
+
this.onInteractionResponse = options.onInteractionResponse;
|
|
88
|
+
this.onStatusChange = options.onStatusChange || (() => {});
|
|
89
|
+
|
|
90
|
+
this.sessionToken = generateSessionToken();
|
|
91
|
+
this.connected = false;
|
|
92
|
+
this.polling = false;
|
|
93
|
+
this.pollAbortController = null;
|
|
94
|
+
this.initialHistorySent = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the full remote URL for browser access
|
|
99
|
+
*/
|
|
100
|
+
getRemoteUrl() {
|
|
101
|
+
return `${this.serverUrl}/s/${this.sessionToken}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Connect to the remote server
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
async connect() {
|
|
109
|
+
try {
|
|
110
|
+
// Test connection by making a simple request
|
|
111
|
+
const testUrl = `${this.serverUrl}/api/history/${this.sessionToken}`;
|
|
112
|
+
const response = await makeRequest(testUrl, { method: 'GET' });
|
|
113
|
+
|
|
114
|
+
// 404 is expected for new sessions
|
|
115
|
+
if (response.status !== 404 && response.status !== 200) {
|
|
116
|
+
throw new Error(`Server returned ${response.status}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.connected = true;
|
|
120
|
+
this.onStatusChange('connected');
|
|
121
|
+
|
|
122
|
+
// Start polling for interactions
|
|
123
|
+
this.startPolling();
|
|
124
|
+
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.log(`${C.yellow}Warning: Could not connect to remote server: ${err.message}${C.reset}`);
|
|
127
|
+
// Don't fail - remote is optional
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Send a message to the server via HTTP POST
|
|
133
|
+
*/
|
|
134
|
+
async send(msg) {
|
|
135
|
+
if (!this.connected) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const url = `${this.serverUrl}/api/ws/cli`;
|
|
141
|
+
await makeRequest(url, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: {
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
},
|
|
146
|
+
}, msg);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(`${C.dim}Remote: Failed to send message: ${err.message}${C.reset}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Send initial session info with history
|
|
154
|
+
* @param {Array} history - Array of history entries
|
|
155
|
+
*/
|
|
156
|
+
async sendSessionInit(history = []) {
|
|
157
|
+
this.initialHistorySent = true;
|
|
158
|
+
await this.send({
|
|
159
|
+
type: 'session_init',
|
|
160
|
+
sessionToken: this.sessionToken,
|
|
161
|
+
workflowName: this.workflowName,
|
|
162
|
+
history,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Send an event to the server
|
|
168
|
+
* @param {object} event - Event object with timestamp, event type, etc.
|
|
169
|
+
*/
|
|
170
|
+
async sendEvent(event) {
|
|
171
|
+
// Only send events after initial history has been sent
|
|
172
|
+
if (!this.initialHistorySent) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await this.send({
|
|
177
|
+
type: 'event',
|
|
178
|
+
sessionToken: this.sessionToken,
|
|
179
|
+
...event,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Send session end notification
|
|
185
|
+
* @param {string} reason - Reason for ending (completed, failed, user_quit)
|
|
186
|
+
*/
|
|
187
|
+
async sendSessionEnd(reason = 'completed') {
|
|
188
|
+
await this.send({
|
|
189
|
+
type: 'session_end',
|
|
190
|
+
sessionToken: this.sessionToken,
|
|
191
|
+
reason,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Start polling for interaction responses
|
|
197
|
+
*/
|
|
198
|
+
startPolling() {
|
|
199
|
+
if (this.polling) return;
|
|
200
|
+
this.polling = true;
|
|
201
|
+
|
|
202
|
+
this.poll();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Poll for interaction responses
|
|
207
|
+
*/
|
|
208
|
+
async poll() {
|
|
209
|
+
while (this.polling && this.connected) {
|
|
210
|
+
try {
|
|
211
|
+
const url = `${this.serverUrl}/api/ws/cli?token=${this.sessionToken}&timeout=30000`;
|
|
212
|
+
const response = await makeRequest(url, { method: 'GET' });
|
|
213
|
+
|
|
214
|
+
if (response.status === 200 && response.data) {
|
|
215
|
+
const { type, slug, targetKey, response: interactionResponse } = response.data;
|
|
216
|
+
|
|
217
|
+
if (type === 'interaction_response' && this.onInteractionResponse) {
|
|
218
|
+
this.onInteractionResponse(slug, targetKey, interactionResponse);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// If 204 (no content), just continue polling
|
|
223
|
+
// Small delay between polls
|
|
224
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
225
|
+
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Connection error - wait and retry
|
|
228
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Stop polling
|
|
235
|
+
*/
|
|
236
|
+
stopPolling() {
|
|
237
|
+
this.polling = false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Disconnect from the server
|
|
242
|
+
*/
|
|
243
|
+
async disconnect() {
|
|
244
|
+
this.stopPolling();
|
|
245
|
+
|
|
246
|
+
if (this.connected) {
|
|
247
|
+
await this.sendSessionEnd('completed');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.connected = false;
|
|
251
|
+
}
|
|
252
|
+
}
|
package/lib/runtime/agent.js
CHANGED
|
@@ -342,13 +342,10 @@ async function handleInteraction(runtime, interaction) {
|
|
|
342
342
|
const filePath = path.join(runtime.interactionsDir, `${slug}.md`);
|
|
343
343
|
|
|
344
344
|
// Create interaction file
|
|
345
|
-
const fileContent =
|
|
345
|
+
const fileContent = `<!-- Note: Edit this file directly and press 'y' in the terminal when finished. Safe to clear this file. -->
|
|
346
|
+
# ${slug}
|
|
346
347
|
|
|
347
348
|
${content}
|
|
348
|
-
|
|
349
|
-
---
|
|
350
|
-
Enter your response below:
|
|
351
|
-
|
|
352
349
|
`;
|
|
353
350
|
|
|
354
351
|
fs.writeFileSync(filePath, fileContent);
|
package/lib/runtime/prompt.js
CHANGED
|
@@ -11,6 +11,15 @@ import path from 'path';
|
|
|
11
11
|
import readline from 'readline';
|
|
12
12
|
import { getCurrentRuntime } from './runtime.js';
|
|
13
13
|
|
|
14
|
+
const C = {
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
bold: '\x1b[1m',
|
|
17
|
+
cyan: '\x1b[36m',
|
|
18
|
+
yellow: '\x1b[33m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
dim: '\x1b[2m'
|
|
21
|
+
};
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* Request user input with memory-based resume support
|
|
16
25
|
* @param {string} question - Question to ask the user
|
|
@@ -35,9 +44,8 @@ export async function initialPrompt(question, options = {}) {
|
|
|
35
44
|
|
|
36
45
|
// Check if we're in TTY mode (interactive terminal)
|
|
37
46
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
38
|
-
// Interactive mode - prompt directly
|
|
39
|
-
|
|
40
|
-
const answer = await askQuestion(question);
|
|
47
|
+
// Interactive mode - prompt directly, with remote support
|
|
48
|
+
const answer = await askQuestionWithRemote(runtime, question, slug, memoryKey);
|
|
41
49
|
console.log('');
|
|
42
50
|
|
|
43
51
|
// Save the response to memory
|
|
@@ -56,13 +64,10 @@ export async function initialPrompt(question, options = {}) {
|
|
|
56
64
|
// Non-TTY mode - create interaction file and wait inline
|
|
57
65
|
const interactionFile = path.join(runtime.interactionsDir, `${slug}.md`);
|
|
58
66
|
|
|
59
|
-
const fileContent =
|
|
67
|
+
const fileContent = `<!-- Note: Edit this file directly and press 'y' in the terminal when finished. Safe to clear this file. -->
|
|
68
|
+
# ${slug}
|
|
60
69
|
|
|
61
70
|
${question}
|
|
62
|
-
|
|
63
|
-
---
|
|
64
|
-
Enter your response below:
|
|
65
|
-
|
|
66
71
|
`;
|
|
67
72
|
|
|
68
73
|
fs.writeFileSync(interactionFile, fileContent);
|
|
@@ -91,7 +96,57 @@ Enter your response below:
|
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
/**
|
|
94
|
-
* Interactive terminal question
|
|
99
|
+
* Interactive terminal question with remote support
|
|
100
|
+
* Allows both local TTY input and remote browser responses
|
|
101
|
+
*/
|
|
102
|
+
function askQuestionWithRemote(runtime, question, slug, memoryKey) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
let resolved = false;
|
|
105
|
+
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
resolved = true;
|
|
108
|
+
runtime.pendingRemoteInteraction = null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Set up remote interaction listener if remote is enabled
|
|
112
|
+
if (runtime.remoteEnabled) {
|
|
113
|
+
runtime.pendingRemoteInteraction = {
|
|
114
|
+
slug,
|
|
115
|
+
targetKey: memoryKey,
|
|
116
|
+
resolve: (response) => {
|
|
117
|
+
if (resolved) return;
|
|
118
|
+
cleanup();
|
|
119
|
+
rl.close();
|
|
120
|
+
console.log(`\n${C.green}✓ Answered via remote${C.reset}`);
|
|
121
|
+
resolve(response);
|
|
122
|
+
},
|
|
123
|
+
reject: () => {} // Prompts don't reject
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const rl = readline.createInterface({
|
|
128
|
+
input: process.stdin,
|
|
129
|
+
output: process.stdout
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Show remote URL if available
|
|
133
|
+
let prompt = `\n${C.cyan}${C.bold}${question}${C.reset}`;
|
|
134
|
+
if (runtime.remoteEnabled && runtime.remoteUrl) {
|
|
135
|
+
prompt += `\n${C.dim}(Remote: ${runtime.remoteUrl})${C.reset}`;
|
|
136
|
+
}
|
|
137
|
+
prompt += `\n${C.yellow}> ${C.reset}`;
|
|
138
|
+
|
|
139
|
+
rl.question(prompt, (answer) => {
|
|
140
|
+
if (resolved) return;
|
|
141
|
+
cleanup();
|
|
142
|
+
rl.close();
|
|
143
|
+
resolve(answer.trim());
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Interactive terminal question (simple, no remote support)
|
|
95
150
|
*/
|
|
96
151
|
function askQuestion(question) {
|
|
97
152
|
return new Promise((resolve) => {
|
|
@@ -100,7 +155,8 @@ function askQuestion(question) {
|
|
|
100
155
|
output: process.stdout
|
|
101
156
|
});
|
|
102
157
|
|
|
103
|
-
|
|
158
|
+
// Colorize the question and the input marker
|
|
159
|
+
rl.question(`\n${C.cyan}${C.bold}${question}${C.reset}\n${C.yellow}> ${C.reset}`, (answer) => {
|
|
104
160
|
rl.close();
|
|
105
161
|
resolve(answer.trim());
|
|
106
162
|
});
|
|
@@ -116,4 +172,4 @@ function generateSlug(question) {
|
|
|
116
172
|
.replace(/[^a-z0-9]+/g, '-')
|
|
117
173
|
.replace(/^-+|-+$/g, '')
|
|
118
174
|
.substring(0, 30);
|
|
119
|
-
}
|
|
175
|
+
}
|
package/lib/runtime/runtime.js
CHANGED
|
@@ -10,11 +10,26 @@ import fs from 'fs';
|
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import readline from 'readline';
|
|
12
12
|
import { createMemoryProxy } from './memory.js';
|
|
13
|
+
import { RemoteClient } from '../remote/client.js';
|
|
13
14
|
|
|
14
15
|
// Global runtime reference for agent() and memory access
|
|
15
16
|
// stored on globalThis to ensure singleton access across different module instances (CLI vs local)
|
|
16
17
|
const RUNTIME_KEY = Symbol.for('agent-state-machine.runtime');
|
|
17
18
|
|
|
19
|
+
// ANSI Colors
|
|
20
|
+
const C = {
|
|
21
|
+
reset: '\x1b[0m',
|
|
22
|
+
bold: '\x1b[1m',
|
|
23
|
+
dim: '\x1b[2m',
|
|
24
|
+
red: '\x1b[31m',
|
|
25
|
+
green: '\x1b[32m',
|
|
26
|
+
yellow: '\x1b[33m',
|
|
27
|
+
blue: '\x1b[34m',
|
|
28
|
+
magenta: '\x1b[35m',
|
|
29
|
+
cyan: '\x1b[36m',
|
|
30
|
+
gray: '\x1b[90m'
|
|
31
|
+
};
|
|
32
|
+
|
|
18
33
|
export function getCurrentRuntime() {
|
|
19
34
|
return globalThis[RUNTIME_KEY];
|
|
20
35
|
}
|
|
@@ -63,6 +78,12 @@ export class WorkflowRuntime {
|
|
|
63
78
|
|
|
64
79
|
// Load steering
|
|
65
80
|
this.steering = this.loadSteering();
|
|
81
|
+
|
|
82
|
+
// Remote follow state
|
|
83
|
+
this.remoteClient = null;
|
|
84
|
+
this.remoteEnabled = false;
|
|
85
|
+
this.remoteUrl = null;
|
|
86
|
+
this.pendingRemoteInteraction = null; // { slug, targetKey, resolve, reject }
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
ensureDirectories() {
|
|
@@ -82,7 +103,7 @@ export class WorkflowRuntime {
|
|
|
82
103
|
try {
|
|
83
104
|
return JSON.parse(fs.readFileSync(this.stateFile, 'utf-8'));
|
|
84
105
|
} catch (err) {
|
|
85
|
-
console.warn(
|
|
106
|
+
console.warn(`${C.yellow}Warning: Failed to load state: ${err.message}${C.reset}`);
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
return {};
|
|
@@ -105,7 +126,7 @@ export class WorkflowRuntime {
|
|
|
105
126
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
106
127
|
steering.enabled = config.enabled !== false;
|
|
107
128
|
} catch (err) {
|
|
108
|
-
console.warn(
|
|
129
|
+
console.warn(`${C.yellow}Warning: Failed to load steering config: ${err.message}${C.reset}`);
|
|
109
130
|
}
|
|
110
131
|
}
|
|
111
132
|
|
|
@@ -149,6 +170,39 @@ export class WorkflowRuntime {
|
|
|
149
170
|
existing = fs.readFileSync(this.historyFile, 'utf-8');
|
|
150
171
|
}
|
|
151
172
|
fs.writeFileSync(this.historyFile, line + existing);
|
|
173
|
+
|
|
174
|
+
// Forward to remote if connected
|
|
175
|
+
if (this.remoteClient && this.remoteEnabled) {
|
|
176
|
+
this.remoteClient.sendEvent(entry);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load all history entries from history.jsonl
|
|
182
|
+
* @returns {Array} Array of history entry objects
|
|
183
|
+
*/
|
|
184
|
+
loadHistory() {
|
|
185
|
+
if (!fs.existsSync(this.historyFile)) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const content = fs.readFileSync(this.historyFile, 'utf-8');
|
|
191
|
+
return content
|
|
192
|
+
.trim()
|
|
193
|
+
.split('\n')
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.map(line => {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(line);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
.filter(Boolean);
|
|
203
|
+
} catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
152
206
|
}
|
|
153
207
|
|
|
154
208
|
/**
|
|
@@ -188,7 +242,7 @@ export class WorkflowRuntime {
|
|
|
188
242
|
this.persist();
|
|
189
243
|
this.prependHistory({ event: 'WORKFLOW_COMPLETED' });
|
|
190
244
|
|
|
191
|
-
console.log(`\n✓ Workflow '${this.workflowName}' completed successfully
|
|
245
|
+
console.log(`\n${C.green}✓ Workflow '${this.workflowName}' completed successfully!${C.reset}`);
|
|
192
246
|
} catch (err) {
|
|
193
247
|
this.status = 'FAILED';
|
|
194
248
|
this._error = err.message;
|
|
@@ -199,7 +253,7 @@ export class WorkflowRuntime {
|
|
|
199
253
|
error: err.message
|
|
200
254
|
});
|
|
201
255
|
|
|
202
|
-
console.error(`\n✗ Workflow '${this.workflowName}' failed: ${err.message}`);
|
|
256
|
+
console.error(`\n${C.red}✗ Workflow '${this.workflowName}' failed: ${err.message}${C.reset}`);
|
|
203
257
|
throw err;
|
|
204
258
|
} finally {
|
|
205
259
|
clearCurrentRuntime();
|
|
@@ -208,10 +262,17 @@ export class WorkflowRuntime {
|
|
|
208
262
|
|
|
209
263
|
/**
|
|
210
264
|
* Wait for user to confirm interaction is complete, then return the response
|
|
265
|
+
* Supports both local TTY input and remote browser responses
|
|
211
266
|
*/
|
|
212
267
|
async waitForInteraction(filePath, slug, targetKey) {
|
|
213
|
-
console.log(`\n⏸
|
|
214
|
-
|
|
268
|
+
console.log(`\n${C.yellow}${C.bold}⏸ Interaction required.${C.reset}`);
|
|
269
|
+
|
|
270
|
+
if (this.remoteEnabled && this.remoteUrl) {
|
|
271
|
+
console.log(` ${C.cyan}Remote:${C.reset} ${this.remoteUrl}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
console.log(` ${C.dim}Edit: ${filePath}${C.reset}`);
|
|
275
|
+
console.log(` Enter ${C.bold}y${C.reset} to proceed or ${C.bold}q${C.reset} to quit.\n`);
|
|
215
276
|
|
|
216
277
|
const rl = readline.createInterface({
|
|
217
278
|
input: process.stdin,
|
|
@@ -219,12 +280,57 @@ export class WorkflowRuntime {
|
|
|
219
280
|
});
|
|
220
281
|
|
|
221
282
|
return new Promise((resolve, reject) => {
|
|
283
|
+
// Track if we've already resolved (to prevent double-resolution)
|
|
284
|
+
let resolved = false;
|
|
285
|
+
|
|
286
|
+
const cleanup = () => {
|
|
287
|
+
resolved = true;
|
|
288
|
+
rl.close();
|
|
289
|
+
this.pendingRemoteInteraction = null;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Set up remote interaction listener
|
|
293
|
+
if (this.remoteEnabled) {
|
|
294
|
+
this.pendingRemoteInteraction = {
|
|
295
|
+
slug,
|
|
296
|
+
targetKey,
|
|
297
|
+
resolve: (response) => {
|
|
298
|
+
if (resolved) return;
|
|
299
|
+
cleanup();
|
|
300
|
+
|
|
301
|
+
// Store in memory
|
|
302
|
+
this._rawMemory[targetKey] = response;
|
|
303
|
+
this.persist();
|
|
304
|
+
|
|
305
|
+
this.prependHistory({
|
|
306
|
+
event: 'INTERACTION_RESOLVED',
|
|
307
|
+
slug,
|
|
308
|
+
targetKey,
|
|
309
|
+
source: 'remote'
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
console.log(`\n${C.green}✓ Interaction resolved (remote): ${slug}${C.reset}`);
|
|
313
|
+
resolve(response);
|
|
314
|
+
},
|
|
315
|
+
reject: (err) => {
|
|
316
|
+
if (resolved) return;
|
|
317
|
+
cleanup();
|
|
318
|
+
reject(err);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Local TTY input loop
|
|
222
324
|
const ask = () => {
|
|
223
|
-
|
|
325
|
+
if (resolved) return;
|
|
326
|
+
|
|
327
|
+
rl.question(`${C.dim}> ${C.reset}`, (answer) => {
|
|
328
|
+
if (resolved) return;
|
|
329
|
+
|
|
224
330
|
const a = answer.trim().toLowerCase();
|
|
225
331
|
if (a === 'y') {
|
|
226
|
-
|
|
227
|
-
// Read and return the response
|
|
332
|
+
cleanup();
|
|
333
|
+
// Read and return the response from file
|
|
228
334
|
try {
|
|
229
335
|
const response = this.readInteractionResponse(filePath, slug, targetKey);
|
|
230
336
|
resolve(response);
|
|
@@ -232,7 +338,7 @@ export class WorkflowRuntime {
|
|
|
232
338
|
reject(err);
|
|
233
339
|
}
|
|
234
340
|
} else if (a === 'q') {
|
|
235
|
-
|
|
341
|
+
cleanup();
|
|
236
342
|
reject(new Error('User quit workflow'));
|
|
237
343
|
} else {
|
|
238
344
|
ask();
|
|
@@ -243,6 +349,17 @@ export class WorkflowRuntime {
|
|
|
243
349
|
});
|
|
244
350
|
}
|
|
245
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Handle interaction response received from remote browser
|
|
354
|
+
* Called by RemoteClient when it receives an interaction_response message
|
|
355
|
+
*/
|
|
356
|
+
handleRemoteInteraction(slug, targetKey, response) {
|
|
357
|
+
if (this.pendingRemoteInteraction &&
|
|
358
|
+
this.pendingRemoteInteraction.slug === slug) {
|
|
359
|
+
this.pendingRemoteInteraction.resolve(response);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
246
363
|
/**
|
|
247
364
|
* Read the user's response from an interaction file
|
|
248
365
|
*/
|
|
@@ -252,17 +369,10 @@ export class WorkflowRuntime {
|
|
|
252
369
|
}
|
|
253
370
|
|
|
254
371
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
372
|
+
const response = content.trim();
|
|
255
373
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const parts = content.split(separator);
|
|
259
|
-
if (parts.length < 2) {
|
|
260
|
-
throw new Error(`Interaction file missing separator: ${filePath}`);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const response = parts.slice(1).join(separator).trim();
|
|
264
|
-
if (!response || response === 'Enter your response below:') {
|
|
265
|
-
throw new Error(`Interaction response is empty. Please fill in: ${filePath}`);
|
|
374
|
+
if (!response) {
|
|
375
|
+
throw new Error(`Interaction file is empty: ${filePath}`);
|
|
266
376
|
}
|
|
267
377
|
|
|
268
378
|
// Store in memory for reference
|
|
@@ -275,7 +385,7 @@ export class WorkflowRuntime {
|
|
|
275
385
|
targetKey
|
|
276
386
|
});
|
|
277
387
|
|
|
278
|
-
console.log(`\n✓ Interaction resolved: ${slug}`);
|
|
388
|
+
console.log(`\n${C.green}✓ Interaction resolved: ${slug}${C.reset}`);
|
|
279
389
|
return response;
|
|
280
390
|
}
|
|
281
391
|
|
|
@@ -283,20 +393,27 @@ export class WorkflowRuntime {
|
|
|
283
393
|
* Show workflow status
|
|
284
394
|
*/
|
|
285
395
|
showStatus() {
|
|
286
|
-
console.log(`\
|
|
287
|
-
console.log('─'.repeat(40));
|
|
288
|
-
|
|
396
|
+
console.log(`\n${C.bold}Workflow: ${C.cyan}${this.workflowName}${C.reset}`);
|
|
397
|
+
console.log(`${C.dim}${'─'.repeat(40)}${C.reset}`);
|
|
398
|
+
|
|
399
|
+
let statusColor = C.reset;
|
|
400
|
+
if (this.status === 'COMPLETED') statusColor = C.green;
|
|
401
|
+
if (this.status === 'FAILED') statusColor = C.red;
|
|
402
|
+
if (this.status === 'RUNNING') statusColor = C.blue;
|
|
403
|
+
if (this.status === 'IDLE') statusColor = C.gray;
|
|
404
|
+
|
|
405
|
+
console.log(`Status: ${statusColor}${this.status}${C.reset}`);
|
|
289
406
|
|
|
290
407
|
if (this.startedAt) {
|
|
291
408
|
console.log(`Started: ${this.startedAt}`);
|
|
292
409
|
}
|
|
293
410
|
|
|
294
411
|
if (this._error) {
|
|
295
|
-
console.log(`Error: ${this._error}`);
|
|
412
|
+
console.log(`Error: ${C.red}${this._error}${C.reset}`);
|
|
296
413
|
}
|
|
297
414
|
|
|
298
415
|
const memoryKeys = Object.keys(this._rawMemory).filter((k) => !k.startsWith('_'));
|
|
299
|
-
console.log(`\nMemory Keys: ${memoryKeys.length}`);
|
|
416
|
+
console.log(`\nMemory Keys: ${C.yellow}${memoryKeys.length}${C.reset}`);
|
|
300
417
|
if (memoryKeys.length > 0) {
|
|
301
418
|
console.log(` ${memoryKeys.slice(0, 10).join(', ')}${memoryKeys.length > 10 ? '...' : ''}`);
|
|
302
419
|
}
|
|
@@ -306,8 +423,8 @@ export class WorkflowRuntime {
|
|
|
306
423
|
* Show execution history
|
|
307
424
|
*/
|
|
308
425
|
showHistory(limit = 20) {
|
|
309
|
-
console.log(`\
|
|
310
|
-
console.log('─'.repeat(40));
|
|
426
|
+
console.log(`\n${C.bold}History: ${C.cyan}${this.workflowName}${C.reset}`);
|
|
427
|
+
console.log(`${C.dim}${'─'.repeat(40)}${C.reset}`);
|
|
311
428
|
|
|
312
429
|
if (!fs.existsSync(this.historyFile)) {
|
|
313
430
|
console.log('No history found.');
|
|
@@ -325,10 +442,17 @@ export class WorkflowRuntime {
|
|
|
325
442
|
try {
|
|
326
443
|
const entry = JSON.parse(line);
|
|
327
444
|
const time = entry.timestamp ? entry.timestamp.split('T')[1]?.split('.')[0] : '';
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (entry.
|
|
331
|
-
if (entry.
|
|
445
|
+
|
|
446
|
+
let eventColor = C.reset;
|
|
447
|
+
if (entry.event.endsWith('_STARTED')) eventColor = C.blue;
|
|
448
|
+
if (entry.event.endsWith('_COMPLETED')) eventColor = C.green;
|
|
449
|
+
if (entry.event.endsWith('_FAILED')) eventColor = C.red;
|
|
450
|
+
if (entry.event.includes('INTERACTION')) eventColor = C.yellow;
|
|
451
|
+
|
|
452
|
+
console.log(`${C.dim}[${time}]${C.reset} ${eventColor}${entry.event}${C.reset}`);
|
|
453
|
+
if (entry.agent) console.log(` ${C.dim}Agent:${C.reset} ${entry.agent}`);
|
|
454
|
+
if (entry.slug) console.log(` ${C.dim}Slug:${C.reset} ${entry.slug}`);
|
|
455
|
+
if (entry.error) console.log(` ${C.red}Error: ${entry.error}${C.reset}`);
|
|
332
456
|
} catch {}
|
|
333
457
|
}
|
|
334
458
|
}
|
|
@@ -348,7 +472,7 @@ export class WorkflowRuntime {
|
|
|
348
472
|
this.persist();
|
|
349
473
|
this.prependHistory({ event: 'WORKFLOW_RESET' });
|
|
350
474
|
|
|
351
|
-
console.log(`\n✓ Workflow '${this.workflowName}' reset`);
|
|
475
|
+
console.log(`\n${C.yellow}✓ Workflow '${this.workflowName}' reset${C.reset}`);
|
|
352
476
|
}
|
|
353
477
|
|
|
354
478
|
/**
|
|
@@ -377,6 +501,62 @@ export class WorkflowRuntime {
|
|
|
377
501
|
|
|
378
502
|
this.persist();
|
|
379
503
|
|
|
380
|
-
console.log(`\n✓ Workflow '${this.workflowName}' hard reset (history and interactions cleared)`);
|
|
504
|
+
console.log(`\n${C.red}✓ Workflow '${this.workflowName}' hard reset (history and interactions cleared)${C.reset}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Enable remote follow mode
|
|
509
|
+
* @param {string} serverUrl - Base URL of the remote server
|
|
510
|
+
* @returns {Promise<string>} The remote URL for browser access
|
|
511
|
+
*/
|
|
512
|
+
async enableRemote(serverUrl) {
|
|
513
|
+
this.remoteClient = new RemoteClient({
|
|
514
|
+
serverUrl,
|
|
515
|
+
workflowName: this.workflowName,
|
|
516
|
+
onInteractionResponse: (slug, targetKey, response) => {
|
|
517
|
+
this.handleRemoteInteraction(slug, targetKey, response);
|
|
518
|
+
},
|
|
519
|
+
onStatusChange: (status) => {
|
|
520
|
+
if (status === 'disconnected') {
|
|
521
|
+
console.log(`${C.yellow}Remote: Connection lost, attempting to reconnect...${C.reset}`);
|
|
522
|
+
} else if (status === 'connected' && this.remoteEnabled) {
|
|
523
|
+
console.log(`${C.green}Remote: Reconnected${C.reset}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
await this.remoteClient.connect();
|
|
529
|
+
|
|
530
|
+
// Send existing history if connected
|
|
531
|
+
if (this.remoteClient.connected) {
|
|
532
|
+
const history = this.loadHistory();
|
|
533
|
+
this.remoteClient.sendSessionInit(history);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
this.remoteEnabled = true;
|
|
537
|
+
this.remoteUrl = this.remoteClient.getRemoteUrl();
|
|
538
|
+
|
|
539
|
+
console.log(`\n${C.cyan}${C.bold}Remote follow enabled${C.reset}`);
|
|
540
|
+
console.log(` ${C.dim}URL:${C.reset} ${this.remoteUrl}\n`);
|
|
541
|
+
|
|
542
|
+
return this.remoteUrl;
|
|
381
543
|
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Disable remote follow mode and disconnect
|
|
547
|
+
*/
|
|
548
|
+
async disableRemote() {
|
|
549
|
+
if (this.remoteClient) {
|
|
550
|
+
await this.remoteClient.disconnect();
|
|
551
|
+
this.remoteClient = null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
this.remoteEnabled = false;
|
|
555
|
+
this.remoteUrl = null;
|
|
556
|
+
this.pendingRemoteInteraction = null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function escapeRegExp(string) {
|
|
561
|
+
return string.replace(/[.*+?^${}()|[\\]/g, '\\$&'); // $& means the whole matched string
|
|
382
562
|
}
|
package/lib/setup.js
CHANGED
|
@@ -80,8 +80,8 @@ export default async function() {
|
|
|
80
80
|
console.log('Starting ${workflowName} workflow...');
|
|
81
81
|
|
|
82
82
|
// Example: Get user input (saved to memory)
|
|
83
|
-
const
|
|
84
|
-
console.log('Example prompt answer:',
|
|
83
|
+
const userLocation = await initialPrompt('Where do you live?');
|
|
84
|
+
console.log('Example prompt answer:', userLocation);
|
|
85
85
|
|
|
86
86
|
const userInfo = await agent('yoda-name-collector');
|
|
87
87
|
memory.userInfo = userInfo;
|
|
@@ -92,7 +92,7 @@ export default async function() {
|
|
|
92
92
|
console.log('Example agent memory.userInfo:', memory.userInfo || userInfo);
|
|
93
93
|
|
|
94
94
|
// Context is provided automatically
|
|
95
|
-
const { greeting } = await agent('yoda-greeter');
|
|
95
|
+
const { greeting } = await agent('yoda-greeter', { userLocation });
|
|
96
96
|
console.log('Example agent greeting:', greeting);
|
|
97
97
|
|
|
98
98
|
// Or you can provide context manually
|
|
@@ -171,7 +171,7 @@ output: greeting
|
|
|
171
171
|
|
|
172
172
|
# Greeting Task
|
|
173
173
|
|
|
174
|
-
Generate a friendly greeting for {{name}} in a yoda style. Prompt user for their actual {{name}} if you dont have it.
|
|
174
|
+
Generate a friendly greeting for {{name}} from {{location}} in a yoda style. Prompt user for their actual {{name}} if you dont have it.
|
|
175
175
|
|
|
176
176
|
Once you have it create a yoda-greeting.md file in root dir with the greeting.
|
|
177
177
|
|
|
@@ -345,8 +345,8 @@ export default async function() {
|
|
|
345
345
|
console.log('Starting project-builder workflow...');
|
|
346
346
|
|
|
347
347
|
// Example: Get user input (saved to memory)
|
|
348
|
-
const
|
|
349
|
-
console.log('Example prompt answer:',
|
|
348
|
+
const userLocation = await initialPrompt('Where do you live?');
|
|
349
|
+
console.log('Example prompt answer:', userLocation);
|
|
350
350
|
|
|
351
351
|
const userInfo = await agent('yoda-name-collector');
|
|
352
352
|
memory.userInfo = userInfo;
|
|
@@ -357,7 +357,7 @@ export default async function() {
|
|
|
357
357
|
console.log('Example agent memory.userInfo:', memory.userInfo || userInfo);
|
|
358
358
|
|
|
359
359
|
// Context is provided automatically
|
|
360
|
-
const { greeting } = await agent('yoda-greeter');
|
|
360
|
+
const { greeting } = await agent('yoda-greeter', { userLocation });
|
|
361
361
|
console.log('Example agent greeting:', greeting);
|
|
362
362
|
|
|
363
363
|
// Or you can provide context manually
|