157 lines
4.5 KiB
JavaScript
157 lines
4.5 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const DEFAULT_LOG = path.join('_byan-output', '.tool-failures.jsonl');
|
|
const MAX_LOG_ENTRIES = 200;
|
|
|
|
const ERROR_PATTERNS = [
|
|
/tool result missing/i,
|
|
/internal error/i,
|
|
/tool_use_error/i,
|
|
];
|
|
|
|
// Tools whose response echoes user-authored or file content (Write/Edit
|
|
// return file paths + content fragments, Read echoes file content
|
|
// verbatim). Pattern match on their response fires false positives when
|
|
// the file content itself contains the literal phrase "internal error"
|
|
// (e.g. a doc about errors, a test fixture, a hook that detects errors).
|
|
// For these, only trust the explicit is_error flag.
|
|
const ECHO_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'Read']);
|
|
|
|
function detectFailure(payload) {
|
|
if (!payload || typeof payload !== 'object') return null;
|
|
|
|
const resp = payload.tool_response ?? payload.toolResponse ?? payload.response;
|
|
const toolName = payload.tool_name || payload.toolName || '';
|
|
|
|
if (resp && typeof resp === 'object') {
|
|
if (resp.is_error === true || resp.isError === true) {
|
|
return { kind: 'is_error', detail: textOf(resp) };
|
|
}
|
|
}
|
|
|
|
// Do not pattern-match on echo-heavy tools — only trust is_error flag.
|
|
if (ECHO_TOOLS.has(toolName)) {
|
|
return null;
|
|
}
|
|
|
|
const combined = [
|
|
JSON.stringify(resp ?? ''),
|
|
JSON.stringify(payload.error ?? ''),
|
|
].join('\n');
|
|
|
|
for (const re of ERROR_PATTERNS) {
|
|
const m = combined.match(re);
|
|
if (m) return { kind: 'pattern', detail: m[0] };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function textOf(resp) {
|
|
if (typeof resp === 'string') return resp;
|
|
if (resp?.content) {
|
|
if (typeof resp.content === 'string') return resp.content;
|
|
if (Array.isArray(resp.content)) {
|
|
return resp.content
|
|
.map((c) => (c && typeof c === 'object' ? c.text || JSON.stringify(c) : String(c)))
|
|
.join(' ');
|
|
}
|
|
}
|
|
return JSON.stringify(resp).slice(0, 500);
|
|
}
|
|
|
|
function appendFailure(event, options = {}) {
|
|
const logPath = resolveLogPath(options.logPath, options.projectRoot);
|
|
ensureDir(path.dirname(logPath));
|
|
|
|
const entry = {
|
|
timestamp: (event.timestamp || new Date()).toISOString?.() || String(event.timestamp),
|
|
tool_name: event.tool_name || 'unknown',
|
|
kind: event.kind,
|
|
detail: (event.detail || '').toString().slice(0, 200),
|
|
};
|
|
|
|
fs.appendFileSync(logPath, JSON.stringify(entry) + '\n');
|
|
rotate(logPath);
|
|
return entry;
|
|
}
|
|
|
|
function readRecent(options = {}) {
|
|
const logPath = resolveLogPath(options.logPath, options.projectRoot);
|
|
if (!fs.existsSync(logPath)) return [];
|
|
const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean);
|
|
return lines
|
|
.map((l) => {
|
|
try {
|
|
return JSON.parse(l);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function evaluate({ now = new Date(), entries, toolName }) {
|
|
const windows = [
|
|
{ pattern: /tool result missing/i, seconds: 300, threshold: 1, label: 'tool result missing (blocking on 1st occurrence)' },
|
|
{ pattern: /internal error/i, seconds: 300, threshold: 1, label: 'internal error (blocking on 1st occurrence)' },
|
|
{ pattern: null, toolName, seconds: 120, threshold: 2, label: `2 ${toolName} failures in 2 min` },
|
|
];
|
|
|
|
for (const w of windows) {
|
|
const cutoff = now.getTime() - w.seconds * 1000;
|
|
const matching = entries.filter((e) => {
|
|
const ts = Date.parse(e.timestamp);
|
|
if (!Number.isFinite(ts) || ts < cutoff) return false;
|
|
if (w.toolName && e.tool_name !== w.toolName) return false;
|
|
if (w.pattern && !w.pattern.test(e.detail || '')) return false;
|
|
return true;
|
|
});
|
|
if (matching.length >= w.threshold) {
|
|
return {
|
|
blocked: true,
|
|
reason: w.label,
|
|
count: matching.length,
|
|
recent: matching.slice(-w.threshold),
|
|
};
|
|
}
|
|
}
|
|
|
|
return { blocked: false };
|
|
}
|
|
|
|
function resolveLogPath(explicit, projectRoot) {
|
|
if (explicit) return explicit;
|
|
const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
return path.join(root, DEFAULT_LOG);
|
|
}
|
|
|
|
function ensureDir(dir) {
|
|
try {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function rotate(logPath) {
|
|
try {
|
|
const raw = fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean);
|
|
if (raw.length > MAX_LOG_ENTRIES) {
|
|
const trimmed = raw.slice(-MAX_LOG_ENTRIES).join('\n') + '\n';
|
|
fs.writeFileSync(logPath, trimmed);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_LOG,
|
|
ERROR_PATTERNS,
|
|
detectFailure,
|
|
appendFailure,
|
|
readRecent,
|
|
evaluate,
|
|
};
|