UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

404 lines (353 loc) 12.8 kB
/** * Surface Checker - Determines when Bob should surface to ask human * * Story 11.4: Bob Surface Criteria * * This module evaluates codified criteria to determine when the AI * should interrupt and ask for human decision, ensuring consistent * behavior regardless of LLM reasoning. * * @module core/orchestration/surface-checker * @version 1.0.0 */ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); /** * @typedef {Object} SurfaceContext * @property {number} [estimated_cost] - Estimated cost in USD * @property {string} [risk_level] - Risk level ('LOW', 'MEDIUM', 'HIGH') * @property {string} [risk_details] - Details about the risk * @property {number} [valid_options_count] - Number of valid options available * @property {string} [options_with_tradeoffs] - Formatted options with trade-offs * @property {number} [errors_in_task] - Number of consecutive errors in current task * @property {string} [error_summary] - Summary of errors encountered * @property {string} [action_type] - Type of action being performed * @property {string} [action_description] - Description of the action * @property {string} [affected_files] - Files affected by destructive action * @property {string} [requested_scope] - Requested scope * @property {string} [approved_scope] - Approved scope * @property {string} [scope_difference] - Difference between scopes * @property {boolean} [requires_api_key] - Whether operation requires API key * @property {boolean} [requires_payment] - Whether operation requires payment * @property {boolean} [requires_external_service] - Whether operation requires external service * @property {string} [dependency_description] - Description of external dependency */ /** * @typedef {Object} SurfaceResult * @property {boolean} should_surface - Whether Bob should surface to ask human * @property {string|null} criterion_id - ID of the triggered criterion (null if no surface) * @property {string|null} criterion_name - Name of the triggered criterion * @property {string|null} action - Action to take (null if no surface) * @property {string|null} message - Interpolated message to display (null if no surface) * @property {string|null} severity - Severity level (null if no surface) * @property {boolean} can_bypass - Whether this criterion can be bypassed in YOLO mode */ /** * @typedef {Object} ActionConfig * @property {string} type - Action type * @property {string} prompt_type - Prompt type to use * @property {string|null} default - Default value * @property {number} timeout_seconds - Timeout in seconds * @property {string} on_timeout - Action on timeout * @property {Array<{value: string, label: string}>} [options] - Options for select prompts * @property {string} [required_input] - Required input for explicit confirm */ class SurfaceChecker { /** * Create a SurfaceChecker instance * @param {string} [criteriaPath] - Path to criteria YAML file (optional, uses default) */ constructor(criteriaPath = null) { this.criteriaPath = criteriaPath || path.join(__dirname, 'bob-surface-criteria.yaml'); this.criteria = null; this._loaded = false; } /** * Load criteria from YAML file * @returns {boolean} Whether loading was successful */ load() { try { if (!fs.existsSync(this.criteriaPath)) { console.warn(`[SurfaceChecker] Criteria file not found: ${this.criteriaPath}`); return false; } const content = fs.readFileSync(this.criteriaPath, 'utf8'); this.criteria = yaml.load(content); this._loaded = true; return true; } catch (error) { console.error(`[SurfaceChecker] Failed to load criteria: ${error.message}`); return false; } } /** * Ensure criteria are loaded * @private */ _ensureLoaded() { if (!this._loaded) { this.load(); } } /** * Evaluate a condition against the context * @param {string} condition - Condition expression * @param {SurfaceContext} context - Context to evaluate against * @returns {boolean} Whether condition is met */ evaluateCondition(condition, context) { // Handle comparison operators // Pattern: field operator value // Supported: >, <, >=, <=, ==, != // Greater than const gtMatch = condition.match(/^(\w+)\s*>\s*(\d+(?:\.\d+)?)$/); if (gtMatch) { const [, field, value] = gtMatch; return (context[field] || 0) > parseFloat(value); } // Greater than or equal const gteMatch = condition.match(/^(\w+)\s*>=\s*(\d+(?:\.\d+)?)$/); if (gteMatch) { const [, field, value] = gteMatch; return (context[field] || 0) >= parseFloat(value); } // Less than const ltMatch = condition.match(/^(\w+)\s*<\s*(\d+(?:\.\d+)?)$/); if (ltMatch) { const [, field, value] = ltMatch; return (context[field] || 0) < parseFloat(value); } // Less than or equal const lteMatch = condition.match(/^(\w+)\s*<=\s*(\d+(?:\.\d+)?)$/); if (lteMatch) { const [, field, value] = lteMatch; return (context[field] || 0) <= parseFloat(value); } // Equality with string const eqStrMatch = condition.match(/^(\w+)\s*==\s*['"](\w+)['"]$/); if (eqStrMatch) { const [, field, value] = eqStrMatch; return context[field] === value; } // Equality with number const eqNumMatch = condition.match(/^(\w+)\s*==\s*(\d+(?:\.\d+)?)$/); if (eqNumMatch) { const [, field, value] = eqNumMatch; return context[field] === parseFloat(value); } // IN operator for destructive actions const inMatch = condition.match(/^(\w+)\s+IN\s+(\w+)$/); if (inMatch) { const [, field, listName] = inMatch; const list = this.criteria?.criteria?.[listName] || []; return Array.isArray(list) && list.includes(context[field]); } // Scope comparison (requested_scope > approved_scope) if (condition === 'requested_scope > approved_scope') { const requested = context.requested_scope || ''; const approved = context.approved_scope || ''; // Compare by length or explicit scope_expanded flag return context.scope_expanded === true || requested.length > approved.length; } // OR conditions if (condition.includes(' OR ')) { const parts = condition.split(' OR ').map((p) => p.trim()); return parts.some((part) => this.evaluateCondition(part, context)); } // AND conditions if (condition.includes(' AND ')) { const parts = condition.split(' AND ').map((p) => p.trim()); return parts.every((part) => this.evaluateCondition(part, context)); } // Boolean field check if (/^[a-z_]+$/.test(condition)) { return Boolean(context[condition]); } // Unknown condition - log warning and return false (safe default) console.warn(`[SurfaceChecker] Unknown condition format: ${condition}`); return false; } /** * Interpolate message template with context values * @param {string} template - Message template with ${var} placeholders * @param {SurfaceContext} context - Context with values * @returns {string} Interpolated message */ interpolateMessage(template, context) { if (!template) return ''; return template.replace(/\$\{(\w+)\}/g, (match, key) => { if (key in context) { const value = context[key]; // Format numbers with 2 decimal places if they're currency if (typeof value === 'number' && key.includes('cost')) { return value.toFixed(2); } return String(value ?? ''); } return match; // Keep original if not found }); } /** * Check if Bob should surface to ask human * @param {SurfaceContext} context - Current execution context * @returns {SurfaceResult} Result indicating whether to surface and how */ shouldSurface(context) { this._ensureLoaded(); // Default result - no surface needed const noSurface = { should_surface: false, criterion_id: null, criterion_name: null, action: null, message: null, severity: null, can_bypass: true, }; if (!this.criteria || !this.criteria.criteria) { return noSurface; } // Get evaluation order const evaluationOrder = this.criteria.evaluation_order || Object.keys(this.criteria.criteria); // Evaluate criteria in order (first match wins) for (const criterionKey of evaluationOrder) { const criterion = this.criteria.criteria[criterionKey]; // Skip if it's not a criterion object (e.g., destructive_actions list) if (!criterion || !criterion.condition || !criterion.id) { continue; } const conditionMet = this.evaluateCondition(criterion.condition, context); if (conditionMet) { return { should_surface: true, criterion_id: criterion.id, criterion_name: criterion.name || criterionKey, action: criterion.action, message: this.interpolateMessage(criterion.message, context), severity: criterion.severity || 'info', can_bypass: criterion.bypass !== false, }; } } return noSurface; } /** * Get action configuration for a given action name * @param {string} actionName - Name of the action * @returns {ActionConfig|null} Action configuration or null if not found */ getActionConfig(actionName) { this._ensureLoaded(); if (!this.criteria || !this.criteria.actions) { return null; } return this.criteria.actions[actionName] || null; } /** * Get all criteria definitions * @returns {Object} Criteria definitions */ getCriteria() { this._ensureLoaded(); return this.criteria?.criteria || {}; } /** * Get the list of destructive actions * @returns {string[]} List of destructive action types */ getDestructiveActions() { this._ensureLoaded(); return this.criteria?.criteria?.destructive_actions || []; } /** * Check if an action type is destructive * @param {string} actionType - Action type to check * @returns {boolean} Whether the action is destructive */ isDestructiveAction(actionType) { const destructiveActions = this.getDestructiveActions(); return destructiveActions.includes(actionType); } /** * Get criteria metadata * @returns {Object} Metadata from criteria file */ getMetadata() { this._ensureLoaded(); return this.criteria?.metadata || {}; } /** * Validate that criteria file is properly formatted * @returns {{valid: boolean, errors: string[]}} Validation result */ validate() { this._ensureLoaded(); const errors = []; if (!this.criteria) { errors.push('Criteria file not loaded'); return { valid: false, errors }; } if (!this.criteria.version) { errors.push('Missing version field'); } if (!this.criteria.criteria) { errors.push('Missing criteria section'); } else { // Validate each criterion const criteriaEntries = Object.entries(this.criteria.criteria); for (const [key, criterion] of criteriaEntries) { // Skip non-criterion entries (like destructive_actions list) if (Array.isArray(criterion)) continue; if (!criterion || typeof criterion !== 'object') continue; if (!criterion.id) { errors.push(`Criterion '${key}' missing id field`); } if (!criterion.condition) { errors.push(`Criterion '${key}' missing condition field`); } if (!criterion.action) { errors.push(`Criterion '${key}' missing action field`); } if (!criterion.message) { errors.push(`Criterion '${key}' missing message field`); } } } if (!this.criteria.actions) { errors.push('Missing actions section'); } return { valid: errors.length === 0, errors, }; } } /** * Create a SurfaceChecker instance and load criteria * @param {string} [criteriaPath] - Optional custom path to criteria file * @returns {SurfaceChecker} Loaded SurfaceChecker instance */ function createSurfaceChecker(criteriaPath = null) { const checker = new SurfaceChecker(criteriaPath); checker.load(); return checker; } /** * Convenience function to check if should surface * @param {SurfaceContext} context - Execution context * @param {string} [criteriaPath] - Optional custom path to criteria file * @returns {SurfaceResult} Surface check result */ function shouldSurface(context, criteriaPath = null) { const checker = createSurfaceChecker(criteriaPath); return checker.shouldSurface(context); } module.exports = { SurfaceChecker, createSurfaceChecker, shouldSurface, };