Files
brain/tools/router-gate-decide.mjs
T

184 lines
7.8 KiB
JavaScript

// tools/router-gate-decide.mjs
/**
* Core decision module — router-gate v4 spec §4 (4 поведения) + §10.1.
* Pure: вся behavior-логика allow/block/unlock. Safe-baseline metering, skill-scope,
* Bash-content — отдельные слои в хуке (Stream B/G), поверх decide().
*
* NOTE on knownInRegistry: directInvocation.knownInRegistry===false is NOT handled
* here. Caller (Stream G hook) MUST check knownInRegistry and emit AskUser (with
* fuzzy-match suggestions) before calling decide(). decide() unlocks direct
* invocations regardless of registry membership.
*/
export const SAFE_BASELINE_TOOLS = ['Read', 'Grep', 'Glob', 'LS', 'TodoWrite', 'AskUserQuestion'];
export const MUTATING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Skill', 'Task'];
const SAFE_SET = new Set(SAFE_BASELINE_TOOLS);
const MUT_SET = new Set(MUTATING_TOOLS);
export function isSafeBaselineTool(name) { return SAFE_SET.has(name); }
export function isMutatingTool(name) { return MUT_SET.has(name); }
function normName(n) { return String(n || '').trim().toLowerCase(); }
function suffixMatch(a, b) {
const x = normName(a); const y = normName(b);
return x === y || x.endsWith(':' + y) || y.endsWith(':' + x);
}
export function nodeMatches(recommendation, toolUse, resolveAlias = (x) => x) {
if (!recommendation || !toolUse) return false;
const resolved = resolveAlias(recommendation);
if (toolUse.name === 'Skill') {
return suffixMatch(resolved, toolUse.input?.skill);
}
if (toolUse.name === 'Task') {
return suffixMatch(resolved, toolUse.input?.subagent_type);
}
return false;
}
export const DIRECT_INVOCATION_PATTERNS = [
{ re: /^\/([a-z0-9_-]+)(\s|$)/iu, type: 'slash', group: 1 },
// Fix 1: left boundary (?<![\p{L}\p{N}_]) guards against fused-word matches (e.g. вообщевызови).
// Fix 2: polite imperatives вызовите/примените added alongside вызови/примени.
{ re: /(?<![\p{L}\p{N}_])(?:вызови|вызовите|примени|примените)\s+Skill\(([^)]+)\)/iu, type: 'skill_call', group: 1 },
{ re: /использу(?:й|йте)\s+#(\d+)/iu, type: 'registry_num', group: 1 },
// Fix 1: left boundary on Cyrillic-leading literal_name pattern (mirrors todowrite-skill-verifier).
{ re: /(?<![\p{L}\p{N}_])(?:делай|сделай|вызови|примени|используй)\s+([a-z][a-z0-9:-]+)/iu, type: 'literal_name', group: 1 },
];
export function detectDirectInvocation(userPrompt, opts = {}) {
const { registryHas = () => true } = opts;
const text = String(userPrompt || '');
for (const { re, type, group } of DIRECT_INVOCATION_PATTERNS) {
const m = text.match(re);
if (m) {
const name = m[group];
const known = type === 'registry_num' ? registryHas('#' + name) : registryHas(name);
return { matched: true, type, name, knownInRegistry: known };
}
}
return { matched: false };
}
// Used by Stream G hook layer pre-decide(); decide() does not call this.
export function crossCheckSelfSuggested(directName, recentControllerResponses) {
const target = normName(directName);
const patterns = [
/Skill\(([^)]+)\)/giu,
/(?:делай|используй|вызови)\s+([a-z][a-z0-9:-]+)/giu,
/\/([a-z0-9_-]+)/giu,
];
for (const resp of recentControllerResponses || []) {
for (const re of patterns) {
for (const m of String(resp).matchAll(re)) {
if (suffixMatch(target, m[1])) return { selfSuggested: true };
}
}
}
return { selfSuggested: false };
}
const DAY_MS = 86_400_000;
export function newChainState(chain, nowMs) {
const iso = new Date(nowMs).toISOString();
return {
schema_version: 1,
chain_active: chain,
chain_step: 0,
initialized_at: iso,
last_step_at: iso,
expires_at: new Date(nowMs + DAY_MS).toISOString(),
};
}
export function isChainExpired(chainState, nowMs) {
if (!chainState?.initialized_at) return false;
return nowMs - new Date(chainState.initialized_at).getTime() > DAY_MS;
}
export function chainStateUpdate(chainState, matchedNode, nowMs) {
const expected = chainState.chain_active?.[chainState.chain_step];
if (matchedNode && expected && suffixMatch(matchedNode, expected)) {
return {
...chainState,
chain_step: chainState.chain_step + 1,
last_step_at: new Date(nowMs).toISOString(),
// initialized_at UNCHANGED — anti-tickle (v4.0 C19)
};
}
return chainState;
}
/**
* Core 4-behaviour decision function (§4.1-§4.4 spec).
* NOTE: directInvocation.knownInRegistry===false is NOT handled here. Caller
* (Stream G hook) MUST check knownInRegistry and emit AskUser (with fuzzy-match
* suggestions) before calling decide(). decide() unlocks direct invocations
* regardless of registry membership.
*/
export function decide(ctx) {
const {
classification = {}, turnState = {}, toolUse = {},
directInvocation = null, chainState = null, nowMs = Date.now(),
resolveAlias = (x) => x,
} = ctx;
const recNode = classification.recommended_node ?? null;
const recChain = classification.recommended_chain ?? [];
const { askuser_called = false, skill_invoked_matching = false } = turnState;
const toolName = toolUse.name;
// Поведение 1 — direct invocation precedence
if (directInvocation?.matched) {
if (toolName === 'Skill' || toolName === 'Task') {
return { decision: 'unlock', reason: 'direct invocation', behavior_branch: '1_direct_invocation' };
}
return { decision: 'allow', reason: 'direct invocation precedence', behavior_branch: '1_direct_invocation' };
}
// Поведение 3 — chain active
if (recChain.length >= 1 || chainState) {
if (chainState && isChainExpired(chainState, nowMs)) {
return { decision: 'block', reason: 'chain expired 24h from initialization (anti-tickle)', behavior_branch: '3_chain' };
}
const expectedNode = chainState?.chain_active?.[chainState?.chain_step] ?? recChain[0];
if ((toolName === 'Skill' || toolName === 'Task') && nodeMatches(expectedNode, toolUse, resolveAlias)) {
return { decision: 'unlock', reason: 'chain step match', behavior_branch: '3_chain' };
}
if (isSafeBaselineTool(toolName)) {
return { decision: 'allow', reason: 'safe-baseline in chain', behavior_branch: '3_chain' };
}
if (askuser_called) {
return { decision: 'allow', reason: 'askuser answered', behavior_branch: '3_chain' };
}
return { decision: 'block', reason: 'chain active — вызови AskUserQuestion для следующего шага', behavior_branch: '3_chain' };
}
// Поведение 2 — single recommendation
if (recNode !== null && recChain.length === 0) {
if (skill_invoked_matching) {
return { decision: 'allow', reason: 'skill invoked matching', behavior_branch: '2_single_rec' };
}
if (askuser_called) {
return { decision: 'allow', reason: 'askuser answered', behavior_branch: '2_single_rec' };
}
if ((toolName === 'Skill' || toolName === 'Task') && nodeMatches(recNode, toolUse, resolveAlias)) {
return { decision: 'unlock', reason: 'skill matches rec_node', behavior_branch: '2_single_rec' };
}
if (isSafeBaselineTool(toolName)) {
return { decision: 'allow', reason: 'safe-baseline', behavior_branch: '2_single_rec' };
}
return { decision: 'block', reason: `Router рекомендовал ${recNode}, вызови AskUserQuestion с предложениями`, behavior_branch: '2_single_rec' };
}
// Поведение 4 — silence
if (isSafeBaselineTool(toolName)) {
return { decision: 'allow', reason: 'safe-baseline (silence)', behavior_branch: '4_silence' };
}
if (askuser_called) {
return { decision: 'allow', reason: 'askuser answered (silence)', behavior_branch: '4_silence' };
}
return { decision: 'block', reason: 'AskUserQuestion required для mutating tool (silence)', behavior_branch: '4_silence' };
}