agent-state-machine 1.0.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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * File: /lib/runtime/agent.js
3
+ */
4
+
5
+ /**
6
+ * Agent execution module for native JS workflows
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { createRequire } from 'module';
12
+ import { pathToFileURL } from 'url';
13
+ import { getCurrentRuntime } from './runtime.js';
14
+
15
+ const require = createRequire(import.meta.url);
16
+
17
+ /**
18
+ * Run an agent with context
19
+ * @param {string} name - Agent name (file basename)
20
+ * @param {object} params - Parameters passed to agent
21
+ */
22
+ export async function agent(name, params = {}) {
23
+ const runtime = getCurrentRuntime();
24
+ if (!runtime) {
25
+ throw new Error('agent() must be called within a workflow context');
26
+ }
27
+
28
+ console.log(` [Agent: ${name}] Starting...`);
29
+ runtime.prependHistory({
30
+ event: 'AGENT_STARTED',
31
+ agent: name
32
+ });
33
+
34
+ try {
35
+ const result = await executeAgent(runtime, name, params);
36
+
37
+ console.log(` [Agent: ${name}] Completed`);
38
+ runtime.prependHistory({
39
+ event: 'AGENT_COMPLETED',
40
+ agent: name,
41
+ output: result
42
+ });
43
+
44
+ return result;
45
+ } catch (error) {
46
+ runtime.prependHistory({
47
+ event: 'AGENT_FAILED',
48
+ agent: name,
49
+ error: error.message
50
+ });
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Execute an agent (load and run)
57
+ */
58
+ export async function executeAgent(runtime, name, params) {
59
+ const agentsDir = runtime.agentsDir;
60
+
61
+ // Try JS agents (.js/.mjs/.cjs)
62
+ const jsCandidates = [
63
+ path.join(agentsDir, `${name}.js`),
64
+ path.join(agentsDir, `${name}.mjs`),
65
+ path.join(agentsDir, `${name}.cjs`)
66
+ ];
67
+
68
+ for (const p of jsCandidates) {
69
+ if (fs.existsSync(p)) {
70
+ return executeJSAgent(runtime, p, name, params);
71
+ }
72
+ }
73
+
74
+ // Try Markdown agent
75
+ const mdPath = path.join(agentsDir, `${name}.md`);
76
+ if (fs.existsSync(mdPath)) {
77
+ return executeMDAgent(runtime, mdPath, name, params);
78
+ }
79
+
80
+ throw new Error(
81
+ `Agent not found: ${name} (looked for ${jsCandidates
82
+ .map((p) => path.basename(p))
83
+ .join(', ')} or ${name}.md in ${agentsDir})`
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Execute a JavaScript agent
89
+ * - ESM (.js/.mjs): loaded via dynamic import with cache-bust for hot reload
90
+ * - CJS (.cjs): loaded via require() with cache clear
91
+ */
92
+ async function executeJSAgent(runtime, agentPath, name, params) {
93
+ const ext = path.extname(agentPath).toLowerCase();
94
+
95
+ let agentModule;
96
+ if (ext === '.cjs') {
97
+ // Clear require cache for hot reloading
98
+ delete require.cache[require.resolve(agentPath)];
99
+ agentModule = require(agentPath);
100
+ } else {
101
+ // Cache-busted import for hot reload behavior
102
+ const url = pathToFileURL(agentPath).href;
103
+ agentModule = await import(`${url}?t=${Date.now()}`);
104
+ }
105
+
106
+ const handler =
107
+ agentModule?.handler ||
108
+ agentModule?.default ||
109
+ agentModule;
110
+
111
+ if (typeof handler !== 'function') {
112
+ throw new Error(`Agent ${name} does not export a function`);
113
+ }
114
+
115
+ // Build context
116
+ const context = {
117
+ ...runtime._rawMemory,
118
+ ...params,
119
+ _steering: runtime.steering,
120
+ _config: {
121
+ models: runtime.workflowConfig.models,
122
+ apiKeys: runtime.workflowConfig.apiKeys,
123
+ workflowDir: runtime.workflowDir
124
+ }
125
+ };
126
+
127
+ const result = await handler(context);
128
+
129
+ // Handle interaction response from JS agent
130
+ if (result && result._interaction) {
131
+ const interactionResponse = await handleInteraction(runtime, result._interaction);
132
+
133
+ // Use the interaction response as the primary output if it exists
134
+ // This allows the workflow to receive the user's input directly
135
+ if (typeof result === 'object') {
136
+ // Merge response into result or replace?
137
+ // For consistency with MDAgent, we might want to return { result: response }
138
+ // but JS agents are more flexible.
139
+ // Let's assume the agent wants the response mixed in or as the return.
140
+ // A safe bet is to return the response if the agent explicitly asked for interaction.
141
+ return { ...result, result: interactionResponse };
142
+ }
143
+ return interactionResponse;
144
+ }
145
+
146
+ // Clean internal properties from result
147
+ if (result && typeof result === 'object') {
148
+ const cleanResult = { ...result };
149
+ delete cleanResult._steering;
150
+ delete cleanResult._config;
151
+ delete cleanResult._loop;
152
+ delete cleanResult._interaction;
153
+
154
+ // If agent returned a context-like object, only return non-internal keys
155
+ const meaningfulKeys = Object.keys(cleanResult).filter((k) => !k.startsWith('_'));
156
+ if (meaningfulKeys.length > 0) {
157
+ const output = {};
158
+ for (const key of meaningfulKeys) output[key] = cleanResult[key];
159
+ return output;
160
+ }
161
+
162
+ return cleanResult;
163
+ }
164
+
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Execute a Markdown agent (prompt-based)
170
+ */
171
+ async function executeMDAgent(runtime, agentPath, name, params) {
172
+ const { llm, parseJSON, parseInteractionRequest } = await import('../llm.js');
173
+
174
+ const content = fs.readFileSync(agentPath, 'utf-8');
175
+ const { config, prompt } = parseMarkdownAgent(content);
176
+
177
+ const outputKey = config.output || 'result';
178
+ const targetKey = config.interactionKey || outputKey;
179
+
180
+ // Build context
181
+ const context = {
182
+ ...runtime._rawMemory,
183
+ ...params,
184
+ _steering: runtime.steering,
185
+ _config: {
186
+ models: runtime.workflowConfig.models,
187
+ apiKeys: runtime.workflowConfig.apiKeys,
188
+ workflowDir: runtime.workflowDir
189
+ }
190
+ };
191
+
192
+ // Interpolate variables in prompt
193
+ const interpolatedPrompt = interpolatePrompt(prompt, context);
194
+
195
+ const model = config.model || 'fast';
196
+
197
+ console.log(` Using model: ${model}`);
198
+
199
+ const response = await llm(context, {
200
+ model: model,
201
+ prompt: interpolatedPrompt,
202
+ includeContext: config.includeContext !== 'false'
203
+ });
204
+
205
+ // Parse output based on format
206
+ let output = response.text;
207
+ if (config.format === 'json') {
208
+ try {
209
+ output = parseJSON(response.text);
210
+ } catch {
211
+ console.warn(` Warning: Failed to parse JSON output`);
212
+ }
213
+ }
214
+
215
+ // Check for interaction request
216
+ const explicitInteraction =
217
+ config.format === 'interaction' ||
218
+ config.interaction === 'true' ||
219
+ (typeof config.interaction === 'string' && config.interaction.length > 0);
220
+
221
+ const parsedInteraction = parseInteractionRequest(response.text);
222
+ const structuredInteraction =
223
+ config.autoInteract !== 'false' && parsedInteraction.isInteraction;
224
+
225
+ if (explicitInteraction || structuredInteraction) {
226
+ const slugRaw =
227
+ (typeof config.interaction === 'string' && config.interaction !== 'true'
228
+ ? config.interaction
229
+ : null) ||
230
+ config.interactionSlug ||
231
+ config.interactionKey ||
232
+ outputKey ||
233
+ name;
234
+
235
+ const slug = sanitizeSlug(slugRaw);
236
+ const targetKey = config.interactionKey || outputKey || slug;
237
+ const interactionContent = structuredInteraction ? parsedInteraction.question : response.text;
238
+
239
+ const userResponse = await handleInteraction(runtime, {
240
+ slug,
241
+ targetKey,
242
+ content: interactionContent
243
+ });
244
+
245
+ // Return the user's response as the agent result
246
+ return { [outputKey]: userResponse };
247
+ }
248
+
249
+ // Return result object
250
+ return { [outputKey]: output };
251
+ }
252
+
253
+ /**
254
+ * Parse markdown agent with simple frontmatter
255
+ */
256
+ function parseMarkdownAgent(content) {
257
+ // Frontmatter format:
258
+ // ---
259
+ // key: value
260
+ // ---
261
+ // prompt...
262
+ if (content.startsWith('---')) {
263
+ const parts = content.split('---');
264
+ if (parts.length >= 3) {
265
+ const frontmatter = parts[1].trim();
266
+ const prompt = parts.slice(2).join('---').trim();
267
+
268
+ const config = {};
269
+ frontmatter.split('\n').forEach((line) => {
270
+ const [key, ...rest] = line.split(':');
271
+ if (key && rest.length) {
272
+ const value = rest.join(':').trim();
273
+ config[key.trim()] = value.replace(/^["']|["']$/g, '');
274
+ }
275
+ });
276
+
277
+ return { config, prompt };
278
+ }
279
+ }
280
+
281
+ return { config: {}, prompt: content.trim() };
282
+ }
283
+
284
+ /**
285
+ * Interpolate {{variables}} in prompt template
286
+ */
287
+ function interpolatePrompt(template, context) {
288
+ return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, pathStr) => {
289
+ const value = getByPath(context, pathStr);
290
+ return value !== undefined ? String(value) : match;
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Get nested value by dot-notation path
296
+ */
297
+ function getByPath(obj, pathStr) {
298
+ const trimmed = String(pathStr || '').trim();
299
+ if (!trimmed) return undefined;
300
+
301
+ const normalized = trimmed.startsWith('context.')
302
+ ? trimmed.slice('context.'.length)
303
+ : trimmed;
304
+
305
+ const parts = normalized.split('.').filter(Boolean);
306
+ let current = obj;
307
+
308
+ for (const part of parts) {
309
+ if (current == null) return undefined;
310
+ current = current[String(part)];
311
+ }
312
+
313
+ return current;
314
+ }
315
+
316
+ function sanitizeSlug(input) {
317
+ const raw = String(input || '').trim();
318
+ const cleaned = raw
319
+ .toLowerCase()
320
+ .replace(/[^a-z0-9._-]+/g, '-')
321
+ .replace(/-+/g, '-')
322
+ .replace(/^-|-$/g, '');
323
+
324
+ return cleaned || 'interaction';
325
+ }
326
+
327
+ /**
328
+ * Handle interaction (create file, wait for user, return response)
329
+ */
330
+ async function handleInteraction(runtime, interaction) {
331
+ const slug = sanitizeSlug(interaction.slug);
332
+ const targetKey = String(interaction.targetKey || slug);
333
+ const content = String(interaction.content || '').trim();
334
+
335
+ const filePath = path.join(runtime.interactionsDir, `${slug}.md`);
336
+
337
+ // Create interaction file
338
+ const fileContent = `# ${slug}
339
+
340
+ ${content}
341
+
342
+ ---
343
+ Enter your response below:
344
+
345
+ `;
346
+
347
+ fs.writeFileSync(filePath, fileContent);
348
+
349
+ runtime.prependHistory({
350
+ event: 'INTERACTION_REQUESTED',
351
+ slug,
352
+ targetKey
353
+ });
354
+
355
+ // Block and wait for user input (instead of throwing)
356
+ const response = await runtime.waitForInteraction(filePath, slug, targetKey);
357
+
358
+ return response;
359
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * File: /lib/runtime/index.js
3
+ */
4
+
5
+ /**
6
+ * Runtime module exports for native JS workflows
7
+ */
8
+
9
+ export {
10
+ WorkflowRuntime,
11
+ getCurrentRuntime,
12
+ setCurrentRuntime,
13
+ clearCurrentRuntime
14
+ } from './runtime.js';
15
+
16
+ export { agent, executeAgent } from './agent.js';
17
+ export { initialPrompt } from './prompt.js';
18
+ export { parallel, parallelLimit } from './parallel.js';
19
+ export { createMemoryProxy } from './memory.js';
20
+
21
+ import { getCurrentRuntime } from './runtime.js';
22
+
23
+ /**
24
+ * Get the current workflow's memory object
25
+ * Returns a proxy that auto-persists on mutation
26
+ */
27
+ export function getMemory() {
28
+ const runtime = getCurrentRuntime();
29
+ if (!runtime) {
30
+ // Return empty object when not in workflow context
31
+ // This allows imports to work without throwing
32
+ return {};
33
+ }
34
+ return runtime.memory;
35
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * File: /lib/runtime/memory.js
3
+ */
4
+
5
+ /**
6
+ * Memory Proxy Module
7
+ * Creates a proxy object that auto-persists to disk on mutation
8
+ */
9
+
10
+ let persistTimeout = null;
11
+ const DEBOUNCE_MS = 10;
12
+
13
+ /**
14
+ * Schedule a debounced persistence call
15
+ */
16
+ function schedulePersist(fn) {
17
+ if (persistTimeout) clearTimeout(persistTimeout);
18
+ persistTimeout = setTimeout(() => {
19
+ fn();
20
+ persistTimeout = null;
21
+ }, DEBOUNCE_MS);
22
+ }
23
+
24
+ /**
25
+ * Force immediate persistence (clears any pending debounce)
26
+ */
27
+ export function flushPersist(fn) {
28
+ if (persistTimeout) {
29
+ clearTimeout(persistTimeout);
30
+ persistTimeout = null;
31
+ }
32
+ fn();
33
+ }
34
+
35
+ /**
36
+ * Create a proxy that auto-saves on mutation
37
+ * @param {object} data - The raw data object to proxy
38
+ * @param {function} persistFn - Function to call when data changes
39
+ * @param {object} root - Root object for nested proxy tracking (internal)
40
+ * @returns {Proxy} Proxied memory object
41
+ */
42
+ export function createMemoryProxy(data, persistFn, root = null) {
43
+ const handler = {
44
+ get(target, prop) {
45
+ // Return primitives and functions as-is
46
+ if (prop === '_raw') return target;
47
+ if (prop === '_flush') return () => flushPersist(persistFn);
48
+
49
+ const value = target[prop];
50
+
51
+ // Don't proxy internal properties, functions, or primitives
52
+ if (typeof prop === 'symbol') return value;
53
+ if (prop.startsWith('_')) return value;
54
+ if (typeof value === 'function') return value;
55
+ if (value === null || typeof value !== 'object') return value;
56
+
57
+ // Wrap nested objects in proxies for deep observation
58
+ return createMemoryProxy(value, persistFn, root || data);
59
+ },
60
+
61
+ set(target, prop, value) {
62
+ // Skip internal properties (don't trigger persist)
63
+ if (typeof prop === 'symbol' || prop.startsWith('_')) {
64
+ target[prop] = value;
65
+ return true;
66
+ }
67
+
68
+ target[prop] = value;
69
+ schedulePersist(persistFn);
70
+ return true;
71
+ },
72
+
73
+ deleteProperty(target, prop) {
74
+ if (typeof prop === 'symbol' || prop.startsWith('_')) {
75
+ delete target[prop];
76
+ return true;
77
+ }
78
+
79
+ delete target[prop];
80
+ schedulePersist(persistFn);
81
+ return true;
82
+ }
83
+ };
84
+
85
+ return new Proxy(data, handler);
86
+ }
87
+
88
+ export { schedulePersist };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * File: /lib/runtime/parallel.js
3
+ */
4
+
5
+ /**
6
+ * Parallel execution module for running multiple agents concurrently
7
+ */
8
+
9
+ /**
10
+ * Execute multiple agent calls in parallel
11
+ * @param {Promise[]} agentCalls - Array of agent() call promises
12
+ * @returns {Promise<any[]>} Array of results in same order as input
13
+ *
14
+ * @example
15
+ * const [review1, review2] = await parallel([
16
+ * agent('code-review', { file: 'src/a.js' }),
17
+ * agent('code-review', { file: 'src/b.js' })
18
+ * ]);
19
+ */
20
+ export async function parallel(agentCalls) {
21
+ if (!Array.isArray(agentCalls)) {
22
+ throw new Error('parallel() expects an array of promises');
23
+ }
24
+
25
+ if (agentCalls.length === 0) {
26
+ return [];
27
+ }
28
+
29
+ // Promise.all maintains order
30
+ return Promise.all(agentCalls);
31
+ }
32
+
33
+ /**
34
+ * Execute agent calls in parallel with a concurrency limit
35
+ * @param {Promise[]} agentCalls - Array of agent() call promises
36
+ * @param {number} limit - Maximum concurrent executions
37
+ * @returns {Promise<any[]>} Array of results in same order as input
38
+ */
39
+ export async function parallelLimit(agentCalls, limit = 3) {
40
+ if (!Array.isArray(agentCalls)) {
41
+ throw new Error('parallelLimit() expects an array of promises');
42
+ }
43
+
44
+ if (agentCalls.length === 0) {
45
+ return [];
46
+ }
47
+
48
+ const results = new Array(agentCalls.length);
49
+ let currentIndex = 0;
50
+
51
+ async function worker() {
52
+ while (currentIndex < agentCalls.length) {
53
+ const index = currentIndex++;
54
+ results[index] = await agentCalls[index];
55
+ }
56
+ }
57
+
58
+ // Start up to 'limit' workers
59
+ const workers = [];
60
+ for (let i = 0; i < Math.min(limit, agentCalls.length); i++) {
61
+ workers.push(worker());
62
+ }
63
+
64
+ await Promise.all(workers);
65
+ return results;
66
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * File: /lib/runtime/prompt.js
3
+ */
4
+
5
+ /**
6
+ * Initial prompt module for user input collection
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import readline from 'readline';
12
+ import { getCurrentRuntime } from './runtime.js';
13
+
14
+ /**
15
+ * Request user input with memory-based resume support
16
+ * @param {string} question - Question to ask the user
17
+ * @param {object} options - Options
18
+ * @param {string} options.slug - Unique identifier for this prompt (for file)
19
+ * @returns {Promise<string>} User's response
20
+ */
21
+ export async function initialPrompt(question, options = {}) {
22
+ const runtime = getCurrentRuntime();
23
+ if (!runtime) {
24
+ throw new Error('initialPrompt() must be called within a workflow context');
25
+ }
26
+
27
+ const slug = options.slug || generateSlug(question);
28
+ const memoryKey = `_interaction_${slug}`;
29
+
30
+ runtime.prependHistory({
31
+ event: 'PROMPT_REQUESTED',
32
+ slug,
33
+ question
34
+ });
35
+
36
+ // Check if we're in TTY mode (interactive terminal)
37
+ if (process.stdin.isTTY && process.stdout.isTTY) {
38
+ // Interactive mode - prompt directly
39
+ console.log('');
40
+ const answer = await askQuestion(question);
41
+ console.log('');
42
+
43
+ // Save the response to memory
44
+ runtime._rawMemory[memoryKey] = answer;
45
+ runtime.persist();
46
+
47
+ runtime.prependHistory({
48
+ event: 'PROMPT_ANSWERED',
49
+ slug,
50
+ answer: answer.substring(0, 100) + (answer.length > 100 ? '...' : '')
51
+ });
52
+
53
+ return answer;
54
+ }
55
+
56
+ // Non-TTY mode - create interaction file and wait inline
57
+ const interactionFile = path.join(runtime.interactionsDir, `${slug}.md`);
58
+
59
+ const fileContent = `# ${slug}
60
+
61
+ ${question}
62
+
63
+ ---
64
+ Enter your response below:
65
+
66
+ `;
67
+
68
+ fs.writeFileSync(interactionFile, fileContent);
69
+
70
+ runtime.prependHistory({
71
+ event: 'INTERACTION_REQUESTED',
72
+ slug,
73
+ targetKey: memoryKey,
74
+ file: interactionFile
75
+ });
76
+
77
+ // Block and wait for user input (instead of throwing)
78
+ const answer = await runtime.waitForInteraction(interactionFile, slug, memoryKey);
79
+
80
+ runtime._rawMemory[memoryKey] = answer;
81
+ runtime.persist();
82
+
83
+ runtime.prependHistory({
84
+ event: 'PROMPT_ANSWERED',
85
+ slug,
86
+ answer: answer.substring(0, 100) + (answer.length > 100 ? '...' : '')
87
+ });
88
+
89
+ return answer;
90
+ }
91
+
92
+ /**
93
+ * Interactive terminal question
94
+ */
95
+ function askQuestion(question) {
96
+ return new Promise((resolve) => {
97
+ const rl = readline.createInterface({
98
+ input: process.stdin,
99
+ output: process.stdout
100
+ });
101
+
102
+ rl.question(`${question}\n> `, (answer) => {
103
+ rl.close();
104
+ resolve(answer.trim());
105
+ });
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Generate a slug from question text
111
+ */
112
+ function generateSlug(question) {
113
+ return question
114
+ .toLowerCase()
115
+ .replace(/[^a-z0-9]+/g, '-')
116
+ .replace(/^-+|-+$/g, '')
117
+ .substring(0, 30);
118
+ }