@gigaai/newton 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/README.md +142 -0
- package/dist/acp.js +257 -0
- package/dist/cli.js +220 -0
- package/dist/executor.js +287 -0
- package/dist/journal.js +72 -0
- package/dist/progress.js +61 -0
- package/dist/providers.js +255 -0
- package/dist/schema.js +184 -0
- package/dist/version.js +1 -0
- package/package.json +48 -0
package/dist/executor.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { cpus } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { AcpAgentSession } from "./acp.js";
|
|
5
|
+
import { agentCallKey, loadResumeCache, RunJournal } from "./journal.js";
|
|
6
|
+
import { balancedSlice, extractJson, INVALID, structuredOutputInstruction, structuredOutputRetryPrompt, validateAgainstSchema, } from "./schema.js";
|
|
7
|
+
const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
|
|
8
|
+
const SCRIPT_PARAMS = ["agent", "parallel", "pipeline", "phase", "log", "args", "budget", "Date", "Math"];
|
|
9
|
+
export function loadWorkflow(file) {
|
|
10
|
+
const absolute = resolve(file);
|
|
11
|
+
const source = readFileSync(absolute, "utf8");
|
|
12
|
+
const metaMatch = source.match(/export\s+const\s+meta\s*=/);
|
|
13
|
+
if (!metaMatch || metaMatch.index === undefined) {
|
|
14
|
+
throw new Error(`${file}: workflow must begin with \`export const meta = { name, description, ... }\``);
|
|
15
|
+
}
|
|
16
|
+
const braceStart = source.indexOf("{", metaMatch.index);
|
|
17
|
+
const metaLiteral = braceStart === -1 ? undefined : balancedSlice(source, braceStart);
|
|
18
|
+
if (!metaLiteral) {
|
|
19
|
+
throw new Error(`${file}: could not parse the meta object literal`);
|
|
20
|
+
}
|
|
21
|
+
let meta;
|
|
22
|
+
try {
|
|
23
|
+
meta = new Function(`"use strict"; return (${metaLiteral});`)();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new Error(`${file}: meta must be a pure object literal (${error.message})`);
|
|
27
|
+
}
|
|
28
|
+
if (!meta?.name || !meta?.description) {
|
|
29
|
+
throw new Error(`${file}: meta requires both \`name\` and \`description\``);
|
|
30
|
+
}
|
|
31
|
+
const body = source.replace(/export\s+const\s+meta\s*=/, "const meta =");
|
|
32
|
+
return { file: absolute, meta, body };
|
|
33
|
+
}
|
|
34
|
+
/** Compile without running — catches syntax errors and non-literal meta. */
|
|
35
|
+
export function compileWorkflow(workflow) {
|
|
36
|
+
new AsyncFunction(...SCRIPT_PARAMS, workflow.body);
|
|
37
|
+
}
|
|
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
|
+
class Semaphore {
|
|
64
|
+
queue = [];
|
|
65
|
+
available;
|
|
66
|
+
constructor(size) {
|
|
67
|
+
this.available = size;
|
|
68
|
+
}
|
|
69
|
+
async acquire() {
|
|
70
|
+
if (this.available > 0) {
|
|
71
|
+
this.available -= 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await new Promise((resolveWait) => this.queue.push(resolveWait));
|
|
75
|
+
}
|
|
76
|
+
release() {
|
|
77
|
+
const next = this.queue.shift();
|
|
78
|
+
if (next)
|
|
79
|
+
next();
|
|
80
|
+
else
|
|
81
|
+
this.available += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const AGENT_PREAMBLE = [
|
|
85
|
+
"You are a worker inside a deterministic multi-agent workflow.",
|
|
86
|
+
"Your final message is returned verbatim to a script, not shown to a human —",
|
|
87
|
+
"return raw data with no greetings, preamble, or offers of further help.",
|
|
88
|
+
"",
|
|
89
|
+
].join("\n");
|
|
90
|
+
export async function runWorkflow(options) {
|
|
91
|
+
const startedAt = Date.now();
|
|
92
|
+
const workflow = loadWorkflow(options.file);
|
|
93
|
+
const scriptFn = new AsyncFunction(...SCRIPT_PARAMS, workflow.body);
|
|
94
|
+
const workspace = resolve(options.cwd);
|
|
95
|
+
const journal = new RunJournal(workspace);
|
|
96
|
+
const resumeCache = options.resumeRunId ? loadResumeCache(workspace, options.resumeRunId) : undefined;
|
|
97
|
+
const reporter = options.reporter;
|
|
98
|
+
const maxAgents = options.maxAgents ?? 200;
|
|
99
|
+
const agentTimeoutMs = options.agentTimeoutMs ?? 20 * 60 * 1000;
|
|
100
|
+
const concurrency = options.concurrency ?? Math.min(16, Math.max(2, cpus().length - 2));
|
|
101
|
+
const semaphore = new Semaphore(concurrency);
|
|
102
|
+
const budgetTotal = options.budgetTokens ?? null;
|
|
103
|
+
let agentCounter = 0;
|
|
104
|
+
let outputTokens = 0;
|
|
105
|
+
let currentPhase;
|
|
106
|
+
journal.append({
|
|
107
|
+
type: "workflow_start",
|
|
108
|
+
runId: journal.runId,
|
|
109
|
+
file: workflow.file,
|
|
110
|
+
name: workflow.meta.name,
|
|
111
|
+
args: options.args ?? null,
|
|
112
|
+
resumedFrom: options.resumeRunId ?? null,
|
|
113
|
+
});
|
|
114
|
+
reporter.start(workflow.meta, journal.runId, journal.runDir);
|
|
115
|
+
const budget = {
|
|
116
|
+
total: budgetTotal,
|
|
117
|
+
spent: () => outputTokens,
|
|
118
|
+
remaining: () => (budgetTotal === null ? Infinity : Math.max(0, budgetTotal - outputTokens)),
|
|
119
|
+
};
|
|
120
|
+
async function agentFn(prompt, callOptions = {}) {
|
|
121
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
122
|
+
throw new Error("agent() requires a non-empty prompt string");
|
|
123
|
+
}
|
|
124
|
+
const spec = {
|
|
125
|
+
provider: callOptions.provider,
|
|
126
|
+
model: callOptions.model,
|
|
127
|
+
effort: callOptions.effort,
|
|
128
|
+
access: callOptions.access,
|
|
129
|
+
command: callOptions.command,
|
|
130
|
+
};
|
|
131
|
+
const id = ++agentCounter;
|
|
132
|
+
if (id > maxAgents) {
|
|
133
|
+
throw new Error(`agent cap reached (${maxAgents}); raise with --max-agents`);
|
|
134
|
+
}
|
|
135
|
+
if (budgetTotal !== null && outputTokens >= budgetTotal) {
|
|
136
|
+
throw new Error(`token budget exhausted (${outputTokens}/${budgetTotal} output tokens)`);
|
|
137
|
+
}
|
|
138
|
+
const key = agentCallKey(prompt, spec, callOptions.schema ?? null);
|
|
139
|
+
const label = callOptions.label ?? prompt.replace(/\s+/g, " ").trim().slice(0, 64);
|
|
140
|
+
const phase = callOptions.phase ?? currentPhase;
|
|
141
|
+
const cachedQueue = resumeCache?.get(key);
|
|
142
|
+
if (cachedQueue && cachedQueue.length > 0) {
|
|
143
|
+
const result = cachedQueue.shift();
|
|
144
|
+
journal.append({ type: "agent_end", id, key, label, phase, status: "ok", cached: true, result });
|
|
145
|
+
reporter.agentStart(id, label, spec, phase, true);
|
|
146
|
+
reporter.agentEnd(id, true, 0, "cached");
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
await semaphore.acquire();
|
|
150
|
+
const agentStartedAt = Date.now();
|
|
151
|
+
reporter.agentStart(id, label, spec, phase, false);
|
|
152
|
+
journal.append({ type: "agent_start", id, key, label, phase, spec, prompt });
|
|
153
|
+
const trace = journal.traceWriter(id);
|
|
154
|
+
try {
|
|
155
|
+
const fullPrompt = AGENT_PREAMBLE + prompt + (callOptions.schema ? structuredOutputInstruction(callOptions.schema) : "");
|
|
156
|
+
const session = new AcpAgentSession({
|
|
157
|
+
spec,
|
|
158
|
+
prompt: fullPrompt,
|
|
159
|
+
cwd: callOptions.cwd ? resolve(workspace, callOptions.cwd) : workspace,
|
|
160
|
+
timeoutMs: callOptions.timeoutMs ?? agentTimeoutMs,
|
|
161
|
+
trace,
|
|
162
|
+
});
|
|
163
|
+
let turn = await session.prompt();
|
|
164
|
+
let result = turn.text;
|
|
165
|
+
let failure = turn.error;
|
|
166
|
+
if (!failure && callOptions.schema) {
|
|
167
|
+
for (let attempt = 0;; attempt++) {
|
|
168
|
+
const parsed = extractJson(turn.text);
|
|
169
|
+
const errors = parsed === INVALID
|
|
170
|
+
? ["response contained no parseable JSON"]
|
|
171
|
+
: validateAgainstSchema(parsed, callOptions.schema);
|
|
172
|
+
if (errors.length === 0) {
|
|
173
|
+
result = parsed;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (attempt >= 2) {
|
|
177
|
+
failure = `structured output failed after ${attempt + 1} attempts: ${errors.join("; ")}`;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
turn = await session.promptAgain(structuredOutputRetryPrompt(errors, callOptions.schema));
|
|
181
|
+
if (turn.error) {
|
|
182
|
+
failure = turn.error;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
session.close();
|
|
188
|
+
const seconds = Math.round((Date.now() - agentStartedAt) / 1000);
|
|
189
|
+
const turnTokens = turn.usage?.outputTokens ?? 0;
|
|
190
|
+
outputTokens += turnTokens;
|
|
191
|
+
if (failure) {
|
|
192
|
+
journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: failure, seconds });
|
|
193
|
+
reporter.agentEnd(id, false, seconds, failure);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
journal.append({
|
|
197
|
+
type: "agent_end",
|
|
198
|
+
id,
|
|
199
|
+
key,
|
|
200
|
+
label,
|
|
201
|
+
phase,
|
|
202
|
+
status: "ok",
|
|
203
|
+
seconds,
|
|
204
|
+
toolCalls: turn.toolCalls,
|
|
205
|
+
usage: turn.usage,
|
|
206
|
+
result,
|
|
207
|
+
});
|
|
208
|
+
reporter.agentEnd(id, true, seconds, turn.usage ? `${turn.toolCalls} tools · ${turnTokens} out-tok` : `${turn.toolCalls} tools`);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
semaphore.release();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function parallelFn(thunks) {
|
|
216
|
+
if (!Array.isArray(thunks) || thunks.some((t) => typeof t !== "function")) {
|
|
217
|
+
throw new Error("parallel() takes an array of zero-argument functions");
|
|
218
|
+
}
|
|
219
|
+
return Promise.all(thunks.map((thunk) => Promise.resolve()
|
|
220
|
+
.then(thunk)
|
|
221
|
+
.catch((error) => {
|
|
222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
223
|
+
journal.append({ type: "log", message: `parallel task failed: ${message}` });
|
|
224
|
+
reporter.log(`parallel task failed: ${message}`);
|
|
225
|
+
return null;
|
|
226
|
+
})));
|
|
227
|
+
}
|
|
228
|
+
async function pipelineFn(items, ...stages) {
|
|
229
|
+
if (!Array.isArray(items))
|
|
230
|
+
throw new Error("pipeline() takes an array of items first");
|
|
231
|
+
if (stages.length === 0 || stages.some((s) => typeof s !== "function")) {
|
|
232
|
+
throw new Error("pipeline() takes one or more stage functions after the items");
|
|
233
|
+
}
|
|
234
|
+
return Promise.all(items.map(async (item, index) => {
|
|
235
|
+
let previous = item;
|
|
236
|
+
for (const stage of stages) {
|
|
237
|
+
try {
|
|
238
|
+
previous = await stage(previous, item, index);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
242
|
+
journal.append({ type: "log", message: `pipeline item ${index} dropped: ${message}` });
|
|
243
|
+
reporter.log(`pipeline item ${index} dropped: ${message}`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return previous;
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
function phaseFn(title) {
|
|
251
|
+
if (typeof title !== "string")
|
|
252
|
+
throw new Error("phase() takes a title string");
|
|
253
|
+
currentPhase = title;
|
|
254
|
+
journal.append({ type: "phase", title });
|
|
255
|
+
reporter.phase(title);
|
|
256
|
+
}
|
|
257
|
+
function logFn(message) {
|
|
258
|
+
const text = typeof message === "string" ? message : JSON.stringify(message);
|
|
259
|
+
journal.append({ type: "log", message: text });
|
|
260
|
+
reporter.log(text);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const result = await scriptFn(agentFn, parallelFn, pipelineFn, phaseFn, logFn, options.args, budget, guardedDate, guardedMath);
|
|
264
|
+
const runResult = {
|
|
265
|
+
runId: journal.runId,
|
|
266
|
+
status: "ok",
|
|
267
|
+
result: result === undefined ? null : result,
|
|
268
|
+
agents: agentCounter,
|
|
269
|
+
outputTokens,
|
|
270
|
+
durationMs: Date.now() - startedAt,
|
|
271
|
+
};
|
|
272
|
+
journal.append({ type: "workflow_end", status: "ok", result: runResult.result, agents: agentCounter, outputTokens });
|
|
273
|
+
return runResult;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
journal.append({ type: "workflow_end", status: "error", error: message, agents: agentCounter, outputTokens });
|
|
278
|
+
return {
|
|
279
|
+
runId: journal.runId,
|
|
280
|
+
status: "error",
|
|
281
|
+
error: message,
|
|
282
|
+
agents: agentCounter,
|
|
283
|
+
outputTokens,
|
|
284
|
+
durationMs: Date.now() - startedAt,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Durable run artifacts under <workspace>/.newton/runs/<runId>/:
|
|
6
|
+
* journal.jsonl — one entry per lifecycle event; the resume source
|
|
7
|
+
* agents/<id>.jsonl — full ACP wire trace per agent (the future evals feed)
|
|
8
|
+
*/
|
|
9
|
+
export class RunJournal {
|
|
10
|
+
runId;
|
|
11
|
+
runDir;
|
|
12
|
+
journalPath;
|
|
13
|
+
constructor(workspace, runId) {
|
|
14
|
+
this.runId = runId ?? newRunId();
|
|
15
|
+
this.runDir = join(workspace, ".newton", "runs", this.runId);
|
|
16
|
+
this.journalPath = join(this.runDir, "journal.jsonl");
|
|
17
|
+
mkdirSync(join(this.runDir, "agents"), { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
append(entry) {
|
|
20
|
+
const record = { t: new Date().toISOString(), ...entry };
|
|
21
|
+
appendFileSync(this.journalPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
22
|
+
}
|
|
23
|
+
traceWriter(agentId) {
|
|
24
|
+
const tracePath = join(this.runDir, "agents", `${agentId}.jsonl`);
|
|
25
|
+
return (event) => {
|
|
26
|
+
appendFileSync(tracePath, `${JSON.stringify({ t: new Date().toISOString(), ...event })}\n`, "utf8");
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function newRunId() {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const stamp = now
|
|
33
|
+
.toISOString()
|
|
34
|
+
.replace(/[-:]/g, "")
|
|
35
|
+
.replace(/\..+/, "")
|
|
36
|
+
.replace("T", "-");
|
|
37
|
+
return `${stamp}-${randomBytes(3).toString("hex")}`;
|
|
38
|
+
}
|
|
39
|
+
export function agentCallKey(prompt, spec, schema) {
|
|
40
|
+
return createHash("sha256")
|
|
41
|
+
.update(JSON.stringify({ prompt, spec, schema }))
|
|
42
|
+
.digest("hex")
|
|
43
|
+
.slice(0, 32);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resume cache: successful agent results from a prior run, keyed by
|
|
47
|
+
* (prompt, spec, schema) hash. Duplicate keys replay in call order.
|
|
48
|
+
*/
|
|
49
|
+
export function loadResumeCache(workspace, runId) {
|
|
50
|
+
const journalPath = join(workspace, ".newton", "runs", runId, "journal.jsonl");
|
|
51
|
+
if (!existsSync(journalPath)) {
|
|
52
|
+
throw new Error(`No journal found for run "${runId}" (looked at ${journalPath})`);
|
|
53
|
+
}
|
|
54
|
+
const cache = new Map();
|
|
55
|
+
for (const line of readFileSync(journalPath, "utf8").split("\n")) {
|
|
56
|
+
if (!line.trim())
|
|
57
|
+
continue;
|
|
58
|
+
let entry;
|
|
59
|
+
try {
|
|
60
|
+
entry = JSON.parse(line);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (entry.type === "agent_end" && entry.status === "ok" && typeof entry.key === "string") {
|
|
66
|
+
const queue = cache.get(entry.key) ?? [];
|
|
67
|
+
queue.push(entry.result);
|
|
68
|
+
cache.set(entry.key, queue);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return cache;
|
|
72
|
+
}
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
const paint = (code, text) => (useColor ? `[${code}m${text}[0m` : text);
|
|
3
|
+
const dim = (t) => paint("2", t);
|
|
4
|
+
const bold = (t) => paint("1", t);
|
|
5
|
+
const green = (t) => paint("32", t);
|
|
6
|
+
const red = (t) => paint("31", t);
|
|
7
|
+
const cyan = (t) => paint("36", t);
|
|
8
|
+
function specLabel(spec) {
|
|
9
|
+
if (spec.command?.length)
|
|
10
|
+
return spec.command[0];
|
|
11
|
+
return [spec.provider ?? "openai", spec.model].filter(Boolean).join("/");
|
|
12
|
+
}
|
|
13
|
+
/** Human progress on stderr; stdout stays clean for the result JSON. */
|
|
14
|
+
export class HumanReporter {
|
|
15
|
+
start(meta, runId, runDir) {
|
|
16
|
+
process.stderr.write(`${bold(`◆ ${meta.name}`)} ${dim(`— run ${runId}`)}\n${dim(` ${runDir}`)}\n`);
|
|
17
|
+
}
|
|
18
|
+
phase(title) {
|
|
19
|
+
process.stderr.write(`${cyan(`▸ ${title}`)}\n`);
|
|
20
|
+
}
|
|
21
|
+
log(message) {
|
|
22
|
+
process.stderr.write(`${dim(`· ${message}`)}\n`);
|
|
23
|
+
}
|
|
24
|
+
agentStart(id, label, spec, phase, cached) {
|
|
25
|
+
const suffix = cached ? dim(" (cached)") : "";
|
|
26
|
+
process.stderr.write(` ${dim(`#${id}`)} ${specLabel(spec)} ${dim("·")} ${label}${suffix}\n`);
|
|
27
|
+
}
|
|
28
|
+
agentEnd(id, ok, seconds, detail) {
|
|
29
|
+
const mark = ok ? green("✔") : red("✖");
|
|
30
|
+
const summary = ok ? detail : detail.split("\n")[0].slice(0, 200);
|
|
31
|
+
process.stderr.write(` ${mark} ${dim(`#${id}`)} ${seconds}s ${dim(`· ${summary}`)}\n`);
|
|
32
|
+
}
|
|
33
|
+
done(run) {
|
|
34
|
+
const mark = run.status === "ok" ? green("✔") : red("✖");
|
|
35
|
+
process.stderr.write(`${mark} ${bold(run.status)} ${dim(`· ${run.agents} agents · ${run.outputTokens} out-tok · ${Math.round(run.durationMs / 1000)}s`)}\n`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** NDJSON events on stdout — the machine-readable surface for driving agents. */
|
|
39
|
+
export class JsonReporter {
|
|
40
|
+
emit(event) {
|
|
41
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
42
|
+
}
|
|
43
|
+
start(meta, runId, runDir) {
|
|
44
|
+
this.emit({ event: "start", name: meta.name, runId, runDir });
|
|
45
|
+
}
|
|
46
|
+
phase(title) {
|
|
47
|
+
this.emit({ event: "phase", title });
|
|
48
|
+
}
|
|
49
|
+
log(message) {
|
|
50
|
+
this.emit({ event: "log", message });
|
|
51
|
+
}
|
|
52
|
+
agentStart(id, label, spec, phase, cached) {
|
|
53
|
+
this.emit({ event: "agent_start", id, label, spec, phase, cached });
|
|
54
|
+
}
|
|
55
|
+
agentEnd(id, ok, seconds, detail) {
|
|
56
|
+
this.emit({ event: "agent_end", id, ok, seconds, detail });
|
|
57
|
+
}
|
|
58
|
+
done(run) {
|
|
59
|
+
this.emit({ event: "result", ...run });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { delimiter, dirname, join } from "node:path";
|
|
6
|
+
function findOnPath(binary) {
|
|
7
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
8
|
+
if (!dir)
|
|
9
|
+
continue;
|
|
10
|
+
const candidate = join(dir, binary);
|
|
11
|
+
try {
|
|
12
|
+
accessSync(candidate, constants.X_OK);
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// keep scanning
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Combined stdout+stderr of a short command, or undefined on any failure. */
|
|
22
|
+
function probe(command) {
|
|
23
|
+
try {
|
|
24
|
+
return execFileSync("sh", ["-c", `${command} 2>&1`], { timeout: 4000, encoding: "utf8" });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function fileContains(path, needle) {
|
|
31
|
+
try {
|
|
32
|
+
return readFileSync(path, "utf8").includes(needle);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function envAuth(...vars) {
|
|
39
|
+
const found = vars.find((v) => process.env[v]);
|
|
40
|
+
return found ? { state: "ok", detail: `${found} set` } : undefined;
|
|
41
|
+
}
|
|
42
|
+
function codexSandbox(access) {
|
|
43
|
+
switch (access) {
|
|
44
|
+
case "read":
|
|
45
|
+
return "read-only";
|
|
46
|
+
case "full":
|
|
47
|
+
return "danger-full-access";
|
|
48
|
+
default:
|
|
49
|
+
return "workspace-write";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const PROVIDERS = [
|
|
53
|
+
{
|
|
54
|
+
name: "codex",
|
|
55
|
+
aliases: ["openai"],
|
|
56
|
+
bins: ["codex-acp"],
|
|
57
|
+
bundled: { pkg: "@agentclientprotocol/codex-acp", bin: "codex-acp" },
|
|
58
|
+
npxPkg: "@agentclientprotocol/codex-acp",
|
|
59
|
+
acceptsModel: true,
|
|
60
|
+
args: (spec) => {
|
|
61
|
+
// Approvals off: agents act within the sandbox instead of round-tripping
|
|
62
|
+
// permission prompts. Access is controlled by the sandbox mode itself.
|
|
63
|
+
const args = ["-c", 'approval_policy="never"', "-c", `sandbox_mode="${codexSandbox(spec.access)}"`];
|
|
64
|
+
if (spec.model)
|
|
65
|
+
args.push("-c", `model="${spec.model}"`);
|
|
66
|
+
if (spec.effort)
|
|
67
|
+
args.push("-c", `model_reasoning_effort="${spec.effort}"`);
|
|
68
|
+
return args;
|
|
69
|
+
},
|
|
70
|
+
auth: () => {
|
|
71
|
+
const status = probe("codex login status");
|
|
72
|
+
if (status && /logged in/i.test(status))
|
|
73
|
+
return { state: "ok", detail: status.trim().split("\n")[0] };
|
|
74
|
+
return (envAuth("CODEX_API_KEY", "OPENAI_API_KEY") ?? {
|
|
75
|
+
state: "none",
|
|
76
|
+
detail: "run `codex login` or set OPENAI_API_KEY",
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "claude",
|
|
82
|
+
aliases: ["anthropic", "claude-code"],
|
|
83
|
+
bins: ["claude-agent-acp", "claude-code-acp"],
|
|
84
|
+
npxPkg: "@agentclientprotocol/claude-agent-acp",
|
|
85
|
+
acceptsModel: true,
|
|
86
|
+
args: () => [],
|
|
87
|
+
env: (spec) => (spec.model ? { ANTHROPIC_MODEL: spec.model } : {}),
|
|
88
|
+
auth: () => {
|
|
89
|
+
const fromEnv = envAuth("ANTHROPIC_API_KEY");
|
|
90
|
+
if (fromEnv)
|
|
91
|
+
return fromEnv;
|
|
92
|
+
if (fileContains(join(homedir(), ".claude.json"), "oauthAccount")) {
|
|
93
|
+
return { state: "ok", detail: "Claude Code account in ~/.claude.json" };
|
|
94
|
+
}
|
|
95
|
+
return findOnPath("claude")
|
|
96
|
+
? { state: "unknown", detail: "claude CLI installed; login state not detectable" }
|
|
97
|
+
: { state: "none", detail: "install Claude Code and log in, or set ANTHROPIC_API_KEY" };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "gemini",
|
|
102
|
+
aliases: ["google"],
|
|
103
|
+
bins: ["gemini"],
|
|
104
|
+
npxPkg: "@google/gemini-cli",
|
|
105
|
+
acceptsModel: true,
|
|
106
|
+
args: (spec) => {
|
|
107
|
+
const args = ["--experimental-acp"];
|
|
108
|
+
if (spec.model)
|
|
109
|
+
args.push("-m", spec.model);
|
|
110
|
+
if (spec.access === "full")
|
|
111
|
+
args.push("--yolo");
|
|
112
|
+
return args;
|
|
113
|
+
},
|
|
114
|
+
auth: () => existsSync(join(homedir(), ".gemini", "oauth_creds.json"))
|
|
115
|
+
? { state: "ok", detail: "Google OAuth in ~/.gemini" }
|
|
116
|
+
: (envAuth("GEMINI_API_KEY", "GOOGLE_API_KEY") ?? {
|
|
117
|
+
state: "none",
|
|
118
|
+
detail: "run `gemini` once to log in, or set GEMINI_API_KEY",
|
|
119
|
+
}),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "cursor",
|
|
123
|
+
aliases: ["cursor-agent"],
|
|
124
|
+
bins: ["cursor-agent-acp"],
|
|
125
|
+
npxPkg: "cursor-agent-acp",
|
|
126
|
+
acceptsModel: false,
|
|
127
|
+
args: () => [],
|
|
128
|
+
auth: () => {
|
|
129
|
+
if (!findOnPath("cursor-agent")) {
|
|
130
|
+
return { state: "none", detail: "cursor-agent CLI not installed" };
|
|
131
|
+
}
|
|
132
|
+
const status = probe("cursor-agent status");
|
|
133
|
+
return status && /logged in/i.test(status)
|
|
134
|
+
? { state: "ok", detail: "cursor-agent logged in" }
|
|
135
|
+
: { state: "none", detail: "run `cursor-agent login`" };
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "opencode",
|
|
140
|
+
aliases: [],
|
|
141
|
+
bins: ["opencode"],
|
|
142
|
+
npxPkg: "opencode-ai",
|
|
143
|
+
acceptsModel: false,
|
|
144
|
+
args: () => ["acp"],
|
|
145
|
+
auth: () => existsSync(join(homedir(), ".local", "share", "opencode", "auth.json"))
|
|
146
|
+
? { state: "ok", detail: "credentials in ~/.local/share/opencode" }
|
|
147
|
+
: { state: "unknown", detail: "no auth.json found; opencode may use provider env vars" },
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "qwen",
|
|
151
|
+
aliases: ["qwen-code"],
|
|
152
|
+
bins: ["qwen"],
|
|
153
|
+
npxPkg: "@qwen-code/qwen-code",
|
|
154
|
+
acceptsModel: true,
|
|
155
|
+
args: (spec) => {
|
|
156
|
+
const args = ["--acp"];
|
|
157
|
+
if (spec.model)
|
|
158
|
+
args.push("-m", spec.model);
|
|
159
|
+
return args;
|
|
160
|
+
},
|
|
161
|
+
auth: () => existsSync(join(homedir(), ".qwen", "oauth_creds.json"))
|
|
162
|
+
? { state: "ok", detail: "Qwen OAuth in ~/.qwen" }
|
|
163
|
+
: (envAuth("DASHSCOPE_API_KEY", "OPENAI_API_KEY") ?? {
|
|
164
|
+
state: "none",
|
|
165
|
+
detail: "run `qwen` once to log in",
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "goose",
|
|
170
|
+
aliases: [],
|
|
171
|
+
bins: ["goose"],
|
|
172
|
+
acceptsModel: false,
|
|
173
|
+
args: () => ["acp"],
|
|
174
|
+
auth: () => existsSync(join(homedir(), ".config", "goose", "config.yaml"))
|
|
175
|
+
? { state: "ok", detail: "config in ~/.config/goose" }
|
|
176
|
+
: { state: "none", detail: "run `goose configure`" },
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "copilot",
|
|
180
|
+
aliases: ["github-copilot"],
|
|
181
|
+
bins: ["copilot-language-server"],
|
|
182
|
+
npxPkg: "@github/copilot-language-server",
|
|
183
|
+
acceptsModel: false,
|
|
184
|
+
args: () => ["--acp"],
|
|
185
|
+
auth: () => {
|
|
186
|
+
const status = probe("gh auth status");
|
|
187
|
+
return status && /logged in/i.test(status)
|
|
188
|
+
? { state: "unknown", detail: "gh logged in; Copilot subscription not verified" }
|
|
189
|
+
: { state: "unknown", detail: "uses GitHub auth; not detectable here" };
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
function findProvider(name) {
|
|
194
|
+
return PROVIDERS.find((p) => p.name === name || p.aliases.includes(name));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Locate an npm-distributed launcher script relative to this package.
|
|
198
|
+
* Fails inside standalone binaries (no node_modules) — callers fall back
|
|
199
|
+
* to PATH and then npx so the compiled `newton` binary still works.
|
|
200
|
+
*/
|
|
201
|
+
function resolveBundledBin(pkg, binName) {
|
|
202
|
+
try {
|
|
203
|
+
const require = createRequire(import.meta.url);
|
|
204
|
+
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
|
|
205
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
206
|
+
const rel = pkgJson.bin?.[binName];
|
|
207
|
+
return rel ? join(dirname(pkgJsonPath), rel) : undefined;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function resolveAgentCommand(spec) {
|
|
214
|
+
if (spec.command?.length) {
|
|
215
|
+
const [command, ...args] = spec.command;
|
|
216
|
+
return { command: command, args, env: {}, modelHandled: false };
|
|
217
|
+
}
|
|
218
|
+
const name = spec.provider ?? "codex";
|
|
219
|
+
const provider = findProvider(name);
|
|
220
|
+
if (!provider) {
|
|
221
|
+
const known = PROVIDERS.map((p) => p.name).join(" | ");
|
|
222
|
+
throw new Error(`Unknown provider "${name}". Use ${known}, or pass command: ["my-agent", "--acp"].`);
|
|
223
|
+
}
|
|
224
|
+
const args = provider.args(spec);
|
|
225
|
+
const env = provider.env?.(spec) ?? {};
|
|
226
|
+
const modelHandled = Boolean(spec.model) && provider.acceptsModel;
|
|
227
|
+
if (provider.bundled) {
|
|
228
|
+
const script = resolveBundledBin(provider.bundled.pkg, provider.bundled.bin);
|
|
229
|
+
if (script)
|
|
230
|
+
return { command: process.execPath, args: [script, ...args], env, modelHandled };
|
|
231
|
+
}
|
|
232
|
+
for (const bin of provider.bins) {
|
|
233
|
+
const onPath = findOnPath(bin);
|
|
234
|
+
if (onPath)
|
|
235
|
+
return { command: onPath, args, env, modelHandled };
|
|
236
|
+
}
|
|
237
|
+
if (provider.npxPkg) {
|
|
238
|
+
return { command: "npx", args: ["-y", provider.npxPkg, ...args], env, modelHandled };
|
|
239
|
+
}
|
|
240
|
+
throw new Error(`Provider "${provider.name}" needs one of [${provider.bins.join(", ")}] on PATH — no npx fallback exists for it.`);
|
|
241
|
+
}
|
|
242
|
+
export function describeProviders() {
|
|
243
|
+
return PROVIDERS.map((provider) => {
|
|
244
|
+
const bundled = provider.bundled && resolveBundledBin(provider.bundled.pkg, provider.bundled.bin);
|
|
245
|
+
const onPath = provider.bins.map(findOnPath).find(Boolean);
|
|
246
|
+
const resolvesVia = bundled ?? onPath ?? (provider.npxPkg ? `npx -y ${provider.npxPkg}` : "not installed");
|
|
247
|
+
return {
|
|
248
|
+
provider: provider.name,
|
|
249
|
+
aliases: provider.aliases,
|
|
250
|
+
resolvesVia,
|
|
251
|
+
installed: Boolean(bundled ?? onPath),
|
|
252
|
+
auth: provider.auth(),
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
}
|