397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
4.6 KiB
JavaScript
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' };
|
|
}
|