Files
ai-code-review/src/lib/agents.js
T
Jeffery 8662e8ca80
node-actions/template: CI / BUILD (pull_request) Successful in 5s
CI / TEST (Claude) (pull_request) Successful in 27s
CI / TEST (Codex) (pull_request) Successful in 31s
CI / TEST (Antigravity) (pull_request) Failing after 36s
docs(審查步驟編號): 依新順序重編步驟編號與說明
2026-07-20 09:46:12 +08:00

168 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const { execFile, execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
// AI agent 工具介接:依 antigravity → codex → claude 順序偵測可用工具,
// 以非互動模式(stdin 餵提示)執行 sub agent 並取回最終回覆。
const TOOLS = [
{
name: 'antigravity',
// 需人工確認:antigravity 的非互動執行參數尚未驗證(開發機無此工具),此處先比照 codex exec 的形式。
buildArgs: ({ model }) => ['exec', ...(model ? ['-m', model] : []), '-'],
resultFrom: 'stdout',
},
{
name: 'codex',
buildArgs: ({ model, lastMessageFile }) => [
'exec',
'--skip-git-repo-check',
'--sandbox', 'read-only',
'--output-last-message', lastMessageFile,
...(model ? ['-m', model] : []),
'-',
],
resultFrom: 'lastMessageFile',
},
{
name: 'claude',
buildArgs: ({ model }) => ['-p', '--output-format', 'text', ...(model ? ['--model', model] : [])],
resultFrom: 'stdout',
},
];
/**
* 依固定優先序(antigravity → codex → claude)偵測本機可用的 AI CLI 工具。
*
* 逐一以同步方式執行 `<tool> --version`(逾時 30 秒),第一個成功者即中選,
* 並取其 stdout 第一行作為版本字串;偵測失敗(未安裝、不可執行、逾時)
* 則靜默換下一個工具。本函式不會拋出例外。
*
* 注意:antigravity 的非互動執行參數尚未驗證(需人工確認),本函式僅確認
* `--version` 可執行,不保證後續 runAgent 的參數組合正確。
*
* @returns {{ name: string, buildArgs: Function, resultFrom: string, version: string } | null}
* 中選工具的描述物件(TOOLS 項目加上 version 欄位);所有工具皆不可用時回傳 null。
* @remarks
* 使用情境:action 主流程(步驟 3)啟動審查前呼叫一次,取得工具描述後交給
* runAgent 執行;若回傳 null,主流程會記 ERR 並以失敗收場(無工具即無法審查)。
*/
function detectTool() {
for (const tool of TOOLS) {
try {
const version = execFileSync(tool.name, ['--version'], { encoding: 'utf8', timeout: 30_000 })
.trim()
.split('\n')[0];
return { ...tool, version };
} catch {
// 不可用(未安裝或無法執行)→ 換下一個。
}
}
return null;
}
/**
* 以非互動模式執行一次 sub agent:把提示從 stdin 餵給偵測到的 AI CLI 工具,
* 等子行程結束後回傳最終文字回覆。
*
* 依 tool.resultFrom 決定結果來源:stdoutantigravity、claude),或
* codex 專用的 --output-last-message 暫存檔(codex exec 的 stdout 夾雜過程
* 訊息,改讀工具寫出的最終回覆檔,讀取後即刪除)。
*
* 本函式永不 reject:任何失敗(非零退出碼、逾時、maxBuffer 超限)都以
* { ok: false, error } resolve,由呼叫端決定降級行為;並對 child.stdin
* 掛空 error handler,避免工具提早結束時 EPIPE 造成整個 action 噴例外。
*
* 注意:antigravity 的非互動參數尚未驗證(需人工確認),以該工具執行時
* 可能因參數不符而以 ok: false 收場。
*
* @param {{ name: string, buildArgs: Function, resultFrom: string }} tool
* 工具描述物件(通常來自 detectTool() 的回傳值)。
* @param {Object} options 執行選項(解構參數)。
* @param {string} [options.model] 指定模型名稱;未給時不帶模型參數,使用工具預設模型。
* @param {string} options.prompt 要餵給 agent 的完整提示文字,經 stdin 寫入。
* @param {string} [options.cwd] 子行程工作目錄;影響工具讀取檔案的相對路徑基準。
* @param {number} [options.timeoutMs=600000] 子行程逾時毫秒數(預設 10 分鐘),逾時即終止並回報 ok: false。
* @returns {Promise<{ ok: boolean, output: string, stderr: string, error: Error | null }>}
* ok 表示子行程是否成功結束;output 為最終回覆文字(codex 取自
* --output-last-message 檔,其餘取 stdout);stderr 供除錯;error 為失敗原因(成功時為 null)。
* @remarks
* 使用情境:審查流程對每個角色組好提示後呼叫本函式,
* 例如 `const r = await runAgent(tool, { model, prompt, cwd: workspace });`
* 再以 `r.ok ? extractJson(r.output) : null` 取回結構化 findings
* 失敗時記 log 並跳過該角色,不中斷整個 action。
*/
function runAgent(tool, { model, prompt, cwd, timeoutMs = 600_000 }) {
return new Promise((resolve) => {
const lastMessageFile = path.join(
os.tmpdir(),
`ai-review-${process.pid}-${Math.random().toString(36).slice(2)}.txt`,
);
const args = tool.buildArgs({ model, lastMessageFile });
const child = execFile(
tool.name,
args,
{ cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 },
(error, stdout, stderr) => {
let output = stdout || '';
// codex exec 的 stdout 夾雜過程訊息,改讀 --output-last-message 寫出的最終回覆。
if (tool.resultFrom === 'lastMessageFile' && fs.existsSync(lastMessageFile)) {
const last = fs.readFileSync(lastMessageFile, 'utf8').trim();
if (last) output = last;
fs.rmSync(lastMessageFile, { force: true });
}
resolve({ ok: !error, output, stderr: stderr || '', error });
},
);
child.stdin.on('error', () => {
// 工具提早結束時避免 EPIPE 讓整個 action 噴例外。
});
child.stdin.write(prompt);
child.stdin.end();
});
}
/**
* 從 agent 的自由文字回覆中萃取 JSON,容忍 Markdown code fence 與前後雜訊。
*
* 處理順序:先取第一個 ``` 或 ```json fence 的內文;再依序以
* 「第一個 [ 到最後一個 ]」、「第一個 { 到最後一個 }」的最大範圍切片
* 嘗試 JSON.parse(陣列優先);最後退而直接 parse 整段文字。
* 本函式不會拋出例外,所有 parse 失敗一律回傳 null。
*
* @param {string} text agent 回覆的原始文字;可為空或 null/undefined。
* @returns {any | null} 解析成功的 JSON 值(通常為 findings 陣列或物件);無法解析時為 null。
* @remarks
* 使用情境:搭配 runAgent 使用——LLM 即使被要求輸出純 JSON,實務上仍常
* 包在 ```json fence 內或前後夾說明文字,例如
* `const findings = extractJson(result.output) ?? [];`
* 可穩定取回 review findings;回傳 null 時呼叫端應視為該次回覆無效並降級處理。
*/
function extractJson(text) {
if (!text) return null;
let t = text.trim();
const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(t);
if (fence) t = fence[1].trim();
for (const [open, close] of [['[', ']'], ['{', '}']]) {
const start = t.indexOf(open);
const end = t.lastIndexOf(close);
if (start !== -1 && end > start) {
try {
return JSON.parse(t.slice(start, end + 1));
} catch {
// 換下一種括號組合再試。
}
}
}
try {
return JSON.parse(t);
} catch {
return null;
}
}
module.exports = { TOOLS, detectTool, runAgent, extractJson };