diff --git a/src/types.ts b/src/types.ts index 4e6a88e..70c751a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -183,3 +183,129 @@ export interface PluginSettings { lastIndexTime: number; cacheConfig: CacheConfig; } + +// ============================================================ +// Workflow Engine Types (Multi-Step/Chained Reasoning) +// ============================================================ + +/** + * Supported step types in a workflow. + * - 'llm': Call the LLM with a prompt (supports variable interpolation). + * - 'vault_search': Search the vault using VaultIndexer. + * - 'tool': Execute a tool via ToolExecutor. + * - 'format': Transform/format data using a template string. + */ +export type WorkflowStepType = 'llm' | 'vault_search' | 'tool' | 'format'; + +/** + * Result of executing a single workflow step. + */ +export interface WorkflowStepResult { + stepId: string; + stepName: string; + success: boolean; + data: unknown; + error?: string; + timestamp: number; +} + +/** + * Configuration for a single workflow step. + */ +export interface WorkflowStep { + id: string; + type: WorkflowStepType; + name: string; + description?: string; + config: WorkflowStepConfig; + /** Optional: if set, this step waits for the named step to complete before running */ + dependsOn?: string; +} + +/** + * Union of all possible step configurations based on step type. + */ +export type WorkflowStepConfig = + | LlmStepConfig + | VaultSearchStepConfig + | ToolStepConfig + | FormatStepConfig; + +/** + * Call the LLM with a system prompt and user prompt. + * Both fields support variable interpolation via {{variable_name}} syntax. + * Variables can reference prior step outputs as {{stepId.output}}. + */ +export interface LlmStepConfig { + type: 'llm'; + systemPrompt?: string; + userPrompt: string; + includeToolCalls?: boolean; +} + +/** + * Search the vault for relevant notes. + * The query field supports variable interpolation. + */ +export interface VaultSearchStepConfig { + type: 'vault_search'; + query: string; + limit?: number; + tagFilter?: string; +} + +/** + * Execute a tool (e.g., read_vault_file, create_file, search_vault_files). + * The args field supports variable interpolation. + */ +export interface ToolStepConfig { + type: 'tool'; + toolName: string; + args: Record; +} + +/** + * Format/transform previous step output into a template. + * The template supports {{stepId.output}} and {{stepId.output.property}} syntax. + */ +export interface FormatStepConfig { + type: 'format'; + template: string; + outputFormat?: 'markdown' | 'text' | 'json'; +} + +/** + * Full definition of a workflow (chain of steps). + */ +export interface WorkflowDefinition { + id: string; + name: string; + description: string; + steps: WorkflowStep[]; +} + +/** + * Mutable context passed through workflow execution. + * Stores intermediate results for variable interpolation. + */ +export interface WorkflowExecutionContext { + /** Keyed by step ID -> step result data */ + variables: Map; + /** Full step results for debugging/logging */ + stepResults: WorkflowStepResult[]; + /** Conversation history to pass to LLM steps */ + conversationHistory: OllamaMessage[]; +} + +/** + * Final result after executing all workflow steps. + */ +export interface WorkflowExecutionResult { + workflowId: string; + workflowName: string; + success: boolean; + stepResults: WorkflowStepResult[]; + /** Output of the last successfully executed step */ + finalOutput: unknown; + error?: string; +} diff --git a/src/workflow-engine/index.ts b/src/workflow-engine/index.ts new file mode 100644 index 0000000..a9d1d52 --- /dev/null +++ b/src/workflow-engine/index.ts @@ -0,0 +1,3 @@ +// src/workflow-engine/index.ts + +export { WorkflowEngine } from './workflow-engine'; diff --git a/src/workflow-engine/workflow-engine.ts b/src/workflow-engine/workflow-engine.ts new file mode 100644 index 0000000..6c257c4 --- /dev/null +++ b/src/workflow-engine/workflow-engine.ts @@ -0,0 +1,817 @@ +// src/workflow-engine/workflow-engine.ts + +import { Vault, App } from 'obsidian'; +import { + WorkflowStep, + WorkflowStepType, + WorkflowStepResult, + WorkflowDefinition, + WorkflowExecutionContext, + WorkflowExecutionResult, + LlmStepConfig, + VaultSearchStepConfig, + ToolStepConfig, + FormatStepConfig, + OllamaMessage, + OllamaTool, +} from '../types'; +import { VaultIndexer } from '../vault-indexer'; +import { ToolExecutor } from '../tool-executor'; +import { OllamaClient } from '../ollama-client'; +import { ConversationStateManager } from '../conversation-state'; +import { Logger } from '../utils'; +import { safeParseJson } from '../utils'; + +/** + * Pattern to match {{variable}} or {{variable.property}} syntax in strings. + */ +const VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g; + +/** + * WorkflowEngine orchestrates multi-step/chained reasoning workflows. + * + * Each workflow is a sequence of steps that can: + * - Call the LLM with prompts + * - Search the vault for relevant notes + * - Execute tools (file operations, vault queries) + * - Format/transform data + * + * Steps can reference outputs from previous steps via {{stepId.output}} syntax. + */ +export class WorkflowEngine { + private vaultIndexer: VaultIndexer; + private toolExecutor: ToolExecutor; + private ollamaClient: OllamaClient; + private conversationStateManager: ConversationStateManager; + private maxSteps: number; + private maxWorkflowDuration: number; + + constructor( + vault: Vault, + app: App, + ollamaUrl: string, + model: string, + options?: { + maxSteps?: number; + maxWorkflowDuration?: number; + cacheConfig?: import('../types').CacheConfig; + } + ) { + this.vaultIndexer = new VaultIndexer(vault); + this.toolExecutor = new ToolExecutor(vault, app); + this.ollamaClient = new OllamaClient(ollamaUrl, model, undefined, options?.cacheConfig); + this.conversationStateManager = new ConversationStateManager(); + this.maxSteps = options?.maxSteps ?? 20; + this.maxWorkflowDuration = options?.maxWorkflowDuration ?? 300_000; // 5 minutes + } + + /** + * Execute a workflow definition. + * @param definition The workflow to execute + * @param initialVariables Optional initial variables to seed the context + * @returns The execution result with all step outputs + */ + async executeWorkflow( + definition: WorkflowDefinition, + initialVariables?: Record + ): Promise { + Logger.info(`Starting workflow: ${definition.name} (${definition.id})`, 'workflow-engine'); + + const startTime = Date.now(); + const context = this.createExecutionContext(initialVariables); + + // Validate the workflow before execution + const validationError = this.validateWorkflow(definition); + if (validationError) { + Logger.error(`Workflow validation failed: ${validationError}`, 'workflow-engine'); + return { + workflowId: definition.id, + workflowName: definition.name, + success: false, + stepResults: [], + finalOutput: null, + error: validationError, + }; + } + + try { + // Execute steps in topological order + const orderedSteps = this.topologicalSort(definition.steps); + + let stepCount = 0; + for (const step of orderedSteps) { + // Check timeout + const elapsed = Date.now() - startTime; + if (elapsed > this.maxWorkflowDuration) { + throw new Error(`Workflow exceeded maximum duration of ${this.maxWorkflowDuration}ms`); + } + + // Check max steps + stepCount++; + if (stepCount > this.maxSteps) { + throw new Error(`Workflow exceeded maximum step count of ${this.maxSteps}`); + } + + // Wait for dependency if exists + if (step.dependsOn) { + const depResult = context.stepResults.find((r) => r.stepId === step.dependsOn); + if (!depResult) { + throw new Error(`Step ${step.id}: dependency '${step.dependsOn}' not found`); + } + if (!depResult.success) { + Logger.warn( + `Step ${step.id}: dependency '${step.dependsOn}' failed, skipping`, + 'workflow-engine' + ); + // Record a failure for this step due to failed dependency + context.stepResults.push({ + stepId: step.id, + stepName: step.name, + success: false, + data: null, + error: `Dependency '${step.dependsOn}' failed: ${depResult.error}`, + timestamp: Date.now(), + }); + continue; + } + } + + // Execute the step + Logger.debug(`Executing step: ${step.name} (${step.id})`, 'workflow-engine'); + const result = await this.executeStep(step, context); + context.stepResults.push(result); + + // Store the result data in variables for interpolation + context.variables.set(step.id, result.data); + + // Update conversation history for LLM steps + if (step.type === 'llm' && result.success) { + this.conversationStateManager.updateShortTermContext({ + role: 'assistant', + content: String(result.data), + }); + } + } + + // Determine final output (last successful step's data) + const lastSuccessfulResult = [...context.stepResults].reverse().find((r) => r.success); + const finalOutput = lastSuccessfulResult?.data ?? null; + + const success = context.stepResults.every((r) => r.success); + + Logger.info( + `Workflow completed: ${definition.name} (${success ? 'success' : 'partial'})`, + 'workflow-engine' + ); + + return { + workflowId: definition.id, + workflowName: definition.name, + success, + stepResults: context.stepResults, + finalOutput, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Workflow failed: ${errorMessage}`, 'workflow-engine'); + + return { + workflowId: definition.id, + workflowName: definition.name, + success: false, + stepResults: context.stepResults, + finalOutput: null, + error: errorMessage, + }; + } + } + + /** + * Execute a workflow from a natural language description. + * The LLM will generate the workflow steps, then we execute them. + * + * @param userQuery The user's natural language request + * @param availableTools Optional list of available tools to inform the LLM + * @returns The execution result + */ + async executeWorkflowFromQuery( + userQuery: string, + availableTools?: OllamaTool[] + ): Promise { + Logger.info(`Generating workflow from query: ${userQuery}`, 'workflow-engine'); + + // First, ask the LLM to generate a workflow plan + const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools); + if (!workflow) { + return { + workflowId: 'auto-generated', + workflowName: 'Auto-generated workflow', + success: false, + stepResults: [], + finalOutput: null, + error: 'Failed to generate workflow from query', + }; + } + + // Add a system variable with the original query + const initialVariables: Record = { + original_query: userQuery, + }; + + return this.executeWorkflow(workflow, initialVariables); + } + + /** + * Generate a workflow definition from a natural language query using the LLM. + */ + private async generateWorkflowFromQuery( + query: string, + availableTools?: OllamaTool[] + ): Promise { + const toolDescriptions = + availableTools?.map((t) => `- ${t.function.name}: ${t.function.description}`).join('\n') ?? + ''; + + const systemPrompt = `You are a workflow planner. Given a user query, break it down into a sequence of workflow steps. + +Available step types: +- vault_search: Search the vault for notes. Config: { type: 'vault_search', query: string, limit?: number, tagFilter?: string } +- llm: Call an LLM. Config: { type: 'llm', systemPrompt?: string, userPrompt: string } +- tool: Execute a tool. Config: { type: 'tool', toolName: string, args: object } +- format: Format output. Config: { type: 'format', template: string, outputFormat?: 'markdown' | 'text' | 'json' } + +Available tools: +${toolDescriptions} + +Variable interpolation syntax: +- Use {{stepId.output}} to reference a previous step's output +- Use {{stepId.output.property}} to reference a property of a step's output +- Use {{original_query}} to reference the original user query + +Return a JSON object with this structure: +{ + "id": "workflow-uuid", + "name": "workflow name", + "description": "description", + "steps": [ + { + "id": "step_1", + "type": "vault_search" | "llm" | "tool" | "format", + "name": "step name", + "description": "optional description", + "config": { /* step-specific config */ }, + "dependsOn": "optional_step_id" + } + ] +} + +Rules: +1. Number steps sequentially (step_1, step_2, etc.) +2. Use dependsOn to specify ordering when needed +3. Use variable interpolation to pass data between steps +4. Keep the workflow minimal but effective +5. End with a format step if the user wants structured output`; + + const userPrompt = `User query: ${query}`; + + try { + const messages: OllamaMessage[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ]; + + const response = await this.ollamaClient.chat(messages, []); + const content = response.content?.trim(); + + if (!content) { + Logger.error('Empty response from LLM when generating workflow', 'workflow-engine'); + return null; + } + + // Extract JSON from the response (handle markdown code blocks) + const jsonMatch = + content.match(/\```(?:json)?\s*([\s\S]*?)\```/) ?? content.match(/\{[\s\S]*\}/); + const jsonString = jsonMatch ? jsonMatch[1] : content; + + const parsed = safeParseJson(jsonString) as unknown; + if (!parsed || typeof parsed !== 'object') { + Logger.error('Invalid workflow JSON from LLM', 'workflow-engine'); + return null; + } + + return parsed as WorkflowDefinition; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Failed to generate workflow: ${errorMessage}`, 'workflow-engine'); + return null; + } + } + + /** + * Create a fresh execution context. + */ + private createExecutionContext( + initialVariables?: Record + ): WorkflowExecutionContext { + const variables = new Map(); + if (initialVariables) { + for (const [key, value] of Object.entries(initialVariables)) { + variables.set(key, value); + } + } + + return { + variables, + stepResults: [], + conversationHistory: [], + }; + } + + /** + * Execute a single workflow step. + */ + private async executeStep( + step: WorkflowStep, + context: WorkflowExecutionContext + ): Promise { + const timestamp = Date.now(); + try { + // Interpolate variables in the step config + const interpolatedConfig = this.interpolateVariables(step.config, context.variables); + + let data: unknown; + switch (step.type) { + case 'llm': + data = await this.executeLlmStep(interpolatedConfig as LlmStepConfig, context); + break; + case 'vault_search': + data = await this.executeVaultSearchStep(interpolatedConfig as VaultSearchStepConfig); + break; + case 'tool': + data = await this.executeToolStep(interpolatedConfig as ToolStepConfig); + break; + case 'format': + data = this.executeFormatStep(interpolatedConfig as FormatStepConfig, context); + break; + default: + throw new Error(`Unknown step type: ${step.type}`); + } + + return { + stepId: step.id, + stepName: step.name, + success: true, + data, + timestamp, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Step ${step.id} failed: ${errorMessage}`, 'workflow-engine'); + + return { + stepId: step.id, + stepName: step.name, + success: false, + data: null, + error: errorMessage, + timestamp, + }; + } + } + + /** + * Execute an LLM step. + */ + private async executeLlmStep( + config: LlmStepConfig, + context: WorkflowExecutionContext + ): Promise { + const messages: OllamaMessage[] = []; + + // Add system prompt if provided + if (config.systemPrompt) { + messages.push({ + role: 'system', + content: config.systemPrompt, + }); + } + + // Add conversation history + messages.push(...context.conversationHistory); + + // Add user prompt + messages.push({ + role: 'user', + content: config.userPrompt, + }); + + // Determine if we should include tools + const tools = config.includeToolCalls ? [] : []; + + const response = await this.ollamaClient.chat(messages, tools); + return response.content ?? ''; + } + + /** + * Execute a vault search step. + */ + private async executeVaultSearchStep(config: VaultSearchStepConfig): Promise { + const limit = config.limit ?? 5; + const entries = await this.vaultIndexer.searchVault(config.query, limit); + + // Apply tag filter if specified + const filtered = config.tagFilter + ? entries.filter((entry) => { + const tags = entry.frontmatter?.tags ?? ''; + return tags.toLowerCase().includes(config.tagFilter!.toLowerCase()); + }) + : entries; + + return filtered.map((entry) => ({ + path: entry.file.path, + title: entry.title, + content: entry.content, + score: entry.score, + tags: entry.frontmatter?.tags, + })); + } + + /** + * Execute a tool step. + */ + private async executeToolStep(config: ToolStepConfig): Promise { + const args: Record = config.args ?? {}; + const result = await this.toolExecutor.executeTool(config.toolName, args); + + return { + success: result.success, + message: result.message, + data: result.data, + }; + } + + /** + * Execute a format step (template rendering). + */ + private executeFormatStep(config: FormatStepConfig, context: WorkflowExecutionContext): string { + let output = config.template; + output = String(this.interpolateVariables(output, context.variables)); + + // Apply output formatting + if (config.outputFormat === 'json') { + try { + const parsed = safeParseJson(output); + return JSON.stringify(parsed, null, 2); + } catch { + // Return as-is if not valid JSON + return output; + } + } + + return output; + } + + /** + * Interpolate {{variables}} in a string or object. + * Supports: + * - {{variableName}} -> value from context + * - {{stepId.output}} -> data from a step result + * - {{stepId.output.property}} -> nested property access + */ + private interpolateVariables(input: unknown, variables: Map): unknown { + if (typeof input === 'string') { + return this.interpolateString(input, variables); + } + + if (Array.isArray(input)) { + return input.map((item) => this.interpolateVariables(item, variables)); + } + + if (input !== null && typeof input === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + result[key] = this.interpolateVariables(value, variables); + } + return result; + } + + return input; + } + + /** + * Interpolate variables in a string. + */ + private interpolateString(input: string, variables: Map): string { + return input.replace(VARIABLE_PATTERN, (_match, variablePath) => { + const value = this.resolveVariable(variablePath, variables); + if (value === undefined) { + // Keep the original placeholder if variable not found + Logger.warn(`Variable '${variablePath}' not found during interpolation`, 'workflow-engine'); + return _match; + } + + // Handle different value types + if (typeof value === 'object' && value !== null) { + return JSON.stringify(value); + } + + return String(value); + }); + } + + /** + * Resolve a variable path like "step_1.output" or "step_1.output.property". + */ + private resolveVariable(path: string, variables: Map): unknown { + const parts = path.split('.'); + + // Check if this is a step output reference (stepId.output or stepId.output.property) + if (parts.length >= 2 && parts[1] === 'output') { + const stepId = parts[0]; + const stepData = variables.get(stepId); + + if (parts.length === 2) { + return stepData; + } + + // Navigate into nested properties + let current = stepData; + for (let i = 2; i < parts.length; i++) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[parts[i]]; + } + return current; + } + + // Direct variable lookup + return variables.get(path); + } + + /** + * Perform topological sort on steps to determine execution order. + * This respects the dependsOn field and ensures steps run in the correct order. + */ + private topologicalSort(steps: WorkflowStep[]): WorkflowStep[] { + const stepMap = new Map(); + for (const step of steps) { + stepMap.set(step.id, step); + } + + const result: WorkflowStep[] = []; + const visited = new Set(); + const visiting = new Set(); + + const visit = (stepId: string): void => { + if (visited.has(stepId)) return; + if (visiting.has(stepId)) { + throw new Error(`Circular dependency detected involving step '${stepId}'`); + } + + const step = stepMap.get(stepId); + if (!step) { + throw new Error(`Step '${stepId}' not found`); + } + + visiting.add(stepId); + + // Visit dependencies first + if (step.dependsOn) { + visit(step.dependsOn); + } + + visiting.delete(stepId); + visited.add(stepId); + result.push(step); + }; + + // Visit all steps + for (const step of steps) { + visit(step.id); + } + + return result; + } + + /** + * Validate a workflow definition before execution. + * Returns null if valid, or an error message string if invalid. + */ + private validateWorkflow(definition: WorkflowDefinition): string | null { + if (!definition.id) { + return 'Workflow must have an id'; + } + + if (!definition.name) { + return 'Workflow must have a name'; + } + + if (!definition.steps || !Array.isArray(definition.steps)) { + return 'Workflow must have a steps array'; + } + + if (definition.steps.length === 0) { + return 'Workflow must have at least one step'; + } + + const stepIds = new Set(); + for (const step of definition.steps) { + if (!step.id) { + return 'Each step must have an id'; + } + + if (!step.type) { + return `Step '${step.id}' must have a type`; + } + + if (!step.name) { + return `Step '${step.id}' must have a name`; + } + + if (!step.config) { + return `Step '${step.id}' must have a config`; + } + + // Check for duplicate step ids + if (stepIds.has(step.id)) { + return `Duplicate step id: '${step.id}'`; + } + stepIds.add(step.id); + + // Validate step type + const validTypes: WorkflowStepType[] = ['llm', 'vault_search', 'tool', 'format']; + if (!validTypes.includes(step.type)) { + return `Step '${step.id}' has invalid type: '${step.type}'`; + } + + // Validate dependsOn references a valid step + if (step.dependsOn && !definition.steps.some((s) => s.id === step.dependsOn)) { + return `Step '${step.id}' depends on unknown step: '${step.dependsOn}'`; + } + + // Validate step-specific config + const configError = this.validateStepConfig(step); + if (configError) { + return configError; + } + } + + return null; + } + + /** + * Validate a step's configuration based on its type. + */ + private validateStepConfig(step: WorkflowStep): string | null { + switch (step.type) { + case 'llm': { + const config = step.config as LlmStepConfig; + if (!config.userPrompt) { + return `LLM step '${step.id}' requires a userPrompt`; + } + break; + } + + case 'vault_search': { + const config = step.config as VaultSearchStepConfig; + if (!config.query) { + return `Vault search step '${step.id}' requires a query`; + } + break; + } + + case 'tool': { + const config = step.config as ToolStepConfig; + if (!config.toolName) { + return `Tool step '${step.id}' requires a toolName`; + } + break; + } + + case 'format': { + const config = step.config as FormatStepConfig; + if (!config.template) { + return `Format step '${step.id}' requires a template`; + } + break; + } + } + + return null; + } + + /** + * Get all built-in workflow definitions (presets). + */ + static getBuiltInWorkflows(): WorkflowDefinition[] { + return [ + WorkflowEngine.createMeetingSummaryWorkflow(), + WorkflowEngine.createNoteAnalyzerWorkflow(), + ]; + } + + /** + * Create a workflow that summarizes meeting notes from the last week. + */ + private static createMeetingSummaryWorkflow(): WorkflowDefinition { + return { + id: 'meeting-summary', + name: 'Meeting Notes Summary', + description: + 'Analyzes meeting notes from the last week, extracts decisions, and creates a summary.', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Find Meeting Notes', + description: 'Search for notes tagged with #meeting', + config: { + type: 'vault_search', + query: 'meeting', + limit: 10, + tagFilter: 'meeting', + }, + }, + { + id: 'step_2', + type: 'llm', + name: 'Extract Decisions', + description: 'Extract key decisions and assigned owners from meeting notes', + config: { + type: 'llm', + systemPrompt: + 'You are a meeting analyst. Extract key decisions, action items, and assigned owners from meeting notes.', + userPrompt: `Here are meeting notes from recent meetings:\n\n{{step_1.output}}\n\nPlease extract:\n1. Key decisions made\n2. Action items with assigned owners\n3. Deadlines if mentioned\n\nFormat as a structured list.`, + }, + dependsOn: 'step_1', + }, + { + id: 'step_3', + type: 'format', + name: 'Format Summary', + description: 'Format the results into a markdown table', + config: { + type: 'format', + template: + '# Meeting Summary\n\n## Decisions and Action Items\n\n{{step_2.output}}\n\n---\n*Generated by Workflow Engine*', + outputFormat: 'markdown', + }, + + dependsOn: 'step_2', + }, + ], + }; + } + + /** + * Create a workflow that analyzes notes and generates insights. + */ + private static createNoteAnalyzerWorkflow(): WorkflowDefinition { + return { + id: 'note-analyzer', + name: 'Note Analyzer', + description: 'Analyzes notes and generates insights, summaries, and suggestions.', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Search Notes', + description: 'Search for relevant notes based on query', + config: { + type: 'vault_search', + query: '{{original_query}}', + limit: 5, + }, + }, + { + id: 'step_2', + type: 'llm', + name: 'Analyze Content', + description: 'Analyze the found notes for insights', + config: { + type: 'llm', + systemPrompt: + 'You are an analytical assistant. Analyze the provided notes and identify key themes, insights, and connections.', + userPrompt: `Original query: {{original_query}}\n\nFound notes:\n{{step_1.output}}\n\nPlease provide:\n1. Key themes identified\n2. Important insights\n3. Potential connections between notes\n4. Suggestions for further exploration`, + }, + dependsOn: 'step_1', + }, + { + id: 'step_3', + type: 'format', + name: 'Format Results', + description: 'Format the analysis into a readable report', + config: { + type: 'format', + template: + '# Analysis Report\n\n## Query: {{original_query}}\n\n## Findings\n\n{{step_2.output}}\n\n---\n*Generated by Workflow Engine*', + outputFormat: 'markdown', + }, + + dependsOn: 'step_2', + }, + ], + }; + } +} diff --git a/tests/workflow-engine.test.ts b/tests/workflow-engine.test.ts new file mode 100644 index 0000000..9ba50e4 --- /dev/null +++ b/tests/workflow-engine.test.ts @@ -0,0 +1,1704 @@ +// tests/workflow-engine.test.ts + +import { WorkflowEngine } from '../src/workflow-engine/workflow-engine'; +import { + WorkflowDefinition, + WorkflowStep, + WorkflowStepResult, + WorkflowExecutionContext, + WorkflowExecutionResult, + OllamaMessage, + OllamaTool, +} from '../src/types'; +import { VaultIndexer } from '../src/vault-indexer'; +import { ToolExecutor } from '../src/tool-executor'; +import { OllamaClient } from '../src/ollama-client'; +import { ConversationStateManager } from '../src/conversation-state'; +import { Vault, App, Workspace, WorkspaceLeaf, TFile } from '../__mocks__/obsidian'; + +// ==================== Mock Setup ==================== + +jest.mock('../src/vault-indexer'); +jest.mock('../src/tool-executor'); +jest.mock('../src/ollama-client'); +jest.mock('../src/conversation-state'); + +// ==================== Helpers ==================== + +function createMockVault(): Vault { + const mockVault = new Vault(); + mockVault.getMarkdownFiles = jest.fn(() => []); + return mockVault; +} + +function createMockApp(): App { + const mockApp = new App(); + mockApp.vault = createMockVault(); + return mockApp; +} + +function createMockWorkflowEngine( + mockVault?: Vault, + mockApp?: App +): { + engine: WorkflowEngine; + mockVaultIndexer: jest.Mocked; + mockToolExecutor: jest.Mocked; + mockOllamaClient: jest.Mocked; + mockConversationStateManager: jest.Mocked; +} { + const vault = mockVault ?? createMockVault(); + const app = mockApp ?? createMockApp(); + + const engine = new WorkflowEngine(vault as any, app as any, 'http://localhost:11434', 'llama3'); + + // Access private properties via jest mocking + const mockVaultIndexer = VaultIndexer as unknown as jest.Mocked; + const mockToolExecutor = ToolExecutor as unknown as jest.Mocked; + const mockOllamaClient = OllamaClient as unknown as jest.Mocked; + const mockConversationStateManager = ConversationStateManager as unknown as jest.Mocked< + typeof ConversationStateManager + >; + + return { + engine, + mockVaultIndexer: mockVaultIndexer.prototype as unknown as jest.Mocked, + mockToolExecutor: mockToolExecutor.prototype as unknown as jest.Mocked, + mockOllamaClient: mockOllamaClient.prototype as unknown as jest.Mocked, + mockConversationStateManager: + mockConversationStateManager.prototype as unknown as jest.Mocked, + }; +} + +// ==================== Tests ==================== + +describe('WorkflowEngine', () => { + let engine: WorkflowEngine; + let mockVaultIndexer: jest.Mocked; + let mockToolExecutor: jest.Mocked; + let mockOllamaClient: jest.Mocked; + let mockConversationStateManager: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + const mocks = createMockWorkflowEngine(); + engine = mocks.engine; + mockVaultIndexer = mocks.mockVaultIndexer; + mockToolExecutor = mocks.mockToolExecutor; + mockOllamaClient = mocks.mockOllamaClient; + mockConversationStateManager = mocks.mockConversationStateManager; + + // Setup default mock behaviors + mockVaultIndexer.searchVault = jest.fn().mockResolvedValue([]); + mockToolExecutor.executeTool = jest.fn().mockResolvedValue({ + success: true, + message: 'Tool executed successfully', + data: null, + }); + mockOllamaClient.chat = jest.fn().mockResolvedValue({ + role: 'assistant', + content: 'Mock LLM response', + tool_calls: [], + }); + mockConversationStateManager.updateShortTermContext = jest.fn(); + mockConversationStateManager.clear = jest.fn(); + }); + + // ==================== Validation Tests ==================== + + describe('validateWorkflow (via executeWorkflow)', () => { + it('should reject workflow with missing id', async () => { + const definition: WorkflowDefinition = { + id: '', + name: 'Test Workflow', + description: 'Test', + steps: [], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe('Workflow must have an id'); + expect(result.workflowId).toBe(''); + }); + + it('should reject workflow with missing name', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: '', + description: 'Test', + steps: [], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe('Workflow must have a name'); + }); + + it('should reject workflow with missing steps array', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [], + }; + + // Test with empty steps + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe('Workflow must have at least one step'); + }); + + it('should reject workflow with step missing id', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: '', + type: 'llm', + name: 'Test Step', + config: { + type: 'llm', + userPrompt: 'Hello', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe('Each step must have an id'); + }); + + it('should reject workflow with step missing type', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: '' as any, + name: 'Test Step', + config: {} as any, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Step 'step_1' must have a type"); + }); + + it('should reject workflow with step missing name', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'llm', + name: '', + config: { + type: 'llm', + userPrompt: 'Hello', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Step 'step_1' must have a name"); + }); + + it('should reject workflow with step missing config', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'Test Step', + config: null as unknown as never, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Step 'step_1' must have a config"); + }); + + it('should reject workflow with duplicate step ids', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'Step 1', + config: { + type: 'llm', + userPrompt: 'Hello', + }, + }, + { + id: 'step_1', + type: 'format', + name: 'Step 2', + config: { + type: 'format', + template: 'Test', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Duplicate step id: 'step_1'"); + }); + + it('should reject workflow with invalid step type', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'invalid_type' as any, + name: 'Test Step', + config: {} as any, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Step 'step_1' has invalid type: 'invalid_type'"); + }); + + it('should reject workflow with dependsOn referencing unknown step', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'Step 1', + config: { + type: 'llm', + userPrompt: 'Hello', + }, + }, + { + id: 'step_2', + type: 'format', + name: 'Step 2', + config: { + type: 'format', + template: 'Test', + }, + dependsOn: 'nonexistent_step', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Step 'step_2' depends on unknown step: 'nonexistent_step'"); + }); + + it('should reject LLM step without userPrompt', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM Step', + config: { + type: 'llm', + userPrompt: '', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("LLM step 'step_1' requires a userPrompt"); + }); + + it('should reject vault_search step without query', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Search Step', + config: { + type: 'vault_search', + query: '', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Vault search step 'step_1' requires a query"); + }); + + it('should reject tool step without toolName', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'tool', + name: 'Tool Step', + config: { + type: 'tool', + toolName: '', + args: {}, + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Tool step 'step_1' requires a toolName"); + }); + + it('should reject format step without template', async () => { + const definition: WorkflowDefinition = { + id: 'test-workflow', + name: 'Test Workflow', + description: 'Test', + steps: [ + { + id: 'step_1', + type: 'format', + name: 'Format Step', + config: { + type: 'format', + template: '', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toBe("Format step 'step_1' requires a template"); + }); + }); + + // ==================== Step Execution Tests ==================== + + describe('LLM Step Execution', () => { + it('should execute a simple LLM step successfully', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'This is the LLM response', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'llm-workflow', + name: 'LLM Workflow', + description: 'Test LLM step', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM Step', + config: { + type: 'llm', + userPrompt: 'Hello, world!', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults).toHaveLength(1); + expect(result.stepResults[0].stepId).toBe('step_1'); + expect(result.stepResults[0].success).toBe(true); + expect(result.stepResults[0].data).toBe('This is the LLM response'); + expect(result.finalOutput).toBe('This is the LLM response'); + }); + + it('should include system prompt in LLM step', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'Response with system prompt', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'llm-workflow', + name: 'LLM Workflow', + description: 'Test LLM step with system prompt', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM Step', + config: { + type: 'llm', + systemPrompt: 'You are a helpful assistant.', + userPrompt: 'Hello!', + }, + }, + ], + }; + + await engine.executeWorkflow(definition); + + expect(mockOllamaClient.chat).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + content: 'You are a helpful assistant.', + }), + ]), + [] + ); + }); + + it('should handle LLM step failure gracefully', async () => { + mockOllamaClient.chat.mockRejectedValueOnce(new Error('LLM API error')); + + const definition: WorkflowDefinition = { + id: 'llm-workflow', + name: 'LLM Workflow', + description: 'Test LLM step failure', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM Step', + config: { + type: 'llm', + userPrompt: 'Hello!', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.stepResults[0].success).toBe(false); + expect(result.stepResults[0].error).toBe('LLM API error'); + }); + }); + + describe('Vault Search Step Execution', () => { + it('should execute vault search step successfully', async () => { + const mockEntries = [ + { + file: { path: 'meeting-note.md', basename: 'meeting-note' }, + title: 'Team Meeting', + content: 'Meeting notes content', + score: 10, + frontmatter: { tags: 'meeting,team' }, + }, + { + file: { path: 'project-update.md', basename: 'project-update' }, + title: 'Project Update', + content: 'Project progress notes', + score: 8, + frontmatter: { tags: 'meeting,project' }, + }, + ]; + + mockVaultIndexer.searchVault.mockResolvedValueOnce(mockEntries as any); + + const definition: WorkflowDefinition = { + id: 'search-workflow', + name: 'Search Workflow', + description: 'Test vault search', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Find Meeting Notes', + config: { + type: 'vault_search', + query: 'meeting', + limit: 5, + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[0].success).toBe(true); + expect(Array.isArray(result.stepResults[0].data)).toBe(true); + expect(result.stepResults[0].data as any[]).toHaveLength(2); + expect((result.stepResults[0].data as any[])[0].path).toBe('meeting-note.md'); + }); + + it('should apply tag filter in vault search', async () => { + const mockEntries = [ + { + file: { path: 'meeting-note.md', basename: 'meeting-note' }, + title: 'Team Meeting', + content: 'Meeting notes', + score: 10, + frontmatter: { tags: 'meeting,team' }, + }, + { + file: { path: 'personal-note.md', basename: 'personal-note' }, + title: 'Personal Note', + content: 'Personal thoughts', + score: 8, + frontmatter: { tags: 'personal,daily' }, + }, + ]; + + mockVaultIndexer.searchVault.mockResolvedValueOnce(mockEntries as any); + + const definition: WorkflowDefinition = { + id: 'search-workflow', + name: 'Search Workflow', + description: 'Test vault search with tag filter', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Find Meeting Notes', + config: { + type: 'vault_search', + query: 'meeting', + limit: 10, + tagFilter: 'meeting', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + const data = result.stepResults[0].data as any[]; + expect(data).toHaveLength(1); + expect(data[0].path).toBe('meeting-note.md'); + }); + + it('should use default limit of 5 in vault search', async () => { + mockVaultIndexer.searchVault.mockResolvedValueOnce([]); + + const definition: WorkflowDefinition = { + id: 'search-workflow', + name: 'Search Workflow', + description: 'Test vault search default limit', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Search', + config: { + type: 'vault_search', + query: 'test', + }, + }, + ], + }; + + await engine.executeWorkflow(definition); + + expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('test', 5); + }); + }); + + describe('Tool Step Execution', () => { + it('should execute tool step successfully', async () => { + mockToolExecutor.executeTool.mockResolvedValueOnce({ + success: true, + message: 'File created', + data: { path: 'new-file.md', size: 100 }, + }); + + const definition: WorkflowDefinition = { + id: 'tool-workflow', + name: 'Tool Workflow', + description: 'Test tool execution', + steps: [ + { + id: 'step_1', + type: 'tool', + name: 'Create File', + config: { + type: 'tool', + toolName: 'create_file', + args: { + path: 'new-file.md', + content: 'Hello, world!', + }, + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[0].success).toBe(true); + expect(result.stepResults[0].data).toEqual({ + success: true, + message: 'File created', + data: { path: 'new-file.md', size: 100 }, + }); + }); + + it('should handle tool execution failure', async () => { + mockToolExecutor.executeTool.mockRejectedValueOnce(new Error('Tool execution failed')); + + const definition: WorkflowDefinition = { + id: 'tool-workflow', + name: 'Tool Workflow', + description: 'Test tool failure', + steps: [ + { + id: 'step_1', + type: 'tool', + name: 'Create File', + config: { + type: 'tool', + toolName: 'create_file', + args: { + path: 'new-file.md', + content: 'Hello!', + }, + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.stepResults[0].success).toBe(false); + expect(result.stepResults[0].error).toBe('Tool execution failed'); + }); + }); + + describe('Format Step Execution', () => { + it('should execute format step with variable interpolation', async () => { + mockVaultIndexer.searchVault.mockResolvedValueOnce([ + { + file: { path: 'note.md' }, + title: 'Test Note', + content: 'Note content', + score: 10, + frontmatter: {}, + }, + ] as any); + + const definition: WorkflowDefinition = { + id: 'format-workflow', + name: 'Format Workflow', + description: 'Test format step', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Search', + config: { + type: 'vault_search', + query: 'test', + }, + }, + { + id: 'step_2', + type: 'format', + name: 'Format Results', + config: { + type: 'format', + template: 'Search results: {{step_1.output}}', + }, + dependsOn: 'step_1', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[1].success).toBe(true); + expect(typeof result.stepResults[1].data).toBe('string'); + expect(result.stepResults[1].data as string).toContain('Search results:'); + }); + + it('should format output as JSON when outputFormat is json', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'LLM output', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'format-workflow', + name: 'Format Workflow', + description: 'Test JSON format', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM Step', + config: { + type: 'llm', + userPrompt: 'Hello!', + }, + }, + { + id: 'step_2', + type: 'format', + name: 'Format as JSON', + config: { + type: 'format', + template: '{"result": "{{step_1.output}}"}', + outputFormat: 'json', + }, + dependsOn: 'step_1', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + const formattedOutput = result.stepResults[1].data as string; + expect(formattedOutput).toContain('"result"'); + expect(formattedOutput).toContain('LLM output'); + }); + + it('should handle non-JSON template when outputFormat is json', async () => { + const definition: WorkflowDefinition = { + id: 'format-workflow', + name: 'Format Workflow', + description: 'Test JSON format with invalid JSON', + steps: [ + { + id: 'step_1', + type: 'format', + name: 'Format Step', + config: { + type: 'format', + template: 'Not valid JSON {{{', + outputFormat: 'json', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[0].data).toBe('Not valid JSON {{{'); + }); + }); + + // ==================== Multi-Step Workflow Tests ==================== + + describe('Multi-Step Workflow Execution', () => { + it('should execute multiple steps in sequence', async () => { + mockVaultIndexer.searchVault.mockResolvedValueOnce([ + { + file: { path: 'note.md' }, + title: 'Test Note', + content: 'Content', + score: 10, + frontmatter: {}, + }, + ] as any); + + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'Extracted: Key decision from meeting', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'multi-step-workflow', + name: 'Multi-Step Workflow', + description: 'Test multi-step execution', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Find Notes', + config: { + type: 'vault_search', + query: 'meeting', + limit: 5, + }, + }, + { + id: 'step_2', + type: 'llm', + name: 'Extract Decisions', + config: { + type: 'llm', + systemPrompt: 'Extract decisions from meeting notes.', + userPrompt: '{{step_1.output}}', + }, + dependsOn: 'step_1', + }, + { + id: 'step_3', + type: 'format', + name: 'Format Output', + config: { + type: 'format', + template: '# Summary\n\n{{step_2.output}}', + outputFormat: 'markdown', + }, + dependsOn: 'step_2', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults).toHaveLength(3); + expect(result.stepResults[0].success).toBe(true); + expect(result.stepResults[1].success).toBe(true); + expect(result.stepResults[2].success).toBe(true); + expect(typeof result.finalOutput).toBe('string'); + expect(result.finalOutput as string).toContain('# Summary'); + }); + + it('should stop execution when a dependency fails', async () => { + mockVaultIndexer.searchVault.mockRejectedValueOnce(new Error('Search failed')); + + const definition: WorkflowDefinition = { + id: 'dependency-workflow', + name: 'Dependency Workflow', + description: 'Test dependency failure', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Search', + config: { + type: 'vault_search', + query: 'test', + }, + }, + { + id: 'step_2', + type: 'llm', + name: 'Process', + config: { + type: 'llm', + userPrompt: '{{step_1.output}}', + }, + dependsOn: 'step_1', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.stepResults).toHaveLength(2); + expect(result.stepResults[0].success).toBe(false); + expect(result.stepResults[0].error).toBe('Search failed'); + // Step 2 should be skipped due to failed dependency + expect(result.stepResults[1].success).toBe(false); + expect(result.stepResults[1].error).toContain("Dependency 'step_1' failed"); + }); + + it('should pass data between steps via variable interpolation', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'Processed data', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'interpolation-workflow', + name: 'Interpolation Workflow', + description: 'Test variable interpolation between steps', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'Generate Data', + config: { + type: 'llm', + userPrompt: 'Generate some data', + }, + }, + { + id: 'step_2', + type: 'format', + name: 'Use Data', + config: { + type: 'format', + template: 'Previous result: {{step_1.output}}', + }, + dependsOn: 'step_1', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[1].data as string).toContain('Previous result: Processed data'); + }); + + it('should handle initial variables correctly', async () => { + mockVaultIndexer.searchVault.mockResolvedValueOnce([ + { + file: { path: 'note.md' }, + title: 'Meeting', + content: 'Content', + score: 10, + frontmatter: {}, + }, + ] as any); + + const definition: WorkflowDefinition = { + id: 'initial-vars-workflow', + name: 'Initial Variables Workflow', + description: 'Test initial variables', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Search', + config: { + type: 'vault_search', + query: '{{original_query}}', + }, + }, + ], + }; + + await engine.executeWorkflow(definition, { original_query: 'my search term' }); + + expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('my search term', 5); + }); + + it('should mark workflow as partial success when some steps fail', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'First response', + tool_calls: [], + }); + mockOllamaClient.chat.mockRejectedValueOnce(new Error('Second LLM call failed')); + + const definition: WorkflowDefinition = { + id: 'partial-workflow', + name: 'Partial Workflow', + description: 'Test partial success', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'First LLM Call', + config: { + type: 'llm', + userPrompt: 'First prompt', + }, + }, + { + id: 'step_2', + type: 'llm', + name: 'Second LLM Call', + config: { + type: 'llm', + userPrompt: 'Second prompt', + }, + }, + { + id: 'step_3', + type: 'format', + name: 'Format', + config: { + type: 'format', + template: 'Done', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.stepResults).toHaveLength(3); + expect(result.stepResults[0].success).toBe(true); + expect(result.stepResults[1].success).toBe(false); + expect(result.stepResults[2].success).toBe(true); + }); + }); + + // ==================== Variable Interpolation Tests ==================== + + describe('Variable Interpolation', () => { + it('should interpolate simple variables', async () => { + mockVaultIndexer.searchVault.mockResolvedValueOnce([ + { + file: { path: 'note.md' }, + title: 'Note', + content: 'Content', + score: 10, + frontmatter: {}, + }, + ] as any); + + const definition: WorkflowDefinition = { + id: 'interpolation-workflow', + name: 'Interpolation Test', + description: 'Test variable interpolation', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Search', + config: { + type: 'vault_search', + query: 'test', + }, + }, + { + id: 'step_2', + type: 'llm', + name: 'Process', + config: { + type: 'llm', + userPrompt: 'Here are the results: {{step_1.output}}', + }, + dependsOn: 'step_1', + }, + ], + }; + + await engine.executeWorkflow(definition); + + // Verify the LLM was called with interpolated content + const chatCall = mockOllamaClient.chat.mock.calls[0]; + const messages = chatCall[0] as OllamaMessage[]; + const userMessage = messages.find((m) => m.role === 'user'); + expect(userMessage?.content).toContain('Here are the results:'); + }); + + it('should handle nested property access in interpolation', async () => { + mockToolExecutor.executeTool.mockResolvedValueOnce({ + success: true, + message: 'Success', + data: { + path: 'file.md', + content: 'File content', + metadata: { author: 'John' }, + }, + }); + + const definition: WorkflowDefinition = { + id: 'nested-workflow', + name: 'Nested Interpolation', + description: 'Test nested property access', + steps: [ + { + id: 'step_1', + type: 'tool', + name: 'Read File', + config: { + type: 'tool', + toolName: 'read_vault_file', + args: { path: 'file.md' }, + }, + }, + { + id: 'step_2', + type: 'format', + name: 'Extract Author', + config: { + type: 'format', + template: 'Author: {{step_1.output.data.metadata.author}}', + }, + dependsOn: 'step_1', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + const output = result.stepResults[1].data as string; + expect(output).toContain('Author:'); + }); + + it('should keep placeholder when variable not found', async () => { + const definition: WorkflowDefinition = { + id: 'missing-var-workflow', + name: 'Missing Variable', + description: 'Test missing variable handling', + steps: [ + { + id: 'step_1', + type: 'format', + name: 'Format', + config: { + type: 'format', + template: 'Hello {{nonexistent.output}}!', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[0].data).toBe('Hello {{nonexistent.output}}!'); + }); + + it('should interpolate variables in tool args', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'Some data', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'tool-args-workflow', + name: 'Tool Args Interpolation', + description: 'Test variable interpolation in tool args', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'Generate Content', + config: { + type: 'llm', + userPrompt: 'Generate content', + }, + }, + { + id: 'step_2', + type: 'tool', + name: 'Create File', + config: { + type: 'tool', + toolName: 'create_file', + args: { + path: 'output.md', + content: '{{step_1.output}}', + }, + }, + dependsOn: 'step_1', + }, + ], + }; + + await engine.executeWorkflow(definition); + + // Verify tool was called with interpolated args + expect(mockToolExecutor.executeTool).toHaveBeenCalledWith( + 'create_file', + expect.objectContaining({ + content: 'Some data', + }) + ); + }); + }); + + // ==================== Topological Sort Tests ==================== + + describe('Step Ordering (Topological Sort)', () => { + it('should execute steps in correct order based on dependencies', async () => { + const executionOrder: string[] = []; + + mockOllamaClient.chat = jest.fn().mockImplementation(() => { + return Promise.resolve({ + role: 'assistant', + content: 'Response', + tool_calls: [], + }); + }); + + const definition: WorkflowDefinition = { + id: 'order-workflow', + name: 'Order Test', + description: 'Test step ordering', + steps: [ + { + id: 'step_c', + type: 'format', + name: 'Final Format', + config: { + type: 'format', + template: '{{step_a.output}} + {{step_b.output}}', + }, + dependsOn: 'step_b', + }, + { + id: 'step_a', + type: 'llm', + name: 'First LLM', + config: { + type: 'llm', + userPrompt: 'First', + }, + }, + { + id: 'step_b', + type: 'llm', + name: 'Second LLM', + config: { + type: 'llm', + userPrompt: 'Second', + }, + dependsOn: 'step_a', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + // Verify step_a was executed before step_b + const stepAIndex = result.stepResults.findIndex((r) => r.stepId === 'step_a'); + const stepBIndex = result.stepResults.findIndex((r) => r.stepId === 'step_b'); + const stepCIndex = result.stepResults.findIndex((r) => r.stepId === 'step_c'); + expect(stepAIndex).toBeLessThan(stepBIndex); + expect(stepBIndex).toBeLessThan(stepCIndex); + }); + + it('should detect circular dependencies', async () => { + const definition: WorkflowDefinition = { + id: 'circular-workflow', + name: 'Circular Test', + description: 'Test circular dependency detection', + steps: [ + { + id: 'step_a', + type: 'llm', + name: 'Step A', + config: { + type: 'llm', + userPrompt: 'A', + }, + dependsOn: 'step_b', + }, + { + id: 'step_b', + type: 'llm', + name: 'Step B', + config: { + type: 'llm', + userPrompt: 'B', + }, + dependsOn: 'step_a', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toContain('Circular dependency'); + }); + }); + + // ==================== Built-in Workflows Tests ==================== + + describe('Built-in Workflows', () => { + it('should provide meeting summary workflow', () => { + const workflows = WorkflowEngine.getBuiltInWorkflows(); + + const meetingWorkflow = workflows.find((w) => w.id === 'meeting-summary'); + expect(meetingWorkflow).toBeDefined(); + expect(meetingWorkflow?.name).toBe('Meeting Notes Summary'); + expect(meetingWorkflow?.steps).toHaveLength(3); + }); + + it('should provide note analyzer workflow', () => { + const workflows = WorkflowEngine.getBuiltInWorkflows(); + + const analyzerWorkflow = workflows.find((w) => w.id === 'note-analyzer'); + expect(analyzerWorkflow).toBeDefined(); + expect(analyzerWorkflow?.name).toBe('Note Analyzer'); + expect(analyzerWorkflow?.steps).toHaveLength(3); + }); + + it('should return multiple built-in workflows', () => { + const workflows = WorkflowEngine.getBuiltInWorkflows(); + + expect(workflows.length).toBeGreaterThanOrEqual(2); + }); + }); + + // ==================== Timeout and Limits Tests ==================== + + describe('Timeout and Limits', () => { + it('should respect maxSteps limit', async () => { + // Create engine with very low max steps + const limitedEngine = new WorkflowEngine( + createMockVault() as any, + createMockApp() as any, + 'http://localhost:11434', + 'llama3', + { maxSteps: 2 } + ); + + mockOllamaClient.chat = jest.fn().mockResolvedValue({ + role: 'assistant', + content: 'Response', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'many-steps-workflow', + name: 'Many Steps', + description: 'Test max steps limit', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'Step 1', + config: { type: 'llm', userPrompt: '1' }, + }, + { + id: 'step_2', + type: 'llm', + name: 'Step 2', + config: { type: 'llm', userPrompt: '2' }, + }, + { + id: 'step_3', + type: 'llm', + name: 'Step 3', + config: { type: 'llm', userPrompt: '3' }, + }, + ], + }; + + const result = await limitedEngine.executeWorkflow(definition); + + expect(result.success).toBe(false); + expect(result.error).toContain('exceeded maximum step count'); + }); + }); + + // ==================== Edge Cases ==================== + + describe('Edge Cases', () => { + it('should handle empty workflow result data', async () => { + mockVaultIndexer.searchVault.mockResolvedValueOnce([]); + + const definition: WorkflowDefinition = { + id: 'empty-result-workflow', + name: 'Empty Results', + description: 'Test empty search results', + steps: [ + { + id: 'step_1', + type: 'vault_search', + name: 'Search', + config: { + type: 'vault_search', + query: 'nonexistent', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[0].data).toEqual([]); + }); + + it('should handle LLM returning empty content', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: '', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'empty-llm-workflow', + name: 'Empty LLM', + description: 'Test empty LLM response', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM', + config: { + type: 'llm', + userPrompt: 'Hello', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[0].data).toBe(''); + }); + + it('should handle tool returning null data', async () => { + mockToolExecutor.executeTool.mockResolvedValueOnce({ + success: true, + message: 'Done', + data: null, + }); + + const definition: WorkflowDefinition = { + id: 'null-data-workflow', + name: 'Null Data', + description: 'Test null tool data', + steps: [ + { + id: 'step_1', + type: 'tool', + name: 'Tool', + config: { + type: 'tool', + toolName: 'some_tool', + args: {}, + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults[0].data).toEqual({ + success: true, + message: 'Done', + data: null, + }); + }); + + it('should include workflow metadata in result', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'Response', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'metadata-workflow', + name: 'Metadata Test', + description: 'Test workflow metadata', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM', + config: { + type: 'llm', + userPrompt: 'Hello', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.workflowId).toBe('metadata-workflow'); + expect(result.workflowName).toBe('Metadata Test'); + expect(result.stepResults[0].timestamp).toBeGreaterThan(0); + }); + + it('should handle steps without dependsOn executing in definition order', async () => { + mockOllamaClient.chat = jest.fn().mockResolvedValue({ + role: 'assistant', + content: 'Response', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'no-deps-workflow', + name: 'No Dependencies', + description: 'Test execution without dependencies', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'Step 1', + config: { type: 'llm', userPrompt: '1' }, + }, + { + id: 'step_2', + type: 'llm', + name: 'Step 2', + config: { type: 'llm', userPrompt: '2' }, + }, + { + id: 'step_3', + type: 'llm', + name: 'Step 3', + config: { type: 'llm', userPrompt: '3' }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.success).toBe(true); + expect(result.stepResults).toHaveLength(3); + expect(mockOllamaClient.chat).toHaveBeenCalledTimes(3); + }); + + it('should handle array interpolation in tool args', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'First LLM output', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'array-args-workflow', + name: 'Array Args', + description: 'Test array interpolation in tool args', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'Generate', + config: { + type: 'llm', + userPrompt: 'Generate content', + }, + }, + { + id: 'step_2', + type: 'tool', + name: 'Create', + config: { + type: 'tool', + toolName: 'create_file', + args: { + path: 'output.md', + content: '{{step_1.output}}', + }, + }, + dependsOn: 'step_1', + }, + ], + }; + + await engine.executeWorkflow(definition); + + expect(mockToolExecutor.executeTool).toHaveBeenCalled(); + }); + }); + + // ==================== Workflow Execution Result Tests ==================== + + describe('WorkflowExecutionResult', () => { + it('should return finalOutput from last successful step', async () => { + mockOllamaClient.chat.mockResolvedValueOnce({ + role: 'assistant', + content: 'Intermediate response', + tool_calls: [], + }); + + const definition: WorkflowDefinition = { + id: 'final-output-workflow', + name: 'Final Output Test', + description: 'Test final output', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM', + config: { + type: 'llm', + userPrompt: 'Hello', + }, + }, + { + id: 'step_2', + type: 'format', + name: 'Format', + config: { + type: 'format', + template: '# Final\n\n{{step_1.output}}', + }, + dependsOn: 'step_1', + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.finalOutput).toBe('# Final\n\nIntermediate response'); + }); + + it('should return null finalOutput when all steps fail', async () => { + mockOllamaClient.chat.mockRejectedValueOnce(new Error('LLM failed')); + + const definition: WorkflowDefinition = { + id: 'failed-workflow', + name: 'Failed Workflow', + description: 'Test all steps failing', + steps: [ + { + id: 'step_1', + type: 'llm', + name: 'LLM', + config: { + type: 'llm', + userPrompt: 'Hello', + }, + }, + ], + }; + + const result = await engine.executeWorkflow(definition); + + expect(result.finalOutput).toBeNull(); + }); + }); +});