UNPKG

@meldscience/meld

Version:

pipeable one-shot prompt scripting toolkit

333 lines (270 loc) 10.7 kB
# Plan for Implementing Concurrency Below is a step-by-step plan for adding concurrency to the AI calls in your existing codebase. The main goal is to allow multiple AI calls to happen in parallel—especially useful for variations/iterations in Oneshot. The plan includes where to insert concurrency, how to structure it in a modular way, and sample code that fits into your existing structure with minimal impact. ### 1. Where Concurrency Matters - Oneshot.process(): Currently, this method iterates over each variation (and each iteration per variation) in a straightforward nested loop, making calls one by one. - Desired: Have multiple calls to the AI provider happen in parallel. For example, if you have 3 variations, each needing 2 iterations, that’s 6 calls. Instead of waiting for each call sequentially, you could run them concurrently (e.g., up to 2 or 3 at a time). ### 2. Minimal Codebase Changes We will: - Add a new concurrency option (e.g., maxConcurrency) to OneshotOptions. - Build a small concurrency utility that can run tasks with a cap on how many run at once. - Replace the sequential loops in Oneshot.process() with this concurrency approach. You won’t need to rewrite your entire project. The rest of the code (e.g., PromptScript, Oneshotcat, ps, etc.) stays intact. ### 3. Concurrency Utility Create a reusable module (e.g., src/concurrency.ts) to manage concurrency: ```typescript /** * concurrency.ts * A lightweight concurrency queue/limiter. */ /** * Runs an array of async tasks with a given concurrency limit. * * @param tasks - Array of no-arg functions that return a Promise. * @param maxConcurrency - Maximum number of tasks to run at once. * @returns Array of the resolved values from each task, in the same order. */ export async function runConcurrent<T>( tasks: Array<() => Promise<T>>, maxConcurrency: number ): Promise<T[]> { if (maxConcurrency <= 0) { throw new Error('maxConcurrency must be > 0'); } const results: T[] = new Array(tasks.length); let currentIndex = 0; let activeCount = 0; return new Promise((resolve, reject) => { // Start worker function function runNext() { if (currentIndex >= tasks.length) { // All tasks have been scheduled if (activeCount === 0) { // Nothing is running, so we’re done resolve(results); } return; } const idx = currentIndex; currentIndex++; activeCount++; const task = tasks[idx]; task() .then((res) => { results[idx] = res; }) .catch((err) => { reject(err); }) .finally(() => { activeCount--; runNext(); // Schedule the next task }); } // Start up to maxConcurrency tasks initially for (let i = 0; i < Math.min(maxConcurrency, tasks.length); i++) { runNext(); } }); } ``` Key Points: - Order Preservation: We keep an array of results and place each result by index. This ensures the final array aligns with the original task order, even though they finish at various times. - Max Concurrency: We never exceed maxConcurrency active tasks. ### 4. Extend OneshotOptions In src/oneshot.ts, update the interface: ```typescript export interface OneshotOptions { model: string; promptFile?: string; promptText?: string; // if you later decide to allow direct text system?: string; systemFile?: string; variations?: string[]; iterations?: number; /** * Maximum number of AI calls at once. Default: 1 (sequential). */ maxConcurrency?: number; } ``` Rationale: - We introduce maxConcurrency?: number. - If not provided, default to 1 (the old sequential behavior). ### 5. Modify Oneshot.process() Now we’ll collect tasks for each variation and iteration, and then use runConcurrent(): ```typescript import { runConcurrent } from './concurrency'; export class Oneshot { // ... public async process(): Promise<ResponseEnvelope[]> { const { promptFile, systemFile, system, variations, iterations, model, maxConcurrency = 1 } = this.options; // 1. Read userPrompt from file or text let userPrompt = ''; if (promptFile && existsSync(promptFile)) { userPrompt = readFileSync(promptFile, 'utf-8'); } // else if (this.options.promptText) { // userPrompt = this.options.promptText; // } // else throw new ToolError(...); // 2. Read systemPrompt from file if provided let systemPrompt = system || ''; if (systemFile) { // ... } // 3. Prepare variations array const finalVariations = (variations && variations.length > 0) ? variations : [undefined]; const totalTasks: Array<() => Promise<ResponseEnvelope>> = []; // 4. Build tasks array finalVariations.forEach((variation) => { for (let i = 0; i < (iterations || 1); i++) { totalTasks.push(async () => { // Combine user prompt + variation const combinedUserPrompt = variation ? `${userPrompt}\n\n${variation}` : userPrompt; const response = await this.provider.sendPrompt({ model, systemPrompt, userPrompt: combinedUserPrompt }); return { variation, systemPrompt, response }; }); } }); // 5. Execute tasks with concurrency const results = await runConcurrent(totalTasks, maxConcurrency); return results; } } ``` Notes: - totalTasks: We gather all calls we plan to make into a single array of “task” functions. - runConcurrent(...): We pass in the array, along with maxConcurrency. This will run them in parallel (up to the limit) and return the results in order. ### 6. Update CLI oneshot.ts in bin/oneshot.ts Allow the user to specify --max-concurrency <number>: ```typescript #!/usr/bin/env node import { Command } from 'commander'; import { Oneshot, AnthropicProvider, OpenAIProvider } from '../src/oneshot'; import { loadConfig } from '../src/config'; const program = new Command() .argument('<model>', 'Model identifier') .argument('<prompt>', 'Prompt file') .option('--system <prompt>', 'System prompt') .option('--system-file <path>', 'System prompt file') .option('--variations <json>', 'JSON array of variation prompts') .option('--iterations <number>', 'Number of iterations', '1') .option('--max-concurrency <number>', 'Maximum concurrent calls', '1') .action(async (model, prompt, options) => { const config = loadConfig(); const provider = model.includes('gpt') ? new OpenAIProvider(config.openaiApiKey) : new AnthropicProvider(config.anthropicApiKey); let parsedVariations: string[] | undefined; if (options.variations) { parsedVariations = JSON.parse(options.variations); } const oneshot = new Oneshot({ model, promptFile: prompt, system: options.system, systemFile: options['systemFile'], variations: parsedVariations, iterations: parseInt(options.iterations), maxConcurrency: parseInt(options.maxConcurrency) }, provider); const results = await oneshot.process(); // Print or save results console.log(JSON.stringify(results, null, 2)); }); program.parse(); ``` oneshotcat.ts in bin/oneshotcat.ts Similarly, just pass maxConcurrency along: ```typescript #!/usr/bin/env node import { Command } from 'commander'; import { Oneshotcat } from '../src/oneshotcat'; import { loadConfig } from '../src/config'; const program = new Command() .argument('<model>', 'Model identifier') .argument('<input>', 'Input .ps.md file') // ... other existing options .option('--max-concurrency <number>', 'Maximum concurrent calls', '1') .action(async (model, input, options) => { // Merge options into the Oneshotcat const finalOptions = { model, promptFile: input, // ... other options maxConcurrency: parseInt(options.maxConcurrency) }; const cat = new Oneshotcat(finalOptions); const results = await cat.process(); console.log(JSON.stringify(results, null, 2)); }); program.parse(); ``` ### 7. Oneshotcat Changes Internally, Oneshotcat just sets up a Oneshot instance with whatever maxConcurrency is provided. Because you’ll likely store the concurrency setting in the combined options, no additional major changes are required—just ensure Oneshotcat passes it along: ```typescript // oneshotcat.ts // ... public async process(): Promise<ResponseEnvelope[]> { // Step 1: ps.process() → expanded const expanded = await ps.process(); // Step 2: create temp file or store in memory writeFileSync(tempFilePath, expanded, 'utf-8'); // Step 3: Oneshot const config = loadConfig(); const provider = this.oneshotOptions.model.includes('gpt') ? new OpenAIProvider(config.openaiApiKey ?? '') : new AnthropicProvider(config.anthropicApiKey ?? ''); // The key here is to include `maxConcurrency`. const oneshot = new Oneshot({ model: this.oneshotOptions.model, promptFile: tempFilePath, system: this.oneshotOptions.system, systemFile: this.oneshotOptions.systemFile, variations: this.oneshotOptions.variations, iterations: this.oneshotOptions.iterations, maxConcurrency: this.oneshotOptions.maxConcurrency }, provider); return oneshot.process(); } ``` ### 7. Testing Strategy 1. Unit Test runConcurrent() in isolation: - Scenario A: 5 tasks, maxConcurrency = 2. Ensure tasks run in the correct order and the results are in the correct array positions. - Scenario B: Test a failing task. Confirm the promise rejects immediately (the queue stops scheduling new tasks). - Scenario C: Check boundary conditions (0 tasks, maxConcurrency=1, etc.). 2. Integration Test: - Use a mock AIProvider that simulates different response times. - Provide a series of variations/iterations. - Confirm parallel requests are happening by checking timestamps or internal logging. - Confirm the final aggregated results are in the right order. 3. CLI Test: - For oneshot with --max-concurrency 3 and 6 total requests (2 variations × 3 iterations each). - Mock provider to see all 6 requests start but only 3 at a time. ### 8. Summary of Changes 1. Create src/concurrency.ts with a runConcurrent function. 2. Add maxConcurrency to OneshotOptions. 3. Refactor loops in Oneshot.process() to collect tasks and run them via runConcurrent(...). 4. Update CLI commands (oneshot.ts, oneshotcat.ts) to accept --max-concurrency. 5. Test thoroughly with a mock AI provider and concurrency-limited runs.