opencara 0.10.0 → 0.11.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 +570 -162
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,6 +6,30 @@ import { Command as Command2 } from "commander";
|
|
|
6
6
|
// src/commands/agent.ts
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import crypto from "crypto";
|
|
9
|
+
import * as fs4 from "fs";
|
|
10
|
+
import * as path4 from "path";
|
|
11
|
+
|
|
12
|
+
// ../shared/dist/types.js
|
|
13
|
+
function isRepoAllowed(repoConfig, targetOwner, targetRepo, agentOwner) {
|
|
14
|
+
if (!repoConfig)
|
|
15
|
+
return true;
|
|
16
|
+
const fullRepo = `${targetOwner}/${targetRepo}`;
|
|
17
|
+
switch (repoConfig.mode) {
|
|
18
|
+
case "all":
|
|
19
|
+
return true;
|
|
20
|
+
case "own":
|
|
21
|
+
return agentOwner === targetOwner;
|
|
22
|
+
case "whitelist":
|
|
23
|
+
return (repoConfig.list ?? []).includes(fullRepo);
|
|
24
|
+
case "blacklist":
|
|
25
|
+
return !(repoConfig.list ?? []).includes(fullRepo);
|
|
26
|
+
default:
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ../shared/dist/review-config.js
|
|
32
|
+
import { parse as parseYaml } from "yaml";
|
|
9
33
|
|
|
10
34
|
// src/config.ts
|
|
11
35
|
import * as fs from "fs";
|
|
@@ -16,17 +40,7 @@ var DEFAULT_PLATFORM_URL = "https://api.opencara.dev";
|
|
|
16
40
|
var CONFIG_DIR = path.join(os.homedir(), ".opencara");
|
|
17
41
|
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
42
|
var DEFAULT_MAX_DIFF_SIZE_KB = 100;
|
|
19
|
-
|
|
20
|
-
const raw = data.limits;
|
|
21
|
-
if (!raw || typeof raw !== "object") return null;
|
|
22
|
-
const obj = raw;
|
|
23
|
-
const limits = {};
|
|
24
|
-
if (typeof obj.tokens_per_day === "number") limits.tokens_per_day = obj.tokens_per_day;
|
|
25
|
-
if (typeof obj.tokens_per_month === "number") limits.tokens_per_month = obj.tokens_per_month;
|
|
26
|
-
if (typeof obj.reviews_per_day === "number") limits.reviews_per_day = obj.reviews_per_day;
|
|
27
|
-
if (Object.keys(limits).length === 0) return null;
|
|
28
|
-
return limits;
|
|
29
|
-
}
|
|
43
|
+
var DEFAULT_MAX_CONSECUTIVE_ERRORS = 10;
|
|
30
44
|
var VALID_REPO_MODES = ["all", "own", "whitelist", "blacklist"];
|
|
31
45
|
var REPO_PATTERN = /^[^/]+\/[^/]+$/;
|
|
32
46
|
var RepoConfigError = class extends Error {
|
|
@@ -91,8 +105,8 @@ function parseAgents(data) {
|
|
|
91
105
|
if (typeof obj.command === "string") agent.command = obj.command;
|
|
92
106
|
if (obj.router === true) agent.router = true;
|
|
93
107
|
if (obj.review_only === true) agent.review_only = true;
|
|
94
|
-
|
|
95
|
-
if (
|
|
108
|
+
if (typeof obj.github_token === "string") agent.github_token = obj.github_token;
|
|
109
|
+
if (typeof obj.codebase_dir === "string") agent.codebase_dir = obj.codebase_dir;
|
|
96
110
|
const repoConfig = parseRepoConfig(obj, i);
|
|
97
111
|
if (repoConfig) agent.repos = repoConfig;
|
|
98
112
|
agents.push(agent);
|
|
@@ -100,11 +114,13 @@ function parseAgents(data) {
|
|
|
100
114
|
return agents;
|
|
101
115
|
}
|
|
102
116
|
function loadConfig() {
|
|
117
|
+
const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
|
|
103
118
|
const defaults = {
|
|
104
|
-
|
|
105
|
-
platformUrl: DEFAULT_PLATFORM_URL,
|
|
119
|
+
platformUrl: envPlatformUrl || DEFAULT_PLATFORM_URL,
|
|
106
120
|
maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
|
|
107
|
-
|
|
121
|
+
maxConsecutiveErrors: DEFAULT_MAX_CONSECUTIVE_ERRORS,
|
|
122
|
+
githubToken: null,
|
|
123
|
+
codebaseDir: null,
|
|
108
124
|
agentCommand: null,
|
|
109
125
|
agents: null
|
|
110
126
|
};
|
|
@@ -117,20 +133,136 @@ function loadConfig() {
|
|
|
117
133
|
return defaults;
|
|
118
134
|
}
|
|
119
135
|
return {
|
|
120
|
-
|
|
121
|
-
platformUrl: typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL,
|
|
136
|
+
platformUrl: envPlatformUrl || (typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL),
|
|
122
137
|
maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
|
|
123
|
-
|
|
138
|
+
maxConsecutiveErrors: typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS,
|
|
139
|
+
githubToken: typeof data.github_token === "string" ? data.github_token : null,
|
|
140
|
+
codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
|
|
124
141
|
agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
|
|
125
142
|
agents: parseAgents(data)
|
|
126
143
|
};
|
|
127
144
|
}
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
145
|
+
function resolveGithubToken(agentToken, globalToken) {
|
|
146
|
+
return agentToken ? agentToken : globalToken;
|
|
147
|
+
}
|
|
148
|
+
function resolveCodebaseDir(agentDir, globalDir) {
|
|
149
|
+
const raw = agentDir || globalDir;
|
|
150
|
+
if (!raw) return null;
|
|
151
|
+
if (raw.startsWith("~/") || raw === "~") {
|
|
152
|
+
return path.join(os.homedir(), raw.slice(1));
|
|
153
|
+
}
|
|
154
|
+
return path.resolve(raw);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/codebase.ts
|
|
158
|
+
import { execFileSync } from "child_process";
|
|
159
|
+
import * as fs2 from "fs";
|
|
160
|
+
import * as path2 from "path";
|
|
161
|
+
|
|
162
|
+
// src/sanitize.ts
|
|
163
|
+
var GITHUB_TOKEN_PATTERN = /\b(ghp_[A-Za-z0-9_]{1,255}|gho_[A-Za-z0-9_]{1,255}|ghs_[A-Za-z0-9_]{1,255}|ghr_[A-Za-z0-9_]{1,255}|github_pat_[A-Za-z0-9_]{1,255})\b/g;
|
|
164
|
+
var EMBEDDED_TOKEN_PATTERN = /x-access-token:[^@\s]+@/g;
|
|
165
|
+
var AUTH_HEADER_PATTERN = /(Authorization:)\s*(?:token|Bearer)\s+[^\s,;'"]+/gi;
|
|
166
|
+
function sanitizeTokens(input) {
|
|
167
|
+
return input.replace(GITHUB_TOKEN_PATTERN, "***").replace(EMBEDDED_TOKEN_PATTERN, "x-access-token:***@").replace(AUTH_HEADER_PATTERN, "$1 ***");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/codebase.ts
|
|
171
|
+
var VALID_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
172
|
+
function cloneOrUpdate(owner, repo, prNumber, baseDir, githubToken, taskId) {
|
|
173
|
+
validatePathSegment(owner, "owner");
|
|
174
|
+
validatePathSegment(repo, "repo");
|
|
175
|
+
if (taskId) {
|
|
176
|
+
validatePathSegment(taskId, "taskId");
|
|
177
|
+
}
|
|
178
|
+
const repoDir = taskId ? path2.join(baseDir, owner, repo, taskId) : path2.join(baseDir, owner, repo);
|
|
179
|
+
const cloneUrl = buildCloneUrl(owner, repo, githubToken);
|
|
180
|
+
let cloned = false;
|
|
181
|
+
if (!fs2.existsSync(path2.join(repoDir, ".git"))) {
|
|
182
|
+
fs2.mkdirSync(repoDir, { recursive: true });
|
|
183
|
+
git(["clone", "--depth", "1", cloneUrl, repoDir]);
|
|
184
|
+
cloned = true;
|
|
185
|
+
}
|
|
186
|
+
git(["fetch", "--force", "--depth", "1", "origin", `pull/${prNumber}/head`], repoDir);
|
|
187
|
+
git(["checkout", "FETCH_HEAD"], repoDir);
|
|
188
|
+
return { localPath: repoDir, cloned };
|
|
189
|
+
}
|
|
190
|
+
function cleanupTaskDir(dirPath) {
|
|
191
|
+
if (!path2.isAbsolute(dirPath) || dirPath.split(path2.sep).filter(Boolean).length < 3) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
fs2.rmSync(dirPath, { recursive: true, force: true });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err?.code !== "ENOENT") {
|
|
198
|
+
console.warn(`[cleanup] Failed to remove ${dirPath}: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function validatePathSegment(segment, name) {
|
|
203
|
+
if (!VALID_NAME_PATTERN.test(segment) || segment === "." || segment === "..") {
|
|
204
|
+
throw new Error(`Invalid ${name}: '${segment}' contains disallowed characters`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function buildCloneUrl(owner, repo, githubToken) {
|
|
208
|
+
if (githubToken) {
|
|
209
|
+
return `https://x-access-token:${githubToken}@github.com/${owner}/${repo}.git`;
|
|
210
|
+
}
|
|
211
|
+
return `https://github.com/${owner}/${repo}.git`;
|
|
212
|
+
}
|
|
213
|
+
function git(args, cwd) {
|
|
214
|
+
try {
|
|
215
|
+
return execFileSync("git", args, {
|
|
216
|
+
cwd,
|
|
217
|
+
encoding: "utf-8",
|
|
218
|
+
timeout: 12e4,
|
|
219
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
220
|
+
});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
223
|
+
throw new Error(sanitizeTokens(message));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/github-auth.ts
|
|
228
|
+
import { execSync } from "child_process";
|
|
229
|
+
function getGhCliToken() {
|
|
230
|
+
try {
|
|
231
|
+
const result = execSync("gh auth token", {
|
|
232
|
+
timeout: 5e3,
|
|
233
|
+
encoding: "utf-8",
|
|
234
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
235
|
+
});
|
|
236
|
+
const token = result.trim();
|
|
237
|
+
return token.length > 0 ? token : null;
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function resolveGithubToken2(configToken, deps = {}) {
|
|
243
|
+
const getEnv = deps.getEnv ?? ((key) => process.env[key]);
|
|
244
|
+
const getGhToken = deps.getGhToken ?? getGhCliToken;
|
|
245
|
+
const envToken = getEnv("GITHUB_TOKEN");
|
|
246
|
+
if (envToken) {
|
|
247
|
+
return { token: envToken, method: "env" };
|
|
248
|
+
}
|
|
249
|
+
const ghToken = getGhToken();
|
|
250
|
+
if (ghToken) {
|
|
251
|
+
return { token: ghToken, method: "gh-cli" };
|
|
252
|
+
}
|
|
253
|
+
if (configToken) {
|
|
254
|
+
return { token: configToken, method: "config" };
|
|
255
|
+
}
|
|
256
|
+
return { token: null, method: "none" };
|
|
257
|
+
}
|
|
258
|
+
var AUTH_LOG_MESSAGES = {
|
|
259
|
+
env: "GitHub auth: using GITHUB_TOKEN env var",
|
|
260
|
+
"gh-cli": "GitHub auth: using gh CLI token",
|
|
261
|
+
config: "GitHub auth: using config github_token",
|
|
262
|
+
none: "GitHub auth: none (public repos only)"
|
|
263
|
+
};
|
|
264
|
+
function logAuthMethod(method, log) {
|
|
265
|
+
log(AUTH_LOG_MESSAGES[method]);
|
|
134
266
|
}
|
|
135
267
|
|
|
136
268
|
// src/http.ts
|
|
@@ -142,9 +274,8 @@ var HttpError = class extends Error {
|
|
|
142
274
|
}
|
|
143
275
|
};
|
|
144
276
|
var ApiClient = class {
|
|
145
|
-
constructor(baseUrl,
|
|
277
|
+
constructor(baseUrl, debug) {
|
|
146
278
|
this.baseUrl = baseUrl;
|
|
147
|
-
this.apiKey = apiKey;
|
|
148
279
|
this.debug = debug ?? process.env.OPENCARA_DEBUG === "1";
|
|
149
280
|
}
|
|
150
281
|
debug;
|
|
@@ -152,32 +283,28 @@ var ApiClient = class {
|
|
|
152
283
|
if (this.debug) console.debug(`[ApiClient] ${msg}`);
|
|
153
284
|
}
|
|
154
285
|
headers() {
|
|
155
|
-
|
|
286
|
+
return {
|
|
156
287
|
"Content-Type": "application/json"
|
|
157
288
|
};
|
|
158
|
-
if (this.apiKey) {
|
|
159
|
-
h["Authorization"] = `Bearer ${this.apiKey}`;
|
|
160
|
-
}
|
|
161
|
-
return h;
|
|
162
289
|
}
|
|
163
|
-
async get(
|
|
164
|
-
this.log(`GET ${
|
|
165
|
-
const res = await fetch(`${this.baseUrl}${
|
|
290
|
+
async get(path5) {
|
|
291
|
+
this.log(`GET ${path5}`);
|
|
292
|
+
const res = await fetch(`${this.baseUrl}${path5}`, {
|
|
166
293
|
method: "GET",
|
|
167
294
|
headers: this.headers()
|
|
168
295
|
});
|
|
169
|
-
return this.handleResponse(res,
|
|
296
|
+
return this.handleResponse(res, path5);
|
|
170
297
|
}
|
|
171
|
-
async post(
|
|
172
|
-
this.log(`POST ${
|
|
173
|
-
const res = await fetch(`${this.baseUrl}${
|
|
298
|
+
async post(path5, body) {
|
|
299
|
+
this.log(`POST ${path5}`);
|
|
300
|
+
const res = await fetch(`${this.baseUrl}${path5}`, {
|
|
174
301
|
method: "POST",
|
|
175
302
|
headers: this.headers(),
|
|
176
303
|
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
177
304
|
});
|
|
178
|
-
return this.handleResponse(res,
|
|
305
|
+
return this.handleResponse(res, path5);
|
|
179
306
|
}
|
|
180
|
-
async handleResponse(res,
|
|
307
|
+
async handleResponse(res, path5) {
|
|
181
308
|
if (!res.ok) {
|
|
182
309
|
let message = `HTTP ${res.status}`;
|
|
183
310
|
try {
|
|
@@ -185,15 +312,21 @@ var ApiClient = class {
|
|
|
185
312
|
if (body.error) message = body.error;
|
|
186
313
|
} catch {
|
|
187
314
|
}
|
|
188
|
-
this.log(`${res.status} ${message} (${
|
|
315
|
+
this.log(`${res.status} ${message} (${path5})`);
|
|
189
316
|
throw new HttpError(res.status, message);
|
|
190
317
|
}
|
|
191
|
-
this.log(`${res.status} OK (${
|
|
318
|
+
this.log(`${res.status} OK (${path5})`);
|
|
192
319
|
return await res.json();
|
|
193
320
|
}
|
|
194
321
|
};
|
|
195
322
|
|
|
196
323
|
// src/retry.ts
|
|
324
|
+
var NonRetryableError = class extends Error {
|
|
325
|
+
constructor(message) {
|
|
326
|
+
super(message);
|
|
327
|
+
this.name = "NonRetryableError";
|
|
328
|
+
}
|
|
329
|
+
};
|
|
197
330
|
var DEFAULT_RETRY = {
|
|
198
331
|
maxAttempts: 3,
|
|
199
332
|
baseDelayMs: 1e3,
|
|
@@ -207,9 +340,11 @@ async function withRetry(fn, options = {}, signal) {
|
|
|
207
340
|
try {
|
|
208
341
|
return await fn();
|
|
209
342
|
} catch (err) {
|
|
343
|
+
if (err instanceof NonRetryableError) throw err;
|
|
210
344
|
lastError = err;
|
|
211
345
|
if (attempt < opts.maxAttempts - 1) {
|
|
212
|
-
const
|
|
346
|
+
const baseDelay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);
|
|
347
|
+
const delay = Math.round(baseDelay * (0.7 + Math.random() * 0.6));
|
|
213
348
|
await sleep(delay, signal);
|
|
214
349
|
}
|
|
215
350
|
}
|
|
@@ -235,9 +370,9 @@ function sleep(ms, signal) {
|
|
|
235
370
|
}
|
|
236
371
|
|
|
237
372
|
// src/tool-executor.ts
|
|
238
|
-
import { spawn, execFileSync } from "child_process";
|
|
239
|
-
import * as
|
|
240
|
-
import * as
|
|
373
|
+
import { spawn, execFileSync as execFileSync2 } from "child_process";
|
|
374
|
+
import * as fs3 from "fs";
|
|
375
|
+
import * as path3 from "path";
|
|
241
376
|
var ToolTimeoutError = class extends Error {
|
|
242
377
|
constructor(message) {
|
|
243
378
|
super(message);
|
|
@@ -248,9 +383,9 @@ var MIN_PARTIAL_RESULT_LENGTH = 50;
|
|
|
248
383
|
var MAX_STDERR_LENGTH = 1e3;
|
|
249
384
|
function validateCommandBinary(commandTemplate) {
|
|
250
385
|
const { command } = parseCommandTemplate(commandTemplate);
|
|
251
|
-
if (
|
|
386
|
+
if (path3.isAbsolute(command)) {
|
|
252
387
|
try {
|
|
253
|
-
|
|
388
|
+
fs3.accessSync(command, fs3.constants.X_OK);
|
|
254
389
|
return true;
|
|
255
390
|
} catch {
|
|
256
391
|
return false;
|
|
@@ -259,9 +394,9 @@ function validateCommandBinary(commandTemplate) {
|
|
|
259
394
|
try {
|
|
260
395
|
const isWindows = process.platform === "win32";
|
|
261
396
|
if (isWindows) {
|
|
262
|
-
|
|
397
|
+
execFileSync2("where", [command], { stdio: "pipe" });
|
|
263
398
|
} else {
|
|
264
|
-
|
|
399
|
+
execFileSync2("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
|
|
265
400
|
}
|
|
266
401
|
return true;
|
|
267
402
|
} catch {
|
|
@@ -324,9 +459,12 @@ function parseTokenUsage(stdout, stderr) {
|
|
|
324
459
|
if (qwenMatch) return { tokens: parseInt(qwenMatch[1], 10), parsed: true };
|
|
325
460
|
return { tokens: estimateTokens(stdout), parsed: false };
|
|
326
461
|
}
|
|
327
|
-
function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
|
|
462
|
+
function executeTool(commandTemplate, prompt, timeoutMs, signal, vars, cwd) {
|
|
328
463
|
const promptViaArg = commandTemplate.includes("${PROMPT}");
|
|
329
464
|
const allVars = { ...vars, PROMPT: prompt };
|
|
465
|
+
if (cwd && !allVars["CODEBASE_DIR"]) {
|
|
466
|
+
allVars["CODEBASE_DIR"] = cwd;
|
|
467
|
+
}
|
|
330
468
|
const { command, args } = parseCommandTemplate(commandTemplate, allVars);
|
|
331
469
|
return new Promise((resolve2, reject) => {
|
|
332
470
|
if (signal?.aborted) {
|
|
@@ -334,7 +472,8 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
|
|
|
334
472
|
return;
|
|
335
473
|
}
|
|
336
474
|
const child = spawn(command, args, {
|
|
337
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
475
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
476
|
+
cwd
|
|
338
477
|
});
|
|
339
478
|
let stdout = "";
|
|
340
479
|
let stderr = "";
|
|
@@ -412,6 +551,26 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
|
|
|
412
551
|
});
|
|
413
552
|
});
|
|
414
553
|
}
|
|
554
|
+
var TEST_COMMAND_PROMPT = "Respond with: OK";
|
|
555
|
+
var TEST_COMMAND_TIMEOUT_MS = 1e4;
|
|
556
|
+
async function testCommand(commandTemplate) {
|
|
557
|
+
const start = Date.now();
|
|
558
|
+
try {
|
|
559
|
+
await executeTool(commandTemplate, TEST_COMMAND_PROMPT, TEST_COMMAND_TIMEOUT_MS);
|
|
560
|
+
return { ok: true, elapsedMs: Date.now() - start };
|
|
561
|
+
} catch (err) {
|
|
562
|
+
const elapsed = Date.now() - start;
|
|
563
|
+
if (err instanceof ToolTimeoutError) {
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
elapsedMs: elapsed,
|
|
567
|
+
error: `command timed out after ${TEST_COMMAND_TIMEOUT_MS / 1e3}s`
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
571
|
+
return { ok: false, elapsedMs: elapsed, error: msg };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
415
574
|
|
|
416
575
|
// src/review.ts
|
|
417
576
|
var TIMEOUT_SAFETY_MARGIN_MS = 3e4;
|
|
@@ -505,7 +664,9 @@ ${userMessage}`;
|
|
|
505
664
|
deps.commandTemplate,
|
|
506
665
|
fullPrompt,
|
|
507
666
|
effectiveTimeout,
|
|
508
|
-
abortController.signal
|
|
667
|
+
abortController.signal,
|
|
668
|
+
void 0,
|
|
669
|
+
deps.codebaseDir ?? void 0
|
|
509
670
|
);
|
|
510
671
|
const { verdict, review } = extractVerdict(result.stdout);
|
|
511
672
|
const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
|
|
@@ -622,7 +783,9 @@ ${userMessage}`;
|
|
|
622
783
|
deps.commandTemplate,
|
|
623
784
|
fullPrompt,
|
|
624
785
|
effectiveTimeout,
|
|
625
|
-
abortController.signal
|
|
786
|
+
abortController.signal,
|
|
787
|
+
void 0,
|
|
788
|
+
deps.codebaseDir ?? void 0
|
|
626
789
|
);
|
|
627
790
|
const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
|
|
628
791
|
return {
|
|
@@ -802,13 +965,45 @@ function formatPostReviewStats(session) {
|
|
|
802
965
|
var DEFAULT_POLL_INTERVAL_MS = 1e4;
|
|
803
966
|
var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
|
|
804
967
|
var MAX_POLL_BACKOFF_MS = 3e5;
|
|
805
|
-
|
|
806
|
-
const
|
|
968
|
+
function createLogger(label) {
|
|
969
|
+
const prefix = label ? `[${label}] ` : "";
|
|
970
|
+
return {
|
|
971
|
+
log: (msg) => console.log(`${prefix}${sanitizeTokens(msg)}`),
|
|
972
|
+
logError: (msg) => console.error(`${prefix}${sanitizeTokens(msg)}`),
|
|
973
|
+
logWarn: (msg) => console.warn(`${prefix}${sanitizeTokens(msg)}`)
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
|
|
977
|
+
function toApiDiffUrl(webUrl) {
|
|
978
|
+
const match = webUrl.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\.diff)?$/);
|
|
979
|
+
if (!match) return null;
|
|
980
|
+
const [, owner, repo, prNumber] = match;
|
|
981
|
+
return `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`;
|
|
982
|
+
}
|
|
983
|
+
async function fetchDiff(diffUrl, githubToken, signal) {
|
|
807
984
|
return withRetry(
|
|
808
985
|
async () => {
|
|
809
|
-
const
|
|
986
|
+
const headers = {};
|
|
987
|
+
let url;
|
|
988
|
+
const apiUrl = githubToken ? toApiDiffUrl(diffUrl) : null;
|
|
989
|
+
if (apiUrl && githubToken) {
|
|
990
|
+
url = apiUrl;
|
|
991
|
+
headers["Authorization"] = `Bearer ${githubToken}`;
|
|
992
|
+
headers["Accept"] = "application/vnd.github.v3.diff";
|
|
993
|
+
} else {
|
|
994
|
+
url = diffUrl.endsWith(".diff") ? diffUrl : `${diffUrl}.diff`;
|
|
995
|
+
if (githubToken) {
|
|
996
|
+
headers["Authorization"] = `Bearer ${githubToken}`;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const response = await fetch(url, { headers, signal });
|
|
810
1000
|
if (!response.ok) {
|
|
811
|
-
|
|
1001
|
+
const msg = `Failed to fetch diff: ${response.status} ${response.statusText}`;
|
|
1002
|
+
if (NON_RETRYABLE_STATUSES.has(response.status)) {
|
|
1003
|
+
const hint = response.status === 404 ? ". If this is a private repo, configure github_token in ~/.opencara/config.yml" : "";
|
|
1004
|
+
throw new NonRetryableError(`${msg}${hint}`);
|
|
1005
|
+
}
|
|
1006
|
+
throw new Error(msg);
|
|
812
1007
|
}
|
|
813
1008
|
return response.text();
|
|
814
1009
|
},
|
|
@@ -816,11 +1011,14 @@ async function fetchDiff(diffUrl, signal) {
|
|
|
816
1011
|
signal
|
|
817
1012
|
);
|
|
818
1013
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1014
|
+
var MAX_DIFF_FETCH_ATTEMPTS = 3;
|
|
1015
|
+
async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, options) {
|
|
1016
|
+
const { pollIntervalMs, maxConsecutiveErrors, routerRelay, reviewOnly, repoConfig, signal } = options;
|
|
1017
|
+
const { log, logError, logWarn } = logger;
|
|
1018
|
+
log(`Agent ${agentId} polling every ${pollIntervalMs / 1e3}s...`);
|
|
822
1019
|
let consecutiveAuthErrors = 0;
|
|
823
1020
|
let consecutiveErrors = 0;
|
|
1021
|
+
const diffFailCounts = /* @__PURE__ */ new Map();
|
|
824
1022
|
while (!signal?.aborted) {
|
|
825
1023
|
try {
|
|
826
1024
|
const pollBody = { agent_id: agentId };
|
|
@@ -828,35 +1026,53 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
828
1026
|
const pollResponse = await client.post("/api/tasks/poll", pollBody);
|
|
829
1027
|
consecutiveAuthErrors = 0;
|
|
830
1028
|
consecutiveErrors = 0;
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1029
|
+
const eligibleTasks = repoConfig ? pollResponse.tasks.filter((t) => isRepoAllowed(repoConfig, t.owner, t.repo)) : pollResponse.tasks;
|
|
1030
|
+
const task = eligibleTasks.find(
|
|
1031
|
+
(t) => (diffFailCounts.get(t.task_id) ?? 0) < MAX_DIFF_FETCH_ATTEMPTS
|
|
1032
|
+
);
|
|
1033
|
+
if (task) {
|
|
1034
|
+
const result = await handleTask(
|
|
834
1035
|
client,
|
|
835
1036
|
agentId,
|
|
836
1037
|
task,
|
|
837
1038
|
reviewDeps,
|
|
838
1039
|
consumptionDeps,
|
|
839
1040
|
agentInfo,
|
|
1041
|
+
logger,
|
|
840
1042
|
routerRelay,
|
|
841
1043
|
signal
|
|
842
1044
|
);
|
|
1045
|
+
if (result.diffFetchFailed) {
|
|
1046
|
+
const count = (diffFailCounts.get(task.task_id) ?? 0) + 1;
|
|
1047
|
+
diffFailCounts.set(task.task_id, count);
|
|
1048
|
+
if (count >= MAX_DIFF_FETCH_ATTEMPTS) {
|
|
1049
|
+
logWarn(` Skipping task ${task.task_id} after ${count} diff fetch failures`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
843
1052
|
}
|
|
844
1053
|
} catch (err) {
|
|
845
1054
|
if (signal?.aborted) break;
|
|
846
1055
|
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) {
|
|
847
1056
|
consecutiveAuthErrors++;
|
|
848
1057
|
consecutiveErrors++;
|
|
849
|
-
|
|
1058
|
+
logError(
|
|
850
1059
|
`Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
|
|
851
1060
|
);
|
|
852
1061
|
if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
|
|
853
|
-
|
|
1062
|
+
logError("Authentication failed repeatedly. Exiting.");
|
|
854
1063
|
break;
|
|
855
1064
|
}
|
|
856
1065
|
} else {
|
|
857
1066
|
consecutiveAuthErrors = 0;
|
|
858
1067
|
consecutiveErrors++;
|
|
859
|
-
|
|
1068
|
+
logError(`Poll error: ${err.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
1071
|
+
logError(
|
|
1072
|
+
`Too many consecutive errors (${consecutiveErrors}/${maxConsecutiveErrors}). Shutting down.`
|
|
1073
|
+
);
|
|
1074
|
+
process.exitCode = 1;
|
|
1075
|
+
break;
|
|
860
1076
|
}
|
|
861
1077
|
if (consecutiveErrors > 0) {
|
|
862
1078
|
const backoff = Math.min(
|
|
@@ -865,7 +1081,7 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
865
1081
|
);
|
|
866
1082
|
const extraDelay = backoff - pollIntervalMs;
|
|
867
1083
|
if (extraDelay > 0) {
|
|
868
|
-
|
|
1084
|
+
logWarn(
|
|
869
1085
|
`Poll failed (${consecutiveErrors} consecutive). Next poll in ${Math.round(backoff / 1e3)}s`
|
|
870
1086
|
);
|
|
871
1087
|
await sleep2(extraDelay, signal);
|
|
@@ -875,10 +1091,12 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
875
1091
|
await sleep2(pollIntervalMs, signal);
|
|
876
1092
|
}
|
|
877
1093
|
}
|
|
878
|
-
async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, routerRelay, signal) {
|
|
1094
|
+
async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, routerRelay, signal) {
|
|
879
1095
|
const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
|
|
880
|
-
|
|
1096
|
+
const { log, logError, logWarn } = logger;
|
|
1097
|
+
log(`
|
|
881
1098
|
Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
1099
|
+
log(` https://github.com/${owner}/${repo}/pull/${pr_number}`);
|
|
882
1100
|
let claimResponse;
|
|
883
1101
|
try {
|
|
884
1102
|
claimResponse = await withRetry(
|
|
@@ -893,22 +1111,65 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
893
1111
|
);
|
|
894
1112
|
} catch (err) {
|
|
895
1113
|
const status = err instanceof HttpError ? ` (${err.status})` : "";
|
|
896
|
-
|
|
897
|
-
return;
|
|
1114
|
+
logError(` Failed to claim task ${task_id}${status}: ${err.message}`);
|
|
1115
|
+
return {};
|
|
898
1116
|
}
|
|
899
1117
|
if (!claimResponse.claimed) {
|
|
900
|
-
|
|
901
|
-
return;
|
|
1118
|
+
log(` Claim rejected: ${claimResponse.reason}`);
|
|
1119
|
+
return {};
|
|
902
1120
|
}
|
|
903
|
-
|
|
1121
|
+
log(` Claimed as ${role}`);
|
|
904
1122
|
let diffContent;
|
|
905
1123
|
try {
|
|
906
|
-
diffContent = await fetchDiff(diff_url, signal);
|
|
907
|
-
|
|
1124
|
+
diffContent = await fetchDiff(diff_url, reviewDeps.githubToken, signal);
|
|
1125
|
+
log(` Diff fetched (${Math.round(diffContent.length / 1024)}KB)`);
|
|
908
1126
|
} catch (err) {
|
|
909
|
-
|
|
910
|
-
await safeReject(
|
|
911
|
-
|
|
1127
|
+
logError(` Failed to fetch diff for task ${task_id}: ${err.message}`);
|
|
1128
|
+
await safeReject(
|
|
1129
|
+
client,
|
|
1130
|
+
task_id,
|
|
1131
|
+
agentId,
|
|
1132
|
+
`Cannot access diff: ${err.message}`,
|
|
1133
|
+
logger
|
|
1134
|
+
);
|
|
1135
|
+
return { diffFetchFailed: true };
|
|
1136
|
+
}
|
|
1137
|
+
let taskReviewDeps = reviewDeps;
|
|
1138
|
+
let taskCheckoutPath = null;
|
|
1139
|
+
if (reviewDeps.codebaseDir) {
|
|
1140
|
+
try {
|
|
1141
|
+
const result = cloneOrUpdate(
|
|
1142
|
+
owner,
|
|
1143
|
+
repo,
|
|
1144
|
+
pr_number,
|
|
1145
|
+
reviewDeps.codebaseDir,
|
|
1146
|
+
reviewDeps.githubToken,
|
|
1147
|
+
task_id
|
|
1148
|
+
);
|
|
1149
|
+
log(` Codebase ${result.cloned ? "cloned" : "updated"}: ${result.localPath}`);
|
|
1150
|
+
taskCheckoutPath = result.localPath;
|
|
1151
|
+
taskReviewDeps = { ...reviewDeps, codebaseDir: result.localPath };
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
logWarn(
|
|
1154
|
+
` Warning: codebase clone failed: ${err.message}. Continuing with diff-only review.`
|
|
1155
|
+
);
|
|
1156
|
+
taskReviewDeps = { ...reviewDeps, codebaseDir: null };
|
|
1157
|
+
}
|
|
1158
|
+
} else {
|
|
1159
|
+
try {
|
|
1160
|
+
validatePathSegment(owner, "owner");
|
|
1161
|
+
validatePathSegment(repo, "repo");
|
|
1162
|
+
validatePathSegment(task_id, "task_id");
|
|
1163
|
+
const repoScopedDir = path4.join(CONFIG_DIR, "repos", owner, repo, task_id);
|
|
1164
|
+
fs4.mkdirSync(repoScopedDir, { recursive: true });
|
|
1165
|
+
taskCheckoutPath = repoScopedDir;
|
|
1166
|
+
taskReviewDeps = { ...reviewDeps, codebaseDir: repoScopedDir };
|
|
1167
|
+
log(` Working directory: ${repoScopedDir}`);
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
logWarn(
|
|
1170
|
+
` Warning: failed to create working directory: ${err.message}. Continuing without scoped cwd.`
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
912
1173
|
}
|
|
913
1174
|
try {
|
|
914
1175
|
if (role === "summary" && "reviews" in claimResponse && claimResponse.reviews) {
|
|
@@ -923,8 +1184,9 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
923
1184
|
prompt,
|
|
924
1185
|
timeout_seconds,
|
|
925
1186
|
claimResponse.reviews,
|
|
926
|
-
|
|
1187
|
+
taskReviewDeps,
|
|
927
1188
|
consumptionDeps,
|
|
1189
|
+
logger,
|
|
928
1190
|
routerRelay,
|
|
929
1191
|
signal
|
|
930
1192
|
);
|
|
@@ -939,50 +1201,64 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
939
1201
|
diffContent,
|
|
940
1202
|
prompt,
|
|
941
1203
|
timeout_seconds,
|
|
942
|
-
|
|
1204
|
+
taskReviewDeps,
|
|
943
1205
|
consumptionDeps,
|
|
1206
|
+
logger,
|
|
944
1207
|
routerRelay,
|
|
945
1208
|
signal
|
|
946
1209
|
);
|
|
947
1210
|
}
|
|
948
1211
|
} catch (err) {
|
|
949
1212
|
if (err instanceof DiffTooLargeError || err instanceof InputTooLargeError) {
|
|
950
|
-
|
|
951
|
-
await safeReject(client, task_id, agentId, err.message);
|
|
1213
|
+
logError(` ${err.message}`);
|
|
1214
|
+
await safeReject(client, task_id, agentId, err.message, logger);
|
|
952
1215
|
} else {
|
|
953
|
-
|
|
954
|
-
await safeError(client, task_id, agentId, err.message);
|
|
1216
|
+
logError(` Error on task ${task_id}: ${err.message}`);
|
|
1217
|
+
await safeError(client, task_id, agentId, err.message, logger);
|
|
1218
|
+
}
|
|
1219
|
+
} finally {
|
|
1220
|
+
if (taskCheckoutPath) {
|
|
1221
|
+
cleanupTaskDir(taskCheckoutPath);
|
|
955
1222
|
}
|
|
956
1223
|
}
|
|
1224
|
+
return {};
|
|
957
1225
|
}
|
|
958
|
-
async function safeReject(client, taskId, agentId, reason) {
|
|
1226
|
+
async function safeReject(client, taskId, agentId, reason, logger) {
|
|
959
1227
|
try {
|
|
960
1228
|
await withRetry(
|
|
961
|
-
() => client.post(`/api/tasks/${taskId}/reject`, {
|
|
1229
|
+
() => client.post(`/api/tasks/${taskId}/reject`, {
|
|
1230
|
+
agent_id: agentId,
|
|
1231
|
+
reason: sanitizeTokens(reason)
|
|
1232
|
+
}),
|
|
962
1233
|
{ maxAttempts: 2 }
|
|
963
1234
|
);
|
|
964
1235
|
} catch (err) {
|
|
965
|
-
|
|
1236
|
+
logger.logError(
|
|
966
1237
|
` Failed to report rejection for task ${taskId}: ${err.message} (logged locally)`
|
|
967
1238
|
);
|
|
968
1239
|
}
|
|
969
1240
|
}
|
|
970
|
-
async function safeError(client, taskId, agentId, error) {
|
|
1241
|
+
async function safeError(client, taskId, agentId, error, logger) {
|
|
971
1242
|
try {
|
|
972
|
-
await withRetry(
|
|
973
|
-
|
|
974
|
-
|
|
1243
|
+
await withRetry(
|
|
1244
|
+
() => client.post(`/api/tasks/${taskId}/error`, {
|
|
1245
|
+
agent_id: agentId,
|
|
1246
|
+
error: sanitizeTokens(error)
|
|
1247
|
+
}),
|
|
1248
|
+
{ maxAttempts: 2 }
|
|
1249
|
+
);
|
|
975
1250
|
} catch (err) {
|
|
976
|
-
|
|
1251
|
+
logger.logError(
|
|
977
1252
|
` Failed to report error for task ${taskId}: ${err.message} (logged locally)`
|
|
978
1253
|
);
|
|
979
1254
|
}
|
|
980
1255
|
}
|
|
981
|
-
async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, routerRelay, signal) {
|
|
1256
|
+
async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
|
|
982
1257
|
let reviewText;
|
|
983
1258
|
let verdict;
|
|
984
1259
|
let tokensUsed;
|
|
985
1260
|
if (routerRelay) {
|
|
1261
|
+
logger.log(` Executing review command: [router mode]`);
|
|
986
1262
|
const fullPrompt = routerRelay.buildReviewPrompt({
|
|
987
1263
|
owner,
|
|
988
1264
|
repo,
|
|
@@ -1001,6 +1277,7 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1001
1277
|
verdict = parsed.verdict;
|
|
1002
1278
|
tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1003
1279
|
} else {
|
|
1280
|
+
logger.log(` Executing review command: ${reviewDeps.commandTemplate}`);
|
|
1004
1281
|
const result = await executeReview(
|
|
1005
1282
|
{
|
|
1006
1283
|
taskId,
|
|
@@ -1018,11 +1295,12 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1018
1295
|
verdict = result.verdict;
|
|
1019
1296
|
tokensUsed = result.tokensUsed;
|
|
1020
1297
|
}
|
|
1298
|
+
const sanitizedReview = sanitizeTokens(reviewText);
|
|
1021
1299
|
await withRetry(
|
|
1022
1300
|
() => client.post(`/api/tasks/${taskId}/result`, {
|
|
1023
1301
|
agent_id: agentId,
|
|
1024
1302
|
type: "review",
|
|
1025
|
-
review_text:
|
|
1303
|
+
review_text: sanitizedReview,
|
|
1026
1304
|
verdict,
|
|
1027
1305
|
tokens_used: tokensUsed
|
|
1028
1306
|
}),
|
|
@@ -1030,26 +1308,68 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1030
1308
|
signal
|
|
1031
1309
|
);
|
|
1032
1310
|
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1033
|
-
|
|
1034
|
-
|
|
1311
|
+
logger.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1312
|
+
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1035
1313
|
}
|
|
1036
|
-
async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, routerRelay, signal) {
|
|
1314
|
+
async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
|
|
1037
1315
|
if (reviews.length === 0) {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
routerRelay
|
|
1316
|
+
let reviewText;
|
|
1317
|
+
let verdict;
|
|
1318
|
+
let tokensUsed2;
|
|
1319
|
+
if (routerRelay) {
|
|
1320
|
+
logger.log(` Executing summary command: [router mode]`);
|
|
1321
|
+
const fullPrompt = routerRelay.buildReviewPrompt({
|
|
1322
|
+
owner,
|
|
1323
|
+
repo,
|
|
1324
|
+
reviewMode: "full",
|
|
1325
|
+
prompt,
|
|
1326
|
+
diffContent
|
|
1327
|
+
});
|
|
1328
|
+
const response = await routerRelay.sendPrompt(
|
|
1329
|
+
"review_request",
|
|
1330
|
+
taskId,
|
|
1331
|
+
fullPrompt,
|
|
1332
|
+
timeoutSeconds
|
|
1333
|
+
);
|
|
1334
|
+
const parsed = routerRelay.parseReviewResponse(response);
|
|
1335
|
+
reviewText = parsed.review;
|
|
1336
|
+
verdict = parsed.verdict;
|
|
1337
|
+
tokensUsed2 = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1338
|
+
} else {
|
|
1339
|
+
logger.log(` Executing summary command: ${reviewDeps.commandTemplate}`);
|
|
1340
|
+
const result = await executeReview(
|
|
1341
|
+
{
|
|
1342
|
+
taskId,
|
|
1343
|
+
diffContent,
|
|
1344
|
+
prompt,
|
|
1345
|
+
owner,
|
|
1346
|
+
repo,
|
|
1347
|
+
prNumber,
|
|
1348
|
+
timeout: timeoutSeconds,
|
|
1349
|
+
reviewMode: "full"
|
|
1350
|
+
},
|
|
1351
|
+
reviewDeps
|
|
1352
|
+
);
|
|
1353
|
+
reviewText = result.review;
|
|
1354
|
+
verdict = result.verdict;
|
|
1355
|
+
tokensUsed2 = result.tokensUsed;
|
|
1356
|
+
}
|
|
1357
|
+
const sanitizedReview = sanitizeTokens(reviewText);
|
|
1358
|
+
await withRetry(
|
|
1359
|
+
() => client.post(`/api/tasks/${taskId}/result`, {
|
|
1360
|
+
agent_id: agentId,
|
|
1361
|
+
type: "summary",
|
|
1362
|
+
review_text: sanitizedReview,
|
|
1363
|
+
verdict,
|
|
1364
|
+
tokens_used: tokensUsed2
|
|
1365
|
+
}),
|
|
1366
|
+
{ maxAttempts: 3 },
|
|
1051
1367
|
signal
|
|
1052
1368
|
);
|
|
1369
|
+
recordSessionUsage(consumptionDeps.session, tokensUsed2);
|
|
1370
|
+
logger.log(` Review submitted as summary (${tokensUsed2.toLocaleString()} tokens)`);
|
|
1371
|
+
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1372
|
+
return;
|
|
1053
1373
|
}
|
|
1054
1374
|
const summaryReviews = reviews.map((r) => ({
|
|
1055
1375
|
agentId: r.agent_id,
|
|
@@ -1061,6 +1381,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1061
1381
|
let summaryText;
|
|
1062
1382
|
let tokensUsed;
|
|
1063
1383
|
if (routerRelay) {
|
|
1384
|
+
logger.log(` Executing summary command: [router mode]`);
|
|
1064
1385
|
const fullPrompt = routerRelay.buildSummaryPrompt({
|
|
1065
1386
|
owner,
|
|
1066
1387
|
repo,
|
|
@@ -1077,6 +1398,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1077
1398
|
summaryText = response;
|
|
1078
1399
|
tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1079
1400
|
} else {
|
|
1401
|
+
logger.log(` Executing summary command: ${reviewDeps.commandTemplate}`);
|
|
1080
1402
|
const result = await executeSummary(
|
|
1081
1403
|
{
|
|
1082
1404
|
taskId,
|
|
@@ -1093,19 +1415,20 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1093
1415
|
summaryText = result.summary;
|
|
1094
1416
|
tokensUsed = result.tokensUsed;
|
|
1095
1417
|
}
|
|
1418
|
+
const sanitizedSummary = sanitizeTokens(summaryText);
|
|
1096
1419
|
await withRetry(
|
|
1097
1420
|
() => client.post(`/api/tasks/${taskId}/result`, {
|
|
1098
1421
|
agent_id: agentId,
|
|
1099
1422
|
type: "summary",
|
|
1100
|
-
review_text:
|
|
1423
|
+
review_text: sanitizedSummary,
|
|
1101
1424
|
tokens_used: tokensUsed
|
|
1102
1425
|
}),
|
|
1103
1426
|
{ maxAttempts: 3 },
|
|
1104
1427
|
signal
|
|
1105
1428
|
);
|
|
1106
1429
|
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1107
|
-
|
|
1108
|
-
|
|
1430
|
+
logger.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1431
|
+
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1109
1432
|
}
|
|
1110
1433
|
function sleep2(ms, signal) {
|
|
1111
1434
|
return new Promise((resolve2) => {
|
|
@@ -1127,29 +1450,42 @@ function sleep2(ms, signal) {
|
|
|
1127
1450
|
async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
|
|
1128
1451
|
const client = new ApiClient(platformUrl);
|
|
1129
1452
|
const session = consumptionDeps?.session ?? createSessionTracker();
|
|
1130
|
-
const deps = consumptionDeps ?? { agentId,
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1453
|
+
const deps = consumptionDeps ?? { agentId, session };
|
|
1454
|
+
const logger = createLogger(options?.label);
|
|
1455
|
+
const { log, logError, logWarn } = logger;
|
|
1456
|
+
log(`Agent ${agentId} starting...`);
|
|
1457
|
+
log(`Platform: ${platformUrl}`);
|
|
1458
|
+
log(`Model: ${agentInfo.model} | Tool: ${agentInfo.tool}`);
|
|
1134
1459
|
if (!reviewDeps) {
|
|
1135
|
-
|
|
1460
|
+
logError("No review command configured. Set command in config.yml");
|
|
1136
1461
|
return;
|
|
1137
1462
|
}
|
|
1463
|
+
if (reviewDeps.commandTemplate && !options?.routerRelay) {
|
|
1464
|
+
log("Testing command...");
|
|
1465
|
+
const result = await testCommand(reviewDeps.commandTemplate);
|
|
1466
|
+
if (result.ok) {
|
|
1467
|
+
log(`Testing command... ok (${(result.elapsedMs / 1e3).toFixed(1)}s)`);
|
|
1468
|
+
} else {
|
|
1469
|
+
logWarn(`Warning: command test failed (${result.error}). Reviews may fail.`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1138
1472
|
const abortController = new AbortController();
|
|
1139
1473
|
process.on("SIGINT", () => {
|
|
1140
|
-
|
|
1474
|
+
log("\nShutting down...");
|
|
1141
1475
|
abortController.abort();
|
|
1142
1476
|
});
|
|
1143
1477
|
process.on("SIGTERM", () => {
|
|
1144
1478
|
abortController.abort();
|
|
1145
1479
|
});
|
|
1146
|
-
await pollLoop(client, agentId, reviewDeps, deps, agentInfo, {
|
|
1480
|
+
await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, {
|
|
1147
1481
|
pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
1482
|
+
maxConsecutiveErrors: options?.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS,
|
|
1148
1483
|
routerRelay: options?.routerRelay,
|
|
1149
1484
|
reviewOnly: options?.reviewOnly,
|
|
1485
|
+
repoConfig: options?.repoConfig,
|
|
1150
1486
|
signal: abortController.signal
|
|
1151
1487
|
});
|
|
1152
|
-
|
|
1488
|
+
log("Agent stopped.");
|
|
1153
1489
|
}
|
|
1154
1490
|
async function startAgentRouter() {
|
|
1155
1491
|
const config = loadConfig();
|
|
@@ -1164,14 +1500,21 @@ async function startAgentRouter() {
|
|
|
1164
1500
|
}
|
|
1165
1501
|
const router = new RouterRelay();
|
|
1166
1502
|
router.start();
|
|
1503
|
+
const configToken = resolveGithubToken(agentConfig?.github_token, config.githubToken);
|
|
1504
|
+
const auth = resolveGithubToken2(configToken);
|
|
1505
|
+
const logger = createLogger(agentConfig?.name ?? "agent[0]");
|
|
1506
|
+
logAuthMethod(auth.method, logger.log);
|
|
1507
|
+
const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
|
|
1167
1508
|
const reviewDeps = {
|
|
1168
1509
|
commandTemplate: commandTemplate ?? "",
|
|
1169
|
-
maxDiffSizeKb: config.maxDiffSizeKb
|
|
1510
|
+
maxDiffSizeKb: config.maxDiffSizeKb,
|
|
1511
|
+
githubToken: auth.token,
|
|
1512
|
+
codebaseDir
|
|
1170
1513
|
};
|
|
1171
1514
|
const session = createSessionTracker();
|
|
1172
|
-
const limits = agentConfig ? resolveAgentLimits(agentConfig.limits, config.limits) : config.limits;
|
|
1173
1515
|
const model = agentConfig?.model ?? "unknown";
|
|
1174
1516
|
const tool = agentConfig?.tool ?? "unknown";
|
|
1517
|
+
const label = agentConfig?.name ?? "agent[0]";
|
|
1175
1518
|
await startAgent(
|
|
1176
1519
|
agentId,
|
|
1177
1520
|
config.platformUrl,
|
|
@@ -1179,47 +1522,52 @@ async function startAgentRouter() {
|
|
|
1179
1522
|
reviewDeps,
|
|
1180
1523
|
{
|
|
1181
1524
|
agentId,
|
|
1182
|
-
limits,
|
|
1183
1525
|
session
|
|
1184
1526
|
},
|
|
1185
1527
|
{
|
|
1528
|
+
maxConsecutiveErrors: config.maxConsecutiveErrors,
|
|
1186
1529
|
routerRelay: router,
|
|
1187
|
-
reviewOnly: agentConfig?.review_only
|
|
1530
|
+
reviewOnly: agentConfig?.review_only,
|
|
1531
|
+
repoConfig: agentConfig?.repos,
|
|
1532
|
+
label
|
|
1188
1533
|
}
|
|
1189
1534
|
);
|
|
1190
1535
|
router.stop();
|
|
1191
1536
|
}
|
|
1192
|
-
|
|
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);
|
|
1537
|
+
function startAgentByIndex(config, agentIndex, pollIntervalMs, auth) {
|
|
1197
1538
|
const agentId = crypto.randomUUID();
|
|
1198
1539
|
let commandTemplate;
|
|
1199
|
-
let limits = config.limits;
|
|
1200
1540
|
let agentConfig;
|
|
1201
1541
|
if (config.agents && config.agents.length > agentIndex) {
|
|
1202
1542
|
agentConfig = config.agents[agentIndex];
|
|
1203
1543
|
commandTemplate = agentConfig.command ?? config.agentCommand ?? void 0;
|
|
1204
|
-
limits = resolveAgentLimits(agentConfig.limits, config.limits);
|
|
1205
1544
|
} else {
|
|
1206
1545
|
commandTemplate = config.agentCommand ?? void 0;
|
|
1207
1546
|
}
|
|
1547
|
+
const label = agentConfig?.name ?? `agent[${agentIndex}]`;
|
|
1208
1548
|
if (!commandTemplate) {
|
|
1549
|
+
console.error(`[${label}] No command configured. Skipping.`);
|
|
1550
|
+
return null;
|
|
1551
|
+
}
|
|
1552
|
+
if (!validateCommandBinary(commandTemplate)) {
|
|
1209
1553
|
console.error(
|
|
1210
|
-
|
|
1554
|
+
`[${label}] Command binary not found: ${commandTemplate.split(" ")[0]}. Skipping.`
|
|
1211
1555
|
);
|
|
1212
|
-
|
|
1213
|
-
return;
|
|
1556
|
+
return null;
|
|
1214
1557
|
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1558
|
+
let githubToken;
|
|
1559
|
+
if (auth.method === "env" || auth.method === "gh-cli") {
|
|
1560
|
+
githubToken = auth.token;
|
|
1561
|
+
} else {
|
|
1562
|
+
const configToken = agentConfig ? resolveGithubToken(agentConfig.github_token, config.githubToken) : config.githubToken;
|
|
1563
|
+
githubToken = configToken;
|
|
1219
1564
|
}
|
|
1565
|
+
const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
|
|
1220
1566
|
const reviewDeps = {
|
|
1221
1567
|
commandTemplate,
|
|
1222
|
-
maxDiffSizeKb: config.maxDiffSizeKb
|
|
1568
|
+
maxDiffSizeKb: config.maxDiffSizeKb,
|
|
1569
|
+
githubToken,
|
|
1570
|
+
codebaseDir
|
|
1223
1571
|
};
|
|
1224
1572
|
const isRouter = agentConfig?.router === true;
|
|
1225
1573
|
let routerRelay;
|
|
@@ -1230,30 +1578,90 @@ agentCommand.command("start").description("Start an agent in polling mode").opti
|
|
|
1230
1578
|
const session = createSessionTracker();
|
|
1231
1579
|
const model = agentConfig?.model ?? "unknown";
|
|
1232
1580
|
const tool = agentConfig?.tool ?? "unknown";
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
}
|
|
1249
|
-
);
|
|
1250
|
-
} finally {
|
|
1581
|
+
const agentPromise = startAgent(
|
|
1582
|
+
agentId,
|
|
1583
|
+
config.platformUrl,
|
|
1584
|
+
{ model, tool },
|
|
1585
|
+
reviewDeps,
|
|
1586
|
+
{ agentId, session },
|
|
1587
|
+
{
|
|
1588
|
+
pollIntervalMs,
|
|
1589
|
+
maxConsecutiveErrors: config.maxConsecutiveErrors,
|
|
1590
|
+
routerRelay,
|
|
1591
|
+
reviewOnly: agentConfig?.review_only,
|
|
1592
|
+
repoConfig: agentConfig?.repos,
|
|
1593
|
+
label
|
|
1594
|
+
}
|
|
1595
|
+
).finally(() => {
|
|
1251
1596
|
routerRelay?.stop();
|
|
1597
|
+
});
|
|
1598
|
+
return agentPromise;
|
|
1599
|
+
}
|
|
1600
|
+
var agentCommand = new Command("agent").description("Manage review agents");
|
|
1601
|
+
agentCommand.command("start").description("Start agents in polling mode").option("--poll-interval <seconds>", "Poll interval in seconds", "10").option("--agent <index>", "Agent index from config.yml (0-based)", "0").option("--all", "Start all configured agents concurrently").action(async (opts) => {
|
|
1602
|
+
const config = loadConfig();
|
|
1603
|
+
const pollIntervalMs = parseInt(opts.pollInterval, 10) * 1e3;
|
|
1604
|
+
const configToken = resolveGithubToken(void 0, config.githubToken);
|
|
1605
|
+
const auth = resolveGithubToken2(configToken);
|
|
1606
|
+
logAuthMethod(auth.method, console.log.bind(console));
|
|
1607
|
+
if (opts.all) {
|
|
1608
|
+
if (!config.agents || config.agents.length === 0) {
|
|
1609
|
+
console.error("No agents configured in ~/.opencara/config.yml");
|
|
1610
|
+
process.exit(1);
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
console.log(`Starting ${config.agents.length} agent(s)...`);
|
|
1614
|
+
const promises = [];
|
|
1615
|
+
let startFailed = false;
|
|
1616
|
+
for (let i = 0; i < config.agents.length; i++) {
|
|
1617
|
+
const p = startAgentByIndex(config, i, pollIntervalMs, auth);
|
|
1618
|
+
if (p) {
|
|
1619
|
+
promises.push(p);
|
|
1620
|
+
} else {
|
|
1621
|
+
startFailed = true;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
if (promises.length === 0) {
|
|
1625
|
+
console.error("No agents could be started. Check your config.");
|
|
1626
|
+
process.exit(1);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (startFailed) {
|
|
1630
|
+
console.error(
|
|
1631
|
+
"One or more agents could not start (see warnings above). Continuing with the rest."
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
console.log(`${promises.length} agent(s) running. Press Ctrl+C to stop all.
|
|
1635
|
+
`);
|
|
1636
|
+
const results = await Promise.allSettled(promises);
|
|
1637
|
+
const failures = results.filter((r) => r.status === "rejected");
|
|
1638
|
+
if (failures.length > 0) {
|
|
1639
|
+
for (const f of failures) {
|
|
1640
|
+
console.error(`Agent exited with error: ${f.reason}`);
|
|
1641
|
+
}
|
|
1642
|
+
process.exit(1);
|
|
1643
|
+
}
|
|
1644
|
+
} else {
|
|
1645
|
+
const maxIndex = (config.agents?.length ?? 0) - 1;
|
|
1646
|
+
const agentIndex = Number(opts.agent);
|
|
1647
|
+
if (!Number.isInteger(agentIndex) || agentIndex < 0 || agentIndex > maxIndex) {
|
|
1648
|
+
console.error(
|
|
1649
|
+
maxIndex >= 0 ? `--agent must be an integer between 0 and ${maxIndex}.` : "No agents configured in ~/.opencara/config.yml"
|
|
1650
|
+
);
|
|
1651
|
+
process.exit(1);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
const p = startAgentByIndex(config, agentIndex, pollIntervalMs, auth);
|
|
1655
|
+
if (!p) {
|
|
1656
|
+
process.exit(1);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
await p;
|
|
1252
1660
|
}
|
|
1253
1661
|
});
|
|
1254
1662
|
|
|
1255
1663
|
// src/index.ts
|
|
1256
|
-
var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.
|
|
1664
|
+
var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.11.0");
|
|
1257
1665
|
program.addCommand(agentCommand);
|
|
1258
1666
|
program.action(() => {
|
|
1259
1667
|
startAgentRouter();
|