@surlymochan/evalid 0.5.19 → 0.5.21
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/CHANGELOG.md +21 -0
- package/RELEASE_NOTES.md +24 -0
- package/bin/xflow.js +102 -5
- package/docs/evidence/m4-slice4-auto-resume.md +96 -0
- package/docs/launch-dossier.md +1 -1
- package/docs/workflow-manual.md +105 -2
- package/package.json +1 -1
- package/scripts/demo-agent-stream.mjs +119 -0
- package/src/core/agent-stream.js +440 -0
- package/src/core/atoms/agent-invoke.js +82 -31
- package/src/core/audit.js +27 -1
- package/src/core/execution-log.js +178 -18
- package/src/core/lite-track-auto.js +126 -0
- package/src/core/log-tail.js +176 -0
- package/src/core/workflow-executor.js +12 -3
- package/src/server.js +118 -11
- package/xflow/atoms/lite_track.py +46 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-stream — turns coding-agent CLI output into execution-log events.
|
|
3
|
+
*
|
|
4
|
+
* `agent_invoke` spawns codex/claude with line-delimited JSON (--json / --output-format
|
|
5
|
+
* stream-json). That output used to be buffered into a string and thrown away, which left a
|
|
6
|
+
* 5-to-30-minute black box in the middle of every heavy phase. Here the same bytes are split
|
|
7
|
+
* into lines, normalized to a small event vocabulary, and appended to the hash-chained
|
|
8
|
+
* execution log, which both the CLI and `xflow serve` already read.
|
|
9
|
+
*
|
|
10
|
+
* Vocabulary (deliberately tiny, and aligned with bench/swebench/harness/arms.py):
|
|
11
|
+
* agent_start | tool_start | tool_end | message_end | agent_end | raw
|
|
12
|
+
*
|
|
13
|
+
* Unknown line shapes degrade to `raw` instead of throwing: a CLI version bump must never be
|
|
14
|
+
* able to break a workflow run.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
18
|
+
import { appendExecutionLog } from './execution-log.js';
|
|
19
|
+
|
|
20
|
+
export const AGENT_EVENT_KINDS = Object.freeze(['agent_start', 'tool_start', 'tool_end', 'message_end', 'agent_end', 'raw']);
|
|
21
|
+
export const DEFAULT_MAX_LINE_BYTES = 1 << 20;
|
|
22
|
+
export const DEFAULT_FLUSH_INTERVAL_MS = 200;
|
|
23
|
+
const MAX_TEXT_CHARS = 2048;
|
|
24
|
+
const MAX_PREVIEW_CHARS = 160;
|
|
25
|
+
const MAX_QUEUE_LENGTH = 500;
|
|
26
|
+
// While a line is being dropped, only this much of it is kept: enough to see the newline that
|
|
27
|
+
// ends it, never enough to matter.
|
|
28
|
+
const DROP_TAIL_CHARS = 1024;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split a byte stream into complete lines.
|
|
32
|
+
*
|
|
33
|
+
* `push` accepts Buffers or strings and never emits a partial line; `flush` emits the trailing
|
|
34
|
+
* line before the process goes away. A line larger than `maxLineBytes` is replaced by a single
|
|
35
|
+
* truncation marker so a runaway output cannot grow memory without bound.
|
|
36
|
+
*/
|
|
37
|
+
export function createLineSplitter(onLine, options = {}) {
|
|
38
|
+
const maxLineBytes = Math.max(1024, Number(options.maxLineBytes) || DEFAULT_MAX_LINE_BYTES);
|
|
39
|
+
const decoder = new StringDecoder('utf8');
|
|
40
|
+
let buffer = '';
|
|
41
|
+
let dropping = false;
|
|
42
|
+
|
|
43
|
+
const emit = (line) => {
|
|
44
|
+
const value = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
45
|
+
try {
|
|
46
|
+
onLine(value);
|
|
47
|
+
} catch {
|
|
48
|
+
// A consumer bug must never break the agent pipe.
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const drain = () => {
|
|
53
|
+
let newlineAt = buffer.indexOf('\n');
|
|
54
|
+
while (newlineAt !== -1) {
|
|
55
|
+
const line = buffer.slice(0, newlineAt);
|
|
56
|
+
buffer = buffer.slice(newlineAt + 1);
|
|
57
|
+
if (dropping) {
|
|
58
|
+
dropping = false;
|
|
59
|
+
} else if (line.length > maxLineBytes) {
|
|
60
|
+
emit(JSON.stringify({ type: 'xflow.line_truncated', bytes: line.length }));
|
|
61
|
+
} else {
|
|
62
|
+
emit(line);
|
|
63
|
+
}
|
|
64
|
+
newlineAt = buffer.indexOf('\n');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
push(chunk) {
|
|
70
|
+
if (chunk === undefined || chunk === null) return;
|
|
71
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
|
72
|
+
drain();
|
|
73
|
+
if (buffer.length > maxLineBytes) {
|
|
74
|
+
if (!dropping) {
|
|
75
|
+
emit(JSON.stringify({ type: 'xflow.line_truncated', bytes: buffer.length }));
|
|
76
|
+
dropping = true;
|
|
77
|
+
}
|
|
78
|
+
// The runaway line was already replaced by its marker, so only its tail is kept: without
|
|
79
|
+
// this a stream that never sends a newline grew the buffer without bound.
|
|
80
|
+
buffer = buffer.slice(-DROP_TAIL_CHARS);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
flush() {
|
|
84
|
+
const tail = decoder.end();
|
|
85
|
+
if (tail) buffer += tail;
|
|
86
|
+
drain();
|
|
87
|
+
if (dropping) {
|
|
88
|
+
buffer = '';
|
|
89
|
+
dropping = false;
|
|
90
|
+
}
|
|
91
|
+
if (buffer.length) {
|
|
92
|
+
const line = buffer;
|
|
93
|
+
buffer = '';
|
|
94
|
+
emit(line);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
get pending() {
|
|
98
|
+
return buffer.length;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** First normalized event for a line, or null. Kept for one-line CLIs and simple callers. */
|
|
104
|
+
export function normalizeAgentEvent(raw, options = {}) {
|
|
105
|
+
const events = normalizeAgentEvents(raw, options);
|
|
106
|
+
return events.length ? events[0] : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every normalized event carried by one CLI line (a Claude message can hold text and tool calls). */
|
|
110
|
+
export function normalizeAgentEvents(raw, options = {}) {
|
|
111
|
+
const adapter = options.adapter || 'unknown';
|
|
112
|
+
const seq = Number(options.seq) || 0;
|
|
113
|
+
const text = String(raw ?? '').trim();
|
|
114
|
+
if (!text) return [];
|
|
115
|
+
|
|
116
|
+
let payload;
|
|
117
|
+
try {
|
|
118
|
+
payload = JSON.parse(text);
|
|
119
|
+
} catch {
|
|
120
|
+
return [{ seq, event: 'raw', reason: 'invalid_json', text: truncateText(text) }];
|
|
121
|
+
}
|
|
122
|
+
if (!payload || typeof payload !== 'object') {
|
|
123
|
+
return [{ seq, event: 'raw', reason: 'non_object_payload', text: truncateText(text) }];
|
|
124
|
+
}
|
|
125
|
+
if (payload.type === 'xflow.line_truncated') {
|
|
126
|
+
return [{ seq, event: 'raw', reason: 'line_truncated', truncated: true, text: String(payload.bytes || 0) }];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const family = detectFamily(payload, adapter);
|
|
130
|
+
const mapped = (family === 'claude' ? mapClaudePayload : mapCodexPayload)(payload)
|
|
131
|
+
.filter((event) => event && event.event);
|
|
132
|
+
if (!mapped.length) return [];
|
|
133
|
+
return mapped.map((event) => ({ seq, ...event }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function detectFamily(payload, adapter) {
|
|
137
|
+
if (adapter === 'claude_cli') return 'claude';
|
|
138
|
+
if (adapter === 'codex_cli') return 'codex';
|
|
139
|
+
const type = String(payload.type || '');
|
|
140
|
+
if (['system', 'assistant', 'result', 'user', 'stream_event'].includes(type)) return 'claude';
|
|
141
|
+
return 'codex';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mapCodexPayload(payload) {
|
|
145
|
+
const type = String(payload.type || '');
|
|
146
|
+
switch (type) {
|
|
147
|
+
case 'thread.started':
|
|
148
|
+
return [{ event: 'agent_start', event_type: type }];
|
|
149
|
+
case 'item.started':
|
|
150
|
+
return mapCodexItem(payload.item, 'start');
|
|
151
|
+
case 'item.completed':
|
|
152
|
+
return mapCodexItem(payload.item, 'end');
|
|
153
|
+
case 'turn.completed':
|
|
154
|
+
return [{ event: 'agent_end', event_type: type, usage: readCodexUsage(payload.usage) }];
|
|
155
|
+
case 'turn.failed':
|
|
156
|
+
case 'error':
|
|
157
|
+
return [{ event: 'raw', event_type: type, level: 'error', text: truncateText(errorText(payload)) }];
|
|
158
|
+
default:
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function mapCodexItem(item, phase) {
|
|
164
|
+
if (!item || typeof item !== 'object') return [];
|
|
165
|
+
const itemType = String(item.type || '');
|
|
166
|
+
if (itemType === 'agent_message') {
|
|
167
|
+
return phase === 'end' ? [{ event: 'message_end', event_type: itemType, text: truncateText(item.text) }] : [];
|
|
168
|
+
}
|
|
169
|
+
if (itemType === 'reasoning') {
|
|
170
|
+
// Model reasoning is not progress a human can act on, and it is the largest payload.
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
if (itemType === 'command_execution') {
|
|
174
|
+
if (phase === 'start') {
|
|
175
|
+
const command = Array.isArray(item.command) ? item.command.join(' ') : String(item.command || '');
|
|
176
|
+
return [toolStart('shell', command, itemType)];
|
|
177
|
+
}
|
|
178
|
+
return [toolEnd('shell', itemType, item.exit_code, item.status)];
|
|
179
|
+
}
|
|
180
|
+
if (itemType === 'mcp_tool_call') {
|
|
181
|
+
const tool = String(item.name || item.tool || 'mcp_tool');
|
|
182
|
+
if (phase === 'start') return [toolStart(tool, stringifyArgs(item.arguments ?? item.input ?? item.args), itemType)];
|
|
183
|
+
return [toolEnd(tool, itemType, item.exit_code, item.status)];
|
|
184
|
+
}
|
|
185
|
+
if (itemType === 'file_change') {
|
|
186
|
+
const paths = Array.isArray(item.changes)
|
|
187
|
+
? item.changes.map((change) => change && change.path).filter(Boolean).join(', ')
|
|
188
|
+
: String(item.path || '');
|
|
189
|
+
if (phase === 'start') return [toolStart('file_change', paths, itemType)];
|
|
190
|
+
return [toolEnd('file_change', itemType, item.exit_code, item.status)];
|
|
191
|
+
}
|
|
192
|
+
if (itemType === 'web_search') {
|
|
193
|
+
return phase === 'start'
|
|
194
|
+
? [toolStart('web_search', String(item.query || ''), itemType)]
|
|
195
|
+
: [toolEnd('web_search', itemType, null, item.status)];
|
|
196
|
+
}
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function mapClaudePayload(payload) {
|
|
201
|
+
const type = String(payload.type || '');
|
|
202
|
+
if (type === 'system') {
|
|
203
|
+
return payload.subtype === 'init' ? [{ event: 'agent_start', event_type: 'system:init' }] : [];
|
|
204
|
+
}
|
|
205
|
+
if (type === 'assistant') {
|
|
206
|
+
const content = Array.isArray(payload.message?.content) ? payload.message.content : [];
|
|
207
|
+
const events = [];
|
|
208
|
+
let pendingText = [];
|
|
209
|
+
const flushText = () => {
|
|
210
|
+
if (!pendingText.length) return;
|
|
211
|
+
events.push({ event: 'message_end', event_type: 'assistant_text', text: truncateText(pendingText.join('\n')) });
|
|
212
|
+
pendingText = [];
|
|
213
|
+
};
|
|
214
|
+
for (const block of content) {
|
|
215
|
+
if (!block || typeof block !== 'object') continue;
|
|
216
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
217
|
+
pendingText.push(block.text);
|
|
218
|
+
} else if (block.type === 'tool_use') {
|
|
219
|
+
// Preserve the order the model produced: text first, then the tool call it announced.
|
|
220
|
+
flushText();
|
|
221
|
+
events.push(toolStart(String(block.name || 'tool'), stringifyArgs(block.input), 'tool_use'));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
flushText();
|
|
225
|
+
return events;
|
|
226
|
+
}
|
|
227
|
+
if (type === 'user') {
|
|
228
|
+
// Tool results echo output the tool_start/tool_end pair already accounted for.
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
if (type === 'result') {
|
|
232
|
+
return [{
|
|
233
|
+
event: 'agent_end',
|
|
234
|
+
event_type: 'result:' + String(payload.subtype || 'success'),
|
|
235
|
+
usage: readClaudeUsage(payload.usage),
|
|
236
|
+
cost_usd: finiteOrNull(payload.total_cost_usd),
|
|
237
|
+
duration_ms: finiteOrNull(payload.duration_ms),
|
|
238
|
+
}];
|
|
239
|
+
}
|
|
240
|
+
if (type === 'stream_event') {
|
|
241
|
+
// Token-level deltas are out of scope; the finished message arrives as an assistant line.
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
if (type === 'error') {
|
|
245
|
+
return [{ event: 'raw', event_type: 'error', level: 'error', text: truncateText(errorText(payload)) }];
|
|
246
|
+
}
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function toolStart(tool, argsText, eventType) {
|
|
251
|
+
const text = typeof argsText === 'string' ? argsText : '';
|
|
252
|
+
return {
|
|
253
|
+
event: 'tool_start',
|
|
254
|
+
event_type: eventType,
|
|
255
|
+
tool,
|
|
256
|
+
args_preview: text ? truncateText(text, MAX_PREVIEW_CHARS) : null,
|
|
257
|
+
args_digest: text ? digest(text) : null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function toolEnd(tool, eventType, exitCode, status) {
|
|
262
|
+
return {
|
|
263
|
+
event: 'tool_end',
|
|
264
|
+
event_type: eventType,
|
|
265
|
+
tool,
|
|
266
|
+
exit_code: finiteOrNull(exitCode),
|
|
267
|
+
status: status ? String(status) : null,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function readCodexUsage(usage) {
|
|
272
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
273
|
+
return compactUsage({
|
|
274
|
+
input: usage.input_tokens,
|
|
275
|
+
output: usage.output_tokens,
|
|
276
|
+
cache_read: usage.cached_input_tokens,
|
|
277
|
+
cache_write: usage.cache_creation_input_tokens,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readClaudeUsage(usage) {
|
|
282
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
283
|
+
return compactUsage({
|
|
284
|
+
input: usage.input_tokens,
|
|
285
|
+
output: usage.output_tokens,
|
|
286
|
+
cache_read: usage.cache_read_input_tokens,
|
|
287
|
+
cache_write: usage.cache_creation_input_tokens,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function compactUsage(raw) {
|
|
292
|
+
const usage = {};
|
|
293
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
294
|
+
const number = Number(value);
|
|
295
|
+
if (Number.isFinite(number) && number >= 0) usage[key] = number;
|
|
296
|
+
}
|
|
297
|
+
return Object.keys(usage).length ? usage : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Collect normalized events from one agent process and append them to the execution log.
|
|
302
|
+
*
|
|
303
|
+
* Writes are batched on a short timer so a chatty CLI produces kilobytes, not megabytes, and a
|
|
304
|
+
* write failure is counted in `stats.write_errors` rather than surfaced to the agent run.
|
|
305
|
+
*/
|
|
306
|
+
export function createAgentEventSink(options = {}) {
|
|
307
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
308
|
+
const changeId = options.changeId || null;
|
|
309
|
+
const missionId = options.missionId || null;
|
|
310
|
+
const phase = typeof options.phase === 'string' ? options.phase : (options.phase?.id || null);
|
|
311
|
+
const atom = options.atom || null;
|
|
312
|
+
const adapter = options.adapter || 'unknown';
|
|
313
|
+
const flushIntervalMs = Math.max(0, Number(options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS));
|
|
314
|
+
const maxQueue = Math.max(1, Number(options.maxQueue) || MAX_QUEUE_LENGTH);
|
|
315
|
+
|
|
316
|
+
const queue = [];
|
|
317
|
+
const stats = { lines: 0, events: 0, written: 0, dropped: 0, truncated: 0, write_errors: 0 };
|
|
318
|
+
let seq = 0;
|
|
319
|
+
let timer = null;
|
|
320
|
+
let writing = Promise.resolve();
|
|
321
|
+
let closed = false;
|
|
322
|
+
|
|
323
|
+
const splitter = createLineSplitter((line) => {
|
|
324
|
+
if (closed || !line) return;
|
|
325
|
+
stats.lines += 1;
|
|
326
|
+
for (const normalized of normalizeAgentEvents(line, { adapter, seq })) {
|
|
327
|
+
seq += 1;
|
|
328
|
+
stats.events += 1;
|
|
329
|
+
if (normalized.truncated) stats.truncated += 1;
|
|
330
|
+
queue.push(buildRecord(normalized, seq));
|
|
331
|
+
if (queue.length > maxQueue) {
|
|
332
|
+
queue.shift();
|
|
333
|
+
stats.dropped += 1;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
schedule();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
function buildRecord(event, eventSeq) {
|
|
340
|
+
const record = {
|
|
341
|
+
kind: 'agent_event',
|
|
342
|
+
event: event.event,
|
|
343
|
+
phase,
|
|
344
|
+
atom,
|
|
345
|
+
adapter,
|
|
346
|
+
seq: eventSeq,
|
|
347
|
+
};
|
|
348
|
+
for (const key of [
|
|
349
|
+
'event_type', 'tool', 'text', 'args_preview', 'args_digest', 'usage',
|
|
350
|
+
'exit_code', 'status', 'level', 'cost_usd', 'duration_ms', 'reason', 'truncated',
|
|
351
|
+
]) {
|
|
352
|
+
const value = event[key];
|
|
353
|
+
if (value === undefined || value === null) continue;
|
|
354
|
+
record[key] = value;
|
|
355
|
+
}
|
|
356
|
+
if (changeId) record.change_id = changeId;
|
|
357
|
+
if (missionId) record.mission_id = missionId;
|
|
358
|
+
return record;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function schedule() {
|
|
362
|
+
if (timer || !queue.length || closed) return;
|
|
363
|
+
timer = setTimeout(() => {
|
|
364
|
+
timer = null;
|
|
365
|
+
flush().catch(() => {});
|
|
366
|
+
}, flushIntervalMs);
|
|
367
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function flush() {
|
|
371
|
+
if (timer) {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
timer = null;
|
|
374
|
+
}
|
|
375
|
+
const batch = queue.splice(0, queue.length);
|
|
376
|
+
if (!batch.length) return writing;
|
|
377
|
+
writing = writing.then(async () => {
|
|
378
|
+
for (const record of batch) {
|
|
379
|
+
try {
|
|
380
|
+
await appendExecutionLog(projectRoot, record);
|
|
381
|
+
stats.written += 1;
|
|
382
|
+
} catch {
|
|
383
|
+
stats.write_errors += 1;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
return writing;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
push(chunk) {
|
|
392
|
+
if (closed) return;
|
|
393
|
+
splitter.push(chunk);
|
|
394
|
+
},
|
|
395
|
+
/** Write the trailing partial line, then every queued event. Safe to call twice. */
|
|
396
|
+
async close() {
|
|
397
|
+
splitter.flush();
|
|
398
|
+
closed = true;
|
|
399
|
+
await flush();
|
|
400
|
+
return stats;
|
|
401
|
+
},
|
|
402
|
+
flush,
|
|
403
|
+
stats,
|
|
404
|
+
get pending() {
|
|
405
|
+
return queue.length;
|
|
406
|
+
},
|
|
407
|
+
get closed() {
|
|
408
|
+
return closed;
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function digest(value) {
|
|
414
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function errorText(payload) {
|
|
418
|
+
return String(payload.message || payload.error?.message || payload.error || payload.type || 'agent error');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function finiteOrNull(value) {
|
|
422
|
+
const number = Number(value);
|
|
423
|
+
return Number.isFinite(number) ? number : null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function stringifyArgs(value) {
|
|
427
|
+
if (value === undefined || value === null) return '';
|
|
428
|
+
if (typeof value === 'string') return value;
|
|
429
|
+
try {
|
|
430
|
+
return JSON.stringify(value);
|
|
431
|
+
} catch {
|
|
432
|
+
return '';
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function truncateText(value, maxChars = MAX_TEXT_CHARS) {
|
|
437
|
+
const text = typeof value === 'string' ? value : String(value ?? '');
|
|
438
|
+
if (text.length <= maxChars) return text;
|
|
439
|
+
return `${text.slice(0, maxChars)}…`;
|
|
440
|
+
}
|
|
@@ -10,6 +10,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, statS
|
|
|
10
10
|
import { resolve, dirname } from 'node:path';
|
|
11
11
|
import { spawn, spawnSync } from 'node:child_process';
|
|
12
12
|
import { persistSyntheticPhaseRun, defaultVerdictForPhase } from '../../agent_team/common.js';
|
|
13
|
+
import { createAgentEventSink } from '../agent-stream.js';
|
|
13
14
|
import { resolveChangePath, writeJsonFile, writeTextFile } from '../change-artifacts.js';
|
|
14
15
|
|
|
15
16
|
const ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
|
@@ -65,6 +66,12 @@ export default async function agentInvoke(args, context) {
|
|
|
65
66
|
changeDir,
|
|
66
67
|
expectedArtifact: artifactPath,
|
|
67
68
|
model: agentCfg.model || process.env.XFLOW_CODEX_MODEL || DEFAULT_CODEX_MODEL,
|
|
69
|
+
projectRoot,
|
|
70
|
+
changeId,
|
|
71
|
+
phaseId: phase?.id || null,
|
|
72
|
+
atomId: taskPackage.atom_id,
|
|
73
|
+
missionId: context.runtime?.missionId || null,
|
|
74
|
+
adapterName: 'codex_cli',
|
|
68
75
|
});
|
|
69
76
|
return {
|
|
70
77
|
...result,
|
|
@@ -82,6 +89,12 @@ export default async function agentInvoke(args, context) {
|
|
|
82
89
|
retryDelayMs,
|
|
83
90
|
changeDir,
|
|
84
91
|
expectedArtifact: artifactPath,
|
|
92
|
+
projectRoot,
|
|
93
|
+
changeId,
|
|
94
|
+
phaseId: phase?.id || null,
|
|
95
|
+
atomId: taskPackage.atom_id,
|
|
96
|
+
missionId: context.runtime?.missionId || null,
|
|
97
|
+
adapterName: 'claude_cli',
|
|
85
98
|
});
|
|
86
99
|
return {
|
|
87
100
|
...result,
|
|
@@ -258,6 +271,24 @@ function resolveArtifactPath(artifacts, changeId, projectRoot) {
|
|
|
258
271
|
return resolve(projectRoot, 'specs', 'changes', changeId, 'artifact.json');
|
|
259
272
|
}
|
|
260
273
|
|
|
274
|
+
// Progress telemetry is optional: a project root is only absent when an atom is invoked
|
|
275
|
+
// directly by a test harness, and in that case the run must still work without a log.
|
|
276
|
+
function createAgentStreamSink(opts) {
|
|
277
|
+
if (!opts.projectRoot) return null;
|
|
278
|
+
try {
|
|
279
|
+
return createAgentEventSink({
|
|
280
|
+
projectRoot: opts.projectRoot,
|
|
281
|
+
changeId: opts.changeId || null,
|
|
282
|
+
missionId: opts.missionId || null,
|
|
283
|
+
phase: opts.phaseId || null,
|
|
284
|
+
atom: opts.atomId || null,
|
|
285
|
+
adapter: opts.adapterName || 'unknown',
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
261
292
|
async function runProc(cmd, args, opts) {
|
|
262
293
|
return new Promise((resolve, reject) => {
|
|
263
294
|
const child = spawn(cmd, args, {
|
|
@@ -274,7 +305,16 @@ async function runProc(cmd, args, opts) {
|
|
|
274
305
|
let timeoutHandle = null;
|
|
275
306
|
let killHandle = null;
|
|
276
307
|
let artifactPollHandle = null;
|
|
277
|
-
child.stdout.on('data', (d) => {
|
|
308
|
+
child.stdout.on('data', (d) => {
|
|
309
|
+
stdout += d.toString();
|
|
310
|
+
if (opts.onData) {
|
|
311
|
+
try {
|
|
312
|
+
opts.onData(d);
|
|
313
|
+
} catch {
|
|
314
|
+
// Telemetry must never be able to break an agent run.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
278
318
|
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
279
319
|
child.on('error', (error) => {
|
|
280
320
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
@@ -356,41 +396,52 @@ async function runCodexExec(prompt, opts) {
|
|
|
356
396
|
args.push('--model', opts.model);
|
|
357
397
|
}
|
|
358
398
|
args.push(prompt);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
timedOutRecovered =
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
399
|
+
const sink = createAgentStreamSink(opts);
|
|
400
|
+
try {
|
|
401
|
+
let { code, signal, stdout, stderr, timedOut, forceKilled, earlyArtifactExit, artifact } = await runProc('codex', args, { cwd: opts.cwd, timeoutMs: opts.timeoutMs, killAfterMs: opts.killAfterMs, input: '', expectedArtifact: opts.expectedArtifact, onData: sink ? (chunk) => sink.push(chunk) : null });
|
|
402
|
+
const output = artifact || await resolveOutputJson(opts.expectedArtifact, 10_000);
|
|
403
|
+
let timedOutRecovered = false;
|
|
404
|
+
if (output && timedOut) {
|
|
405
|
+
timedOutRecovered = true;
|
|
406
|
+
code = 0;
|
|
407
|
+
signal = null;
|
|
408
|
+
timedOut = false;
|
|
409
|
+
stderr += '\n[agent_invoke] valid artifact detected after timeout; accepting artifact output';
|
|
410
|
+
}
|
|
411
|
+
if (output && earlyArtifactExit) {
|
|
412
|
+
code = 0;
|
|
413
|
+
signal = null;
|
|
414
|
+
}
|
|
415
|
+
return { code, signal, stdout, stderr, artifact: output, timedOut, forceKilled, timedOutRecovered, earlyArtifactExit };
|
|
416
|
+
} finally {
|
|
417
|
+
if (sink) await sink.close().catch(() => {});
|
|
372
418
|
}
|
|
373
|
-
return { code, signal, stdout, stderr, artifact: output, timedOut, forceKilled, timedOutRecovered, earlyArtifactExit };
|
|
374
419
|
}
|
|
375
420
|
|
|
376
421
|
async function runClaudeCli(prompt, opts) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
422
|
+
const sink = createAgentStreamSink(opts);
|
|
423
|
+
try {
|
|
424
|
+
let { code, signal, stdout, stderr, timedOut, forceKilled, earlyArtifactExit, artifact } = await runProc('claude', [
|
|
425
|
+
'-p', '--dangerously-skip-permissions', '--no-session-persistence',
|
|
426
|
+
'--output-format', 'stream-json', '--verbose', prompt,
|
|
427
|
+
], { cwd: opts.cwd, timeoutMs: opts.timeoutMs, killAfterMs: opts.killAfterMs, input: '', expectedArtifact: opts.expectedArtifact, onData: sink ? (chunk) => sink.push(chunk) : null });
|
|
428
|
+
const output = artifact || await resolveOutputJson(opts.expectedArtifact, 10_000);
|
|
429
|
+
let timedOutRecovered = false;
|
|
430
|
+
if (output && timedOut) {
|
|
431
|
+
timedOutRecovered = true;
|
|
432
|
+
code = 0;
|
|
433
|
+
signal = null;
|
|
434
|
+
timedOut = false;
|
|
435
|
+
stderr += '\n[agent_invoke] valid artifact detected after timeout; accepting artifact output';
|
|
436
|
+
}
|
|
437
|
+
if (output && earlyArtifactExit) {
|
|
438
|
+
code = 0;
|
|
439
|
+
signal = null;
|
|
440
|
+
}
|
|
441
|
+
return { code, signal, stdout, stderr, artifact: output, timedOut, forceKilled, timedOutRecovered, earlyArtifactExit };
|
|
442
|
+
} finally {
|
|
443
|
+
if (sink) await sink.close().catch(() => {});
|
|
392
444
|
}
|
|
393
|
-
return { code, signal, stdout, stderr, artifact: output, timedOut, forceKilled, timedOutRecovered, earlyArtifactExit };
|
|
394
445
|
}
|
|
395
446
|
|
|
396
447
|
async function runPencilCli(prompt, opts) {
|
package/src/core/audit.js
CHANGED
|
@@ -46,6 +46,7 @@ export const TEST_COMMAND_PATTERNS = [
|
|
|
46
46
|
/node\s+--test\b/,
|
|
47
47
|
/pytest\b/,
|
|
48
48
|
/python3?\s+-m\s+pytest\b/,
|
|
49
|
+
/python3?\s+-m\s+unittest\b/,
|
|
49
50
|
/go\s+test\b/,
|
|
50
51
|
/cargo\s+test\b/,
|
|
51
52
|
/mvn\s+(?:-[^ ]+\s+)*test\b/,
|
|
@@ -54,7 +55,7 @@ export const TEST_COMMAND_PATTERNS = [
|
|
|
54
55
|
|
|
55
56
|
const COMMAND_CHARS = String.raw`[\w@./:+\-='"]`;
|
|
56
57
|
const COMMAND_TAIL = new RegExp(
|
|
57
|
-
String.raw`(?:npm\s+test|npm\s+run\s+[\w:.-]+|pnpm\s+test|yarn\s+test|node\s+--test(?:\s+${COMMAND_CHARS}+)*|pytest(?:\s+${COMMAND_CHARS}+)*|python3?\s+-m\s+pytest(?:\s+${COMMAND_CHARS}+)*|go\s+test(?:\s+${COMMAND_CHARS}+)*|cargo\s+test(?:\s+${COMMAND_CHARS}+)*|mvn\s+(?:-[^\s]+\s+)*test|npx\s+[\w@./-]+(?:\s+${COMMAND_CHARS}+)*)`,
|
|
58
|
+
String.raw`(?:npm\s+test|npm\s+run\s+[\w:.-]+|pnpm\s+test|yarn\s+test|node\s+--test(?:\s+${COMMAND_CHARS}+)*|pytest(?:\s+${COMMAND_CHARS}+)*|python3?\s+-m\s+pytest(?:\s+${COMMAND_CHARS}+)*|python3?\s+-m\s+unittest(?:\s+${COMMAND_CHARS}+)*|go\s+test(?:\s+${COMMAND_CHARS}+)*|cargo\s+test(?:\s+${COMMAND_CHARS}+)*|mvn\s+(?:-[^\s]+\s+)*test|npx\s+[\w@./-]+(?:\s+${COMMAND_CHARS}+)*)`,
|
|
58
59
|
);
|
|
59
60
|
|
|
60
61
|
export function isTestCommand(text) {
|
|
@@ -317,6 +318,7 @@ export function parseDiffFiles(diffText) {
|
|
|
317
318
|
/**
|
|
318
319
|
* Parse pass/fail counts out of a test runner's output.
|
|
319
320
|
* node --test ("# pass 18", "# fail 0"), pytest ("18 passed, 0 failed"),
|
|
321
|
+
* python -m unittest ("Ran 18 tests" + "OK" / "FAILED (failures=1, errors=0)"),
|
|
320
322
|
* mocha ("18 passing"), npm-wrapped node --test (same "# pass" lines).
|
|
321
323
|
*/
|
|
322
324
|
export function parseReplayCounts(output, command) {
|
|
@@ -333,6 +335,30 @@ export function parseReplayCounts(output, command) {
|
|
|
333
335
|
return { passed: nodePass, failed: nodeFail, total: nodeFail !== null ? nodePass + nodeFail : null };
|
|
334
336
|
}
|
|
335
337
|
if (nodePasses.length > 1 || nodeFails.length > 1) return null;
|
|
338
|
+
// python -m unittest prints one summary per run: "Ran N tests in ...",
|
|
339
|
+
// then "OK" (optionally "OK (skipped=S)") or "FAILED (failures=F,
|
|
340
|
+
// errors=E)". Same count semantics as the pytest branch below:
|
|
341
|
+
// passed = ran - failed (- skipped on an OK run).
|
|
342
|
+
const ran = text.match(/Ran (\d+) tests? in /);
|
|
343
|
+
if (ran) {
|
|
344
|
+
const total = Number(ran[1]);
|
|
345
|
+
const ok = text.match(/\nOK(?: \(skipped=(\d+)\))?\s*$/);
|
|
346
|
+
if (ok) {
|
|
347
|
+
const skipped = ok[1] !== undefined ? Number(ok[1]) : 0;
|
|
348
|
+
return { passed: total - skipped, failed: 0, total };
|
|
349
|
+
}
|
|
350
|
+
let m = text.match(/FAILED \(failures=(\d+)(?:, errors=(\d+))?\)/);
|
|
351
|
+
if (m) {
|
|
352
|
+
const failed = Number(m[1]) + (m[2] !== undefined ? Number(m[2]) : 0);
|
|
353
|
+
return { passed: total - failed, failed, total };
|
|
354
|
+
}
|
|
355
|
+
m = text.match(/FAILED \(errors=(\d+)\)/);
|
|
356
|
+
if (m) {
|
|
357
|
+
const failed = Number(m[1]);
|
|
358
|
+
return { passed: total - failed, failed, total };
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
336
362
|
let m = text.match(/(\d+)\s+passed?(?:,\s*(\d+)\s+failed?)?/);
|
|
337
363
|
if (m) {
|
|
338
364
|
return { passed: Number(m[1]), failed: m[2] !== undefined ? Number(m[2]) : null, total: null };
|