168 lines
7.1 KiB
JavaScript
168 lines
7.1 KiB
JavaScript
'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 決定結果來源:stdout(antigravity、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 };
|