@gigaai/newton 0.3.0 → 0.4.1
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 +30 -6
- package/dist/acp.js +38 -2
- package/dist/cli.js +185 -2
- package/dist/control.js +169 -0
- package/dist/executor.js +300 -73
- package/dist/journal.js +40 -3
- package/dist/mcp.js +102 -0
- package/dist/sandbox.js +157 -0
- package/dist/version.js +1 -1
- package/dist/worktree.js +51 -0
- package/package.json +1 -1
package/dist/executor.js
CHANGED
|
@@ -1,11 +1,62 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { cpus } from "node:os";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { AcpAgentSession } from "./acp.js";
|
|
5
|
-
import {
|
|
5
|
+
import { readSkipRequests, writeStatus } from "./control.js";
|
|
6
|
+
import { agentCallKey, loadResumeCache, loadResumeCalls, RunJournal } from "./journal.js";
|
|
7
|
+
import { resolveMcpServers } from "./mcp.js";
|
|
8
|
+
import { checkCompile, createRealm } from "./sandbox.js";
|
|
6
9
|
import { balancedSlice, extractJson, INVALID, structuredOutputInstruction, structuredOutputRetryPrompt, validateAgainstSchema, } from "./schema.js";
|
|
7
|
-
|
|
8
|
-
const SCRIPT_PARAMS = ["agent", "parallel", "pipeline", "phase", "log", "args", "budget", "
|
|
10
|
+
import { createWorktree, finishWorktree } from "./worktree.js";
|
|
11
|
+
const SCRIPT_PARAMS = ["agent", "parallel", "pipeline", "phase", "log", "args", "budget", "workflow"];
|
|
12
|
+
const AGENT_MAX_ATTEMPTS = 3;
|
|
13
|
+
const AGENT_RETRY_BASE_MS = 2_000;
|
|
14
|
+
/**
|
|
15
|
+
* Failures worth a fresh attempt: provider 5xx/overload/rate-limit responses
|
|
16
|
+
* and dropped connections. Deterministic failures (auth, missing binaries,
|
|
17
|
+
* bad model ids, structured-output exhaustion) return null without retrying.
|
|
18
|
+
*/
|
|
19
|
+
const TRANSIENT_AGENT_ERROR = new RegExp([
|
|
20
|
+
"\\b(?:429|500|502|503|504|529)\\b",
|
|
21
|
+
"overloaded",
|
|
22
|
+
"rate.?limit",
|
|
23
|
+
"too many requests",
|
|
24
|
+
"internal server error",
|
|
25
|
+
"service unavailable",
|
|
26
|
+
"bad gateway",
|
|
27
|
+
"gateway timeout",
|
|
28
|
+
"econn(?:reset|refused|aborted)",
|
|
29
|
+
"etimedout",
|
|
30
|
+
"epipe",
|
|
31
|
+
"eai_again",
|
|
32
|
+
"socket hang.?up",
|
|
33
|
+
"connection (?:reset|refused|closed|error)",
|
|
34
|
+
"fetch failed",
|
|
35
|
+
"network error",
|
|
36
|
+
"stream (?:closed|disconnected)",
|
|
37
|
+
].join("|"), "i");
|
|
38
|
+
function errorMessage(error) {
|
|
39
|
+
return typeof error === "object" &&
|
|
40
|
+
error !== null &&
|
|
41
|
+
"message" in error &&
|
|
42
|
+
typeof error.message === "string"
|
|
43
|
+
? error.message
|
|
44
|
+
: String(error);
|
|
45
|
+
}
|
|
46
|
+
function isPlainObject(value) {
|
|
47
|
+
if (Object.prototype.toString.call(value) !== "[object Object]")
|
|
48
|
+
return false;
|
|
49
|
+
const proto = Object.getPrototypeOf(value);
|
|
50
|
+
return proto === null || Object.getPrototypeOf(proto) === null;
|
|
51
|
+
}
|
|
52
|
+
function validateConfig(value) {
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (!isPlainObject(value) || Object.values(value).some((entry) => typeof entry !== "string")) {
|
|
56
|
+
throw new Error("agent() config takes an object of configId → string value");
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
9
60
|
export function loadWorkflow(file) {
|
|
10
61
|
const absolute = resolve(file);
|
|
11
62
|
const source = readFileSync(absolute, "utf8");
|
|
@@ -33,33 +84,8 @@ export function loadWorkflow(file) {
|
|
|
33
84
|
}
|
|
34
85
|
/** Compile without running — catches syntax errors and non-literal meta. */
|
|
35
86
|
export function compileWorkflow(workflow) {
|
|
36
|
-
|
|
87
|
+
checkCompile(SCRIPT_PARAMS, workflow.body);
|
|
37
88
|
}
|
|
38
|
-
// Wall-clock and randomness would silently break resume replay, so scripts get
|
|
39
|
-
// guarded shims instead of the real globals. Pass timestamps in via --args.
|
|
40
|
-
const guardedDate = new Proxy(Date, {
|
|
41
|
-
get(target, prop, receiver) {
|
|
42
|
-
if (prop === "now") {
|
|
43
|
-
return () => {
|
|
44
|
-
throw new Error("Date.now() is unavailable in workflows (breaks resume); pass timestamps via --args");
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
return Reflect.get(target, prop, receiver);
|
|
48
|
-
},
|
|
49
|
-
construct(target, argArray) {
|
|
50
|
-
if (argArray.length === 0) {
|
|
51
|
-
throw new Error("new Date() without arguments is unavailable in workflows (breaks resume)");
|
|
52
|
-
}
|
|
53
|
-
return Reflect.construct(target, argArray);
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
const guardedMath = Object.create(Math, {
|
|
57
|
-
random: {
|
|
58
|
-
value: () => {
|
|
59
|
-
throw new Error("Math.random() is unavailable in workflows (breaks resume); vary prompts by index instead");
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
89
|
class Semaphore {
|
|
64
90
|
queue = [];
|
|
65
91
|
available;
|
|
@@ -89,11 +115,13 @@ const AGENT_PREAMBLE = [
|
|
|
89
115
|
].join("\n");
|
|
90
116
|
export async function runWorkflow(options) {
|
|
91
117
|
const startedAt = Date.now();
|
|
118
|
+
const statusStartedAt = new Date().toISOString();
|
|
92
119
|
const workflow = loadWorkflow(options.file);
|
|
93
|
-
const scriptFn = new AsyncFunction(...SCRIPT_PARAMS, workflow.body);
|
|
94
120
|
const workspace = resolve(options.cwd);
|
|
95
|
-
const journal = new RunJournal(workspace);
|
|
96
|
-
const
|
|
121
|
+
const journal = new RunJournal(workspace, options.runId);
|
|
122
|
+
const resumeMode = options.resumeMode ?? "prefix";
|
|
123
|
+
const resumeCache = options.resumeRunId && resumeMode === "hash" ? loadResumeCache(workspace, options.resumeRunId) : undefined;
|
|
124
|
+
const priorCalls = options.resumeRunId && resumeMode !== "hash" ? loadResumeCalls(workspace, options.resumeRunId) : undefined;
|
|
97
125
|
const reporter = options.reporter;
|
|
98
126
|
const maxAgents = options.maxAgents ?? 200;
|
|
99
127
|
const agentTimeoutMs = options.agentTimeoutMs ?? 20 * 60 * 1000;
|
|
@@ -103,6 +131,33 @@ export async function runWorkflow(options) {
|
|
|
103
131
|
let agentCounter = 0;
|
|
104
132
|
let outputTokens = 0;
|
|
105
133
|
let currentPhase;
|
|
134
|
+
let prefixIntact = true;
|
|
135
|
+
const liveSessions = new Map();
|
|
136
|
+
const skipped = new Set();
|
|
137
|
+
const controlTimer = setInterval(() => {
|
|
138
|
+
for (const id of readSkipRequests(journal.runDir)) {
|
|
139
|
+
if (skipped.has(id))
|
|
140
|
+
continue;
|
|
141
|
+
skipped.add(id);
|
|
142
|
+
liveSessions.get(id)?.abort("skipped via newton skip");
|
|
143
|
+
}
|
|
144
|
+
}, 500);
|
|
145
|
+
controlTimer.unref();
|
|
146
|
+
function syncStatus(status, extra = {}) {
|
|
147
|
+
writeStatus(journal.runDir, {
|
|
148
|
+
runId: journal.runId,
|
|
149
|
+
pid: process.pid,
|
|
150
|
+
file: workflow.file,
|
|
151
|
+
name: workflow.meta.name,
|
|
152
|
+
detached: options.detached ?? false,
|
|
153
|
+
startedAt: statusStartedAt,
|
|
154
|
+
updatedAt: new Date().toISOString(),
|
|
155
|
+
status,
|
|
156
|
+
agents: agentCounter,
|
|
157
|
+
outputTokens,
|
|
158
|
+
...extra,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
106
161
|
journal.append({
|
|
107
162
|
type: "workflow_start",
|
|
108
163
|
runId: journal.runId,
|
|
@@ -110,8 +165,10 @@ export async function runWorkflow(options) {
|
|
|
110
165
|
name: workflow.meta.name,
|
|
111
166
|
args: options.args ?? null,
|
|
112
167
|
resumedFrom: options.resumeRunId ?? null,
|
|
168
|
+
resumeMode: options.resumeRunId ? resumeMode : null,
|
|
113
169
|
});
|
|
114
170
|
reporter.start(workflow.meta, journal.runId, journal.runDir);
|
|
171
|
+
syncStatus("running");
|
|
115
172
|
const budget = {
|
|
116
173
|
total: budgetTotal,
|
|
117
174
|
spent: () => outputTokens,
|
|
@@ -135,61 +192,168 @@ export async function runWorkflow(options) {
|
|
|
135
192
|
if (budgetTotal !== null && outputTokens >= budgetTotal) {
|
|
136
193
|
throw new Error(`token budget exhausted (${outputTokens}/${budgetTotal} output tokens)`);
|
|
137
194
|
}
|
|
138
|
-
const
|
|
195
|
+
const config = validateConfig(callOptions.config);
|
|
196
|
+
const key = agentCallKey(prompt, { ...spec, isolation: callOptions.isolation, mcp: callOptions.mcp, config }, callOptions.schema ?? null);
|
|
139
197
|
const label = callOptions.label ?? prompt.replace(/\s+/g, " ").trim().slice(0, 64);
|
|
140
198
|
const phase = callOptions.phase ?? currentPhase;
|
|
199
|
+
if (priorCalls && prefixIntact) {
|
|
200
|
+
const prior = priorCalls.get(id);
|
|
201
|
+
if (!prior || prior.key !== key) {
|
|
202
|
+
prefixIntact = false;
|
|
203
|
+
}
|
|
204
|
+
else if (prior.ok) {
|
|
205
|
+
journal.append({ type: "agent_end", id, key, label, phase, status: "ok", cached: true, result: prior.result });
|
|
206
|
+
syncStatus("running");
|
|
207
|
+
reporter.agentStart(id, label, spec, phase, true);
|
|
208
|
+
reporter.agentEnd(id, true, 0, "cached");
|
|
209
|
+
return prior.result;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
141
212
|
const cachedQueue = resumeCache?.get(key);
|
|
142
213
|
if (cachedQueue && cachedQueue.length > 0) {
|
|
143
214
|
const result = cachedQueue.shift();
|
|
144
215
|
journal.append({ type: "agent_end", id, key, label, phase, status: "ok", cached: true, result });
|
|
216
|
+
syncStatus("running");
|
|
145
217
|
reporter.agentStart(id, label, spec, phase, true);
|
|
146
218
|
reporter.agentEnd(id, true, 0, "cached");
|
|
147
219
|
return result;
|
|
148
220
|
}
|
|
221
|
+
const mcpServers = callOptions.mcp !== undefined ? resolveMcpServers(workspace, callOptions.mcp) : undefined;
|
|
149
222
|
await semaphore.acquire();
|
|
150
223
|
const agentStartedAt = Date.now();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
224
|
+
let session;
|
|
225
|
+
let wt;
|
|
226
|
+
let worktreeFinished = false;
|
|
227
|
+
// Idempotent so the finally can guarantee cleanup on a throw without
|
|
228
|
+
// double-finalizing the worktree that the normal path already removed.
|
|
229
|
+
const finishCreatedWorktree = async () => {
|
|
230
|
+
if (!wt || worktreeFinished)
|
|
231
|
+
return undefined;
|
|
232
|
+
worktreeFinished = true;
|
|
233
|
+
const wtInfo = await finishWorktree(workspace, wt);
|
|
234
|
+
const worktree = { path: wt.path, dirty: wtInfo.dirty, removed: wtInfo.removed };
|
|
235
|
+
if (wtInfo.dirty)
|
|
236
|
+
reporter.log(`worktree kept: ${wt.path}`);
|
|
237
|
+
return worktree;
|
|
238
|
+
};
|
|
154
239
|
try {
|
|
240
|
+
reporter.agentStart(id, label, spec, phase, false);
|
|
241
|
+
journal.append({ type: "agent_start", id, key, label, phase, spec, prompt });
|
|
242
|
+
const trace = journal.traceWriter(id);
|
|
243
|
+
if (skipped.has(id)) {
|
|
244
|
+
journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: "skipped via newton skip", seconds: 0 });
|
|
245
|
+
syncStatus("running");
|
|
246
|
+
reporter.agentEnd(id, false, 0, "skipped");
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (callOptions.isolation === "worktree") {
|
|
250
|
+
try {
|
|
251
|
+
wt = await createWorktree(workspace, journal.runId, id);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
const message = errorMessage(error);
|
|
255
|
+
journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: message, seconds: 0 });
|
|
256
|
+
syncStatus("running");
|
|
257
|
+
reporter.agentEnd(id, false, 0, message);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (skipped.has(id)) {
|
|
262
|
+
const worktree = await finishCreatedWorktree();
|
|
263
|
+
journal.append({
|
|
264
|
+
type: "agent_end",
|
|
265
|
+
id,
|
|
266
|
+
key,
|
|
267
|
+
label,
|
|
268
|
+
phase,
|
|
269
|
+
status: "error",
|
|
270
|
+
error: "skipped via newton skip",
|
|
271
|
+
seconds: 0,
|
|
272
|
+
...(worktree ? { worktree } : {}),
|
|
273
|
+
});
|
|
274
|
+
syncStatus("running");
|
|
275
|
+
reporter.agentEnd(id, false, 0, "skipped");
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
155
278
|
const fullPrompt = AGENT_PREAMBLE + prompt + (callOptions.schema ? structuredOutputInstruction(callOptions.schema) : "");
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
let turn
|
|
164
|
-
let result
|
|
165
|
-
let failure
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
279
|
+
const cwd = wt
|
|
280
|
+
? callOptions.cwd
|
|
281
|
+
? resolve(wt.path, callOptions.cwd)
|
|
282
|
+
: wt.path
|
|
283
|
+
: callOptions.cwd
|
|
284
|
+
? resolve(workspace, callOptions.cwd)
|
|
285
|
+
: workspace;
|
|
286
|
+
let turn;
|
|
287
|
+
let result;
|
|
288
|
+
let failure;
|
|
289
|
+
for (let attempt = 1;; attempt++) {
|
|
290
|
+
session = new AcpAgentSession({
|
|
291
|
+
spec,
|
|
292
|
+
prompt: fullPrompt,
|
|
293
|
+
cwd,
|
|
294
|
+
timeoutMs: callOptions.timeoutMs ?? agentTimeoutMs,
|
|
295
|
+
mcpServers,
|
|
296
|
+
config,
|
|
297
|
+
trace,
|
|
298
|
+
});
|
|
299
|
+
liveSessions.set(id, session);
|
|
300
|
+
turn = await session.prompt();
|
|
301
|
+
result = turn.text;
|
|
302
|
+
failure = turn.error;
|
|
303
|
+
if (!failure && callOptions.schema) {
|
|
304
|
+
for (let retry = 0;; retry++) {
|
|
305
|
+
const parsed = extractJson(turn.text);
|
|
306
|
+
const errors = parsed === INVALID
|
|
307
|
+
? ["response contained no parseable JSON"]
|
|
308
|
+
: validateAgainstSchema(parsed, callOptions.schema);
|
|
309
|
+
if (errors.length === 0) {
|
|
310
|
+
result = parsed;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
if (retry >= 2) {
|
|
314
|
+
failure = `structured output failed after ${retry + 1} attempts: ${errors.join("; ")}`;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
turn = await session.promptAgain(structuredOutputRetryPrompt(errors, callOptions.schema));
|
|
318
|
+
if (turn.error) {
|
|
319
|
+
failure = turn.error;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
184
322
|
}
|
|
185
323
|
}
|
|
324
|
+
liveSessions.delete(id);
|
|
325
|
+
session.close();
|
|
326
|
+
outputTokens += turn.usage?.outputTokens ?? 0;
|
|
327
|
+
if (!failure ||
|
|
328
|
+
attempt >= AGENT_MAX_ATTEMPTS ||
|
|
329
|
+
turn.stopReason === "timeout" ||
|
|
330
|
+
turn.stopReason === "cancelled" ||
|
|
331
|
+
!TRANSIENT_AGENT_ERROR.test(failure)) {
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
const delayMs = AGENT_RETRY_BASE_MS * 4 ** (attempt - 1);
|
|
335
|
+
journal.append({ type: "agent_retry", id, key, label, phase, attempt, error: failure, delayMs });
|
|
336
|
+
reporter.log(`↻ agent #${id} transient error, retry ${attempt}/${AGENT_MAX_ATTEMPTS - 1} in ${delayMs / 1000}s: ${failure.split("\n")[0]}`);
|
|
337
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
|
|
338
|
+
if (skipped.has(id))
|
|
339
|
+
break;
|
|
186
340
|
}
|
|
187
|
-
|
|
341
|
+
const worktree = await finishCreatedWorktree();
|
|
188
342
|
const seconds = Math.round((Date.now() - agentStartedAt) / 1000);
|
|
189
343
|
const turnTokens = turn.usage?.outputTokens ?? 0;
|
|
190
|
-
outputTokens += turnTokens;
|
|
191
344
|
if (failure) {
|
|
192
|
-
journal.append({
|
|
345
|
+
journal.append({
|
|
346
|
+
type: "agent_end",
|
|
347
|
+
id,
|
|
348
|
+
key,
|
|
349
|
+
label,
|
|
350
|
+
phase,
|
|
351
|
+
status: "error",
|
|
352
|
+
error: failure,
|
|
353
|
+
seconds,
|
|
354
|
+
...(worktree ? { worktree } : {}),
|
|
355
|
+
});
|
|
356
|
+
syncStatus("running");
|
|
193
357
|
reporter.agentEnd(id, false, seconds, failure);
|
|
194
358
|
return null;
|
|
195
359
|
}
|
|
@@ -204,11 +368,19 @@ export async function runWorkflow(options) {
|
|
|
204
368
|
toolCalls: turn.toolCalls,
|
|
205
369
|
usage: turn.usage,
|
|
206
370
|
result,
|
|
371
|
+
...(worktree ? { worktree } : {}),
|
|
207
372
|
});
|
|
373
|
+
syncStatus("running");
|
|
208
374
|
reporter.agentEnd(id, true, seconds, turn.usage ? `${turn.toolCalls} tools · ${turnTokens} out-tok` : `${turn.toolCalls} tools`);
|
|
209
375
|
return result;
|
|
210
376
|
}
|
|
211
377
|
finally {
|
|
378
|
+
if (session) {
|
|
379
|
+
liveSessions.delete(id);
|
|
380
|
+
session.close();
|
|
381
|
+
}
|
|
382
|
+
// No-op unless a throw skipped the normal finalize (guard above).
|
|
383
|
+
await finishCreatedWorktree().catch(() => { });
|
|
212
384
|
semaphore.release();
|
|
213
385
|
}
|
|
214
386
|
}
|
|
@@ -219,7 +391,7 @@ export async function runWorkflow(options) {
|
|
|
219
391
|
return Promise.all(thunks.map((thunk) => Promise.resolve()
|
|
220
392
|
.then(thunk)
|
|
221
393
|
.catch((error) => {
|
|
222
|
-
const message =
|
|
394
|
+
const message = errorMessage(error);
|
|
223
395
|
journal.append({ type: "log", message: `parallel task failed: ${message}` });
|
|
224
396
|
reporter.log(`parallel task failed: ${message}`);
|
|
225
397
|
return null;
|
|
@@ -238,7 +410,7 @@ export async function runWorkflow(options) {
|
|
|
238
410
|
previous = await stage(previous, item, index);
|
|
239
411
|
}
|
|
240
412
|
catch (error) {
|
|
241
|
-
const message =
|
|
413
|
+
const message = errorMessage(error);
|
|
242
414
|
journal.append({ type: "log", message: `pipeline item ${index} dropped: ${message}` });
|
|
243
415
|
reporter.log(`pipeline item ${index} dropped: ${message}`);
|
|
244
416
|
return null;
|
|
@@ -253,14 +425,64 @@ export async function runWorkflow(options) {
|
|
|
253
425
|
currentPhase = title;
|
|
254
426
|
journal.append({ type: "phase", title });
|
|
255
427
|
reporter.phase(title);
|
|
428
|
+
syncStatus("running");
|
|
256
429
|
}
|
|
257
430
|
function logFn(message) {
|
|
258
431
|
const text = typeof message === "string" ? message : JSON.stringify(message);
|
|
259
432
|
journal.append({ type: "log", message: text });
|
|
260
433
|
reporter.log(text);
|
|
261
434
|
}
|
|
435
|
+
const realm = createRealm((message) => logFn(message));
|
|
436
|
+
async function executeScript(loaded, scriptArgs, depth) {
|
|
437
|
+
const scriptFn = realm.compile(SCRIPT_PARAMS, loaded.body, loaded.file);
|
|
438
|
+
return scriptFn(agentFn, parallelFn, pipelineFn, phaseFn, logFn, scriptArgs, budget, makeWorkflowFn(loaded.file, depth));
|
|
439
|
+
}
|
|
440
|
+
function makeWorkflowFn(callerFile, depth) {
|
|
441
|
+
return async (nameOrPath, childArgs) => {
|
|
442
|
+
if (depth >= 1)
|
|
443
|
+
throw new Error("workflow() nesting is limited to one level");
|
|
444
|
+
if (typeof nameOrPath !== "string")
|
|
445
|
+
throw new Error("workflow() takes a workflow name or path string");
|
|
446
|
+
const registryDir = join(workspace, ".newton", "workflows");
|
|
447
|
+
const file = /\.(js|mjs)$/.test(nameOrPath) || nameOrPath.includes("/")
|
|
448
|
+
? resolve(dirname(callerFile), nameOrPath)
|
|
449
|
+
: join(registryDir, `${nameOrPath}.js`);
|
|
450
|
+
if (!existsSync(file)) {
|
|
451
|
+
let names = [];
|
|
452
|
+
try {
|
|
453
|
+
names = readdirSync(registryDir, { withFileTypes: true })
|
|
454
|
+
.filter((entry) => entry.isFile() && /\.(js|mjs)$/.test(entry.name))
|
|
455
|
+
.map((entry) => entry.name.replace(/\.(?:js|mjs)$/, ""))
|
|
456
|
+
.sort();
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
names = [];
|
|
460
|
+
}
|
|
461
|
+
throw new Error(`workflow "${nameOrPath}" not found at ${file}; registered workflows: ${names.length > 0 ? names.join(", ") : "(none)"}`);
|
|
462
|
+
}
|
|
463
|
+
const child = loadWorkflow(file);
|
|
464
|
+
journal.append({ type: "workflow_child_start", name: child.meta.name, file: child.file, args: childArgs ?? null });
|
|
465
|
+
reporter.log(`▸ workflow ${child.meta.name}`);
|
|
466
|
+
const savedPhase = currentPhase;
|
|
467
|
+
try {
|
|
468
|
+
const result = await executeScript(child, childArgs, depth + 1);
|
|
469
|
+
journal.append({ type: "workflow_child_end", name: child.meta.name, status: "ok" });
|
|
470
|
+
reporter.log(`✔ workflow ${child.meta.name}`);
|
|
471
|
+
return result;
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
const message = errorMessage(error);
|
|
475
|
+
journal.append({ type: "workflow_child_end", name: child.meta.name, status: "error", error: message });
|
|
476
|
+
reporter.log(`✖ workflow ${child.meta.name}: ${message}`);
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
currentPhase = savedPhase;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
262
484
|
try {
|
|
263
|
-
const result = await
|
|
485
|
+
const result = await executeScript(workflow, options.args, 0);
|
|
264
486
|
const runResult = {
|
|
265
487
|
runId: journal.runId,
|
|
266
488
|
status: "ok",
|
|
@@ -270,11 +492,13 @@ export async function runWorkflow(options) {
|
|
|
270
492
|
durationMs: Date.now() - startedAt,
|
|
271
493
|
};
|
|
272
494
|
journal.append({ type: "workflow_end", status: "ok", result: runResult.result, agents: agentCounter, outputTokens });
|
|
495
|
+
syncStatus("ok", { result: runResult.result });
|
|
273
496
|
return runResult;
|
|
274
497
|
}
|
|
275
498
|
catch (error) {
|
|
276
|
-
const message =
|
|
499
|
+
const message = errorMessage(error);
|
|
277
500
|
journal.append({ type: "workflow_end", status: "error", error: message, agents: agentCounter, outputTokens });
|
|
501
|
+
syncStatus("error", { error: message });
|
|
278
502
|
return {
|
|
279
503
|
runId: journal.runId,
|
|
280
504
|
status: "error",
|
|
@@ -284,4 +508,7 @@ export async function runWorkflow(options) {
|
|
|
284
508
|
durationMs: Date.now() - startedAt,
|
|
285
509
|
};
|
|
286
510
|
}
|
|
511
|
+
finally {
|
|
512
|
+
clearInterval(controlTimer);
|
|
513
|
+
}
|
|
287
514
|
}
|
package/dist/journal.js
CHANGED
|
@@ -27,7 +27,7 @@ export class RunJournal {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
function newRunId() {
|
|
30
|
+
export function newRunId() {
|
|
31
31
|
const now = new Date();
|
|
32
32
|
const stamp = now
|
|
33
33
|
.toISOString()
|
|
@@ -43,8 +43,14 @@ export function agentCallKey(prompt, spec, schema) {
|
|
|
43
43
|
.slice(0, 32);
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
|
-
* Resume
|
|
47
|
-
*
|
|
46
|
+
* Resume has two modes:
|
|
47
|
+
* - prefix: load prior calls by positional id via loadResumeCalls.
|
|
48
|
+
* - hash: load successful results by (prompt, spec, schema) hash here.
|
|
49
|
+
*
|
|
50
|
+
* Invariant: journal `id` is the positional sequence. Agent ids increment at
|
|
51
|
+
* every agent() call, including cached calls.
|
|
52
|
+
*
|
|
53
|
+
* Duplicate hash keys replay in call order.
|
|
48
54
|
*/
|
|
49
55
|
export function loadResumeCache(workspace, runId) {
|
|
50
56
|
const journalPath = join(workspace, ".newton", "runs", runId, "journal.jsonl");
|
|
@@ -70,3 +76,34 @@ export function loadResumeCache(workspace, runId) {
|
|
|
70
76
|
}
|
|
71
77
|
return cache;
|
|
72
78
|
}
|
|
79
|
+
export function loadResumeCalls(workspace, runId) {
|
|
80
|
+
const journalPath = join(workspace, ".newton", "runs", runId, "journal.jsonl");
|
|
81
|
+
if (!existsSync(journalPath)) {
|
|
82
|
+
throw new Error(`No journal found for run "${runId}" (looked at ${journalPath})`);
|
|
83
|
+
}
|
|
84
|
+
const calls = new Map();
|
|
85
|
+
for (const line of readFileSync(journalPath, "utf8").split("\n")) {
|
|
86
|
+
if (!line.trim())
|
|
87
|
+
continue;
|
|
88
|
+
let entry;
|
|
89
|
+
try {
|
|
90
|
+
entry = JSON.parse(line);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const id = entry.id;
|
|
96
|
+
const key = entry.key;
|
|
97
|
+
if (typeof id !== "number" || !Number.isFinite(id) || typeof key !== "string")
|
|
98
|
+
continue;
|
|
99
|
+
if (entry.type === "agent_start") {
|
|
100
|
+
if (!calls.has(id)) {
|
|
101
|
+
calls.set(id, { key, ok: false });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else if (entry.type === "agent_end") {
|
|
105
|
+
calls.set(id, { key, ok: entry.status === "ok", result: entry.result });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return calls;
|
|
109
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function errorMessage(error) {
|
|
7
|
+
return isRecord(error) && typeof error.message === "string" ? error.message : String(error);
|
|
8
|
+
}
|
|
9
|
+
function readMcpConfig(workspace) {
|
|
10
|
+
const path = join(workspace, ".mcp.json");
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
17
|
+
throw new Error(`MCP config file not found at ${path}`);
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`Could not read ${path}: ${errorMessage(error)}`);
|
|
20
|
+
}
|
|
21
|
+
if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) {
|
|
22
|
+
throw new Error(`${path}: expected { "mcpServers": { ... } }`);
|
|
23
|
+
}
|
|
24
|
+
return { path, servers: parsed.mcpServers };
|
|
25
|
+
}
|
|
26
|
+
function availableNames(servers) {
|
|
27
|
+
const names = Object.keys(servers).sort();
|
|
28
|
+
return names.length > 0 ? names.join(", ") : "(none)";
|
|
29
|
+
}
|
|
30
|
+
function optionalStringArray(value, field) {
|
|
31
|
+
if (value === undefined || value === null)
|
|
32
|
+
return [];
|
|
33
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
34
|
+
throw new Error(`${field} must be an array of strings`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function nameValueEntries(value, field) {
|
|
39
|
+
if (value === undefined || value === null)
|
|
40
|
+
return [];
|
|
41
|
+
if (!isRecord(value))
|
|
42
|
+
throw new Error(`${field} must be an object`);
|
|
43
|
+
return Object.entries(value).map(([name, entryValue]) => ({ name, value: String(entryValue) }));
|
|
44
|
+
}
|
|
45
|
+
function toMcpServer(name, value, source) {
|
|
46
|
+
if (!isRecord(value))
|
|
47
|
+
throw new Error(`${source} must be an object`);
|
|
48
|
+
if (value.type !== undefined) {
|
|
49
|
+
if (value.type !== "http" && value.type !== "sse") {
|
|
50
|
+
throw new Error(`${source} type must be "http" or "sse"`);
|
|
51
|
+
}
|
|
52
|
+
if (typeof value.url !== "string" || !value.url) {
|
|
53
|
+
throw new Error(`${source} missing url`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
type: value.type,
|
|
57
|
+
name,
|
|
58
|
+
url: value.url,
|
|
59
|
+
headers: nameValueEntries(value.headers, `${source} headers`),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (typeof value.command !== "string" || !value.command) {
|
|
63
|
+
throw new Error(`${source} missing command`);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
name,
|
|
67
|
+
command: value.command,
|
|
68
|
+
args: optionalStringArray(value.args, `${source} args`),
|
|
69
|
+
env: nameValueEntries(value.env, `${source} env`),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function resolveMcpServers(workspace, opt) {
|
|
73
|
+
if (opt === true) {
|
|
74
|
+
const config = readMcpConfig(workspace);
|
|
75
|
+
return Object.entries(config.servers).map(([name, server]) => toMcpServer(name, server, `MCP server "${name}"`));
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(opt)) {
|
|
78
|
+
throw new Error("agent() mcp option takes true or an array of names/servers");
|
|
79
|
+
}
|
|
80
|
+
let config;
|
|
81
|
+
const getConfig = () => {
|
|
82
|
+
config ??= readMcpConfig(workspace);
|
|
83
|
+
return config;
|
|
84
|
+
};
|
|
85
|
+
return opt.map((entry) => {
|
|
86
|
+
if (typeof entry === "string") {
|
|
87
|
+
const loaded = getConfig();
|
|
88
|
+
const server = loaded.servers[entry];
|
|
89
|
+
if (server === undefined) {
|
|
90
|
+
throw new Error(`MCP server "${entry}" not found in ${loaded.path}; available: ${availableNames(loaded.servers)}`);
|
|
91
|
+
}
|
|
92
|
+
return toMcpServer(entry, server, `MCP server "${entry}"`);
|
|
93
|
+
}
|
|
94
|
+
if (!isRecord(entry)) {
|
|
95
|
+
throw new Error("agent() mcp option takes true or an array of names/servers");
|
|
96
|
+
}
|
|
97
|
+
if (typeof entry.name !== "string" || !entry.name.trim()) {
|
|
98
|
+
throw new Error("inline MCP server missing name");
|
|
99
|
+
}
|
|
100
|
+
return toMcpServer(entry.name, entry, `inline MCP server "${entry.name}"`);
|
|
101
|
+
});
|
|
102
|
+
}
|