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.
This commit is contained in:
372
packages/ion/src/engine/utils.ts
Normal file
372
packages/ion/src/engine/utils.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user