Files
brain/tools/safe-baseline-metering.mjs
T

124 lines
4.6 KiB
JavaScript

// tools/safe-baseline-metering.mjs
/**
* Safe-baseline metering — router-gate v4 spec §3.1.2 (Direction 1).
* Pure: счётчики Read/Grep/Glob/LS/TodoWrite/AskUserQuestion per task.
* Закрывает skill-substitution laundering (много Read/анализ вместо invoke skill).
*/
import crypto from 'node:crypto';
export const RESET_MARKERS = [
'новая задача', 'сброс контекста', 'забудь предыдущее', 'забудь контекст',
'начнём заново', 'с чистого листа',
];
/**
* Tools whose usage is metered per-task.
* NOTE: 'TodoWrite' maps to the counter key 'TodoWrite_writes' (via `counterKey`).
* Consumers comparing against `state.counts` keys should use 'TodoWrite_writes', not 'TodoWrite'.
*/
export const METERED_TOOLS = ['Read', 'Grep', 'Glob', 'LS', 'TodoWrite', 'AskUserQuestion'];
// Fix 2: deep-freeze nested objects to match tools/cost-pricing.mjs pattern.
export const DEFAULT_THRESHOLDS = Object.freeze({
Read: Object.freeze({ warn: 30, hard: 60 }),
Grep: Object.freeze({ warn: 15, hard: 30 }),
Glob: Object.freeze({ warn: 10, hard: 20 }),
LS: Object.freeze({ warn: 10, hard: 20 }),
TodoWrite_writes: Object.freeze({ warn: 5, hard: 15 }),
AskUserQuestion: Object.freeze({ warn: 2, hard: 30 }),
});
const MUTATING = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Skill', 'Task']);
export function isMutatingForBaseline(toolName) {
return MUTATING.has(toolName);
}
export function isResetMarker(prompt) {
const low = String(prompt || '').toLowerCase();
return RESET_MARKERS.some((m) => low.includes(m));
}
export function deriveTaskId(firstPrompt) {
return crypto.createHash('sha256').update(String(firstPrompt || '')).digest('hex').slice(0, 16);
}
// Fix 1: dedupe `a` into a Set so duplicate keywords don't inflate the count.
export function keywordOverlapCount(a, b) {
const setB = new Set((b || []).map((k) => String(k).toLowerCase()));
const setA = new Set((a || []).map((k) => String(k).toLowerCase()));
let n = 0;
for (const k of setA) if (setB.has(k)) n++;
return n;
}
export function shouldInheritTaskId(prevKeywords, currentKeywords, prompt) {
if (isResetMarker(prompt)) return false;
return keywordOverlapCount(prevKeywords, currentKeywords) >= 2;
}
export function newCounterState({ taskId, startedAtIso, firstPromptExcerpt }) {
return {
schema_version: 1,
task_id: taskId,
task_started_at: startedAtIso,
task_first_prompt_excerpt: String(firstPromptExcerpt || '').slice(0, 200),
counts: { Read: 0, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 },
skill_match_within_task: false,
warnings_issued: [],
hard_blocks_issued: [],
};
}
function counterKey(toolName) {
return toolName === 'TodoWrite' ? 'TodoWrite_writes' : toolName;
}
export function incrementCounter(state, toolName) {
const key = counterKey(toolName);
if (!(key in state.counts)) return state; // not metered
return {
...state,
counts: { ...state.counts, [key]: state.counts[key] + 1 },
};
}
// Fix 3: move mutating check to top (after skillMatched short-circuit) so
// `key`/`th` are only computed in the metered-tool branch where they're used.
export function evaluateThresholds(state, toolName, skillMatched, thresholds = DEFAULT_THRESHOLDS) {
if (skillMatched) return { action: 'allow' };
// mutating tool: block if ANY metered counter reached its hard threshold
if (isMutatingForBaseline(toolName)) {
for (const mk of Object.keys(state.counts)) {
const t = thresholds[mk];
if (t && state.counts[mk] >= t.hard) {
return {
action: 'hard_block',
reason: `Превышен лимит safe-baseline tools (${mk}=${state.counts[mk]}) без Skill match. Паттерн skill-substitution. Вызови recommended skill ИЛИ перезапусти задачу с явным skill invocation.`,
};
}
}
return { action: 'allow' };
}
// metered safe-baseline tool
const key = counterKey(toolName);
const th = thresholds[key];
if (th) {
const count = state.counts[key];
if (count >= th.hard) {
// Read/Grep/etc остаются allowed (legit continuation)
return { action: 'allow', tool: toolName };
}
if (count >= th.warn) {
return {
action: 'soft_flag',
tool: toolName,
reason: `Сделано ${count} ${toolName} в задаче без invoke skill. Invoke recommended skill ИЛИ продолжить direct с явным "direct ok".`,
};
}
}
return { action: 'allow' };
}