/** * Turn envelope classifier — T029, <2ms budget. * * Derives turn_type from the messages envelope using deterministic * heuristics (no neural inference). Falls back to 'unknown' when * the envelope is empty or unclassifiable. * * Classification priority (first match wins): * 1. tool_result — last message is role=tool * 2. planning — planning/architecture signals in recent content * 3. subagent — subagent/exploration context markers * 4. main_loop — default agent loop turn (messages present) * 5. unknown — no messages or empty envelope */ import type { Message, TurnType } from '../types/index.js'; // ─── Constants ──────────────────────────────────────────────────────────────── const TOOL_RESULT_SIZE_THRESHOLD = 50_000; const PLANNING_PATTERNS: readonly RegExp[] = [ /\b(?:plan|planning|architect(?:ure)?|design|refactor|migration)\b/i, /\b(?:step\s*\d|phase\s*\d|breakdown|strategy|trade-?off)\b/i, /^#+\s*(?:plan|design|architecture)/im, // Repo-hygiene / destructive-intent (SP-176, #97) — escalate off main_loop /\b(?:clean\s*up(?:\s+the)?\s+repo|repo\s+cleanup|cleanup\s+the\s+repo)\b/i, /\b(?:clean\s*up|cleanup|mistakenly\s+added|accidentally\s+added|accidental\s+add)\b/i, /\b(?:unstage|git\s+rm|rm\s+-rf|force\s+push|git\s+reset\s+--hard|destructive)\b/i, ]; const SUBAGENT_PATTERNS: readonly RegExp[] = [ /\b(?:subagent|sub-agent|exploration|explore|search|investigate)\b/i, /\b(?:spawned|delegat(?:e|ed|ing)|parallel\s+agent)\b/i, /\b(?:Task|Agent)\.(?:create|spawn|launch)\b/, ]; // ─── Classifier ─────────────────────────────────────────────────────────────── export function classifyTurnEnvelope( messages: readonly Message[] | undefined, ): TurnType { if (!messages || messages.length === 0) { return 'unknown'; } const lastMessage = messages[messages.length - 1]!; if (isToolResult(lastMessage)) { return 'tool_result'; } if (hasPlanningSignals(messages)) { return 'planning'; } if (hasSubagentSignals(messages)) { return 'subagent'; } return 'main_loop'; } // ─── Signal detectors ───────────────────────────────────────────────────────── function isToolResult(message: Message): boolean { if (message.role !== 'tool') return false; return message.content.length <= TOOL_RESULT_SIZE_THRESHOLD; } function hasPlanningSignals(messages: readonly Message[]): boolean { const window = recentWindow(messages, 3); for (const msg of window) { if (matchesAny(msg.content, PLANNING_PATTERNS)) { return true; } } return false; } function hasSubagentSignals(messages: readonly Message[]): boolean { const window = recentWindow(messages, 3); for (const msg of window) { if (matchesAny(msg.content, SUBAGENT_PATTERNS)) { return true; } } return false; } // ─── Helpers ────────────────────────────────────────────────────────────────── function recentWindow( messages: readonly Message[], count: number, ): readonly Message[] { const start = Math.max(0, messages.length - count); return messages.slice(start); } function matchesAny(text: string, patterns: readonly RegExp[]): boolean { for (const pattern of patterns) { if (pattern.test(text)) { return true; } } return false; }