/** * Chain behavior, template resolution, and directory management */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentConfig } from "./agents.js"; import { normalizeSkillInput } from "./skills.js"; const CHAIN_RUNS_DIR = path.join(os.tmpdir(), "pi-chain-runs"); const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours // ============================================================================= // Behavior Resolution Types // ============================================================================= export interface ResolvedStepBehavior { output: string | false; reads: string[] | false; progress: boolean; skills: string[] | false; model?: string; } export interface StepOverrides { output?: string | false; reads?: string[] | false; progress?: boolean; skills?: string[] | false; model?: string; } // ============================================================================= // Chain Step Types // ============================================================================= /** Sequential step: single agent execution */ export interface SequentialStep { agent: string; task?: string; cwd?: string; output?: string | false; reads?: string[] | false; progress?: boolean; skill?: string | string[] | false; model?: string; } /** Parallel task item within a parallel step */ export interface ParallelTaskItem { agent: string; task?: string; cwd?: string; output?: string | false; reads?: string[] | false; progress?: boolean; skill?: string | string[] | false; model?: string; } /** Parallel step: multiple agents running concurrently */ export interface ParallelStep { parallel: ParallelTaskItem[]; concurrency?: number; failFast?: boolean; continueOnError?: boolean; } /** Union type for chain steps */ export type ChainStep = SequentialStep | ParallelStep; // ============================================================================= // Type Guards // ============================================================================= export function isParallelStep(step: ChainStep): step is ParallelStep { return "parallel" in step && Array.isArray((step as ParallelStep).parallel); } /** Get all agent names in a step (single for sequential, multiple for parallel) */ export function getStepAgents(step: ChainStep): string[] { if (isParallelStep(step)) { return step.parallel.map((t) => t.agent); } return [step.agent]; } // ============================================================================= // Chain Directory Management // ============================================================================= export function createChainDir(runId: string, baseDir?: string): string { const chainDir = path.join(baseDir ? path.resolve(baseDir) : CHAIN_RUNS_DIR, runId); fs.mkdirSync(chainDir, { recursive: true }); return chainDir; } export function removeChainDir(chainDir: string): void { try { fs.rmSync(chainDir, { recursive: true }); } catch {} } export async function cleanupOldChainDirs(): Promise { const now = Date.now(); let dirs: string[]; try { dirs = await fs.promises.readdir(CHAIN_RUNS_DIR); } catch { return; } for (const dir of dirs) { try { const dirPath = path.join(CHAIN_RUNS_DIR, dir); const stat = await fs.promises.stat(dirPath); if (stat.isDirectory() && now - stat.mtimeMs > CHAIN_DIR_MAX_AGE_MS) { await fs.promises.rm(dirPath, { recursive: true }); } } catch { // Skip directories that can't be processed; continue with others } } } // ============================================================================= // Template Resolution // ============================================================================= /** Resolved templates for a chain - string for sequential, string[] for parallel */ export type ResolvedTemplates = (string | string[])[]; /** * Resolve templates for a chain with parallel step support. * Returns string for sequential steps, string[] for parallel steps. */ export function resolveChainTemplates(steps: ChainStep[]): ResolvedTemplates { return steps.map((step, i) => { if (isParallelStep(step)) { // Parallel step: resolve each task's template return step.parallel.map((task) => { if (task.task) { return task.task; } // Default for parallel tasks is {previous} return "{previous}"; }); } // Sequential step: existing logic const seq = step as SequentialStep; if (seq.task) { return seq.task; } // Default: first step uses {task}, others use {previous} return i === 0 ? "{task}" : "{previous}"; }); } // ============================================================================= // Behavior Resolution // ============================================================================= /** * Resolve effective chain behavior per step. * Priority: step override > agent frontmatter > false (disabled) */ export function resolveStepBehavior( agentConfig: AgentConfig, stepOverrides: StepOverrides, chainSkills?: string[], ): ResolvedStepBehavior { // Output: step override > frontmatter > false (no output) const output = stepOverrides.output !== undefined ? stepOverrides.output : (agentConfig.output ?? false); // Reads: step override > frontmatter defaultReads > false (no reads) const reads = stepOverrides.reads !== undefined ? stepOverrides.reads : (agentConfig.defaultReads ?? false); // Progress: step override > frontmatter defaultProgress > false const progress = stepOverrides.progress !== undefined ? stepOverrides.progress : (agentConfig.defaultProgress ?? false); let skills: string[] | false; if (stepOverrides.skills === false) { skills = false; } else if (stepOverrides.skills !== undefined) { skills = [...stepOverrides.skills]; if (chainSkills && chainSkills.length > 0) { skills = [...new Set([...skills, ...chainSkills])]; } } else { skills = agentConfig.skills ? [...agentConfig.skills] : []; if (chainSkills && chainSkills.length > 0) { skills = [...new Set([...skills, ...chainSkills])]; } } const model = stepOverrides.model ?? agentConfig.model; return { model, output, progress, reads, skills }; } // ============================================================================= // Chain Instruction Injection // ============================================================================= /** * Resolve a file path: absolute paths pass through, relative paths get chainDir prepended. */ function resolveChainPath(filePath: string, chainDir: string): string { return path.isAbsolute(filePath) ? filePath : path.join(chainDir, filePath); } /** * Build chain instructions from resolved behavior. * These are appended to the task to tell the agent what to read/write. */ export function buildChainInstructions( behavior: ResolvedStepBehavior, chainDir: string, isFirstProgressAgent: boolean, previousSummary?: string, ): { prefix: string; suffix: string } { const prefixParts: string[] = []; const suffixParts: string[] = []; // READS - prepend to override any hardcoded filenames in task text if (behavior.reads && behavior.reads.length > 0) { const files = behavior.reads.map((f) => resolveChainPath(f, chainDir)); prefixParts.push(`[Read from: ${files.join(", ")}]`); } // OUTPUT - prepend so agent knows where to write if (behavior.output) { const outputPath = resolveChainPath(behavior.output, chainDir); prefixParts.push(`[Write to: ${outputPath}]`); } // Progress instructions in suffix (less critical) if (behavior.progress) { const progressPath = path.join(chainDir, "progress.md"); if (isFirstProgressAgent) { suffixParts.push(`Create and maintain progress at: ${progressPath}`); } else { suffixParts.push(`Update progress at: ${progressPath}`); } } // Include previous step's summary in suffix if available if (previousSummary && previousSummary.trim()) { suffixParts.push(`Previous step output:\n${previousSummary.trim()}`); } const prefix = prefixParts.length > 0 ? `${prefixParts.join("\n")}\n\n` : ""; const suffix = suffixParts.length > 0 ? `\n\n---\n${suffixParts.join("\n")}` : ""; return { prefix, suffix }; } // ============================================================================= // Parallel Step Support // ============================================================================= /** * Resolve behaviors for all tasks in a parallel step. * Creates namespaced output paths to avoid collisions. */ export function resolveParallelBehaviors( tasks: ParallelTaskItem[], agentConfigs: AgentConfig[], stepIndex: number, chainSkills?: string[], ): ResolvedStepBehavior[] { return tasks.map((task, taskIndex) => { const config = agentConfigs.find((a) => a.name === task.agent); if (!config) { throw new Error(`Unknown agent: ${task.agent}`); } // Build subdirectory path for this parallel task const subdir = path.join(`parallel-${stepIndex}`, `${taskIndex}-${task.agent}`); // Output: task override > agent default (namespaced) > false // Absolute paths pass through unchanged; relative paths get namespaced under subdir let output: string | false = false; if (task.output !== undefined) { if (task.output === false) { output = false; } else if (path.isAbsolute(task.output)) { ({ output } = task); // Absolute path: use as-is } else { output = path.join(subdir, task.output); // Relative: namespace under subdir } } else if (config.output) { // Agent defaults are always relative, so namespace them output = path.join(subdir, config.output); } // Reads: task override > agent default > false const reads = task.reads !== undefined ? task.reads : (config.defaultReads ?? false); // Progress: task override > agent default > false const progress = task.progress !== undefined ? task.progress : (config.defaultProgress ?? false); const taskSkillInput = normalizeSkillInput(task.skill); let skills: string[] | false; if (taskSkillInput === false) { skills = false; } else if (taskSkillInput !== undefined) { skills = [...taskSkillInput]; if (chainSkills && chainSkills.length > 0) { skills = [...new Set([...skills, ...chainSkills])]; } } else { skills = config.skills ? [...config.skills] : []; if (chainSkills && chainSkills.length > 0) { skills = [...new Set([...skills, ...chainSkills])]; } } const model = task.model ?? config.model; return { model, output, progress, reads, skills }; }); } /** * Create subdirectories for parallel step outputs */ export function createParallelDirs(chainDir: string, stepIndex: number, taskCount: number, agentNames: string[]): void { for (let i = 0; i < taskCount; i++) { const subdir = path.join(chainDir, `parallel-${stepIndex}`, `${i}-${agentNames[i]}`); fs.mkdirSync(subdir, { recursive: true }); } } /** Result from a parallel task (simplified for aggregation) */ export interface ParallelTaskResult { agent: string; taskIndex: number; output: string; exitCode: number; error?: string; outputTargetPath?: string; outputTargetExists?: boolean; } /** * Aggregate outputs from parallel tasks into a single string for {previous}. * Uses clear separators so the next agent can parse all outputs. */ export function aggregateParallelOutputs(results: ParallelTaskResult[]): string { return results .map((r, i) => { const header = `=== Parallel Task ${i + 1} (${r.agent}) ===`; const hasTextOutput = Boolean(r.output?.trim()); const status = r.exitCode === -1 ? "⏭️ SKIPPED" : r.exitCode !== 0 ? `[!] FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}` : r.error ? `[!] WARNING: ${r.error}` : !hasTextOutput && r.outputTargetPath && r.outputTargetExists === false ? `[!] EMPTY OUTPUT (expected output file missing: ${r.outputTargetPath})` : !hasTextOutput && !r.outputTargetPath ? "[!] EMPTY OUTPUT (no textual response returned)" : ""; const body = status ? (hasTextOutput ? `${status}\n${r.output}` : status) : r.output; return `${header}\n${body}`; }) .join("\n\n"); }