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.
372 lines
11 KiB
TypeScript
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;
|
|
} |