Files
boocode/packages/ion/src/engine/utils.ts
indifferentketchup 02063072ab chore: add ion package, codesight wiki, work plans, ascli config
New @boocode/ion package (v0.0.1) for inference optimization network.
.codesight/ wiki artifacts for codebase documentation.
.omo/ work plans for openspec cleanup and enhanced file panel.
2026-06-07 22:16:45 +00:00

372 lines
11 KiB
TypeScript

/**
* Utility functions for the Ion workflow engine.
*
* Provides variable substitution, condition evaluation, error classification,
* and safe messaging helpers used by the DAG executor and top-level executor.
*/
import type { NodeOutput } from '../schema/index.js';
// ---------------------------------------------------------------------------
// Variable substitution
// ---------------------------------------------------------------------------
/**
* Substitute workflow-level variables in a string.
*
* Replaces `${VAR_NAME}` patterns with values from the provided variables map.
* Supports nested dot-notation access (e.g. `${env.API_KEY}`).
*/
export function substituteWorkflowVariables(
template: string,
variables: Record<string, unknown>,
): string {
return template.replace(/\$\{([^}]+)\}/g, (_match, path: string) => {
const parts = path.split('.');
let current: unknown = variables;
for (const part of parts) {
if (current == null || typeof current !== 'object') return '';
current = (current as Record<string, unknown>)[part];
}
return current != null ? String(current) : '';
});
}
/**
* Regex pattern for node output references: `$nodeId.output` or `$nodeId.output.field`.
*/
const NODE_OUTPUT_REF_REGEX =
/\$([a-zA-Z_][\w-]*)\.output(?:\.([a-zA-Z_][\w]*))?/g;
/**
* Substitute node output references in a prompt string.
*
* Resolves `$nodeId.output` → full text output, and
* `$nodeId.output.field` → specific structured field.
*
* @param prompt - The prompt template containing references.
* @param nodeOutputs - Map of node id → NodeOutput.
* @param escapedForBash - If true, escapes special bash characters in the output.
*/
export function substituteNodeOutputRefs(
prompt: string,
nodeOutputs: Map<string, NodeOutput>,
escapedForBash = false,
): string {
return prompt.replace(NODE_OUTPUT_REF_REGEX, (_match, nodeId: string, field?: string) => {
const output = nodeOutputs.get(nodeId);
if (!output) {
throw new OutputRefError(
`Node output reference $${nodeId}.output not found. ` +
`Available nodes: ${[...nodeOutputs.keys()].join(', ')}`,
);
}
let value: string;
if (field) {
value = resolveNodeOutputField(output, field);
} else {
value = output.text ?? '';
}
if (escapedForBash) {
value = value.replace(/(["'$`\\!])/g, '\\$1');
}
return value;
});
}
/**
* Resolve a specific field from a node's structured output.
*
* @throws OutputRefError if the field doesn't exist or output has no fields.
*/
export function resolveNodeOutputField(output: NodeOutput, field: string): string {
if (!output.fields || !(field in output.fields)) {
throw new OutputRefError(
`Node ${output.nodeId} output does not have field "${field}". ` +
`Available fields: ${output.fields ? Object.keys(output.fields).join(', ') : '(none)'}`,
);
}
const value = output.fields[field];
return value != null ? String(value) : '';
}
/**
* Build a complete prompt string with context injection.
*
* Applies workflow variable substitution and node output reference
* substitution to produce the final prompt sent to the AI provider.
*/
export function buildPromptWithContext(
prompt: string,
variables: Record<string, unknown>,
nodeOutputs: Map<string, NodeOutput>,
escapedForBash = false,
): string {
let result = substituteWorkflowVariables(prompt, variables);
result = substituteNodeOutputRefs(result, nodeOutputs, escapedForBash);
return result;
}
// ---------------------------------------------------------------------------
// Condition evaluation
// ---------------------------------------------------------------------------
/**
* Evaluate a condition expression against the current workflow context.
*
* Supports simple expressions:
* - Truthy/falsy string check (empty string = false)
* - Comparison: `==`, `!=`, `>`, `<`, `>=`, `<=`
* - Boolean literals: `true`, `false`
* - Variable references: `${var}` resolved before evaluation
*
* @param condition - The condition string to evaluate.
* @param variables - Workflow variables for substitution.
* @returns Whether the condition is truthy.
*/
export function evaluateCondition(
condition: string | undefined,
variables: Record<string, unknown>,
): boolean {
if (condition === undefined || condition === '') return true;
// Substitute variables first
const resolved = substituteWorkflowVariables(condition, variables).trim();
// Boolean literals
if (resolved === 'true') return true;
if (resolved === 'false') return false;
// Empty after substitution = falsy
if (resolved === '') return false;
// Comparison operators
const comparisonMatch = resolved.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/);
if (comparisonMatch) {
const [, left, op, right] = comparisonMatch;
const leftVal = left!.trim();
const rightVal = right!.trim();
switch (op) {
case '==':
return leftVal === rightVal;
case '!=':
return leftVal !== rightVal;
case '>=':
return parseFloat(leftVal) >= parseFloat(rightVal);
case '<=':
return parseFloat(leftVal) <= parseFloat(rightVal);
case '>':
return parseFloat(leftVal) > parseFloat(rightVal);
case '<':
return parseFloat(leftVal) < parseFloat(rightVal);
}
}
// Default: non-empty string is truthy
return resolved.length > 0;
}
// ---------------------------------------------------------------------------
// Error classification
// ---------------------------------------------------------------------------
/** Error categories for classification. */
export type ErrorCategory = 'transient' | 'permanent' | 'timeout' | 'rate_limit' | 'unknown';
/**
* Classify an error into a category for retry decisions.
*/
export function classifyError(error: unknown): ErrorCategory {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
// Timeout errors
if (
msg.includes('timeout') ||
msg.includes('timed out') ||
msg.includes('aborted') ||
error.name === 'AbortError' ||
error.name === 'TimeoutError'
) {
return 'timeout';
}
// Rate limiting
if (
msg.includes('rate limit') ||
msg.includes('429') ||
msg.includes('too many requests')
) {
return 'rate_limit';
}
// Permanent errors
if (
msg.includes('authentication') ||
msg.includes('unauthorized') ||
msg.includes('forbidden') ||
msg.includes('401') ||
msg.includes('403') ||
msg.includes('invalid api key') ||
msg.includes('permission denied')
) {
return 'permanent';
}
// Transient errors
if (
msg.includes('network') ||
msg.includes('econnreset') ||
msg.includes('econnrefused') ||
msg.includes('enotfound') ||
msg.includes('socket hang up') ||
msg.includes('500') ||
msg.includes('502') ||
msg.includes('503') ||
msg.includes('504') ||
msg.includes('internal server error') ||
msg.includes('bad gateway') ||
msg.includes('service unavailable') ||
msg.includes('gateway timeout')
) {
return 'transient';
}
}
return 'unknown';
}
// ---------------------------------------------------------------------------
// Safe messaging
// ---------------------------------------------------------------------------
/**
* Safely send a message to the platform, swallowing errors.
*
* Used for non-critical notifications where failure should not
* abort the workflow.
*/
export async function safeSendMessage(
platform: { sendMessage: (convId: string, msg: string, meta?: Record<string, unknown>) => Promise<void> },
conversationId: string,
message: string,
metadata?: Record<string, unknown>,
): Promise<void> {
try {
await platform.sendMessage(conversationId, message, metadata);
} catch {
// Swallow — this is a best-effort notification
}
}
// ---------------------------------------------------------------------------
// Custom errors
// ---------------------------------------------------------------------------
/** Thrown when a node output reference cannot be resolved. */
export class OutputRefError extends Error {
constructor(message: string) {
super(message);
this.name = 'OutputRefError';
}
}
/** Thrown when a cycle is detected in the DAG. */
export class DagCycleError extends Error {
constructor(nodeCount: number, layerSum: number) {
super(
`Cycle detected in DAG: ${nodeCount} nodes but only ${layerSum} reachable via topological sort`,
);
this.name = 'DagCycleError';
}
}
/** Thrown when a node execution times out. */
export class NodeTimeoutError extends Error {
constructor(nodeId: string, timeoutMs: number) {
super(`Node "${nodeId}" timed out after ${timeoutMs}ms`);
this.name = 'NodeTimeoutError';
}
}
/** Thrown when an approval is rejected. */
export class ApprovalRejectedError extends Error {
constructor(nodeId: string, reason?: string) {
super(`Approval rejected for node "${nodeId}"${reason ? `: ${reason}` : ''}`);
this.name = 'ApprovalRejectedError';
}
}
/** Thrown when a loop exceeds its maximum iterations. */
export class LoopMaxIterationsError extends Error {
constructor(nodeId: string, iterations: number) {
super(`Loop node "${nodeId}" exceeded max iterations (${iterations})`);
this.name = 'LoopMaxIterationsError';
}
}
// ---------------------------------------------------------------------------
// Subprocess formatting
// ---------------------------------------------------------------------------
/**
* Format a subprocess failure into a human-readable error message.
*/
export function formatSubprocessFailure(
command: string,
exitCode: number | null,
stdout: string,
stderr: string,
): string {
const parts: string[] = [];
parts.push(`Command failed: ${command}`);
if (exitCode != null) parts.push(`Exit code: ${exitCode}`);
if (stderr.trim()) parts.push(`stderr: ${stderr.trim()}`);
if (stdout.trim()) parts.push(`stdout: ${stdout.trim()}`);
return parts.join('\n');
}
// ---------------------------------------------------------------------------
// Misc helpers
// ---------------------------------------------------------------------------
/**
* Sleep for a given number of milliseconds.
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Retry a function with exponential backoff.
*
* @param fn - The function to retry.
* @param maxAttempts - Maximum number of attempts.
* @param baseDelayMs - Base delay between retries in ms.
* @param shouldRetry - Optional predicate to decide if a retry is warranted.
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxAttempts: number,
baseDelayMs = 1000,
shouldRetry?: (error: unknown) => boolean,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (shouldRetry && !shouldRetry(error)) throw error;
if (attempt < maxAttempts) {
const delay = baseDelayMs * Math.pow(2, attempt - 1);
await sleep(delay);
}
}
}
throw lastError;
}