agentproc 0.1.1 → 0.3.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/package.json +8 -5
- package/src/cli.js +633 -0
- package/src/hub.js +310 -0
- package/src/hub.test.js +327 -0
- package/src/runner.js +395 -0
- package/src/runner.test.js +439 -0
package/src/runner.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* AgentProc runner — the core engine that turns a profile + message into a
|
|
4
|
+
* protocol-compliant agent invocation.
|
|
5
|
+
*
|
|
6
|
+
* This module is the canonical implementation of the AgentProc bridge-side
|
|
7
|
+
* contract (spec/protocol.md). The CLI (cli.js) is a thin wrapper around it.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - Parse and validate a profile object
|
|
11
|
+
* - Substitute {{MESSAGE}}, {{SESSION_ID}}, {{SESSION_NAME}} placeholders
|
|
12
|
+
* - Inject AGENT_* env vars + profile env block
|
|
13
|
+
* - Spawn the agent command (no shell)
|
|
14
|
+
* - Read stdout line by line, classify protocol lines vs reply body
|
|
15
|
+
* - Forward AGENT_PARTIAL: in real time (via onPartial callback)
|
|
16
|
+
* - Capture the last AGENT_SESSION: line (last-wins rule)
|
|
17
|
+
* - Honor AGENT_ERROR: lines
|
|
18
|
+
* - Enforce timeout_secs with SIGTERM → kill_grace_secs → SIGKILL
|
|
19
|
+
* - Write message to stdin and close (when profile.stdin === 'message')
|
|
20
|
+
* - Return { reply, sessionId, error, exitCode }
|
|
21
|
+
*
|
|
22
|
+
* Exports:
|
|
23
|
+
* run(profile, options) -> Promise<RunResult>
|
|
24
|
+
* parseProfileYaml(yamlString) -> Profile
|
|
25
|
+
* classifyLine(line) -> { kind: 'session'|'partial'|'error'|'body', value }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { spawn } = require('node:child_process');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const os = require('node:os');
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Constants
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const PROTOCOL_VERSION = '0.1';
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TIMEOUT_SECS = 1800;
|
|
39
|
+
const DEFAULT_KILL_GRACE_SECS = 5;
|
|
40
|
+
const DEFAULT_MAX_REPLY_CHARS = 8000;
|
|
41
|
+
|
|
42
|
+
const PREFIX_SESSION = 'AGENT_SESSION:';
|
|
43
|
+
const PREFIX_PARTIAL = 'AGENT_PARTIAL:';
|
|
44
|
+
const PREFIX_ERROR = 'AGENT_ERROR:';
|
|
45
|
+
|
|
46
|
+
// Exit codes per spec
|
|
47
|
+
const EXIT_SUCCESS = 0;
|
|
48
|
+
const EXIT_ERROR = 1;
|
|
49
|
+
const EXIT_TIMEOUT = 124;
|
|
50
|
+
const EXIT_SIGINT = 130;
|
|
51
|
+
const EXIT_SIGTERM = 143;
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Profile parsing & validation
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate and normalize a profile object (the `agentproc:` block from YAML,
|
|
59
|
+
* already extracted by the caller — or a top-level profile for spec compatibility).
|
|
60
|
+
*
|
|
61
|
+
* @param {object} raw - Profile object.
|
|
62
|
+
* @returns {object} Normalized profile.
|
|
63
|
+
* @throws {Error} if required fields are missing or invalid.
|
|
64
|
+
*/
|
|
65
|
+
function normalizeProfile(raw) {
|
|
66
|
+
if (!raw || typeof raw !== 'object') {
|
|
67
|
+
throw new Error('profile must be an object');
|
|
68
|
+
}
|
|
69
|
+
// Accept either a top-level profile (spec form: command, args, ...)
|
|
70
|
+
// or a hub form ({ agentproc: { command, ... } }).
|
|
71
|
+
const p = raw.agentproc ? { ...raw.agentproc } : { ...raw };
|
|
72
|
+
|
|
73
|
+
if (typeof p.command !== 'string' || p.command.trim() === '') {
|
|
74
|
+
throw new Error('profile.command must be a non-empty string');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Split command into argv on whitespace, no shell (per spec).
|
|
78
|
+
const argv = p.command.trim().split(/\s+/);
|
|
79
|
+
if (argv.length === 0) {
|
|
80
|
+
throw new Error('profile.command produced empty argv');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
argv,
|
|
85
|
+
args: Array.isArray(p.args) ? p.args.map(String) : [],
|
|
86
|
+
cwd: p.cwd ? expandPath(String(p.cwd)) : undefined,
|
|
87
|
+
env: p.env && typeof p.env === 'object' ? p.env : {},
|
|
88
|
+
stdin: p.stdin === 'message' ? 'message' : 'none',
|
|
89
|
+
timeout_secs: Number.isFinite(p.timeout_secs) ? p.timeout_secs : DEFAULT_TIMEOUT_SECS,
|
|
90
|
+
kill_grace_secs: Number.isFinite(p.kill_grace_secs) ? p.kill_grace_secs : DEFAULT_KILL_GRACE_SECS,
|
|
91
|
+
max_reply_chars: Number.isFinite(p.max_reply_chars) ? p.max_reply_chars : DEFAULT_MAX_REPLY_CHARS,
|
|
92
|
+
streaming: p.streaming !== false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function expandPath(p) {
|
|
97
|
+
if (p === '~') return os.homedir();
|
|
98
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
99
|
+
return p;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Substitute {{MESSAGE}}, {{SESSION_ID}}, {{SESSION_NAME}} placeholders
|
|
104
|
+
* in a string value. Per spec, no shell is involved.
|
|
105
|
+
*/
|
|
106
|
+
function substitute(value, ctx) {
|
|
107
|
+
return String(value)
|
|
108
|
+
.replace(/\{\{MESSAGE\}\}/g, ctx.message || '')
|
|
109
|
+
.replace(/\{\{SESSION_ID\}\}/g, ctx.sessionId || '')
|
|
110
|
+
.replace(/\{\{SESSION_NAME\}\}/g, ctx.sessionName || '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Expand ${VAR} references against process.env, like a typical shell would.
|
|
115
|
+
* Unknown variables expand to empty string (POSIX sh behavior).
|
|
116
|
+
*/
|
|
117
|
+
function expandEnvRef(value, env) {
|
|
118
|
+
return String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
|
|
119
|
+
const v = env[name];
|
|
120
|
+
return v !== undefined ? v : '';
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Line classification (per spec)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Try to JSON-decode a value after a prefix.
|
|
130
|
+
* Lenient mode (default per spec): on failure, return the raw text.
|
|
131
|
+
*/
|
|
132
|
+
function decodeJsonValue(raw) {
|
|
133
|
+
const text = raw.trim();
|
|
134
|
+
if (text === '') return '';
|
|
135
|
+
try {
|
|
136
|
+
const v = JSON.parse(text);
|
|
137
|
+
return typeof v === 'string' ? v : String(v);
|
|
138
|
+
} catch {
|
|
139
|
+
// Lenient: treat as plain string.
|
|
140
|
+
return text;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Classify one stdout line.
|
|
146
|
+
* @param {string} line - Raw line, without trailing newline.
|
|
147
|
+
* @returns {{ kind: string, value: string }}
|
|
148
|
+
* kind is 'session' | 'partial' | 'error' | 'body'.
|
|
149
|
+
*/
|
|
150
|
+
function classifyLine(line) {
|
|
151
|
+
// Per spec: bridges MAY match against the stripped line to be tolerant
|
|
152
|
+
// of leading whitespace from heredocs. We match the raw line — agents
|
|
153
|
+
// that want their text to NOT be a protocol line should prefix with space.
|
|
154
|
+
if (line.startsWith(PREFIX_SESSION)) {
|
|
155
|
+
return { kind: 'session', value: line.slice(PREFIX_SESSION.length).trim() };
|
|
156
|
+
}
|
|
157
|
+
if (line.startsWith(PREFIX_PARTIAL)) {
|
|
158
|
+
return { kind: 'partial', value: decodeJsonValue(line.slice(PREFIX_PARTIAL.length)) };
|
|
159
|
+
}
|
|
160
|
+
if (line.startsWith(PREFIX_ERROR)) {
|
|
161
|
+
return { kind: 'error', value: decodeJsonValue(line.slice(PREFIX_ERROR.length)) };
|
|
162
|
+
}
|
|
163
|
+
return { kind: 'body', value: line };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// run() — the main entry point
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @typedef {Object} RunOptions
|
|
172
|
+
* @property {string} message - User message (required).
|
|
173
|
+
* @property {string} [sessionId] - Session id from the previous turn (empty = new).
|
|
174
|
+
* @property {string} [sessionName] - Human-readable session name.
|
|
175
|
+
* @property {string} [fromUser] - Sender identifier.
|
|
176
|
+
* @property {boolean} [streaming] - Override profile.streaming.
|
|
177
|
+
* @property {Object<string, string>} [extraEnv] - Additional env vars (CLI --env).
|
|
178
|
+
* @property {string} [cwd] - Override profile.cwd (CLI --cwd).
|
|
179
|
+
* @property {number} [timeoutSecs] - Override profile.timeout_secs (CLI --timeout).
|
|
180
|
+
* @property {function(string): void} [onPartial] - Streaming callback.
|
|
181
|
+
* @property {function(string): void} [onSession] - Called when session id captured.
|
|
182
|
+
* @property {function(string): void} [onError] - Called on AGENT_ERROR:.
|
|
183
|
+
* @property {function(string): void} [onProtocolLine] - Raw protocol line (verbose/debug).
|
|
184
|
+
* @property {function(string): void} [onStderr] - Agent's stderr line.
|
|
185
|
+
* @property {boolean} [forwardStdin] - If true, write message to stdin (override profile.stdin).
|
|
186
|
+
*/
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @typedef {Object} RunResult
|
|
190
|
+
* @property {string} reply - Concatenated reply body (non-protocol lines).
|
|
191
|
+
* @property {string} sessionId - Final session id (last AGENT_SESSION: wins; '' if none).
|
|
192
|
+
* @property {string} error - Error message from AGENT_ERROR:, or '' if none.
|
|
193
|
+
* @property {number} exitCode - Process exit code (124 = timeout, etc.).
|
|
194
|
+
* @property {boolean} timedOut - Whether the run was killed by timeout.
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Run an agent process per the AgentProc spec.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} profileRaw - The profile (top-level form or hub `agentproc:` form).
|
|
201
|
+
* @param {RunOptions} options
|
|
202
|
+
* @returns {Promise<RunResult>}
|
|
203
|
+
*/
|
|
204
|
+
async function run(profileRaw, options) {
|
|
205
|
+
if (!options || typeof options.message !== 'string') {
|
|
206
|
+
throw new Error('options.message is required');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const profile = normalizeProfile(profileRaw);
|
|
210
|
+
const sessionId = options.sessionId || '';
|
|
211
|
+
const sessionName = options.sessionName || 'default';
|
|
212
|
+
const streaming = options.streaming !== undefined ? !!options.streaming : profile.streaming;
|
|
213
|
+
const timeoutSecs = options.timeoutSecs !== undefined ? options.timeoutSecs : profile.timeout_secs;
|
|
214
|
+
const cwd = options.cwd || profile.cwd;
|
|
215
|
+
|
|
216
|
+
// Build the substitution context for {{MESSAGE}} etc.
|
|
217
|
+
const substCtx = {
|
|
218
|
+
message: options.message,
|
|
219
|
+
sessionId,
|
|
220
|
+
sessionName,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Build argv: command + args (with placeholders substituted).
|
|
224
|
+
const argv = [...profile.argv];
|
|
225
|
+
for (const a of profile.args) {
|
|
226
|
+
argv.push(substitute(a, substCtx));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Build env: start with process.env (so PATH etc. work), add profile.env
|
|
230
|
+
// (with ${VAR} refs expanded against process.env), then add AGENT_* vars.
|
|
231
|
+
const env = { ...process.env };
|
|
232
|
+
for (const [k, v] of Object.entries(profile.env)) {
|
|
233
|
+
env[k] = expandEnvRef(substitute(v, substCtx), process.env);
|
|
234
|
+
}
|
|
235
|
+
if (options.extraEnv) {
|
|
236
|
+
for (const [k, v] of Object.entries(options.extraEnv)) {
|
|
237
|
+
env[k] = String(v);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Inject AGENT_* vars per spec.
|
|
242
|
+
env.AGENT_MESSAGE = options.message;
|
|
243
|
+
env.AGENT_SESSION_ID = sessionId;
|
|
244
|
+
env.AGENT_SESSION_NAME = sessionName;
|
|
245
|
+
env.AGENT_FROM_USER = options.fromUser || '';
|
|
246
|
+
env.AGENT_STREAMING = streaming ? '1' : '0';
|
|
247
|
+
env.AGENT_PROTOCOL_VERSION = PROTOCOL_VERSION;
|
|
248
|
+
|
|
249
|
+
// Spawn — no shell. Cwd optional.
|
|
250
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
251
|
+
cwd,
|
|
252
|
+
env,
|
|
253
|
+
stdio: [
|
|
254
|
+
profile.stdin === 'message' || options.forwardStdin ? 'pipe' : 'ignore',
|
|
255
|
+
'pipe',
|
|
256
|
+
'pipe',
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/** @type {RunResult} */
|
|
261
|
+
const result = {
|
|
262
|
+
reply: '',
|
|
263
|
+
sessionId: '',
|
|
264
|
+
error: '',
|
|
265
|
+
exitCode: 0,
|
|
266
|
+
timedOut: false,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const bodyLines = [];
|
|
270
|
+
let killed = false;
|
|
271
|
+
|
|
272
|
+
// ---- stdout: line-by-line classification ----
|
|
273
|
+
let stdoutBuf = '';
|
|
274
|
+
child.stdout.on('data', chunk => {
|
|
275
|
+
stdoutBuf += chunk.toString();
|
|
276
|
+
let nl;
|
|
277
|
+
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
|
|
278
|
+
const rawLine = stdoutBuf.slice(0, nl);
|
|
279
|
+
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
280
|
+
handleLine(rawLine);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
function handleLine(rawLine) {
|
|
285
|
+
// Strip a trailing \r (CRLF tolerance) but otherwise treat raw.
|
|
286
|
+
const line = rawLine.replace(/\r$/, '');
|
|
287
|
+
const c = classifyLine(line);
|
|
288
|
+
if (c.kind === 'session') {
|
|
289
|
+
result.sessionId = c.value; // last wins
|
|
290
|
+
if (options.onSession) options.onSession(c.value);
|
|
291
|
+
if (options.onProtocolLine) options.onProtocolLine(line);
|
|
292
|
+
} else if (c.kind === 'partial') {
|
|
293
|
+
if (streaming && options.onPartial) options.onPartial(c.value);
|
|
294
|
+
if (options.onProtocolLine) options.onProtocolLine(line);
|
|
295
|
+
} else if (c.kind === 'error') {
|
|
296
|
+
result.error = c.value;
|
|
297
|
+
if (options.onError) options.onError(c.value);
|
|
298
|
+
if (options.onProtocolLine) options.onProtocolLine(line);
|
|
299
|
+
} else {
|
|
300
|
+
bodyLines.push(line);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- stderr: forward as debug ----
|
|
305
|
+
let stderrBuf = '';
|
|
306
|
+
child.stderr.on('data', chunk => {
|
|
307
|
+
stderrBuf += chunk.toString();
|
|
308
|
+
let nl;
|
|
309
|
+
while ((nl = stderrBuf.indexOf('\n')) >= 0) {
|
|
310
|
+
const line = stderrBuf.slice(0, nl);
|
|
311
|
+
stderrBuf = stderrBuf.slice(nl + 1);
|
|
312
|
+
if (options.onStderr) options.onStderr(line.replace(/\r$/, ''));
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ---- stdin (if requested) ----
|
|
317
|
+
if (profile.stdin === 'message' || options.forwardStdin) {
|
|
318
|
+
child.stdin.write(options.message);
|
|
319
|
+
child.stdin.end();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- timeout handling per spec: SIGTERM → grace → SIGKILL ----
|
|
323
|
+
let timer = null;
|
|
324
|
+
if (timeoutSecs > 0) {
|
|
325
|
+
timer = setTimeout(() => {
|
|
326
|
+
killed = true;
|
|
327
|
+
result.timedOut = true;
|
|
328
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
329
|
+
setTimeout(() => {
|
|
330
|
+
try {
|
|
331
|
+
if (!child.exitCode && child.signalCode === null) {
|
|
332
|
+
child.kill('SIGKILL');
|
|
333
|
+
}
|
|
334
|
+
} catch {}
|
|
335
|
+
}, (profile.kill_grace_secs || DEFAULT_KILL_GRACE_SECS) * 1000);
|
|
336
|
+
}, timeoutSecs * 1000);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- wait for exit ----
|
|
340
|
+
const exitCode = await new Promise(resolve => {
|
|
341
|
+
child.on('close', code => resolve(code));
|
|
342
|
+
child.on('error', err => {
|
|
343
|
+
// spawn error (ENOENT etc.)
|
|
344
|
+
if (options.onStderr) options.onStderr(`[agentproc runner] spawn error: ${err.message}`);
|
|
345
|
+
resolve(EXIT_ERROR);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (timer) clearTimeout(timer);
|
|
350
|
+
|
|
351
|
+
// Flush any remaining stdout buffer as a final line (without trailing \n).
|
|
352
|
+
if (stdoutBuf.length > 0) {
|
|
353
|
+
handleLine(stdoutBuf.replace(/\r$/, ''));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Compose reply body.
|
|
357
|
+
result.reply = bodyLines.join('\n');
|
|
358
|
+
if (result.reply.length > profile.max_reply_chars) {
|
|
359
|
+
const suffix = profile.max_reply_chars === DEFAULT_MAX_REPLY_CHARS
|
|
360
|
+
? '\n\n…(truncated)'
|
|
361
|
+
: '';
|
|
362
|
+
result.reply = result.reply.slice(0, profile.max_reply_chars) + suffix;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Exit code per spec.
|
|
366
|
+
if (killed) {
|
|
367
|
+
result.exitCode = EXIT_TIMEOUT;
|
|
368
|
+
} else if (result.error) {
|
|
369
|
+
// AGENT_ERROR was emitted — treat as failure even if exit was 0.
|
|
370
|
+
result.exitCode = exitCode === 0 ? EXIT_ERROR : exitCode;
|
|
371
|
+
} else {
|
|
372
|
+
result.exitCode = exitCode;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
module.exports = {
|
|
379
|
+
run,
|
|
380
|
+
normalizeProfile,
|
|
381
|
+
classifyLine,
|
|
382
|
+
decodeJsonValue,
|
|
383
|
+
substitute,
|
|
384
|
+
expandEnvRef,
|
|
385
|
+
expandPath,
|
|
386
|
+
PROTOCOL_VERSION,
|
|
387
|
+
DEFAULT_TIMEOUT_SECS,
|
|
388
|
+
DEFAULT_KILL_GRACE_SECS,
|
|
389
|
+
DEFAULT_MAX_REPLY_CHARS,
|
|
390
|
+
EXIT_SUCCESS,
|
|
391
|
+
EXIT_ERROR,
|
|
392
|
+
EXIT_TIMEOUT,
|
|
393
|
+
EXIT_SIGINT,
|
|
394
|
+
EXIT_SIGTERM,
|
|
395
|
+
};
|