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/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
+ };