Files
brain/tools/skill-scope-verifier.mjs
T

89 lines
3.2 KiB
JavaScript

// tools/skill-scope-verifier.mjs
/**
* Skill scope verification — router-gate v4 spec §3.7 + v4.1 (Direction 2).
* Pure: трекинг tools после Skill match, off-scope ratio, content-level scope.
* v4.1: hard-block at 30% off-scope (was 50%), content-level file-path check.
*/
import { globMatch } from './path-normalization.mjs';
export const V4_1_SCOPE_THRESHOLDS = Object.freeze({
off_scope_warn_ratio: 0.15,
off_scope_hard_ratio: 0.30,
content_level_scope_check: true,
min_tools_for_evaluation: 3,
});
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
export function initTracker({ skillInvoked, turnId, toolUseIdOfSkill, scopeConfig, tsSkillInvokedIso }) {
return {
skill_invoked: skillInvoked,
turn_id: turnId,
tool_use_id_of_skill: toolUseIdOfSkill,
scope_config: scopeConfig,
tool_count_since_skill: 0,
off_scope_count: 0,
expected_output_written: false,
ts_skill_invoked: tsSkillInvokedIso,
};
}
export function updateTracker(tracker, toolName, toolInput = {}) {
const cfg = tracker.scope_config || {};
const expected = cfg.expected_tools || [];
const offScope = expected.length > 0 && !expected.includes(toolName);
let outputWritten = tracker.expected_output_written;
if (WRITE_TOOLS.has(toolName) && cfg.expected_output_glob && toolInput.file_path) {
if (globMatch(toolInput.file_path, cfg.expected_output_glob)) outputWritten = true;
}
return {
...tracker,
tool_count_since_skill: tracker.tool_count_since_skill + 1,
off_scope_count: tracker.off_scope_count + (offScope ? 1 : 0),
expected_output_written: outputWritten,
};
}
export function evaluateScope(tracker, thresholds = V4_1_SCOPE_THRESHOLDS) {
if (tracker.tool_count_since_skill < thresholds.min_tools_for_evaluation) {
return { action: 'allow' };
}
const ratio = tracker.off_scope_count / tracker.tool_count_since_skill;
if (ratio >= thresholds.off_scope_hard_ratio) {
return {
action: 'hard_block',
reason: 'Skill scope hard-block (v4.1): off-scope tools >30%. Skill mismatch — invoke matching Skill или AskUser approval для scope-broadening.',
};
}
if (ratio >= thresholds.off_scope_warn_ratio) {
return { action: 'soft_flag', surface_in_next_prompt: true };
}
return { action: 'allow' };
}
export function contentScopeCheck(toolName, filePath, scopeConfig) {
if (!WRITE_TOOLS.has(toolName)) return { action: 'allow' };
const cs = scopeConfig?.content_scope?.[toolName];
if (!cs || !cs.allowed_globs) return { action: 'allow' };
if (!filePath) return { action: 'allow' };
const ok = cs.allowed_globs.some((g) => globMatch(filePath, g));
if (ok) return { action: 'allow' };
return {
action: 'hard_block',
reason: 'v4.1 content-level scope: file path не соответствует Skill expected output domain',
};
}
export function maxCallsCheck(tracker) {
const max = tracker.scope_config?.max_tool_calls_post_skill;
if (max && tracker.tool_count_since_skill >= max && !tracker.expected_output_written) {
return {
action: 'hard_block',
reason: `Skill scope exceeded (>=${max} tools post-skill без expected output). Требуется new Skill match ИЛИ AskUser approval.`,
};
}
return { action: 'allow' };
}