opencara 0.8.0 → 0.10.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/dist/index.js +607 -1369
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command2 } from "commander";
|
|
5
5
|
|
|
6
|
-
// src/commands/
|
|
6
|
+
// src/commands/agent.ts
|
|
7
7
|
import { Command } from "commander";
|
|
8
|
-
import
|
|
8
|
+
import crypto from "crypto";
|
|
9
9
|
|
|
10
10
|
// src/config.ts
|
|
11
11
|
import * as fs from "fs";
|
|
@@ -15,10 +15,6 @@ import { parse, stringify } from "yaml";
|
|
|
15
15
|
var DEFAULT_PLATFORM_URL = "https://api.opencara.dev";
|
|
16
16
|
var CONFIG_DIR = path.join(os.homedir(), ".opencara");
|
|
17
17
|
var CONFIG_FILE = process.env.OPENCARA_CONFIG && process.env.OPENCARA_CONFIG.trim() ? path.resolve(process.env.OPENCARA_CONFIG) : path.join(CONFIG_DIR, "config.yml");
|
|
18
|
-
function ensureConfigDir() {
|
|
19
|
-
const dir = path.dirname(CONFIG_FILE);
|
|
20
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
-
}
|
|
22
18
|
var DEFAULT_MAX_DIFF_SIZE_KB = 100;
|
|
23
19
|
function parseLimits(data) {
|
|
24
20
|
const raw = data.limits;
|
|
@@ -74,44 +70,6 @@ function parseRepoConfig(obj, index) {
|
|
|
74
70
|
}
|
|
75
71
|
return config;
|
|
76
72
|
}
|
|
77
|
-
function parseAnonymousAgents(data) {
|
|
78
|
-
const raw = data.anonymous_agents;
|
|
79
|
-
if (!Array.isArray(raw)) return [];
|
|
80
|
-
const entries = [];
|
|
81
|
-
for (let i = 0; i < raw.length; i++) {
|
|
82
|
-
const entry = raw[i];
|
|
83
|
-
if (!entry || typeof entry !== "object") {
|
|
84
|
-
console.warn(`Warning: anonymous_agents[${i}] is not an object, skipping`);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
const obj = entry;
|
|
88
|
-
if (typeof obj.agent_id !== "string" || typeof obj.api_key !== "string" || typeof obj.model !== "string" || typeof obj.tool !== "string") {
|
|
89
|
-
console.warn(
|
|
90
|
-
`Warning: anonymous_agents[${i}] missing required agent_id/api_key/model/tool fields, skipping`
|
|
91
|
-
);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
const anon = {
|
|
95
|
-
agentId: obj.agent_id,
|
|
96
|
-
apiKey: obj.api_key,
|
|
97
|
-
model: obj.model,
|
|
98
|
-
tool: obj.tool
|
|
99
|
-
};
|
|
100
|
-
if (typeof obj.name === "string") anon.name = obj.name;
|
|
101
|
-
if (obj.repo_config && typeof obj.repo_config === "object") {
|
|
102
|
-
const rc = obj.repo_config;
|
|
103
|
-
if (typeof rc.mode === "string" && VALID_REPO_MODES.includes(rc.mode)) {
|
|
104
|
-
const repoConfig = { mode: rc.mode };
|
|
105
|
-
if (Array.isArray(rc.list)) {
|
|
106
|
-
repoConfig.list = rc.list.filter((v) => typeof v === "string");
|
|
107
|
-
}
|
|
108
|
-
anon.repoConfig = repoConfig;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
entries.push(anon);
|
|
112
|
-
}
|
|
113
|
-
return entries;
|
|
114
|
-
}
|
|
115
73
|
function parseAgents(data) {
|
|
116
74
|
if (!("agents" in data)) return null;
|
|
117
75
|
const raw = data.agents;
|
|
@@ -131,6 +89,8 @@ function parseAgents(data) {
|
|
|
131
89
|
const agent = { model: obj.model, tool: obj.tool };
|
|
132
90
|
if (typeof obj.name === "string") agent.name = obj.name;
|
|
133
91
|
if (typeof obj.command === "string") agent.command = obj.command;
|
|
92
|
+
if (obj.router === true) agent.router = true;
|
|
93
|
+
if (obj.review_only === true) agent.review_only = true;
|
|
134
94
|
const agentLimits = parseLimits(obj);
|
|
135
95
|
if (agentLimits) agent.limits = agentLimits;
|
|
136
96
|
const repoConfig = parseRepoConfig(obj, i);
|
|
@@ -146,8 +106,7 @@ function loadConfig() {
|
|
|
146
106
|
maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
|
|
147
107
|
limits: null,
|
|
148
108
|
agentCommand: null,
|
|
149
|
-
agents: null
|
|
150
|
-
anonymousAgents: []
|
|
109
|
+
agents: null
|
|
151
110
|
};
|
|
152
111
|
if (!fs.existsSync(CONFIG_FILE)) {
|
|
153
112
|
return defaults;
|
|
@@ -163,48 +122,8 @@ function loadConfig() {
|
|
|
163
122
|
maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
|
|
164
123
|
limits: parseLimits(data),
|
|
165
124
|
agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
|
|
166
|
-
agents: parseAgents(data)
|
|
167
|
-
anonymousAgents: parseAnonymousAgents(data)
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
function saveConfig(config) {
|
|
171
|
-
ensureConfigDir();
|
|
172
|
-
const data = {
|
|
173
|
-
platform_url: config.platformUrl
|
|
125
|
+
agents: parseAgents(data)
|
|
174
126
|
};
|
|
175
|
-
if (config.apiKey) {
|
|
176
|
-
data.api_key = config.apiKey;
|
|
177
|
-
}
|
|
178
|
-
if (config.maxDiffSizeKb !== DEFAULT_MAX_DIFF_SIZE_KB) {
|
|
179
|
-
data.max_diff_size_kb = config.maxDiffSizeKb;
|
|
180
|
-
}
|
|
181
|
-
if (config.limits) {
|
|
182
|
-
data.limits = config.limits;
|
|
183
|
-
}
|
|
184
|
-
if (config.agentCommand) {
|
|
185
|
-
data.agent_command = config.agentCommand;
|
|
186
|
-
}
|
|
187
|
-
if (config.agents !== null) {
|
|
188
|
-
data.agents = config.agents;
|
|
189
|
-
}
|
|
190
|
-
if (config.anonymousAgents.length > 0) {
|
|
191
|
-
data.anonymous_agents = config.anonymousAgents.map((a) => {
|
|
192
|
-
const entry = {
|
|
193
|
-
agent_id: a.agentId,
|
|
194
|
-
api_key: a.apiKey,
|
|
195
|
-
model: a.model,
|
|
196
|
-
tool: a.tool
|
|
197
|
-
};
|
|
198
|
-
if (a.name) {
|
|
199
|
-
entry.name = a.name;
|
|
200
|
-
}
|
|
201
|
-
if (a.repoConfig) {
|
|
202
|
-
entry.repo_config = a.repoConfig;
|
|
203
|
-
}
|
|
204
|
-
return entry;
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
fs.writeFileSync(CONFIG_FILE, stringify(data), { encoding: "utf-8", mode: 384 });
|
|
208
127
|
}
|
|
209
128
|
function resolveAgentLimits(agentLimits, globalLimits) {
|
|
210
129
|
if (!agentLimits && !globalLimits) return null;
|
|
@@ -213,19 +132,6 @@ function resolveAgentLimits(agentLimits, globalLimits) {
|
|
|
213
132
|
const merged = { ...globalLimits, ...agentLimits };
|
|
214
133
|
return Object.keys(merged).length === 0 ? null : merged;
|
|
215
134
|
}
|
|
216
|
-
function findAnonymousAgent(config, agentId) {
|
|
217
|
-
return config.anonymousAgents.find((a) => a.agentId === agentId) ?? null;
|
|
218
|
-
}
|
|
219
|
-
function removeAnonymousAgent(config, agentId) {
|
|
220
|
-
config.anonymousAgents = config.anonymousAgents.filter((a) => a.agentId !== agentId);
|
|
221
|
-
}
|
|
222
|
-
function requireApiKey(config) {
|
|
223
|
-
if (!config.apiKey) {
|
|
224
|
-
console.error("Not authenticated. Run `opencara login` first.");
|
|
225
|
-
process.exit(1);
|
|
226
|
-
}
|
|
227
|
-
return config.apiKey;
|
|
228
|
-
}
|
|
229
135
|
|
|
230
136
|
// src/http.ts
|
|
231
137
|
var HttpError = class extends Error {
|
|
@@ -236,9 +142,14 @@ var HttpError = class extends Error {
|
|
|
236
142
|
}
|
|
237
143
|
};
|
|
238
144
|
var ApiClient = class {
|
|
239
|
-
constructor(baseUrl, apiKey = null) {
|
|
145
|
+
constructor(baseUrl, apiKey = null, debug) {
|
|
240
146
|
this.baseUrl = baseUrl;
|
|
241
147
|
this.apiKey = apiKey;
|
|
148
|
+
this.debug = debug ?? process.env.OPENCARA_DEBUG === "1";
|
|
149
|
+
}
|
|
150
|
+
debug;
|
|
151
|
+
log(msg) {
|
|
152
|
+
if (this.debug) console.debug(`[ApiClient] ${msg}`);
|
|
242
153
|
}
|
|
243
154
|
headers() {
|
|
244
155
|
const h = {
|
|
@@ -250,245 +161,78 @@ var ApiClient = class {
|
|
|
250
161
|
return h;
|
|
251
162
|
}
|
|
252
163
|
async get(path3) {
|
|
164
|
+
this.log(`GET ${path3}`);
|
|
253
165
|
const res = await fetch(`${this.baseUrl}${path3}`, {
|
|
254
166
|
method: "GET",
|
|
255
167
|
headers: this.headers()
|
|
256
168
|
});
|
|
257
|
-
return this.handleResponse(res);
|
|
169
|
+
return this.handleResponse(res, path3);
|
|
258
170
|
}
|
|
259
171
|
async post(path3, body) {
|
|
172
|
+
this.log(`POST ${path3}`);
|
|
260
173
|
const res = await fetch(`${this.baseUrl}${path3}`, {
|
|
261
174
|
method: "POST",
|
|
262
175
|
headers: this.headers(),
|
|
263
176
|
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
264
177
|
});
|
|
265
|
-
return this.handleResponse(res);
|
|
178
|
+
return this.handleResponse(res, path3);
|
|
266
179
|
}
|
|
267
|
-
async handleResponse(res) {
|
|
180
|
+
async handleResponse(res, path3) {
|
|
268
181
|
if (!res.ok) {
|
|
269
|
-
if (res.status === 401) {
|
|
270
|
-
throw new HttpError(401, "Not authenticated. Run `opencara login` first.");
|
|
271
|
-
}
|
|
272
182
|
let message = `HTTP ${res.status}`;
|
|
273
183
|
try {
|
|
274
184
|
const body = await res.json();
|
|
275
185
|
if (body.error) message = body.error;
|
|
276
186
|
} catch {
|
|
277
187
|
}
|
|
188
|
+
this.log(`${res.status} ${message} (${path3})`);
|
|
278
189
|
throw new HttpError(res.status, message);
|
|
279
190
|
}
|
|
191
|
+
this.log(`${res.status} OK (${path3})`);
|
|
280
192
|
return await res.json();
|
|
281
193
|
}
|
|
282
194
|
};
|
|
283
195
|
|
|
284
|
-
// src/
|
|
285
|
-
var
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
jitter: true
|
|
196
|
+
// src/retry.ts
|
|
197
|
+
var DEFAULT_RETRY = {
|
|
198
|
+
maxAttempts: 3,
|
|
199
|
+
baseDelayMs: 1e3,
|
|
200
|
+
maxDelayMs: 3e4
|
|
290
201
|
};
|
|
291
|
-
function
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (options.jitter) {
|
|
297
|
-
return base + Math.random() * 500;
|
|
298
|
-
}
|
|
299
|
-
return base;
|
|
300
|
-
}
|
|
301
|
-
function sleep(ms) {
|
|
302
|
-
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// src/commands/login.ts
|
|
306
|
-
function promptYesNo(question) {
|
|
307
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
308
|
-
return new Promise((resolve2) => {
|
|
309
|
-
rl.question(question, (answer) => {
|
|
310
|
-
rl.close();
|
|
311
|
-
const normalized = answer.trim().toLowerCase();
|
|
312
|
-
resolve2(normalized === "" || normalized === "y" || normalized === "yes");
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
var loginCommand = new Command("login").description("Authenticate with GitHub via device flow").action(async () => {
|
|
317
|
-
const config = loadConfig();
|
|
318
|
-
const client = new ApiClient(config.platformUrl);
|
|
319
|
-
let flow;
|
|
320
|
-
try {
|
|
321
|
-
flow = await client.post("/auth/device");
|
|
322
|
-
} catch (err) {
|
|
323
|
-
console.error("Failed to start device flow:", err instanceof Error ? err.message : err);
|
|
324
|
-
process.exit(1);
|
|
325
|
-
}
|
|
326
|
-
console.log();
|
|
327
|
-
console.log("To sign in, open this URL in your browser:");
|
|
328
|
-
console.log(` ${flow.verificationUri}`);
|
|
329
|
-
console.log();
|
|
330
|
-
console.log(`And enter this code: ${flow.userCode}`);
|
|
331
|
-
console.log();
|
|
332
|
-
console.log("Waiting for authorization...");
|
|
333
|
-
const intervalMs = flow.interval * 1e3;
|
|
334
|
-
const deadline = Date.now() + flow.expiresIn * 1e3;
|
|
335
|
-
while (Date.now() < deadline) {
|
|
336
|
-
await sleep(intervalMs);
|
|
337
|
-
let tokenRes;
|
|
202
|
+
async function withRetry(fn, options = {}, signal) {
|
|
203
|
+
const opts = { ...DEFAULT_RETRY, ...options };
|
|
204
|
+
let lastError;
|
|
205
|
+
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
|
|
206
|
+
if (signal?.aborted) throw new Error("Aborted");
|
|
338
207
|
try {
|
|
339
|
-
|
|
340
|
-
deviceCode: flow.deviceCode
|
|
341
|
-
});
|
|
208
|
+
return await fn();
|
|
342
209
|
} catch (err) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
process.stdout.write(".");
|
|
348
|
-
continue;
|
|
349
|
-
}
|
|
350
|
-
if (tokenRes.status === "expired") {
|
|
351
|
-
console.error("\nDevice code expired. Please run `opencara login` again.");
|
|
352
|
-
process.exit(1);
|
|
353
|
-
}
|
|
354
|
-
if (tokenRes.status === "complete") {
|
|
355
|
-
config.apiKey = tokenRes.apiKey;
|
|
356
|
-
saveConfig(config);
|
|
357
|
-
console.log("\nLogged in successfully. API key saved to ~/.opencara/config.yml");
|
|
358
|
-
if (config.anonymousAgents.length > 0 && process.stdin.isTTY) {
|
|
359
|
-
console.log();
|
|
360
|
-
console.log(`Found ${config.anonymousAgents.length} anonymous agent(s):`);
|
|
361
|
-
for (const anon of config.anonymousAgents) {
|
|
362
|
-
console.log(` - ${anon.agentId} (${anon.model} / ${anon.tool})`);
|
|
363
|
-
}
|
|
364
|
-
const shouldLink = await promptYesNo("Link to your GitHub account? [Y/n] ");
|
|
365
|
-
if (shouldLink) {
|
|
366
|
-
const authedClient = new ApiClient(config.platformUrl, tokenRes.apiKey);
|
|
367
|
-
let linkedCount = 0;
|
|
368
|
-
const toRemove = [];
|
|
369
|
-
for (const anon of config.anonymousAgents) {
|
|
370
|
-
try {
|
|
371
|
-
await authedClient.post("/api/account/link", {
|
|
372
|
-
anonymousApiKey: anon.apiKey
|
|
373
|
-
});
|
|
374
|
-
toRemove.push(anon.agentId);
|
|
375
|
-
linkedCount++;
|
|
376
|
-
} catch (err) {
|
|
377
|
-
console.error(
|
|
378
|
-
`Failed to link agent ${anon.agentId}:`,
|
|
379
|
-
err instanceof Error ? err.message : err
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
for (const id of toRemove) {
|
|
384
|
-
removeAnonymousAgent(config, id);
|
|
385
|
-
}
|
|
386
|
-
saveConfig(config);
|
|
387
|
-
if (linkedCount > 0) {
|
|
388
|
-
console.log(`Linked ${linkedCount} agent(s) to your account.`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
210
|
+
lastError = err;
|
|
211
|
+
if (attempt < opts.maxAttempts - 1) {
|
|
212
|
+
const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);
|
|
213
|
+
await sleep(delay, signal);
|
|
391
214
|
}
|
|
392
|
-
return;
|
|
393
215
|
}
|
|
394
216
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
import crypto2 from "crypto";
|
|
403
|
-
|
|
404
|
-
// ../shared/dist/api.js
|
|
405
|
-
var DEFAULT_REGISTRY = {
|
|
406
|
-
tools: [
|
|
407
|
-
{
|
|
408
|
-
name: "claude",
|
|
409
|
-
displayName: "Claude",
|
|
410
|
-
binary: "claude",
|
|
411
|
-
commandTemplate: "claude --model ${MODEL} -p ${PROMPT} --output-format text",
|
|
412
|
-
tokenParser: "claude"
|
|
413
|
-
},
|
|
414
|
-
{
|
|
415
|
-
name: "codex",
|
|
416
|
-
displayName: "Codex",
|
|
417
|
-
binary: "codex",
|
|
418
|
-
commandTemplate: "codex --model ${MODEL} -p ${PROMPT}",
|
|
419
|
-
tokenParser: "codex"
|
|
420
|
-
},
|
|
421
|
-
{
|
|
422
|
-
name: "gemini",
|
|
423
|
-
displayName: "Gemini",
|
|
424
|
-
binary: "gemini",
|
|
425
|
-
commandTemplate: "gemini --model ${MODEL} -p ${PROMPT}",
|
|
426
|
-
tokenParser: "gemini"
|
|
427
|
-
},
|
|
428
|
-
{
|
|
429
|
-
name: "qwen",
|
|
430
|
-
displayName: "Qwen",
|
|
431
|
-
binary: "qwen",
|
|
432
|
-
commandTemplate: "qwen --model ${MODEL} -p ${PROMPT} -y",
|
|
433
|
-
tokenParser: "qwen"
|
|
434
|
-
}
|
|
435
|
-
],
|
|
436
|
-
models: [
|
|
437
|
-
{
|
|
438
|
-
name: "claude-opus-4-6",
|
|
439
|
-
displayName: "Claude Opus 4.6",
|
|
440
|
-
tools: ["claude"],
|
|
441
|
-
defaultReputation: 0.8
|
|
442
|
-
},
|
|
443
|
-
{
|
|
444
|
-
name: "claude-opus-4-6[1m]",
|
|
445
|
-
displayName: "Claude Opus 4.6 (1M context)",
|
|
446
|
-
tools: ["claude"],
|
|
447
|
-
defaultReputation: 0.8
|
|
448
|
-
},
|
|
449
|
-
{
|
|
450
|
-
name: "claude-sonnet-4-6",
|
|
451
|
-
displayName: "Claude Sonnet 4.6",
|
|
452
|
-
tools: ["claude"],
|
|
453
|
-
defaultReputation: 0.7
|
|
454
|
-
},
|
|
455
|
-
{
|
|
456
|
-
name: "claude-sonnet-4-6[1m]",
|
|
457
|
-
displayName: "Claude Sonnet 4.6 (1M context)",
|
|
458
|
-
tools: ["claude"],
|
|
459
|
-
defaultReputation: 0.7
|
|
460
|
-
},
|
|
461
|
-
{
|
|
462
|
-
name: "gpt-5-codex",
|
|
463
|
-
displayName: "GPT-5 Codex",
|
|
464
|
-
tools: ["codex"],
|
|
465
|
-
defaultReputation: 0.7
|
|
466
|
-
},
|
|
467
|
-
{
|
|
468
|
-
name: "gemini-2.5-pro",
|
|
469
|
-
displayName: "Gemini 2.5 Pro",
|
|
470
|
-
tools: ["gemini"],
|
|
471
|
-
defaultReputation: 0.7
|
|
472
|
-
},
|
|
473
|
-
{
|
|
474
|
-
name: "qwen3.5-plus",
|
|
475
|
-
displayName: "Qwen 3.5 Plus",
|
|
476
|
-
tools: ["qwen"],
|
|
477
|
-
defaultReputation: 0.6
|
|
478
|
-
},
|
|
479
|
-
{ name: "glm-5", displayName: "GLM-5", tools: ["qwen"], defaultReputation: 0.5 },
|
|
480
|
-
{ name: "kimi-k2.5", displayName: "Kimi K2.5", tools: ["qwen"], defaultReputation: 0.5 },
|
|
481
|
-
{
|
|
482
|
-
name: "minimax-m2.5",
|
|
483
|
-
displayName: "Minimax M2.5",
|
|
484
|
-
tools: ["qwen"],
|
|
485
|
-
defaultReputation: 0.5
|
|
217
|
+
throw lastError;
|
|
218
|
+
}
|
|
219
|
+
function sleep(ms, signal) {
|
|
220
|
+
return new Promise((resolve2) => {
|
|
221
|
+
if (signal?.aborted) {
|
|
222
|
+
resolve2();
|
|
223
|
+
return;
|
|
486
224
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
225
|
+
const timer = setTimeout(resolve2, ms);
|
|
226
|
+
signal?.addEventListener(
|
|
227
|
+
"abort",
|
|
228
|
+
() => {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
resolve2();
|
|
231
|
+
},
|
|
232
|
+
{ once: true }
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
492
236
|
|
|
493
237
|
// src/tool-executor.ts
|
|
494
238
|
import { spawn, execFileSync } from "child_process";
|
|
@@ -559,14 +303,6 @@ function parseCommandTemplate(template, vars = {}) {
|
|
|
559
303
|
}
|
|
560
304
|
return { command: interpolated[0], args: interpolated.slice(1) };
|
|
561
305
|
}
|
|
562
|
-
function resolveCommandTemplate(agentCommand2) {
|
|
563
|
-
if (agentCommand2) {
|
|
564
|
-
return agentCommand2;
|
|
565
|
-
}
|
|
566
|
-
throw new Error(
|
|
567
|
-
"No command configured for this agent. Set command in ~/.opencara/config.yml agents section or run `opencara agent create`."
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
306
|
var CHARS_PER_TOKEN = 4;
|
|
571
307
|
function estimateTokens(text) {
|
|
572
308
|
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
@@ -740,6 +476,7 @@ function extractVerdict(text) {
|
|
|
740
476
|
const review = (before + after).replace(/\n{3,}/g, "\n\n").trim();
|
|
741
477
|
return { verdict: verdictStr, review };
|
|
742
478
|
}
|
|
479
|
+
console.warn("No verdict found in review output, defaulting to COMMENT");
|
|
743
480
|
return { verdict: "comment", review: text };
|
|
744
481
|
}
|
|
745
482
|
async function executeReview(req, deps, runTool = executeTool) {
|
|
@@ -898,1126 +635,627 @@ ${userMessage}`;
|
|
|
898
635
|
}
|
|
899
636
|
}
|
|
900
637
|
|
|
901
|
-
// src/
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
"
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
"
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
].join("")
|
|
944
|
-
);
|
|
638
|
+
// src/router.ts
|
|
639
|
+
import * as readline from "readline";
|
|
640
|
+
var END_OF_RESPONSE = "<<<OPENCARA_END_RESPONSE>>>";
|
|
641
|
+
var RouterRelay = class {
|
|
642
|
+
pending = null;
|
|
643
|
+
responseLines = [];
|
|
644
|
+
rl = null;
|
|
645
|
+
stdout;
|
|
646
|
+
stderr;
|
|
647
|
+
stdin;
|
|
648
|
+
stopped = false;
|
|
649
|
+
constructor(deps) {
|
|
650
|
+
this.stdin = deps?.stdin ?? process.stdin;
|
|
651
|
+
this.stdout = deps?.stdout ?? process.stdout;
|
|
652
|
+
this.stderr = deps?.stderr ?? process.stderr;
|
|
653
|
+
}
|
|
654
|
+
/** Start listening for stdin input */
|
|
655
|
+
start() {
|
|
656
|
+
this.stopped = false;
|
|
657
|
+
this.rl = readline.createInterface({
|
|
658
|
+
input: this.stdin,
|
|
659
|
+
terminal: false
|
|
660
|
+
});
|
|
661
|
+
this.rl.on("line", (line) => {
|
|
662
|
+
this.handleLine(line);
|
|
663
|
+
});
|
|
664
|
+
this.rl.on("close", () => {
|
|
665
|
+
if (this.stopped) return;
|
|
666
|
+
if (this.pending) {
|
|
667
|
+
const response = this.responseLines.join("\n").trim();
|
|
668
|
+
this.responseLines = [];
|
|
669
|
+
clearTimeout(this.pending.timer);
|
|
670
|
+
const task = this.pending;
|
|
671
|
+
this.pending = null;
|
|
672
|
+
if (response.length >= 100) {
|
|
673
|
+
console.warn("Router stdin closed \u2014 accepting partial response");
|
|
674
|
+
task.resolve(response);
|
|
675
|
+
} else {
|
|
676
|
+
task.reject(new Error("Router process died (stdin closed with insufficient response)"));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
});
|
|
945
680
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
var STABILITY_THRESHOLD_MAX_MS = 3e5;
|
|
953
|
-
var WS_PING_INTERVAL_MS = 2e4;
|
|
954
|
-
function startAgent(agentId, platformUrl, apiKey, reviewDeps, consumptionDeps, options) {
|
|
955
|
-
const verbose = options?.verbose ?? false;
|
|
956
|
-
const stabilityThreshold = options?.stabilityThresholdMs ?? CONNECTION_STABILITY_THRESHOLD_MS;
|
|
957
|
-
const repoConfig = options?.repoConfig;
|
|
958
|
-
const displayName = options?.displayName;
|
|
959
|
-
const prefix = options?.label ? `[${options.label}]` : "";
|
|
960
|
-
const log = (...args) => console.log(...prefix ? [prefix, ...args] : args);
|
|
961
|
-
const logError = (...args) => console.error(...prefix ? [prefix, ...args] : args);
|
|
962
|
-
let attempt = 0;
|
|
963
|
-
let intentionalClose = false;
|
|
964
|
-
let heartbeatTimer = null;
|
|
965
|
-
let wsPingTimer = null;
|
|
966
|
-
let currentWs = null;
|
|
967
|
-
let connectionOpenedAt = null;
|
|
968
|
-
let stabilityTimer = null;
|
|
969
|
-
function clearHeartbeatTimer() {
|
|
970
|
-
if (heartbeatTimer) {
|
|
971
|
-
clearTimeout(heartbeatTimer);
|
|
972
|
-
heartbeatTimer = null;
|
|
681
|
+
/** Stop listening and clean up */
|
|
682
|
+
stop() {
|
|
683
|
+
this.stopped = true;
|
|
684
|
+
if (this.rl) {
|
|
685
|
+
this.rl.close();
|
|
686
|
+
this.rl = null;
|
|
973
687
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
688
|
+
if (this.pending) {
|
|
689
|
+
clearTimeout(this.pending.timer);
|
|
690
|
+
this.pending.reject(new Error("Router relay stopped"));
|
|
691
|
+
this.pending = null;
|
|
692
|
+
this.responseLines = [];
|
|
979
693
|
}
|
|
980
694
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
695
|
+
/** Write the prompt as plain text to stdout */
|
|
696
|
+
writePrompt(prompt) {
|
|
697
|
+
try {
|
|
698
|
+
this.stdout.write(prompt + "\n");
|
|
699
|
+
} catch (err) {
|
|
700
|
+
throw new Error(`Failed to write to router: ${err.message}`);
|
|
985
701
|
}
|
|
986
702
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
clearStabilityTimer();
|
|
991
|
-
clearWsPingTimer();
|
|
992
|
-
if (currentWs) currentWs.close();
|
|
993
|
-
log("Disconnected.");
|
|
994
|
-
process.exit(0);
|
|
703
|
+
/** Write a status message to stderr (doesn't interfere with prompt/response on stdout/stdin) */
|
|
704
|
+
writeStatus(message) {
|
|
705
|
+
this.stderr.write(message + "\n");
|
|
995
706
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
if (
|
|
1023
|
-
|
|
707
|
+
/** Build the full prompt for a review request */
|
|
708
|
+
buildReviewPrompt(req) {
|
|
709
|
+
const systemPrompt = buildSystemPrompt(
|
|
710
|
+
req.owner,
|
|
711
|
+
req.repo,
|
|
712
|
+
req.reviewMode
|
|
713
|
+
);
|
|
714
|
+
const userMessage = buildUserMessage(req.prompt, req.diffContent);
|
|
715
|
+
return `${systemPrompt}
|
|
716
|
+
|
|
717
|
+
${userMessage}`;
|
|
718
|
+
}
|
|
719
|
+
/** Build the full prompt for a summary request */
|
|
720
|
+
buildSummaryPrompt(req) {
|
|
721
|
+
const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
|
|
722
|
+
const userMessage = buildSummaryUserMessage(req.prompt, req.reviews, req.diffContent);
|
|
723
|
+
return `${systemPrompt}
|
|
724
|
+
|
|
725
|
+
${userMessage}`;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Send a prompt to the external agent via stdout (plain text)
|
|
729
|
+
* and wait for the response via stdin (plain text, terminated by END_OF_RESPONSE or EOF).
|
|
730
|
+
*/
|
|
731
|
+
sendPrompt(_type, _taskId, prompt, timeoutSec) {
|
|
732
|
+
return new Promise((resolve2, reject) => {
|
|
733
|
+
if (this.pending) {
|
|
734
|
+
reject(new Error("Another prompt is already pending"));
|
|
735
|
+
return;
|
|
1024
736
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
}, stabilityThreshold);
|
|
1034
|
-
});
|
|
1035
|
-
ws.on("message", (data) => {
|
|
1036
|
-
let msg;
|
|
737
|
+
const timeoutMs = timeoutSec * 1e3;
|
|
738
|
+
this.responseLines = [];
|
|
739
|
+
const timer = setTimeout(() => {
|
|
740
|
+
this.pending = null;
|
|
741
|
+
this.responseLines = [];
|
|
742
|
+
reject(new RouterTimeoutError(`Response timeout (${timeoutSec}s)`));
|
|
743
|
+
}, timeoutMs);
|
|
744
|
+
this.pending = { resolve: resolve2, reject, timer };
|
|
1037
745
|
try {
|
|
1038
|
-
|
|
1039
|
-
} catch {
|
|
1040
|
-
|
|
746
|
+
this.writePrompt(prompt);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
clearTimeout(timer);
|
|
749
|
+
this.pending = null;
|
|
750
|
+
this.responseLines = [];
|
|
751
|
+
reject(err);
|
|
1041
752
|
}
|
|
1042
|
-
handleMessage(
|
|
1043
|
-
ws,
|
|
1044
|
-
msg,
|
|
1045
|
-
resetHeartbeatTimer,
|
|
1046
|
-
reviewDeps,
|
|
1047
|
-
consumptionDeps,
|
|
1048
|
-
verbose,
|
|
1049
|
-
repoConfig,
|
|
1050
|
-
displayName,
|
|
1051
|
-
prefix
|
|
1052
|
-
);
|
|
1053
753
|
});
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
754
|
+
}
|
|
755
|
+
/** Parse a review response: extract verdict and review text */
|
|
756
|
+
parseReviewResponse(response) {
|
|
757
|
+
return extractVerdict(response);
|
|
758
|
+
}
|
|
759
|
+
/** Get whether a task is pending (for testing) */
|
|
760
|
+
get pendingCount() {
|
|
761
|
+
return this.pending ? 1 : 0;
|
|
762
|
+
}
|
|
763
|
+
/** Handle a single line from stdin */
|
|
764
|
+
handleLine(line) {
|
|
765
|
+
if (!this.pending) return;
|
|
766
|
+
if (line.trim() === END_OF_RESPONSE) {
|
|
767
|
+
const response = this.responseLines.join("\n").trim();
|
|
768
|
+
this.responseLines = [];
|
|
769
|
+
clearTimeout(this.pending.timer);
|
|
770
|
+
const task = this.pending;
|
|
771
|
+
this.pending = null;
|
|
772
|
+
if (!response || response.length < 10) {
|
|
773
|
+
task.reject(new Error("Router returned empty or trivially short response"));
|
|
1071
774
|
return;
|
|
1072
775
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
if (verbose) {
|
|
1078
|
-
log(`[verbose] WS pong received at ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
1079
|
-
}
|
|
1080
|
-
});
|
|
1081
|
-
ws.on("error", (err) => {
|
|
1082
|
-
logError(`WebSocket error: ${err.message}`);
|
|
1083
|
-
});
|
|
776
|
+
task.resolve(response);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
this.responseLines.push(line);
|
|
1084
780
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
await sleep(delay);
|
|
1091
|
-
connect();
|
|
781
|
+
};
|
|
782
|
+
var RouterTimeoutError = class extends Error {
|
|
783
|
+
constructor(message) {
|
|
784
|
+
super(message);
|
|
785
|
+
this.name = "RouterTimeoutError";
|
|
1092
786
|
}
|
|
1093
|
-
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
// src/consumption.ts
|
|
790
|
+
function createSessionTracker() {
|
|
791
|
+
return { tokens: 0, reviews: 0 };
|
|
1094
792
|
}
|
|
1095
|
-
function
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
} catch {
|
|
1099
|
-
console.error("Failed to send message \u2014 WebSocket may be closed");
|
|
1100
|
-
}
|
|
793
|
+
function recordSessionUsage(session, tokensUsed) {
|
|
794
|
+
session.tokens += tokensUsed;
|
|
795
|
+
session.reviews += 1;
|
|
1101
796
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
} else {
|
|
1123
|
-
console.log(
|
|
1124
|
-
`${pfx}${type} complete (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ", estimated" : ""})`
|
|
1125
|
-
);
|
|
1126
|
-
}
|
|
1127
|
-
console.log(
|
|
1128
|
-
`${pfx}${formatPostReviewStats(tokensUsed, consumptionDeps.session, consumptionDeps.limits)}`
|
|
797
|
+
function formatPostReviewStats(session) {
|
|
798
|
+
return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/commands/agent.ts
|
|
802
|
+
var DEFAULT_POLL_INTERVAL_MS = 1e4;
|
|
803
|
+
var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
|
|
804
|
+
var MAX_POLL_BACKOFF_MS = 3e5;
|
|
805
|
+
async function fetchDiff(diffUrl, signal) {
|
|
806
|
+
const patchUrl = diffUrl.endsWith(".diff") ? diffUrl : `${diffUrl}.diff`;
|
|
807
|
+
return withRetry(
|
|
808
|
+
async () => {
|
|
809
|
+
const response = await fetch(patchUrl);
|
|
810
|
+
if (!response.ok) {
|
|
811
|
+
throw new Error(`Failed to fetch diff: ${response.status} ${response.statusText}`);
|
|
812
|
+
}
|
|
813
|
+
return response.text();
|
|
814
|
+
},
|
|
815
|
+
{ maxAttempts: 2 },
|
|
816
|
+
signal
|
|
1129
817
|
);
|
|
1130
818
|
}
|
|
1131
|
-
function
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
819
|
+
async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, options) {
|
|
820
|
+
const { pollIntervalMs, routerRelay, reviewOnly, signal } = options;
|
|
821
|
+
console.log(`Agent ${agentId} polling every ${pollIntervalMs / 1e3}s...`);
|
|
822
|
+
let consecutiveAuthErrors = 0;
|
|
823
|
+
let consecutiveErrors = 0;
|
|
824
|
+
while (!signal?.aborted) {
|
|
825
|
+
try {
|
|
826
|
+
const pollBody = { agent_id: agentId };
|
|
827
|
+
if (reviewOnly) pollBody.review_only = true;
|
|
828
|
+
const pollResponse = await client.post("/api/tasks/poll", pollBody);
|
|
829
|
+
consecutiveAuthErrors = 0;
|
|
830
|
+
consecutiveErrors = 0;
|
|
831
|
+
if (pollResponse.tasks.length > 0) {
|
|
832
|
+
const task = pollResponse.tasks[0];
|
|
833
|
+
await handleTask(
|
|
834
|
+
client,
|
|
835
|
+
agentId,
|
|
836
|
+
task,
|
|
837
|
+
reviewDeps,
|
|
838
|
+
consumptionDeps,
|
|
839
|
+
agentInfo,
|
|
840
|
+
routerRelay,
|
|
841
|
+
signal
|
|
1149
842
|
);
|
|
1150
843
|
}
|
|
1151
|
-
|
|
1152
|
-
break;
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
if (!reviewDeps) {
|
|
1159
|
-
ws.send(
|
|
1160
|
-
JSON.stringify({
|
|
1161
|
-
type: "review_rejected",
|
|
1162
|
-
id: crypto2.randomUUID(),
|
|
1163
|
-
timestamp: Date.now(),
|
|
1164
|
-
taskId: request.taskId,
|
|
1165
|
-
reason: "Review execution not configured"
|
|
1166
|
-
})
|
|
844
|
+
} catch (err) {
|
|
845
|
+
if (signal?.aborted) break;
|
|
846
|
+
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) {
|
|
847
|
+
consecutiveAuthErrors++;
|
|
848
|
+
consecutiveErrors++;
|
|
849
|
+
console.error(
|
|
850
|
+
`Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
|
|
1167
851
|
);
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
if (consumptionDeps) {
|
|
1172
|
-
const limitResult = await checkConsumptionLimits(
|
|
1173
|
-
consumptionDeps.agentId,
|
|
1174
|
-
consumptionDeps.limits
|
|
1175
|
-
);
|
|
1176
|
-
if (!limitResult.allowed) {
|
|
1177
|
-
trySend(ws, {
|
|
1178
|
-
type: "review_rejected",
|
|
1179
|
-
id: crypto2.randomUUID(),
|
|
1180
|
-
timestamp: Date.now(),
|
|
1181
|
-
taskId: request.taskId,
|
|
1182
|
-
reason: limitResult.reason ?? "consumption_limit_exceeded"
|
|
1183
|
-
});
|
|
1184
|
-
console.log(`${pfx}Review rejected: ${limitResult.reason}`);
|
|
1185
|
-
return;
|
|
1186
|
-
}
|
|
852
|
+
if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
|
|
853
|
+
console.error("Authentication failed repeatedly. Exiting.");
|
|
854
|
+
break;
|
|
1187
855
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
diffContent: request.diffContent,
|
|
1193
|
-
prompt: request.project.prompt,
|
|
1194
|
-
owner: request.project.owner,
|
|
1195
|
-
repo: request.project.repo,
|
|
1196
|
-
prNumber: request.pr.number,
|
|
1197
|
-
timeout: request.timeout,
|
|
1198
|
-
reviewMode: request.reviewMode ?? "full"
|
|
1199
|
-
},
|
|
1200
|
-
reviewDeps
|
|
1201
|
-
);
|
|
1202
|
-
trySend(ws, {
|
|
1203
|
-
type: "review_complete",
|
|
1204
|
-
id: crypto2.randomUUID(),
|
|
1205
|
-
timestamp: Date.now(),
|
|
1206
|
-
taskId: request.taskId,
|
|
1207
|
-
review: result.review,
|
|
1208
|
-
verdict: result.verdict,
|
|
1209
|
-
tokensUsed: result.tokensUsed
|
|
1210
|
-
});
|
|
1211
|
-
await logPostReviewStats(
|
|
1212
|
-
"Review",
|
|
1213
|
-
result.verdict,
|
|
1214
|
-
result.tokensUsed,
|
|
1215
|
-
result.tokensEstimated,
|
|
1216
|
-
consumptionDeps,
|
|
1217
|
-
logPrefix
|
|
1218
|
-
);
|
|
1219
|
-
} catch (err) {
|
|
1220
|
-
if (err instanceof DiffTooLargeError) {
|
|
1221
|
-
trySend(ws, {
|
|
1222
|
-
type: "review_rejected",
|
|
1223
|
-
id: crypto2.randomUUID(),
|
|
1224
|
-
timestamp: Date.now(),
|
|
1225
|
-
taskId: request.taskId,
|
|
1226
|
-
reason: err.message
|
|
1227
|
-
});
|
|
1228
|
-
} else {
|
|
1229
|
-
trySend(ws, {
|
|
1230
|
-
type: "review_error",
|
|
1231
|
-
id: crypto2.randomUUID(),
|
|
1232
|
-
timestamp: Date.now(),
|
|
1233
|
-
taskId: request.taskId,
|
|
1234
|
-
error: err instanceof Error ? err.message : "Unknown error"
|
|
1235
|
-
});
|
|
1236
|
-
}
|
|
1237
|
-
console.error(`${pfx}Review failed:`, err);
|
|
1238
|
-
}
|
|
1239
|
-
})();
|
|
1240
|
-
break;
|
|
1241
|
-
}
|
|
1242
|
-
case "summary_request": {
|
|
1243
|
-
const summaryRequest = msg;
|
|
1244
|
-
console.log(
|
|
1245
|
-
`${pfx}Summary request: task ${summaryRequest.taskId} for ${summaryRequest.project.owner}/${summaryRequest.project.repo}#${summaryRequest.pr.number} (${summaryRequest.reviews.length} reviews)`
|
|
1246
|
-
);
|
|
1247
|
-
if (!reviewDeps) {
|
|
1248
|
-
trySend(ws, {
|
|
1249
|
-
type: "review_rejected",
|
|
1250
|
-
id: crypto2.randomUUID(),
|
|
1251
|
-
timestamp: Date.now(),
|
|
1252
|
-
taskId: summaryRequest.taskId,
|
|
1253
|
-
reason: "Review tool not configured"
|
|
1254
|
-
});
|
|
1255
|
-
break;
|
|
856
|
+
} else {
|
|
857
|
+
consecutiveAuthErrors = 0;
|
|
858
|
+
consecutiveErrors++;
|
|
859
|
+
console.error(`Poll error: ${err.message}`);
|
|
1256
860
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
id: crypto2.randomUUID(),
|
|
1267
|
-
timestamp: Date.now(),
|
|
1268
|
-
taskId: summaryRequest.taskId,
|
|
1269
|
-
reason: limitResult.reason ?? "consumption_limit_exceeded"
|
|
1270
|
-
});
|
|
1271
|
-
console.log(`${pfx}Summary rejected: ${limitResult.reason}`);
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
try {
|
|
1276
|
-
const result = await executeSummary(
|
|
1277
|
-
{
|
|
1278
|
-
taskId: summaryRequest.taskId,
|
|
1279
|
-
reviews: summaryRequest.reviews,
|
|
1280
|
-
prompt: summaryRequest.project.prompt,
|
|
1281
|
-
owner: summaryRequest.project.owner,
|
|
1282
|
-
repo: summaryRequest.project.repo,
|
|
1283
|
-
prNumber: summaryRequest.pr.number,
|
|
1284
|
-
timeout: summaryRequest.timeout,
|
|
1285
|
-
diffContent: summaryRequest.diffContent ?? ""
|
|
1286
|
-
},
|
|
1287
|
-
reviewDeps
|
|
1288
|
-
);
|
|
1289
|
-
trySend(ws, {
|
|
1290
|
-
type: "summary_complete",
|
|
1291
|
-
id: crypto2.randomUUID(),
|
|
1292
|
-
timestamp: Date.now(),
|
|
1293
|
-
taskId: summaryRequest.taskId,
|
|
1294
|
-
summary: result.summary,
|
|
1295
|
-
tokensUsed: result.tokensUsed
|
|
1296
|
-
});
|
|
1297
|
-
await logPostReviewStats(
|
|
1298
|
-
"Summary",
|
|
1299
|
-
void 0,
|
|
1300
|
-
result.tokensUsed,
|
|
1301
|
-
result.tokensEstimated,
|
|
1302
|
-
consumptionDeps,
|
|
1303
|
-
logPrefix
|
|
861
|
+
if (consecutiveErrors > 0) {
|
|
862
|
+
const backoff = Math.min(
|
|
863
|
+
pollIntervalMs * Math.pow(2, consecutiveErrors - 1),
|
|
864
|
+
MAX_POLL_BACKOFF_MS
|
|
865
|
+
);
|
|
866
|
+
const extraDelay = backoff - pollIntervalMs;
|
|
867
|
+
if (extraDelay > 0) {
|
|
868
|
+
console.warn(
|
|
869
|
+
`Poll failed (${consecutiveErrors} consecutive). Next poll in ${Math.round(backoff / 1e3)}s`
|
|
1304
870
|
);
|
|
1305
|
-
|
|
1306
|
-
if (err instanceof InputTooLargeError) {
|
|
1307
|
-
trySend(ws, {
|
|
1308
|
-
type: "review_rejected",
|
|
1309
|
-
id: crypto2.randomUUID(),
|
|
1310
|
-
timestamp: Date.now(),
|
|
1311
|
-
taskId: summaryRequest.taskId,
|
|
1312
|
-
reason: err.message
|
|
1313
|
-
});
|
|
1314
|
-
} else {
|
|
1315
|
-
trySend(ws, {
|
|
1316
|
-
type: "review_error",
|
|
1317
|
-
id: crypto2.randomUUID(),
|
|
1318
|
-
timestamp: Date.now(),
|
|
1319
|
-
taskId: summaryRequest.taskId,
|
|
1320
|
-
error: err instanceof Error ? err.message : "Summary failed"
|
|
1321
|
-
});
|
|
1322
|
-
}
|
|
1323
|
-
console.error(`${pfx}Summary failed:`, err);
|
|
871
|
+
await sleep2(extraDelay, signal);
|
|
1324
872
|
}
|
|
1325
|
-
})();
|
|
1326
|
-
break;
|
|
1327
|
-
}
|
|
1328
|
-
case "error":
|
|
1329
|
-
console.error(`${pfx}Platform error: ${msg.code ?? "unknown"}`);
|
|
1330
|
-
if (msg.code === "auth_revoked") process.exit(1);
|
|
1331
|
-
break;
|
|
1332
|
-
default:
|
|
1333
|
-
break;
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
async function syncAgentToServer(client, serverAgents, localAgent) {
|
|
1337
|
-
const existing = serverAgents.find(
|
|
1338
|
-
(a) => a.model === localAgent.model && a.tool === localAgent.tool
|
|
1339
|
-
);
|
|
1340
|
-
if (existing) {
|
|
1341
|
-
return { agentId: existing.id, created: false };
|
|
1342
|
-
}
|
|
1343
|
-
const body = { model: localAgent.model, tool: localAgent.tool };
|
|
1344
|
-
if (localAgent.name) {
|
|
1345
|
-
body.displayName = localAgent.name;
|
|
1346
|
-
}
|
|
1347
|
-
if (localAgent.repos) {
|
|
1348
|
-
body.repoConfig = localAgent.repos;
|
|
1349
|
-
}
|
|
1350
|
-
const created = await client.post("/api/agents", body);
|
|
1351
|
-
return { agentId: created.id, created: true };
|
|
1352
|
-
}
|
|
1353
|
-
function resolveLocalAgentCommand(localAgent, globalAgentCommand) {
|
|
1354
|
-
const effectiveCommand = localAgent.command ?? globalAgentCommand;
|
|
1355
|
-
return resolveCommandTemplate(effectiveCommand);
|
|
1356
|
-
}
|
|
1357
|
-
var agentCommand = new Command2("agent").description("Manage review agents");
|
|
1358
|
-
agentCommand.command("create").description("Add an agent to local config (interactive or via flags)").option("--model <model>", "AI model name (e.g., claude-opus-4-6)").option("--tool <tool>", "Review tool name (e.g., claude-code)").option("--command <cmd>", "Custom command template (bypasses registry lookup)").action(async (opts) => {
|
|
1359
|
-
const config = loadConfig();
|
|
1360
|
-
requireApiKey(config);
|
|
1361
|
-
let model;
|
|
1362
|
-
let tool;
|
|
1363
|
-
let command = opts.command;
|
|
1364
|
-
if (opts.model && opts.tool) {
|
|
1365
|
-
model = opts.model;
|
|
1366
|
-
tool = opts.tool;
|
|
1367
|
-
} else if (opts.model || opts.tool) {
|
|
1368
|
-
console.error("Both --model and --tool are required in non-interactive mode.");
|
|
1369
|
-
process.exit(1);
|
|
1370
|
-
} else {
|
|
1371
|
-
const client = new ApiClient(config.platformUrl, config.apiKey);
|
|
1372
|
-
let registry;
|
|
1373
|
-
try {
|
|
1374
|
-
registry = await client.get("/api/registry");
|
|
1375
|
-
} catch {
|
|
1376
|
-
console.warn("Could not fetch registry from server. Using built-in defaults.");
|
|
1377
|
-
registry = DEFAULT_REGISTRY;
|
|
1378
|
-
}
|
|
1379
|
-
const { search, input } = await import("@inquirer/prompts");
|
|
1380
|
-
const searchTheme = {
|
|
1381
|
-
style: {
|
|
1382
|
-
keysHelpTip: (keys) => keys.map(([key, action]) => `${key} ${action}`).join(", ") + ", ^C exit"
|
|
1383
|
-
}
|
|
1384
|
-
};
|
|
1385
|
-
const existingAgents = config.agents ?? [];
|
|
1386
|
-
const toolChoices = registry.tools.map((t) => ({
|
|
1387
|
-
name: t.displayName,
|
|
1388
|
-
value: t.name
|
|
1389
|
-
}));
|
|
1390
|
-
try {
|
|
1391
|
-
while (true) {
|
|
1392
|
-
tool = await search({
|
|
1393
|
-
message: "Select a tool:",
|
|
1394
|
-
theme: searchTheme,
|
|
1395
|
-
source: (term) => {
|
|
1396
|
-
const q = (term ?? "").toLowerCase();
|
|
1397
|
-
return toolChoices.filter(
|
|
1398
|
-
(c) => c.name.toLowerCase().includes(q) || c.value.toLowerCase().includes(q)
|
|
1399
|
-
);
|
|
1400
|
-
}
|
|
1401
|
-
});
|
|
1402
|
-
const compatible = registry.models.filter((m) => m.tools.includes(tool));
|
|
1403
|
-
const incompatible = registry.models.filter((m) => !m.tools.includes(tool));
|
|
1404
|
-
const modelChoices = [
|
|
1405
|
-
...compatible.map((m) => ({
|
|
1406
|
-
name: m.displayName,
|
|
1407
|
-
value: m.name
|
|
1408
|
-
})),
|
|
1409
|
-
...incompatible.map((m) => ({
|
|
1410
|
-
name: `\x1B[38;5;249m${m.displayName}\x1B[0m`,
|
|
1411
|
-
value: m.name
|
|
1412
|
-
}))
|
|
1413
|
-
];
|
|
1414
|
-
model = await search({
|
|
1415
|
-
message: "Select a model:",
|
|
1416
|
-
theme: searchTheme,
|
|
1417
|
-
source: (term) => {
|
|
1418
|
-
const q = (term ?? "").toLowerCase();
|
|
1419
|
-
return modelChoices.filter(
|
|
1420
|
-
(c) => c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q)
|
|
1421
|
-
);
|
|
1422
|
-
}
|
|
1423
|
-
});
|
|
1424
|
-
const isDup = existingAgents.some((a) => a.model === model && a.tool === tool);
|
|
1425
|
-
if (isDup) {
|
|
1426
|
-
console.warn(`"${model}" / "${tool}" already exists in config. Choose again.`);
|
|
1427
|
-
continue;
|
|
1428
|
-
}
|
|
1429
|
-
const modelEntry = registry.models.find((m) => m.name === model);
|
|
1430
|
-
if (modelEntry && !modelEntry.tools.includes(tool)) {
|
|
1431
|
-
console.warn(`Warning: "${model}" is not listed as compatible with "${tool}".`);
|
|
1432
|
-
}
|
|
1433
|
-
break;
|
|
1434
|
-
}
|
|
1435
|
-
const toolEntry = registry.tools.find((t) => t.name === tool);
|
|
1436
|
-
const defaultCommand = toolEntry ? toolEntry.commandTemplate.replaceAll("${MODEL}", model) : `${tool} --model ${model} -p \${PROMPT}`;
|
|
1437
|
-
command = await input({
|
|
1438
|
-
message: "Command:",
|
|
1439
|
-
default: defaultCommand,
|
|
1440
|
-
prefill: "editable"
|
|
1441
|
-
});
|
|
1442
|
-
} catch (err) {
|
|
1443
|
-
if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
|
|
1444
|
-
console.log("Cancelled.");
|
|
1445
|
-
return;
|
|
1446
873
|
}
|
|
1447
|
-
throw err;
|
|
1448
874
|
}
|
|
875
|
+
await sleep2(pollIntervalMs, signal);
|
|
1449
876
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
877
|
+
}
|
|
878
|
+
async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, routerRelay, signal) {
|
|
879
|
+
const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
|
|
880
|
+
console.log(`
|
|
881
|
+
Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
882
|
+
let claimResponse;
|
|
883
|
+
try {
|
|
884
|
+
claimResponse = await withRetry(
|
|
885
|
+
() => client.post(`/api/tasks/${task_id}/claim`, {
|
|
886
|
+
agent_id: agentId,
|
|
887
|
+
role,
|
|
888
|
+
model: agentInfo.model,
|
|
889
|
+
tool: agentInfo.tool
|
|
890
|
+
}),
|
|
891
|
+
{ maxAttempts: 2 },
|
|
892
|
+
signal
|
|
1464
893
|
);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
const status = err instanceof HttpError ? ` (${err.status})` : "";
|
|
896
|
+
console.error(` Failed to claim task ${task_id}${status}: ${err.message}`);
|
|
897
|
+
return;
|
|
1465
898
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}
|
|
1470
|
-
const isDuplicate = config.agents.some((a) => a.model === model && a.tool === tool);
|
|
1471
|
-
if (isDuplicate) {
|
|
1472
|
-
console.error(`Agent with model "${model}" and tool "${tool}" already exists in config.`);
|
|
1473
|
-
process.exit(1);
|
|
899
|
+
if (!claimResponse.claimed) {
|
|
900
|
+
console.log(` Claim rejected: ${claimResponse.reason}`);
|
|
901
|
+
return;
|
|
1474
902
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
console.log("Agent added to config:");
|
|
1478
|
-
console.log(` Model: ${model}`);
|
|
1479
|
-
console.log(` Tool: ${tool}`);
|
|
1480
|
-
console.log(` Command: ${command}`);
|
|
1481
|
-
});
|
|
1482
|
-
agentCommand.command("init").description("Import server-side agents into local config").action(async () => {
|
|
1483
|
-
const config = loadConfig();
|
|
1484
|
-
const apiKey = requireApiKey(config);
|
|
1485
|
-
const client = new ApiClient(config.platformUrl, apiKey);
|
|
1486
|
-
let res;
|
|
903
|
+
console.log(` Claimed as ${role}`);
|
|
904
|
+
let diffContent;
|
|
1487
905
|
try {
|
|
1488
|
-
|
|
906
|
+
diffContent = await fetchDiff(diff_url, signal);
|
|
907
|
+
console.log(` Diff fetched (${Math.round(diffContent.length / 1024)}KB)`);
|
|
1489
908
|
} catch (err) {
|
|
1490
|
-
console.error(
|
|
1491
|
-
|
|
1492
|
-
}
|
|
1493
|
-
if (res.agents.length === 0) {
|
|
1494
|
-
console.log("No server-side agents found. Use `opencara agent create` to add one.");
|
|
909
|
+
console.error(` Failed to fetch diff for task ${task_id}: ${err.message}`);
|
|
910
|
+
await safeReject(client, task_id, agentId, `Cannot access diff: ${err.message}`);
|
|
1495
911
|
return;
|
|
1496
912
|
}
|
|
1497
|
-
let registry;
|
|
1498
913
|
try {
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
914
|
+
if (role === "summary" && "reviews" in claimResponse && claimResponse.reviews) {
|
|
915
|
+
await executeSummaryTask(
|
|
916
|
+
client,
|
|
917
|
+
agentId,
|
|
918
|
+
task_id,
|
|
919
|
+
owner,
|
|
920
|
+
repo,
|
|
921
|
+
pr_number,
|
|
922
|
+
diffContent,
|
|
923
|
+
prompt,
|
|
924
|
+
timeout_seconds,
|
|
925
|
+
claimResponse.reviews,
|
|
926
|
+
reviewDeps,
|
|
927
|
+
consumptionDeps,
|
|
928
|
+
routerRelay,
|
|
929
|
+
signal
|
|
930
|
+
);
|
|
1512
931
|
} else {
|
|
1513
|
-
|
|
1514
|
-
|
|
932
|
+
await executeReviewTask(
|
|
933
|
+
client,
|
|
934
|
+
agentId,
|
|
935
|
+
task_id,
|
|
936
|
+
owner,
|
|
937
|
+
repo,
|
|
938
|
+
pr_number,
|
|
939
|
+
diffContent,
|
|
940
|
+
prompt,
|
|
941
|
+
timeout_seconds,
|
|
942
|
+
reviewDeps,
|
|
943
|
+
consumptionDeps,
|
|
944
|
+
routerRelay,
|
|
945
|
+
signal
|
|
1515
946
|
);
|
|
1516
947
|
}
|
|
1517
|
-
existing.push({ model: agent.model, tool: agent.tool, command });
|
|
1518
|
-
imported++;
|
|
1519
|
-
}
|
|
1520
|
-
config.agents = existing;
|
|
1521
|
-
saveConfig(config);
|
|
1522
|
-
console.log(`Imported ${imported} agent(s) to local config.`);
|
|
1523
|
-
if (imported > 0) {
|
|
1524
|
-
console.log("Edit ~/.opencara/config.yml to adjust commands for your system.");
|
|
1525
|
-
}
|
|
1526
|
-
});
|
|
1527
|
-
agentCommand.command("list").description("List registered agents").action(async () => {
|
|
1528
|
-
const config = loadConfig();
|
|
1529
|
-
const apiKey = requireApiKey(config);
|
|
1530
|
-
const client = new ApiClient(config.platformUrl, apiKey);
|
|
1531
|
-
let res;
|
|
1532
|
-
try {
|
|
1533
|
-
res = await client.get("/api/agents");
|
|
1534
948
|
} catch (err) {
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
const stats = await client.get(`/api/stats/${agent.id}`);
|
|
1542
|
-
trustLabels.set(agent.id, stats.agent.trustTier.label);
|
|
1543
|
-
} catch {
|
|
949
|
+
if (err instanceof DiffTooLargeError || err instanceof InputTooLargeError) {
|
|
950
|
+
console.error(` ${err.message}`);
|
|
951
|
+
await safeReject(client, task_id, agentId, err.message);
|
|
952
|
+
} else {
|
|
953
|
+
console.error(` Error on task ${task_id}: ${err.message}`);
|
|
954
|
+
await safeError(client, task_id, agentId, err.message);
|
|
1544
955
|
}
|
|
1545
956
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
957
|
+
}
|
|
958
|
+
async function safeReject(client, taskId, agentId, reason) {
|
|
959
|
+
try {
|
|
960
|
+
await withRetry(
|
|
961
|
+
() => client.post(`/api/tasks/${taskId}/reject`, { agent_id: agentId, reason }),
|
|
962
|
+
{ maxAttempts: 2 }
|
|
963
|
+
);
|
|
964
|
+
} catch (err) {
|
|
965
|
+
console.error(
|
|
966
|
+
` Failed to report rejection for task ${taskId}: ${err.message} (logged locally)`
|
|
1554
967
|
);
|
|
1555
|
-
return { entry: existing, command: command2 };
|
|
1556
968
|
}
|
|
1557
|
-
console.log("Registering anonymous agent...");
|
|
1558
|
-
const client = new ApiClient(config.platformUrl);
|
|
1559
|
-
const body = { model, tool };
|
|
1560
|
-
const res = await client.post("/api/agents/anonymous", body);
|
|
1561
|
-
const entry = {
|
|
1562
|
-
agentId: res.agentId,
|
|
1563
|
-
apiKey: res.apiKey,
|
|
1564
|
-
model,
|
|
1565
|
-
tool
|
|
1566
|
-
};
|
|
1567
|
-
config.anonymousAgents.push(entry);
|
|
1568
|
-
saveConfig(config);
|
|
1569
|
-
console.log(`Agent registered: ${res.agentId} (${model} / ${tool})`);
|
|
1570
|
-
console.log("Credentials saved to ~/.opencara/config.yml");
|
|
1571
|
-
const command = resolveCommandTemplate(
|
|
1572
|
-
DEFAULT_REGISTRY.tools.find((t) => t.name === tool)?.commandTemplate.replaceAll("${MODEL}", model) ?? null
|
|
1573
|
-
);
|
|
1574
|
-
return { entry, command };
|
|
1575
969
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
async (agentIdOrModel, opts) => {
|
|
1581
|
-
let stabilityThresholdMs;
|
|
1582
|
-
if (opts.stabilityThreshold !== void 0) {
|
|
1583
|
-
const val = Number(opts.stabilityThreshold);
|
|
1584
|
-
if (!Number.isInteger(val) || val < STABILITY_THRESHOLD_MIN_MS || val > STABILITY_THRESHOLD_MAX_MS) {
|
|
1585
|
-
console.error(
|
|
1586
|
-
`Invalid --stability-threshold: must be an integer between ${STABILITY_THRESHOLD_MIN_MS} and ${STABILITY_THRESHOLD_MAX_MS}`
|
|
1587
|
-
);
|
|
1588
|
-
process.exit(1);
|
|
1589
|
-
}
|
|
1590
|
-
stabilityThresholdMs = val;
|
|
1591
|
-
}
|
|
1592
|
-
const config = loadConfig();
|
|
1593
|
-
if (opts.anonymous) {
|
|
1594
|
-
if (!opts.model || !opts.tool) {
|
|
1595
|
-
console.error("Both --model and --tool are required with --anonymous.");
|
|
1596
|
-
process.exit(1);
|
|
1597
|
-
}
|
|
1598
|
-
let resolved;
|
|
1599
|
-
try {
|
|
1600
|
-
resolved = await resolveAnonymousAgent(config, opts.model, opts.tool);
|
|
1601
|
-
} catch (err) {
|
|
1602
|
-
console.error(
|
|
1603
|
-
"Failed to register anonymous agent:",
|
|
1604
|
-
err instanceof Error ? err.message : err
|
|
1605
|
-
);
|
|
1606
|
-
process.exit(1);
|
|
1607
|
-
}
|
|
1608
|
-
const { entry, command } = resolved;
|
|
1609
|
-
let reviewDeps2;
|
|
1610
|
-
if (validateCommandBinary(command)) {
|
|
1611
|
-
reviewDeps2 = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
|
|
1612
|
-
} else {
|
|
1613
|
-
console.warn(
|
|
1614
|
-
`Warning: binary "${command.split(" ")[0]}" not found. Reviews will be rejected.`
|
|
1615
|
-
);
|
|
1616
|
-
}
|
|
1617
|
-
const consumptionDeps2 = {
|
|
1618
|
-
agentId: entry.agentId,
|
|
1619
|
-
limits: config.limits,
|
|
1620
|
-
session: createSessionTracker()
|
|
1621
|
-
};
|
|
1622
|
-
console.log(`Starting anonymous agent ${entry.agentId}...`);
|
|
1623
|
-
startAgent(entry.agentId, config.platformUrl, entry.apiKey, reviewDeps2, consumptionDeps2, {
|
|
1624
|
-
verbose: opts.verbose,
|
|
1625
|
-
stabilityThresholdMs,
|
|
1626
|
-
displayName: entry.name,
|
|
1627
|
-
repoConfig: entry.repoConfig
|
|
1628
|
-
});
|
|
1629
|
-
return;
|
|
1630
|
-
}
|
|
1631
|
-
if (config.agents !== null) {
|
|
1632
|
-
const validAgents = [];
|
|
1633
|
-
for (const local of config.agents) {
|
|
1634
|
-
let cmd;
|
|
1635
|
-
try {
|
|
1636
|
-
cmd = resolveLocalAgentCommand(local, config.agentCommand);
|
|
1637
|
-
} catch (err) {
|
|
1638
|
-
console.warn(
|
|
1639
|
-
`Skipping ${local.model}/${local.tool}: ${err instanceof Error ? err.message : "no command template available"}`
|
|
1640
|
-
);
|
|
1641
|
-
continue;
|
|
1642
|
-
}
|
|
1643
|
-
if (!validateCommandBinary(cmd)) {
|
|
1644
|
-
console.warn(
|
|
1645
|
-
`Skipping ${local.model}/${local.tool}: binary "${cmd.split(" ")[0]}" not found`
|
|
1646
|
-
);
|
|
1647
|
-
continue;
|
|
1648
|
-
}
|
|
1649
|
-
validAgents.push({ local, command: cmd });
|
|
1650
|
-
}
|
|
1651
|
-
if (validAgents.length === 0 && config.anonymousAgents.length === 0) {
|
|
1652
|
-
console.error("No valid agents in config. Check that tool binaries are installed.");
|
|
1653
|
-
process.exit(1);
|
|
1654
|
-
}
|
|
1655
|
-
let agentsToStart;
|
|
1656
|
-
const anonAgentsToStart = [];
|
|
1657
|
-
if (opts.all) {
|
|
1658
|
-
agentsToStart = validAgents;
|
|
1659
|
-
anonAgentsToStart.push(...config.anonymousAgents);
|
|
1660
|
-
} else if (agentIdOrModel) {
|
|
1661
|
-
const match = validAgents.find((a) => a.local.model === agentIdOrModel);
|
|
1662
|
-
if (!match) {
|
|
1663
|
-
console.error(`No agent with model "${agentIdOrModel}" found in local config.`);
|
|
1664
|
-
console.error("Available agents:");
|
|
1665
|
-
for (const a of validAgents) {
|
|
1666
|
-
console.error(` ${a.local.model} (${a.local.tool})`);
|
|
1667
|
-
}
|
|
1668
|
-
process.exit(1);
|
|
1669
|
-
}
|
|
1670
|
-
agentsToStart = [match];
|
|
1671
|
-
} else if (validAgents.length === 1) {
|
|
1672
|
-
agentsToStart = [validAgents[0]];
|
|
1673
|
-
console.log(`Using agent ${validAgents[0].local.model} (${validAgents[0].local.tool})`);
|
|
1674
|
-
} else if (validAgents.length === 0) {
|
|
1675
|
-
console.error("No valid authenticated agents in config. Use --anonymous or --all.");
|
|
1676
|
-
process.exit(1);
|
|
1677
|
-
} else {
|
|
1678
|
-
console.error("Multiple agents in config. Specify a model name or use --all:");
|
|
1679
|
-
for (const a of validAgents) {
|
|
1680
|
-
console.error(` ${a.local.model} (${a.local.tool})`);
|
|
1681
|
-
}
|
|
1682
|
-
process.exit(1);
|
|
1683
|
-
}
|
|
1684
|
-
const totalAgents = agentsToStart.length + anonAgentsToStart.length;
|
|
1685
|
-
if (totalAgents > 1) {
|
|
1686
|
-
process.setMaxListeners(process.getMaxListeners() + totalAgents * 2);
|
|
1687
|
-
}
|
|
1688
|
-
let startedCount = 0;
|
|
1689
|
-
let apiKey2;
|
|
1690
|
-
let client2;
|
|
1691
|
-
let serverAgents;
|
|
1692
|
-
if (agentsToStart.length > 0) {
|
|
1693
|
-
apiKey2 = requireApiKey(config);
|
|
1694
|
-
client2 = new ApiClient(config.platformUrl, apiKey2);
|
|
1695
|
-
try {
|
|
1696
|
-
const res = await client2.get("/api/agents");
|
|
1697
|
-
serverAgents = res.agents;
|
|
1698
|
-
} catch (err) {
|
|
1699
|
-
console.error("Failed to fetch agents:", err instanceof Error ? err.message : err);
|
|
1700
|
-
process.exit(1);
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
for (const selected of agentsToStart) {
|
|
1704
|
-
let agentId2;
|
|
1705
|
-
try {
|
|
1706
|
-
const sync = await syncAgentToServer(client2, serverAgents, selected.local);
|
|
1707
|
-
agentId2 = sync.agentId;
|
|
1708
|
-
if (sync.created) {
|
|
1709
|
-
console.log(`Registered new agent ${agentId2} on platform`);
|
|
1710
|
-
serverAgents.push({
|
|
1711
|
-
id: agentId2,
|
|
1712
|
-
model: selected.local.model,
|
|
1713
|
-
tool: selected.local.tool,
|
|
1714
|
-
isAnonymous: false,
|
|
1715
|
-
status: "offline",
|
|
1716
|
-
repoConfig: null,
|
|
1717
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1718
|
-
});
|
|
1719
|
-
}
|
|
1720
|
-
} catch (err) {
|
|
1721
|
-
console.error(
|
|
1722
|
-
`Failed to sync agent ${selected.local.model} to server:`,
|
|
1723
|
-
err instanceof Error ? err.message : err
|
|
1724
|
-
);
|
|
1725
|
-
continue;
|
|
1726
|
-
}
|
|
1727
|
-
const reviewDeps2 = {
|
|
1728
|
-
commandTemplate: selected.command,
|
|
1729
|
-
maxDiffSizeKb: config.maxDiffSizeKb
|
|
1730
|
-
};
|
|
1731
|
-
const consumptionDeps2 = {
|
|
1732
|
-
agentId: agentId2,
|
|
1733
|
-
limits: resolveAgentLimits(selected.local.limits, config.limits),
|
|
1734
|
-
session: createSessionTracker()
|
|
1735
|
-
};
|
|
1736
|
-
const label = selected.local.name || selected.local.model || "unnamed";
|
|
1737
|
-
console.log(`Starting agent ${label} (${agentId2})...`);
|
|
1738
|
-
startAgent(agentId2, config.platformUrl, apiKey2, reviewDeps2, consumptionDeps2, {
|
|
1739
|
-
verbose: opts.verbose,
|
|
1740
|
-
stabilityThresholdMs,
|
|
1741
|
-
repoConfig: selected.local.repos,
|
|
1742
|
-
label
|
|
1743
|
-
});
|
|
1744
|
-
startedCount++;
|
|
1745
|
-
}
|
|
1746
|
-
for (const anon of anonAgentsToStart) {
|
|
1747
|
-
let command;
|
|
1748
|
-
try {
|
|
1749
|
-
command = resolveCommandTemplate(
|
|
1750
|
-
DEFAULT_REGISTRY.tools.find((t) => t.name === anon.tool)?.commandTemplate.replaceAll("${MODEL}", anon.model) ?? null
|
|
1751
|
-
);
|
|
1752
|
-
} catch {
|
|
1753
|
-
console.warn(
|
|
1754
|
-
`Skipping anonymous agent ${anon.agentId}: no command template for tool "${anon.tool}"`
|
|
1755
|
-
);
|
|
1756
|
-
continue;
|
|
1757
|
-
}
|
|
1758
|
-
let reviewDeps2;
|
|
1759
|
-
if (validateCommandBinary(command)) {
|
|
1760
|
-
reviewDeps2 = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
|
|
1761
|
-
} else {
|
|
1762
|
-
console.warn(
|
|
1763
|
-
`Warning: binary "${command.split(" ")[0]}" not found for anonymous agent ${anon.agentId}. Reviews will be rejected.`
|
|
1764
|
-
);
|
|
1765
|
-
}
|
|
1766
|
-
const consumptionDeps2 = {
|
|
1767
|
-
agentId: anon.agentId,
|
|
1768
|
-
limits: config.limits,
|
|
1769
|
-
session: createSessionTracker()
|
|
1770
|
-
};
|
|
1771
|
-
const anonLabel = anon.name || anon.model || "anonymous";
|
|
1772
|
-
console.log(`Starting anonymous agent ${anonLabel} (${anon.agentId})...`);
|
|
1773
|
-
startAgent(anon.agentId, config.platformUrl, anon.apiKey, reviewDeps2, consumptionDeps2, {
|
|
1774
|
-
verbose: opts.verbose,
|
|
1775
|
-
stabilityThresholdMs,
|
|
1776
|
-
displayName: anon.name,
|
|
1777
|
-
repoConfig: anon.repoConfig,
|
|
1778
|
-
label: anonLabel
|
|
1779
|
-
});
|
|
1780
|
-
startedCount++;
|
|
1781
|
-
}
|
|
1782
|
-
if (startedCount === 0) {
|
|
1783
|
-
console.error("No agents could be started.");
|
|
1784
|
-
process.exit(1);
|
|
1785
|
-
}
|
|
1786
|
-
return;
|
|
1787
|
-
}
|
|
1788
|
-
const apiKey = requireApiKey(config);
|
|
1789
|
-
const client = new ApiClient(config.platformUrl, apiKey);
|
|
1790
|
-
console.log(
|
|
1791
|
-
"Hint: No agents in local config. Run `opencara agent init` to import, or `opencara agent create` to add agents."
|
|
1792
|
-
);
|
|
1793
|
-
let agentId = agentIdOrModel;
|
|
1794
|
-
if (!agentId) {
|
|
1795
|
-
let res;
|
|
1796
|
-
try {
|
|
1797
|
-
res = await client.get("/api/agents");
|
|
1798
|
-
} catch (err) {
|
|
1799
|
-
console.error("Failed to list agents:", err instanceof Error ? err.message : err);
|
|
1800
|
-
process.exit(1);
|
|
1801
|
-
}
|
|
1802
|
-
if (res.agents.length === 0) {
|
|
1803
|
-
console.error("No agents registered. Run `opencara agent create` first.");
|
|
1804
|
-
process.exit(1);
|
|
1805
|
-
}
|
|
1806
|
-
if (res.agents.length === 1) {
|
|
1807
|
-
agentId = res.agents[0].id;
|
|
1808
|
-
console.log(`Using agent ${agentId}`);
|
|
1809
|
-
} else {
|
|
1810
|
-
console.error("Multiple agents found. Please specify an agent ID:");
|
|
1811
|
-
for (const a of res.agents) {
|
|
1812
|
-
console.error(` ${a.id} ${a.model} / ${a.tool}`);
|
|
1813
|
-
}
|
|
1814
|
-
process.exit(1);
|
|
1815
|
-
}
|
|
1816
|
-
}
|
|
1817
|
-
let reviewDeps;
|
|
1818
|
-
try {
|
|
1819
|
-
const commandTemplate = resolveCommandTemplate(config.agentCommand);
|
|
1820
|
-
reviewDeps = {
|
|
1821
|
-
commandTemplate,
|
|
1822
|
-
maxDiffSizeKb: config.maxDiffSizeKb
|
|
1823
|
-
};
|
|
1824
|
-
} catch (err) {
|
|
1825
|
-
console.warn(
|
|
1826
|
-
`Warning: ${err instanceof Error ? err.message : "Could not determine agent command."} Reviews will be rejected.`
|
|
1827
|
-
);
|
|
1828
|
-
}
|
|
1829
|
-
const consumptionDeps = {
|
|
1830
|
-
agentId,
|
|
1831
|
-
limits: config.limits,
|
|
1832
|
-
session: createSessionTracker()
|
|
1833
|
-
};
|
|
1834
|
-
console.log(`Starting agent ${agentId}...`);
|
|
1835
|
-
startAgent(agentId, config.platformUrl, apiKey, reviewDeps, consumptionDeps, {
|
|
1836
|
-
verbose: opts.verbose,
|
|
1837
|
-
stabilityThresholdMs,
|
|
1838
|
-
label: agentId
|
|
970
|
+
async function safeError(client, taskId, agentId, error) {
|
|
971
|
+
try {
|
|
972
|
+
await withRetry(() => client.post(`/api/tasks/${taskId}/error`, { agent_id: agentId, error }), {
|
|
973
|
+
maxAttempts: 2
|
|
1839
974
|
});
|
|
975
|
+
} catch (err) {
|
|
976
|
+
console.error(
|
|
977
|
+
` Failed to report error for task ${taskId}: ${err.message} (logged locally)`
|
|
978
|
+
);
|
|
1840
979
|
}
|
|
1841
|
-
);
|
|
1842
|
-
|
|
1843
|
-
// src/commands/stats.ts
|
|
1844
|
-
import { Command as Command3 } from "commander";
|
|
1845
|
-
function formatTrustTier(tier) {
|
|
1846
|
-
const lines = [];
|
|
1847
|
-
const pctPositive = Math.round(tier.positiveRate * 100);
|
|
1848
|
-
lines.push(` Trust: ${tier.label} (${tier.reviewCount} reviews, ${pctPositive}% positive)`);
|
|
1849
|
-
if (tier.nextTier) {
|
|
1850
|
-
const pctProgress = Math.round(tier.progressToNext * 100);
|
|
1851
|
-
const nextLabel = tier.nextTier.charAt(0).toUpperCase() + tier.nextTier.slice(1);
|
|
1852
|
-
lines.push(` Progress to ${nextLabel}: ${pctProgress}%`);
|
|
1853
|
-
}
|
|
1854
|
-
return lines.join("\n");
|
|
1855
980
|
}
|
|
1856
|
-
function
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
if (
|
|
1861
|
-
const
|
|
1862
|
-
|
|
981
|
+
async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, routerRelay, signal) {
|
|
982
|
+
let reviewText;
|
|
983
|
+
let verdict;
|
|
984
|
+
let tokensUsed;
|
|
985
|
+
if (routerRelay) {
|
|
986
|
+
const fullPrompt = routerRelay.buildReviewPrompt({
|
|
987
|
+
owner,
|
|
988
|
+
repo,
|
|
989
|
+
reviewMode: "full",
|
|
990
|
+
prompt,
|
|
991
|
+
diffContent
|
|
992
|
+
});
|
|
993
|
+
const response = await routerRelay.sendPrompt(
|
|
994
|
+
"review_request",
|
|
995
|
+
taskId,
|
|
996
|
+
fullPrompt,
|
|
997
|
+
timeoutSeconds
|
|
998
|
+
);
|
|
999
|
+
const parsed = routerRelay.parseReviewResponse(response);
|
|
1000
|
+
reviewText = parsed.review;
|
|
1001
|
+
verdict = parsed.verdict;
|
|
1002
|
+
tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1863
1003
|
} else {
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1004
|
+
const result = await executeReview(
|
|
1005
|
+
{
|
|
1006
|
+
taskId,
|
|
1007
|
+
diffContent,
|
|
1008
|
+
prompt,
|
|
1009
|
+
owner,
|
|
1010
|
+
repo,
|
|
1011
|
+
prNumber,
|
|
1012
|
+
timeout: timeoutSeconds,
|
|
1013
|
+
reviewMode: "full"
|
|
1014
|
+
},
|
|
1015
|
+
reviewDeps
|
|
1016
|
+
);
|
|
1017
|
+
reviewText = result.review;
|
|
1018
|
+
verdict = result.verdict;
|
|
1019
|
+
tokensUsed = result.tokensUsed;
|
|
1020
|
+
}
|
|
1021
|
+
await withRetry(
|
|
1022
|
+
() => client.post(`/api/tasks/${taskId}/result`, {
|
|
1023
|
+
agent_id: agentId,
|
|
1024
|
+
type: "review",
|
|
1025
|
+
review_text: reviewText,
|
|
1026
|
+
verdict,
|
|
1027
|
+
tokens_used: tokensUsed
|
|
1028
|
+
}),
|
|
1029
|
+
{ maxAttempts: 3 },
|
|
1030
|
+
signal
|
|
1031
|
+
);
|
|
1032
|
+
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1033
|
+
console.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1034
|
+
console.log(formatPostReviewStats(consumptionDeps.session));
|
|
1867
1035
|
}
|
|
1868
|
-
function
|
|
1869
|
-
if (
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1036
|
+
async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, routerRelay, signal) {
|
|
1037
|
+
if (reviews.length === 0) {
|
|
1038
|
+
return executeReviewTask(
|
|
1039
|
+
client,
|
|
1040
|
+
agentId,
|
|
1041
|
+
taskId,
|
|
1042
|
+
owner,
|
|
1043
|
+
repo,
|
|
1044
|
+
prNumber,
|
|
1045
|
+
diffContent,
|
|
1046
|
+
prompt,
|
|
1047
|
+
timeoutSeconds,
|
|
1048
|
+
reviewDeps,
|
|
1049
|
+
consumptionDeps,
|
|
1050
|
+
routerRelay,
|
|
1051
|
+
signal
|
|
1052
|
+
);
|
|
1881
1053
|
}
|
|
1054
|
+
const summaryReviews = reviews.map((r) => ({
|
|
1055
|
+
agentId: r.agent_id,
|
|
1056
|
+
model: "unknown",
|
|
1057
|
+
tool: "unknown",
|
|
1058
|
+
review: r.review_text,
|
|
1059
|
+
verdict: r.verdict
|
|
1060
|
+
}));
|
|
1061
|
+
let summaryText;
|
|
1062
|
+
let tokensUsed;
|
|
1063
|
+
if (routerRelay) {
|
|
1064
|
+
const fullPrompt = routerRelay.buildSummaryPrompt({
|
|
1065
|
+
owner,
|
|
1066
|
+
repo,
|
|
1067
|
+
prompt,
|
|
1068
|
+
reviews: summaryReviews,
|
|
1069
|
+
diffContent
|
|
1070
|
+
});
|
|
1071
|
+
const response = await routerRelay.sendPrompt(
|
|
1072
|
+
"summary_request",
|
|
1073
|
+
taskId,
|
|
1074
|
+
fullPrompt,
|
|
1075
|
+
timeoutSeconds
|
|
1076
|
+
);
|
|
1077
|
+
summaryText = response;
|
|
1078
|
+
tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1079
|
+
} else {
|
|
1080
|
+
const result = await executeSummary(
|
|
1081
|
+
{
|
|
1082
|
+
taskId,
|
|
1083
|
+
reviews: summaryReviews,
|
|
1084
|
+
prompt,
|
|
1085
|
+
owner,
|
|
1086
|
+
repo,
|
|
1087
|
+
prNumber,
|
|
1088
|
+
timeout: timeoutSeconds,
|
|
1089
|
+
diffContent
|
|
1090
|
+
},
|
|
1091
|
+
reviewDeps
|
|
1092
|
+
);
|
|
1093
|
+
summaryText = result.summary;
|
|
1094
|
+
tokensUsed = result.tokensUsed;
|
|
1095
|
+
}
|
|
1096
|
+
await withRetry(
|
|
1097
|
+
() => client.post(`/api/tasks/${taskId}/result`, {
|
|
1098
|
+
agent_id: agentId,
|
|
1099
|
+
type: "summary",
|
|
1100
|
+
review_text: summaryText,
|
|
1101
|
+
tokens_used: tokensUsed
|
|
1102
|
+
}),
|
|
1103
|
+
{ maxAttempts: 3 },
|
|
1104
|
+
signal
|
|
1105
|
+
);
|
|
1106
|
+
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1107
|
+
console.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1108
|
+
console.log(formatPostReviewStats(consumptionDeps.session));
|
|
1882
1109
|
}
|
|
1883
|
-
function
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1110
|
+
function sleep2(ms, signal) {
|
|
1111
|
+
return new Promise((resolve2) => {
|
|
1112
|
+
if (signal?.aborted) {
|
|
1113
|
+
resolve2();
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const timer = setTimeout(resolve2, ms);
|
|
1117
|
+
signal?.addEventListener(
|
|
1118
|
+
"abort",
|
|
1119
|
+
() => {
|
|
1120
|
+
clearTimeout(timer);
|
|
1121
|
+
resolve2();
|
|
1122
|
+
},
|
|
1123
|
+
{ once: true }
|
|
1124
|
+
);
|
|
1125
|
+
});
|
|
1892
1126
|
}
|
|
1893
|
-
async function
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1127
|
+
async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
|
|
1128
|
+
const client = new ApiClient(platformUrl);
|
|
1129
|
+
const session = consumptionDeps?.session ?? createSessionTracker();
|
|
1130
|
+
const deps = consumptionDeps ?? { agentId, limits: null, session };
|
|
1131
|
+
console.log(`Agent ${agentId} starting...`);
|
|
1132
|
+
console.log(`Platform: ${platformUrl}`);
|
|
1133
|
+
console.log(`Model: ${agentInfo.model} | Tool: ${agentInfo.tool}`);
|
|
1134
|
+
if (!reviewDeps) {
|
|
1135
|
+
console.error("No review command configured. Set command in config.yml");
|
|
1136
|
+
return;
|
|
1898
1137
|
}
|
|
1138
|
+
const abortController = new AbortController();
|
|
1139
|
+
process.on("SIGINT", () => {
|
|
1140
|
+
console.log("\nShutting down...");
|
|
1141
|
+
abortController.abort();
|
|
1142
|
+
});
|
|
1143
|
+
process.on("SIGTERM", () => {
|
|
1144
|
+
abortController.abort();
|
|
1145
|
+
});
|
|
1146
|
+
await pollLoop(client, agentId, reviewDeps, deps, agentInfo, {
|
|
1147
|
+
pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
1148
|
+
routerRelay: options?.routerRelay,
|
|
1149
|
+
reviewOnly: options?.reviewOnly,
|
|
1150
|
+
signal: abortController.signal
|
|
1151
|
+
});
|
|
1152
|
+
console.log("Agent stopped.");
|
|
1899
1153
|
}
|
|
1900
|
-
|
|
1154
|
+
async function startAgentRouter() {
|
|
1901
1155
|
const config = loadConfig();
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
isAnonymous: true,
|
|
1911
|
-
status: "offline",
|
|
1912
|
-
repoConfig: anonEntry.repoConfig ?? null,
|
|
1913
|
-
createdAt: ""
|
|
1914
|
-
};
|
|
1915
|
-
const agentStats = await fetchAgentStats(anonClient, anonEntry.agentId);
|
|
1916
|
-
console.log(formatAgentStats(agent, agentStats));
|
|
1917
|
-
return;
|
|
1918
|
-
}
|
|
1156
|
+
const agentId = crypto.randomUUID();
|
|
1157
|
+
let commandTemplate;
|
|
1158
|
+
let agentConfig;
|
|
1159
|
+
if (config.agents && config.agents.length > 0) {
|
|
1160
|
+
agentConfig = config.agents.find((a) => a.router) ?? config.agents[0];
|
|
1161
|
+
commandTemplate = agentConfig.command ?? config.agentCommand ?? void 0;
|
|
1162
|
+
} else {
|
|
1163
|
+
commandTemplate = config.agentCommand ?? void 0;
|
|
1919
1164
|
}
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1165
|
+
const router = new RouterRelay();
|
|
1166
|
+
router.start();
|
|
1167
|
+
const reviewDeps = {
|
|
1168
|
+
commandTemplate: commandTemplate ?? "",
|
|
1169
|
+
maxDiffSizeKb: config.maxDiffSizeKb
|
|
1170
|
+
};
|
|
1171
|
+
const session = createSessionTracker();
|
|
1172
|
+
const limits = agentConfig ? resolveAgentLimits(agentConfig.limits, config.limits) : config.limits;
|
|
1173
|
+
const model = agentConfig?.model ?? "unknown";
|
|
1174
|
+
const tool = agentConfig?.tool ?? "unknown";
|
|
1175
|
+
await startAgent(
|
|
1176
|
+
agentId,
|
|
1177
|
+
config.platformUrl,
|
|
1178
|
+
{ model, tool },
|
|
1179
|
+
reviewDeps,
|
|
1180
|
+
{
|
|
1181
|
+
agentId,
|
|
1182
|
+
limits,
|
|
1183
|
+
session
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
routerRelay: router,
|
|
1187
|
+
reviewOnly: agentConfig?.review_only
|
|
1942
1188
|
}
|
|
1943
|
-
|
|
1944
|
-
|
|
1189
|
+
);
|
|
1190
|
+
router.stop();
|
|
1191
|
+
}
|
|
1192
|
+
var agentCommand = new Command("agent").description("Manage review agents");
|
|
1193
|
+
agentCommand.command("start").description("Start an agent in polling mode").option("--poll-interval <seconds>", "Poll interval in seconds", "10").option("--agent <index>", "Agent index from config.yml (0-based)", "0").action(async (opts) => {
|
|
1194
|
+
const config = loadConfig();
|
|
1195
|
+
const pollIntervalMs = parseInt(opts.pollInterval, 10) * 1e3;
|
|
1196
|
+
const agentIndex = parseInt(opts.agent, 10);
|
|
1197
|
+
const agentId = crypto.randomUUID();
|
|
1198
|
+
let commandTemplate;
|
|
1199
|
+
let limits = config.limits;
|
|
1200
|
+
let agentConfig;
|
|
1201
|
+
if (config.agents && config.agents.length > agentIndex) {
|
|
1202
|
+
agentConfig = config.agents[agentIndex];
|
|
1203
|
+
commandTemplate = agentConfig.command ?? config.agentCommand ?? void 0;
|
|
1204
|
+
limits = resolveAgentLimits(agentConfig.limits, config.limits);
|
|
1205
|
+
} else {
|
|
1206
|
+
commandTemplate = config.agentCommand ?? void 0;
|
|
1945
1207
|
}
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
model: "unknown",
|
|
1952
|
-
tool: "unknown",
|
|
1953
|
-
isAnonymous: false,
|
|
1954
|
-
status: "offline",
|
|
1955
|
-
repoConfig: null,
|
|
1956
|
-
createdAt: ""
|
|
1957
|
-
};
|
|
1958
|
-
try {
|
|
1959
|
-
const agentsRes2 = await client.get("/api/agents");
|
|
1960
|
-
const found = agentsRes2.agents.find((a) => a.id === opts.agent);
|
|
1961
|
-
if (found) {
|
|
1962
|
-
agent.model = found.model;
|
|
1963
|
-
agent.tool = found.tool;
|
|
1964
|
-
}
|
|
1965
|
-
} catch {
|
|
1966
|
-
}
|
|
1967
|
-
const agentStats = await fetchAgentStats(client, opts.agent);
|
|
1968
|
-
console.log(formatAgentStats(agent, agentStats));
|
|
1208
|
+
if (!commandTemplate) {
|
|
1209
|
+
console.error(
|
|
1210
|
+
"No command configured. Set agent_command or agents[].command in ~/.opencara/config.yml"
|
|
1211
|
+
);
|
|
1212
|
+
process.exit(1);
|
|
1969
1213
|
return;
|
|
1970
1214
|
}
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
agentsRes = await client.get("/api/agents");
|
|
1974
|
-
} catch (err) {
|
|
1975
|
-
console.error("Failed to list agents:", err instanceof Error ? err.message : err);
|
|
1215
|
+
if (!validateCommandBinary(commandTemplate)) {
|
|
1216
|
+
console.error(`Command binary not found: ${commandTemplate.split(" ")[0]}`);
|
|
1976
1217
|
process.exit(1);
|
|
1977
|
-
}
|
|
1978
|
-
if (agentsRes.agents.length === 0 && config.anonymousAgents.length === 0) {
|
|
1979
|
-
console.log("No agents registered. Run `opencara agent create` to register one.");
|
|
1980
1218
|
return;
|
|
1981
1219
|
}
|
|
1982
|
-
const
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
tool
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
1220
|
+
const reviewDeps = {
|
|
1221
|
+
commandTemplate,
|
|
1222
|
+
maxDiffSizeKb: config.maxDiffSizeKb
|
|
1223
|
+
};
|
|
1224
|
+
const isRouter = agentConfig?.router === true;
|
|
1225
|
+
let routerRelay;
|
|
1226
|
+
if (isRouter) {
|
|
1227
|
+
routerRelay = new RouterRelay();
|
|
1228
|
+
routerRelay.start();
|
|
1229
|
+
}
|
|
1230
|
+
const session = createSessionTracker();
|
|
1231
|
+
const model = agentConfig?.model ?? "unknown";
|
|
1232
|
+
const tool = agentConfig?.tool ?? "unknown";
|
|
1233
|
+
try {
|
|
1234
|
+
await startAgent(
|
|
1235
|
+
agentId,
|
|
1236
|
+
config.platformUrl,
|
|
1237
|
+
{ model, tool },
|
|
1238
|
+
reviewDeps,
|
|
1239
|
+
{
|
|
1240
|
+
agentId,
|
|
1241
|
+
limits,
|
|
1242
|
+
session
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
pollIntervalMs,
|
|
1246
|
+
routerRelay,
|
|
1247
|
+
reviewOnly: agentConfig?.review_only
|
|
1248
|
+
}
|
|
1249
|
+
);
|
|
1250
|
+
} finally {
|
|
1251
|
+
routerRelay?.stop();
|
|
2014
1252
|
}
|
|
2015
|
-
console.log(outputs.join("\n\n"));
|
|
2016
1253
|
});
|
|
2017
1254
|
|
|
2018
1255
|
// src/index.ts
|
|
2019
|
-
var program = new
|
|
2020
|
-
program.addCommand(loginCommand);
|
|
1256
|
+
var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.10.0");
|
|
2021
1257
|
program.addCommand(agentCommand);
|
|
2022
|
-
program.
|
|
1258
|
+
program.action(() => {
|
|
1259
|
+
startAgentRouter();
|
|
1260
|
+
});
|
|
2023
1261
|
program.parse();
|