diff --git a/src/agents/base.ts b/src/agents/base.ts index 4023c209..b164797c 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -1,14 +1,7 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { auList, auRead } from '@zbigniewsobiecki/au'; -import { AgentBuilder, LLMist, createLogger } from 'llmist'; +import type { createLogger } from 'llmist'; import { WriteFile } from '../gadgets/WriteFile.js'; -import { getCompactionConfig } from '../config/compactionConfig.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; -import { getIterationTrailingMessage } from '../config/hintConfig.js'; -import { getRateLimitForModel } from '../config/rateLimits.js'; -import { getRetryConfig } from '../config/retryConfig.js'; import { AstGrep } from '../gadgets/AstGrep.js'; import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; import { Finish } from '../gadgets/Finish.js'; @@ -17,7 +10,6 @@ import { ReadFile } from '../gadgets/ReadFile.js'; import { RipGrep } from '../gadgets/RipGrep.js'; import { Sleep } from '../gadgets/Sleep.js'; import { CreatePR } from '../gadgets/github/index.js'; -import { initSessionState } from '../gadgets/sessionState.js'; import { Tmux } from '../gadgets/tmux.js'; import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; import { @@ -33,20 +25,20 @@ import { } from '../gadgets/trello/index.js'; import { trelloClient } from '../trello/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { cleanupLogDirectory, cleanupLogFile, createFileLogger } from '../utils/fileLogger.js'; -import { clearWatchdogCleanup, setWatchdogCleanup } from '../utils/lifecycle.js'; import { logger } from '../utils/logging.js'; -import { cleanupTempDir, cloneRepo, createTempDir, runCommand } from '../utils/repo.js'; -import { type PromptContext, getSystemPrompt } from './prompts/index.js'; -import { runAgentLoop } from './utils/agentLoop.js'; -import { createObserverHooks } from './utils/hooks.js'; -import { getLogLevel, readContextFiles, warmTypeScriptCache } from './utils/index.js'; -import { createAgentLogger } from './utils/logging.js'; +import type { PromptContext } from './prompts/index.js'; +import { type BuilderType, createConfiguredBuilder } from './shared/builderFactory.js'; +import { type FileLogger, executeAgentLifecycle } from './shared/lifecycle.js'; +import { resolveModelConfig } from './shared/modelResolution.js'; +import { setupRepository as setupRepo } from './shared/repository.js'; import { - type TrackingContext, - createTrackingContext, - recordSyntheticInvocationId, -} from './utils/tracking.js'; + injectAUContext, + injectContextFiles, + injectDirectoryListing, + injectSyntheticCall, +} from './shared/syntheticCalls.js'; +import type { AgentLogger } from './utils/logging.js'; +import type { TrackingContext } from './utils/tracking.js'; export interface AgentContext { project: ProjectConfig; @@ -66,48 +58,11 @@ export interface AgentRunner { async function setupRepository( project: ProjectConfig, - log: ReturnType, + log: AgentLogger, agentType: string, prBranch?: string, ): Promise { - // Clone repo to temp directory - const repoDir = createTempDir(project.id); - cloneRepo(project, repoDir); - - // Checkout PR branch if provided (for check-failure flow) - if (prBranch) { - log.info('Checking out PR branch', { prBranch }); - await runCommand('git', ['checkout', prBranch], repoDir); - } - - // Run project-specific setup script if it exists (handles dependency installation) - const setupScriptPath = join(repoDir, '.cascade', 'setup.sh'); - if (existsSync(setupScriptPath)) { - log.info('Running project setup script', { path: '.cascade/setup.sh', agentType }); - const setupResult = await runCommand('bash', [setupScriptPath], repoDir, { - AGENT_PROFILE_NAME: agentType, - }); - log.info('Setup script completed', { - exitCode: setupResult.exitCode, - stdout: setupResult.stdout.slice(-500), - stderr: setupResult.stderr.slice(-500), - }); - if (setupResult.exitCode !== 0) { - log.warn('Setup script exited with non-zero code', { exitCode: setupResult.exitCode }); - } - } - - // Warm TypeScript cache to avoid slow first-run compilation during agent execution - log.info('Warming TypeScript cache', { repoDir }); - const tscResult = await warmTypeScriptCache(repoDir); - if (tscResult) { - log.info('TypeScript cache warmed', { - durationMs: tscResult.durationMs, - hadErrors: !!tscResult.error, - }); - } - - return repoDir; + return setupRepo({ project, log, agentType, prBranch, warmTsCache: true }); } // ============================================================================ @@ -118,7 +73,7 @@ interface AgentContextData { systemPrompt: string; model: string; maxIterations: number; - contextFiles: Awaited>; + contextFiles: Awaited>['contextFiles']; cardData: string; prompt: string; } @@ -129,7 +84,7 @@ async function buildAgentContext( repoDir: string, project: ProjectConfig, config: CascadeConfig, - log: ReturnType, + log: { info: (msg: string, ctx?: Record) => void }, triggerType?: string, prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string }, debugContext?: { @@ -165,19 +120,14 @@ async function buildAgentContext( }), }; - // Get system prompt and model - const systemPrompt = project.prompts?.[agentType] || getSystemPrompt(agentType, promptContext); - const model = - modelOverride || - project.agentModels?.[agentType] || - project.model || - config.defaults.agentModels?.[agentType] || - config.defaults.model; - const maxIterations = - config.defaults.agentIterations?.[agentType] || config.defaults.maxIterations; - - // Read context files (CLAUDE.md, AGENTS.md) for synthetic gadget calls - const contextFiles = await readContextFiles(repoDir); + const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ + agentType, + project, + config, + repoDir, + modelOverride, + promptContext, + }); // Pre-fetch card data for synthetic gadget call (only if cardId exists and not debug flow) let cardData = ''; @@ -267,30 +217,11 @@ Start by listing the contents of the log directory, then read and analyze the lo // Agent Builder Creation // ============================================================================ -type BuilderType = ReturnType; - -function createAgentBuilderWithGadgets( - client: LLMist, - ctx: AgentContextData, - llmistLogger: ReturnType, - trackingContext: TrackingContext, - agentType: string, - logWriter: (level: string, message: string, context?: Record) => void, - llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, - repoDir: string, - cardId: string | undefined, -): BuilderType { - // Initialize session state for gadgets (e.g., Finish checks PR requirement for implementation) - initSessionState(agentType); - - // Check if AU features should be enabled (repo has .au file at root) - const auEnabled = existsSync(join(repoDir, '.au')); - +function getBaseAgentGadgets(agentType: string) { // Planning agent is read-only - no file editing capabilities const isReadOnlyAgent = agentType === 'planning'; - // Build gadget list - const baseGadgets = [ + return [ // Filesystem gadgets (read-only for planning) new ListDirectory(), new ReadFile(), @@ -319,9 +250,19 @@ function createAgentBuilderWithGadgets( // Session control new Finish(), ]; +} - const allGadgets = auEnabled ? [...baseGadgets, auList, auRead] : baseGadgets; - +function createAgentBuilderWithGadgets( + client: import('llmist').LLMist, + ctx: AgentContextData, + llmistLogger: ReturnType, + trackingContext: TrackingContext, + agentType: string, + logWriter: (level: string, message: string, context?: Record) => void, + llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, + repoDir: string, + cardId: string | undefined, +): BuilderType { // Build status update config if we have a cardId const statusUpdate = cardId ? { @@ -331,35 +272,26 @@ function createAgentBuilderWithGadgets( } : undefined; - const builder = new AgentBuilder(client) - .withModel(ctx.model) - .withTemperature(0) - .withSystem(ctx.systemPrompt) - .withMaxIterations(ctx.maxIterations) - .withLogger(llmistLogger) - .withRateLimits(getRateLimitForModel(ctx.model)) - .withRetry(getRetryConfig(llmistLogger)) - .withCompaction(getCompactionConfig(agentType)) - .withTrailingMessage(getIterationTrailingMessage(agentType)) - .withTextOnlyHandler('acknowledge') - .withHooks({ - observers: createObserverHooks({ - model: ctx.model, - logWriter, - trackingContext, - llmCallLogger, - statusUpdate, - }), - }) - .withGadgets(...allGadgets); - - // Implementation agent uses sequential execution to ensure file operations - // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) - if (agentType === 'implementation') { - return builder.withGadgetExecutionMode('sequential'); - } - - return builder; + return createConfiguredBuilder({ + client, + agentType, + model: ctx.model, + systemPrompt: ctx.systemPrompt, + maxIterations: ctx.maxIterations, + llmistLogger, + trackingContext, + logWriter, + llmCallLogger, + repoDir, + gadgets: getBaseAgentGadgets(agentType), + statusUpdate, + // Implementation agent uses sequential execution to ensure file operations + // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) + postConfigure: + agentType === 'implementation' + ? (builder) => builder.withGadgetExecutionMode('sequential') + : undefined, + }); } async function injectSyntheticCalls( @@ -368,33 +300,16 @@ async function injectSyntheticCalls( cardData: string, contextFiles: AgentContextData['contextFiles'], trackingContext: TrackingContext, - auEnabled: boolean, + repoDir: string, ): Promise { - let builder = initialBuilder; - - // Inject directory listing as synthetic ListDirectory call (first for codebase orientation) - // Call the actual gadget to generate output (respects .gitignore by default) // Use maxDepth=5 to give agents better visibility into nested structures - const listDirGadget = new ListDirectory(); - const listDirParams = { - comment: 'Pre-fetching codebase structure for context', - directoryPath: '.', - maxDepth: 5, - includeGitIgnored: false, - }; - const listDirResult = listDirGadget.execute(listDirParams); - recordSyntheticInvocationId(trackingContext, 'gc_dir'); - builder = builder.withSyntheticGadgetCall( - 'ListDirectory', - listDirParams, - listDirResult, - 'gc_dir', - ); + let builder = injectDirectoryListing(initialBuilder, trackingContext, 5); // Inject card data as synthetic ReadTrelloCard call (only if cardId exists) if (cardId && cardData) { - recordSyntheticInvocationId(trackingContext, 'gc_card'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'ReadTrelloCard', { cardId, includeComments: true }, cardData, @@ -402,52 +317,8 @@ async function injectSyntheticCalls( ); } - // Inject context files as synthetic ReadFile gadget calls - for (let i = 0; i < contextFiles.length; i++) { - const file = contextFiles[i]; - const invocationId = `gc_init_${i + 1}`; - recordSyntheticInvocationId(trackingContext, invocationId); - builder = builder.withSyntheticGadgetCall( - 'ReadFile', - { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, - file.content, - invocationId, - ); - } - - // Inject AU understanding if enabled (gives agent immediate codebase context) - if (auEnabled) { - const auListResult = (await auList.execute({ - comment: 'Pre-fetching AU entries for context', - path: '.', - maxDepth: 10, - })) as string; - // Only inject if there's actual content - if (auListResult && !auListResult.includes('No AU entries found')) { - recordSyntheticInvocationId(trackingContext, 'gc_au_list'); - builder = builder.withSyntheticGadgetCall( - 'AUList', - { comment: 'Pre-fetching AU entries for context', path: '.', maxDepth: 10 }, - auListResult, - 'gc_au_list', - ); - - // Also inject root-level understanding for high-level context - const auReadResult = (await auRead.execute({ - comment: 'Pre-fetching root-level understanding', - paths: '.', - })) as string; - if (auReadResult && !auReadResult.includes('No understanding exists yet')) { - recordSyntheticInvocationId(trackingContext, 'gc_au_read'); - builder = builder.withSyntheticGadgetCall( - 'AURead', - { comment: 'Pre-fetching root-level understanding', paths: '.' }, - auReadResult, - 'gc_au_read', - ); - } - } - } + builder = injectContextFiles(builder, trackingContext, contextFiles); + builder = await injectAUContext(builder, trackingContext, repoDir); return builder; } @@ -497,7 +368,7 @@ function getLoggerIdentifier( async function setupWorkingDirectory( input: AgentInput, project: ProjectConfig, - log: ReturnType, + log: AgentLogger, agentType: string, prBranch?: string, ): Promise { @@ -526,67 +397,43 @@ export async function executeAgent( return { success: false, output: '', error: 'No card ID or PR context provided' }; } - let repoDir: string | null = null; const debugCardId = isDebugAgent ? (input.originalCardId as string) : undefined; const identifier = getLoggerIdentifier(agentType, cardId, prContext, debugCardId); - const fileLogger = createFileLogger(`cascade-${identifier}`); - const log = createAgentLogger(fileLogger); - - setWatchdogCleanup(async () => { - fileLogger.close(); - if (cardId) { - const logBuffer = await fileLogger.getZippedBuffer(); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logName = `${agentType}-timeout-${timestamp}.zip`; - await trelloClient.addAttachmentFile(cardId, logBuffer, logName); - logger.info('Uploaded timeout log to card', { cardId, logName }); - } - }); - - try { - repoDir = await setupWorkingDirectory(input, project, log, agentType, prContext?.prBranch); - - log.info('Running agent', { - agentType, - cardId, - prNumber: prContext?.prNumber, - prBranch: prContext?.prBranch, - repoDir, - }); - - const debugContext = extractDebugContext(agentType, input); - const ctx = await buildAgentContext( - agentType, - cardId, - repoDir, - project, - config, - log, - input.triggerType, - prContext, - debugContext, - input.modelOverride, - ); - const originalCwd = process.cwd(); - process.chdir(repoDir); + return executeAgentLifecycle({ + loggerIdentifier: identifier, - log.info('Starting llmist agent', { - model: ctx.model, - maxIterations: ctx.maxIterations, - promptLength: ctx.prompt.length, - }); + onWatchdogTimeout: async (fileLogger: FileLogger) => { + if (cardId) { + const logBuffer = await fileLogger.getZippedBuffer(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logName = `${agentType}-timeout-${timestamp}.zip`; + await trelloClient.addAttachmentFile(cardId, logBuffer, logName); + logger.info('Uploaded timeout log to card', { cardId, logName }); + } + }, - try { - process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; - const client = new LLMist({ customModels: CUSTOM_MODELS }); - const llmistLogger = createLogger({ minLevel: getLogLevel() }); - const trackingContext = createTrackingContext(); + setupRepoDir: (log) => + setupWorkingDirectory(input, project, log, agentType, prContext?.prBranch), - // Check if AU features should be enabled (repo has .au file at root) - const auEnabled = existsSync(join(repoDir, '.au')); + buildContext: (repoDir, log) => { + const debugContext = extractDebugContext(agentType, input); + return buildAgentContext( + agentType, + cardId, + repoDir, + project, + config, + log, + input.triggerType, + prContext, + debugContext, + input.modelOverride, + ); + }, - let builder = createAgentBuilderWithGadgets( + createBuilder: ({ client, ctx, llmistLogger, trackingContext, fileLogger, repoDir }) => + createAgentBuilderWithGadgets( client, ctx, llmistLogger, @@ -596,80 +443,25 @@ export async function executeAgent( fileLogger.llmCallLogger, repoDir, cardId, - ); - builder = await injectSyntheticCalls( + ), + + injectSyntheticCalls: ({ builder, ctx, trackingContext, repoDir }) => + injectSyntheticCalls( builder, cardId, ctx.cardData, ctx.contextFiles, trackingContext, - auEnabled, - ); + repoDir, + ), - const agent = builder.ask(ctx.prompt); - const result = await runAgentLoop( - agent, - log, - trackingContext, - interactive === true, - autoAccept === true, - ); + interactive, + autoAccept, + customModels: CUSTOM_MODELS, - log.info('Agent completed', { - cardId, - iterations: result.iterations, - gadgetCalls: result.gadgetCalls, - cost: result.cost, - }); - - const prUrl = extractPRUrl(result.output); - if (prUrl) log.info('PR URL extracted', { prUrl }); - - fileLogger.close(); - const logBuffer = await fileLogger.getZippedBuffer(); - - return { success: true, output: result.output, prUrl, logBuffer, cost: result.cost }; - } finally { - process.chdir(originalCwd); - } - } catch (err) { - logger.error('Agent execution failed', { agentType, error: String(err) }); - - // Get zipped log buffer before returning (if logger exists) - let logBuffer: Buffer | undefined; - try { - fileLogger.close(); - logBuffer = await fileLogger.getZippedBuffer(); - } catch { - // Ignore log buffer errors - } - - return { - success: false, - output: '', - error: String(err), - logBuffer, - }; - } finally { - // Clear watchdog cleanup callback (no longer needed) - clearWatchdogCleanup(); - - // Skip cleanup in local mode to preserve logs for debugging - const isLocalMode = process.env.CASCADE_LOCAL_MODE === 'true'; - - // Cleanup temp directory - if (repoDir && !isLocalMode) { - try { - cleanupTempDir(repoDir); - } catch (err) { - logger.warn('Failed to cleanup temp directory', { repoDir, error: String(err) }); - } - } - // Cleanup log files (buffer already extracted) - if (!isLocalMode) { - cleanupLogFile(fileLogger.logPath); - cleanupLogFile(fileLogger.llmistLogPath); - cleanupLogDirectory(fileLogger.llmCallLogger.logDir); - } - } + postProcess: (output) => { + const prUrl = extractPRUrl(output); + return prUrl ? { prUrl } : {}; + }, + }); } diff --git a/src/agents/respond-to-ci.ts b/src/agents/respond-to-ci.ts index 677926d6..2e69e00f 100644 --- a/src/agents/respond-to-ci.ts +++ b/src/agents/respond-to-ci.ts @@ -1,13 +1,6 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { auList, auRead } from '@zbigniewsobiecki/au'; -import { AgentBuilder, LLMist, createLogger } from 'llmist'; +import type { createLogger } from 'llmist'; import { WriteFile } from '../gadgets/WriteFile.js'; -import { getCompactionConfig } from '../config/compactionConfig.js'; -import { getIterationTrailingMessage } from '../config/hintConfig.js'; -import { getRateLimitForModel } from '../config/rateLimits.js'; -import { getRetryConfig } from '../config/retryConfig.js'; import { AstGrep } from '../gadgets/AstGrep.js'; import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; import { Finish } from '../gadgets/Finish.js'; @@ -21,31 +14,26 @@ import { PostPRComment, UpdatePRComment, } from '../gadgets/github/index.js'; -import { initSessionState, recordInitialComment } from '../gadgets/sessionState.js'; +import { recordInitialComment } from '../gadgets/sessionState.js'; import { Tmux } from '../gadgets/tmux.js'; import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; import type { CheckSuiteStatus } from '../github/client.js'; import { githubClient } from '../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { cleanupLogDirectory, cleanupLogFile, createFileLogger } from '../utils/fileLogger.js'; -import { clearWatchdogCleanup, setWatchdogCleanup } from '../utils/lifecycle.js'; import { logger } from '../utils/logging.js'; +import { runCommand as execCommand } from '../utils/repo.js'; +import { type BuilderType, createConfiguredBuilder } from './shared/builderFactory.js'; +import { executeAgentLifecycle } from './shared/lifecycle.js'; +import { resolveModelConfig } from './shared/modelResolution.js'; +import { formatPRDetails, formatPRDiff } from './shared/prFormatting.js'; +import { setupRepository } from './shared/repository.js'; import { - cleanupTempDir, - cloneRepo, - createTempDir, - runCommand as execCommand, -} from '../utils/repo.js'; -import { getSystemPrompt } from './prompts/index.js'; -import { runAgentLoop } from './utils/agentLoop.js'; -import { createObserverHooks } from './utils/hooks.js'; -import { getLogLevel, readContextFiles } from './utils/index.js'; -import { createAgentLogger } from './utils/logging.js'; -import { - type TrackingContext, - createTrackingContext, - recordSyntheticInvocationId, -} from './utils/tracking.js'; + injectAUContext, + injectContextFiles, + injectDirectoryListing, + injectSyntheticCall, +} from './shared/syntheticCalls.js'; +import type { TrackingContext } from './utils/tracking.js'; interface RespondToCIAgentInput extends AgentInput { prNumber: number; @@ -56,83 +44,10 @@ interface RespondToCIAgentInput extends AgentInput { config: CascadeConfig; } -// ============================================================================ -// Repository Setup -// ============================================================================ - -async function setupRepository( - project: ProjectConfig, - prBranch: string, - log: ReturnType, -): Promise { - // Clone repo to temp directory - const repoDir = createTempDir(project.id); - cloneRepo(project, repoDir); - - // Checkout the PR branch - log.info('Checking out PR branch', { prBranch }); - await execCommand('git', ['checkout', prBranch], repoDir); - - // Run project-specific setup script if it exists (handles dependency installation) - const setupScriptPath = join(repoDir, '.cascade', 'setup.sh'); - if (existsSync(setupScriptPath)) { - log.info('Running project setup script', { - path: '.cascade/setup.sh', - agentType: 'respond-to-ci', - }); - const setupResult = await execCommand('bash', [setupScriptPath], repoDir, { - AGENT_PROFILE_NAME: 'respond-to-ci', - }); - log.info('Setup script completed', { - exitCode: setupResult.exitCode, - stdout: setupResult.stdout.slice(-500), - stderr: setupResult.stderr.slice(-500), - }); - if (setupResult.exitCode !== 0) { - log.warn('Setup script exited with non-zero code', { exitCode: setupResult.exitCode }); - } - } - - return repoDir; -} - // ============================================================================ // CI Data Formatting // ============================================================================ -type PRDetails = Awaited>; -type PRDiff = Awaited>; - -function formatPRDetails(prDetails: PRDetails): string { - return [ - `PR #${prDetails.number}: ${prDetails.title}`, - `State: ${prDetails.state}`, - `Branch: ${prDetails.headRef} -> ${prDetails.baseRef}`, - `URL: ${prDetails.htmlUrl}`, - '', - 'Description:', - prDetails.body || '(no description)', - ].join('\n'); -} - -function formatPRDiff(prDiff: PRDiff): string { - if (prDiff.length === 0) { - return 'No files changed in this PR.'; - } - - const formatted = prDiff.map((f) => { - const lines = [`## ${f.filename}`, `Status: ${f.status} | +${f.additions} -${f.deletions}`]; - if (f.patch) { - lines.push('```diff', f.patch, '```'); - } else { - lines.push('[Binary file or too large to display]'); - } - return lines.join('\n'); - }); - - return `${prDiff.length} file(s) changed:\n\n${formatted.join('\n\n')}`; -} - function formatCheckStatus(checkStatus: CheckSuiteStatus): string { const lines = ['## Check Suite Status', `Total checks: ${checkStatus.totalCount}`, '']; @@ -184,7 +99,7 @@ interface CIContextData { systemPrompt: string; model: string; maxIterations: number; - contextFiles: Awaited>; + contextFiles: Awaited>['contextFiles']; prDetailsFormatted: string; diffFormatted: string; checkStatusFormatted: string; @@ -238,7 +153,10 @@ async function fetchFailedCheckLogs( repo: string, checkStatus: CheckSuiteStatus, repoDir: string, - log: ReturnType, + log: { + info: (msg: string, ctx?: Record) => void; + warn: (msg: string, ctx?: Record) => void; + }, ): Promise { const failedRuns = checkStatus.checkRuns.filter( (cr) => @@ -290,7 +208,7 @@ async function processFailedCheck( owner: string, repo: string, repoDir: string, - log: ReturnType, + log: { info: (msg: string, ctx?: Record) => void }, ): Promise { const matchingRun = runs.find( (r) => @@ -326,22 +244,19 @@ async function buildCIContext( repoDir: string, project: ProjectConfig, config: CascadeConfig, - log: ReturnType, + log: { + info: (msg: string, ctx?: Record) => void; + warn: (msg: string, ctx?: Record) => void; + }, modelOverride?: string, ): Promise { - // Get system prompt and model - const systemPrompt = project.prompts?.['respond-to-ci'] || getSystemPrompt('respond-to-ci', {}); - const model = - modelOverride || - project.agentModels?.['respond-to-ci'] || - project.model || - config.defaults.agentModels?.['respond-to-ci'] || - config.defaults.model; - const maxIterations = - config.defaults.agentIterations?.['respond-to-ci'] || config.defaults.maxIterations; - - // Read context files - const contextFiles = await readContextFiles(repoDir); + const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ + agentType: 'respond-to-ci', + project, + config, + repoDir, + modelOverride, + }); // Fetch PR details and diff log.info('Fetching PR details and diff', { owner, repo, prNumber }); @@ -394,70 +309,49 @@ Use these values when calling GitHub gadgets (GetPRDetails, PostPRComment, Updat // Agent Builder // ============================================================================ -type BuilderType = ReturnType; - -function createRespondToCIAgentBuilder( - client: LLMist, - ctx: CIContextData, - llmistLogger: ReturnType, - trackingContext: TrackingContext, - logWriter: (level: string, message: string, context?: Record) => void, - llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, - repoDir: string, -): BuilderType { - // Initialize session state for gadgets - initSessionState('respond-to-ci'); - - // Check if AU features should be enabled (repo has .au file at root) - const auEnabled = existsSync(join(repoDir, '.au')); - - // Build gadget list - const baseGadgets = [ - // Filesystem gadgets +function getCIGadgets() { + return [ new ListDirectory(), new ReadFile(), new FileSearchAndReplace(), new WriteFile(), new RipGrep(), new AstGrep(), - // Shell commands via tmux new Tmux(), new Sleep(), - // Task tracking gadgets new TodoUpsert(), new TodoUpdateStatus(), new TodoDelete(), - // GitHub gadgets new GetPRDetails(), new GetPRDiff(), new PostPRComment(), new UpdatePRComment(), - // Session control new Finish(), ]; +} - const allGadgets = auEnabled ? [...baseGadgets, auList, auRead] : baseGadgets; - - return new AgentBuilder(client) - .withModel(ctx.model) - .withTemperature(0) - .withSystem(ctx.systemPrompt) - .withMaxIterations(ctx.maxIterations) - .withLogger(llmistLogger) - .withRateLimits(getRateLimitForModel(ctx.model)) - .withRetry(getRetryConfig(llmistLogger)) - .withCompaction(getCompactionConfig('respond-to-ci')) - .withTrailingMessage(getIterationTrailingMessage('respond-to-ci')) - .withTextOnlyHandler('acknowledge') - .withHooks({ - observers: createObserverHooks({ - model: ctx.model, - logWriter, - trackingContext, - llmCallLogger, - }), - }) - .withGadgets(...allGadgets); +function createRespondToCIAgentBuilder( + client: import('llmist').LLMist, + ctx: CIContextData, + llmistLogger: ReturnType, + trackingContext: TrackingContext, + logWriter: (level: string, message: string, context?: Record) => void, + llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, + repoDir: string, +): BuilderType { + return createConfiguredBuilder({ + client, + agentType: 'respond-to-ci', + model: ctx.model, + systemPrompt: ctx.systemPrompt, + maxIterations: ctx.maxIterations, + llmistLogger, + trackingContext, + logWriter, + llmCallLogger, + repoDir, + gadgets: getCIGadgets(), + }); } async function injectCISyntheticCalls( @@ -467,126 +361,66 @@ async function injectCISyntheticCalls( prNumber: number, ctx: CIContextData, trackingContext: TrackingContext, - auEnabled: boolean, + repoDir: string, initialCommentId: number, initialCommentUrl: string, ): Promise { - let builder = initialBuilder; - // Record the acknowledgment comment as synthetic call (already posted earlier) - const initialCommentBody = '🤖 Working on fixing CI failures...'; - recordSyntheticInvocationId(trackingContext, 'gc_initial_comment'); - builder = builder.withSyntheticGadgetCall( + let builder = injectSyntheticCall( + initialBuilder, + trackingContext, 'PostPRComment', { comment: 'Acknowledge CI failures', owner, repo, prNumber, - body: initialCommentBody, + body: '🤖 Working on fixing CI failures...', }, `Comment posted (id: ${initialCommentId}): ${initialCommentUrl}`, 'gc_initial_comment', ); - // Inject directory listing as synthetic ListDirectory call (first for codebase orientation) - const listDirGadget = new ListDirectory(); - const listDirParams = { - comment: 'Pre-fetching codebase structure for context', - directoryPath: '.', - maxDepth: 3, - includeGitIgnored: false, - }; - const listDirResult = listDirGadget.execute(listDirParams); - recordSyntheticInvocationId(trackingContext, 'gc_dir'); - builder = builder.withSyntheticGadgetCall( - 'ListDirectory', - listDirParams, - listDirResult, - 'gc_dir', - ); + builder = injectDirectoryListing(builder, trackingContext); - // Inject PR details - recordSyntheticInvocationId(trackingContext, 'gc_pr_details'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRDetails', { comment: 'Pre-fetching PR details for context', owner, repo, prNumber }, ctx.prDetailsFormatted, 'gc_pr_details', ); - // Inject PR diff - recordSyntheticInvocationId(trackingContext, 'gc_pr_diff'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRDiff', { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber }, ctx.diffFormatted, 'gc_pr_diff', ); - // Inject check status summary - recordSyntheticInvocationId(trackingContext, 'gc_check_status'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetCheckStatus', { comment: 'Pre-fetching CI check status', owner, repo, prNumber }, ctx.checkStatusFormatted, 'gc_check_status', ); - // Inject failed check logs - recordSyntheticInvocationId(trackingContext, 'gc_failed_logs'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetFailedCheckLogs', { comment: 'Pre-fetching failed CI check logs', owner, repo, prNumber }, ctx.failedLogsFormatted, 'gc_failed_logs', ); - // Inject context files - for (let i = 0; i < ctx.contextFiles.length; i++) { - const file = ctx.contextFiles[i]; - const invocationId = `gc_init_${i + 1}`; - recordSyntheticInvocationId(trackingContext, invocationId); - builder = builder.withSyntheticGadgetCall( - 'ReadFile', - { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, - file.content, - invocationId, - ); - } - - // Inject AU understanding if enabled (gives agent immediate codebase context) - if (auEnabled) { - const auListResult = (await auList.execute({ - comment: 'Pre-fetching AU entries for context', - path: '.', - })) as string; - // Only inject if there's actual content - if (auListResult && !auListResult.includes('No AU entries found')) { - recordSyntheticInvocationId(trackingContext, 'gc_au_list'); - builder = builder.withSyntheticGadgetCall( - 'AUList', - { comment: 'Pre-fetching AU entries for context', path: '.' }, - auListResult, - 'gc_au_list', - ); - - // Also inject root-level understanding for high-level context - const auReadResult = (await auRead.execute({ - comment: 'Pre-fetching root-level understanding', - paths: '.', - })) as string; - if (auReadResult && !auReadResult.includes('No understanding exists yet')) { - recordSyntheticInvocationId(trackingContext, 'gc_au_read'); - builder = builder.withSyntheticGadgetCall( - 'AURead', - { comment: 'Pre-fetching root-level understanding', paths: '.' }, - auReadResult, - 'gc_au_read', - ); - } - } - } + builder = injectContextFiles(builder, trackingContext, ctx.contextFiles); + builder = await injectAUContext(builder, trackingContext, repoDir); return builder; } @@ -634,69 +468,37 @@ export async function executeRespondToCIAgent(input: RespondToCIAgentInput): Pro recordInitialComment(initialComment.id); logger.info('Posted initial acknowledgment comment', { prNumber, commentId: initialComment.id }); - let repoDir: string | null = null; - - // Create file logger for this agent run - const fileLogger = createFileLogger(`cascade-ci-${prNumber}`); - const log = createAgentLogger(fileLogger); - - // Register cleanup callback for watchdog timeout - setWatchdogCleanup(async () => { - fileLogger.close(); - // For CI agent, we post a comment to the PR instead of attaching logs to Trello - await githubClient.createPRComment( - owner, - repo, - prNumber, - '⚠️ CI fix agent timed out while attempting to fix failures.', - ); - logger.info('Posted timeout notice to PR', { prNumber }); - }); - - try { - // Setup repository - repoDir = await setupRepository(project, prBranch, log); - - log.info('Running CI fix agent', { prNumber, repoFullName, repoDir, headSha }); - - // Build context - const ctx = await buildCIContext( - owner, - repo, - prNumber, - prBranch, - headSha, - repoDir, - project, - config, - log, - input.modelOverride, - ); - - // Change to repo directory - const originalCwd = process.cwd(); - process.chdir(repoDir); - - log.info('Starting llmist agent', { - model: ctx.model, - maxIterations: ctx.maxIterations, - promptLength: ctx.prompt.length, - }); - - try { - process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; + return executeAgentLifecycle({ + loggerIdentifier: `ci-${prNumber}`, - const client = new LLMist(); - const llmistLogger = createLogger({ minLevel: getLogLevel() }); + onWatchdogTimeout: async () => { + await githubClient.createPRComment( + owner, + repo, + prNumber, + '⚠️ CI fix agent timed out while attempting to fix failures.', + ); + logger.info('Posted timeout notice to PR', { prNumber }); + }, - // Create tracking context for iterations and gadget calls - const trackingContext = createTrackingContext(); + setupRepoDir: (log) => setupRepository({ project, log, agentType: 'respond-to-ci', prBranch }), - // Check if AU features should be enabled (repo has .au file at root) - const auEnabled = existsSync(join(repoDir, '.au')); + buildContext: (repoDir, log) => + buildCIContext( + owner, + repo, + prNumber, + prBranch, + headSha, + repoDir, + project, + config, + log, + input.modelOverride, + ), - // Build agent with gadgets and synthetic calls - let builder = createRespondToCIAgentBuilder( + createBuilder: ({ client, ctx, llmistLogger, trackingContext, fileLogger, repoDir }) => + createRespondToCIAgentBuilder( client, ctx, llmistLogger, @@ -704,82 +506,22 @@ export async function executeRespondToCIAgent(input: RespondToCIAgentInput): Pro fileLogger.write.bind(fileLogger), fileLogger.llmCallLogger, repoDir, - ); - builder = await injectCISyntheticCalls( + ), + + injectSyntheticCalls: ({ builder, ctx, trackingContext, repoDir }) => + injectCISyntheticCalls( builder, owner, repo, prNumber, ctx, trackingContext, - auEnabled, + repoDir, initialComment.id, initialComment.htmlUrl, - ); + ), - // Run the agent - const agent = builder.ask(ctx.prompt); - const result = await runAgentLoop( - agent, - log, - trackingContext, - interactive === true, - autoAccept === true, - ); - - log.info('CI fix agent completed', { - prNumber, - iterations: result.iterations, - gadgetCalls: result.gadgetCalls, - cost: result.cost, - }); - - fileLogger.close(); - const logBuffer = await fileLogger.getZippedBuffer(); - - return { - success: true, - output: result.output, - logBuffer, - cost: result.cost, - }; - } finally { - process.chdir(originalCwd); - } - } catch (err) { - logger.error('CI fix agent execution failed', { prNumber, error: String(err) }); - - let logBuffer: Buffer | undefined; - try { - fileLogger.close(); - logBuffer = await fileLogger.getZippedBuffer(); - } catch { - // Ignore log buffer errors - } - - return { - success: false, - output: '', - error: String(err), - logBuffer, - }; - } finally { - clearWatchdogCleanup(); - - // Skip cleanup in local mode to preserve logs for debugging - const isLocalMode = process.env.CASCADE_LOCAL_MODE === 'true'; - - if (repoDir && !isLocalMode) { - try { - cleanupTempDir(repoDir); - } catch (err) { - logger.warn('Failed to cleanup temp directory', { repoDir, error: String(err) }); - } - } - if (!isLocalMode) { - cleanupLogFile(fileLogger.logPath); - cleanupLogFile(fileLogger.llmistLogPath); - cleanupLogDirectory(fileLogger.llmCallLogger.logDir); - } - } + interactive, + autoAccept, + }); } diff --git a/src/agents/respond-to-review.ts b/src/agents/respond-to-review.ts index 2f5dba6d..e4133b10 100644 --- a/src/agents/respond-to-review.ts +++ b/src/agents/respond-to-review.ts @@ -1,13 +1,6 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { auList, auRead } from '@zbigniewsobiecki/au'; -import { AgentBuilder, LLMist, createLogger } from 'llmist'; +import type { LLMist, createLogger } from 'llmist'; import { WriteFile } from '../gadgets/WriteFile.js'; -import { getCompactionConfig } from '../config/compactionConfig.js'; -import { getIterationTrailingMessage } from '../config/hintConfig.js'; -import { getRateLimitForModel } from '../config/rateLimits.js'; -import { getRetryConfig } from '../config/retryConfig.js'; import { AstGrep } from '../gadgets/AstGrep.js'; import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; import { Finish } from '../gadgets/Finish.js'; @@ -23,30 +16,24 @@ import { ReplyToReviewComment, UpdatePRComment, } from '../gadgets/github/index.js'; -import { initSessionState, recordInitialComment } from '../gadgets/sessionState.js'; +import { recordInitialComment } from '../gadgets/sessionState.js'; import { Tmux } from '../gadgets/tmux.js'; import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; import { githubClient } from '../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { cleanupLogDirectory, cleanupLogFile, createFileLogger } from '../utils/fileLogger.js'; -import { clearWatchdogCleanup, setWatchdogCleanup } from '../utils/lifecycle.js'; import { logger } from '../utils/logging.js'; +import { type BuilderType, createConfiguredBuilder } from './shared/builderFactory.js'; +import { executeAgentLifecycle } from './shared/lifecycle.js'; +import { resolveModelConfig } from './shared/modelResolution.js'; +import { formatPRDetails, formatPRDiff } from './shared/prFormatting.js'; +import { setupRepository } from './shared/repository.js'; import { - cleanupTempDir, - cloneRepo, - createTempDir, - runCommand as execCommand, -} from '../utils/repo.js'; -import { getSystemPrompt } from './prompts/index.js'; -import { runAgentLoop } from './utils/agentLoop.js'; -import { createObserverHooks } from './utils/hooks.js'; -import { getLogLevel, readContextFiles } from './utils/index.js'; -import { createAgentLogger } from './utils/logging.js'; -import { - type TrackingContext, - createTrackingContext, - recordSyntheticInvocationId, -} from './utils/tracking.js'; + injectAUContext, + injectContextFiles, + injectDirectoryListing, + injectSyntheticCall, +} from './shared/syntheticCalls.js'; +import type { TrackingContext } from './utils/tracking.js'; interface RespondToReviewAgentInput extends AgentInput { prNumber: number; @@ -60,67 +47,13 @@ interface RespondToReviewAgentInput extends AgentInput { config: CascadeConfig; } -// ============================================================================ -// Repository Setup -// ============================================================================ - -async function setupRepository( - project: ProjectConfig, - prBranch: string, - log: ReturnType, -): Promise { - // Clone repo to temp directory - const repoDir = createTempDir(project.id); - cloneRepo(project, repoDir); - - // Checkout the PR branch - log.info('Checking out PR branch', { prBranch }); - await execCommand('git', ['checkout', prBranch], repoDir); - - // Run project-specific setup script if it exists (handles dependency installation) - const setupScriptPath = join(repoDir, '.cascade', 'setup.sh'); - if (existsSync(setupScriptPath)) { - log.info('Running project setup script', { - path: '.cascade/setup.sh', - agentType: 'respond-to-review', - }); - const setupResult = await execCommand('bash', [setupScriptPath], repoDir, { - AGENT_PROFILE_NAME: 'respond-to-review', - }); - log.info('Setup script completed', { - exitCode: setupResult.exitCode, - stdout: setupResult.stdout.slice(-500), - stderr: setupResult.stderr.slice(-500), - }); - if (setupResult.exitCode !== 0) { - log.warn('Setup script exited with non-zero code', { exitCode: setupResult.exitCode }); - } - } - - return repoDir; -} - // ============================================================================ // PR Data Formatting // ============================================================================ -type PRDetails = Awaited>; type PRComments = Awaited>; type PRReviews = Awaited>; type PRIssueComments = Awaited>; -type PRDiff = Awaited>; - -function formatPRDetails(prDetails: PRDetails): string { - return [ - `PR #${prDetails.number}: ${prDetails.title}`, - `State: ${prDetails.state}`, - `Branch: ${prDetails.headRef} -> ${prDetails.baseRef}`, - `URL: ${prDetails.htmlUrl}`, - '', - 'Description:', - prDetails.body || '(no description)', - ].join('\n'); -} function formatPRComments(prComments: PRComments): string { if (prComments.length === 0) { @@ -184,24 +117,6 @@ function formatPRIssueComments(prIssueComments: PRIssueComments): string { .join('\n\n'); } -function formatPRDiff(prDiff: PRDiff): string { - if (prDiff.length === 0) { - return 'No files changed in this PR.'; - } - - const formatted = prDiff.map((f) => { - const lines = [`## ${f.filename}`, `Status: ${f.status} | +${f.additions} -${f.deletions}`]; - if (f.patch) { - lines.push('```diff', f.patch, '```'); - } else { - lines.push('[Binary file or too large to display]'); - } - return lines.join('\n'); - }); - - return `${prDiff.length} file(s) changed:\n\n${formatted.join('\n\n')}`; -} - // ============================================================================ // Context Building // ============================================================================ @@ -210,7 +125,7 @@ interface ReviewContextData { systemPrompt: string; model: string; maxIterations: number; - contextFiles: Awaited>; + contextFiles: Awaited>['contextFiles']; prDetailsFormatted: string; commentsFormatted: string; reviewsFormatted: string; @@ -227,22 +142,18 @@ async function buildReviewContext( repoDir: string, project: ProjectConfig, config: CascadeConfig, - log: ReturnType, + log: { info: (msg: string, ctx?: Record) => void }, modelOverride?: string, ): Promise { - // Get system prompt and model - const systemPrompt = - project.prompts?.['respond-to-review'] || getSystemPrompt('respond-to-review', {}); - const model = - modelOverride || - project.agentModels?.review || - project.model || - config.defaults.agentModels?.review || - config.defaults.model; - const maxIterations = config.defaults.agentIterations?.review || config.defaults.maxIterations; - - // Read context files - const contextFiles = await readContextFiles(repoDir); + // respond-to-review shares model/iteration config with 'review' agent + const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ + agentType: 'respond-to-review', + project, + config, + repoDir, + modelOverride, + configKey: 'review', + }); // Fetch PR details, comments, reviews, issue comments, and diff log.info('Fetching PR details, comments, reviews, issue comments, and diff', { @@ -303,72 +214,51 @@ Use these values when calling GitHub gadgets (GetPRComments, ReplyToReviewCommen // Agent Builder // ============================================================================ -type BuilderType = ReturnType; - -function createRespondToReviewAgentBuilder( - client: LLMist, - ctx: ReviewContextData, - llmistLogger: ReturnType, - trackingContext: TrackingContext, - logWriter: (level: string, message: string, context?: Record) => void, - llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, - repoDir: string, -): BuilderType { - // Initialize session state for gadgets - initSessionState('respond-to-review'); - - // Check if AU features should be enabled (repo has .au file at root) - const auEnabled = existsSync(join(repoDir, '.au')); - - // Build gadget list - const baseGadgets = [ - // Filesystem gadgets +function getRespondToReviewGadgets() { + return [ new ListDirectory(), new ReadFile(), new FileSearchAndReplace(), new WriteFile(), new RipGrep(), new AstGrep(), - // Shell commands via tmux new Tmux(), new Sleep(), - // Task tracking gadgets new TodoUpsert(), new TodoUpdateStatus(), new TodoDelete(), - // GitHub gadgets new GetPRDetails(), new GetPRComments(), new GetPRDiff(), new ReplyToReviewComment(), new PostPRComment(), new UpdatePRComment(), - // Session control new Finish(), ]; +} - const allGadgets = auEnabled ? [...baseGadgets, auList, auRead] : baseGadgets; - - return new AgentBuilder(client) - .withModel(ctx.model) - .withTemperature(0) - .withSystem(ctx.systemPrompt) - .withMaxIterations(ctx.maxIterations) - .withLogger(llmistLogger) - .withRateLimits(getRateLimitForModel(ctx.model)) - .withRetry(getRetryConfig(llmistLogger)) - .withCompaction(getCompactionConfig('respond-to-review')) - .withTrailingMessage(getIterationTrailingMessage('respond-to-review')) - .withTextOnlyHandler('acknowledge') - .withHooks({ - observers: createObserverHooks({ - model: ctx.model, - logWriter, - trackingContext, - llmCallLogger, - }), - }) - .withGadgets(...allGadgets); +function createRespondToReviewAgentBuilder( + client: LLMist, + ctx: ReviewContextData, + llmistLogger: ReturnType, + trackingContext: TrackingContext, + logWriter: (level: string, message: string, context?: Record) => void, + llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, + repoDir: string, +): BuilderType { + return createConfiguredBuilder({ + client, + agentType: 'respond-to-review', + model: ctx.model, + systemPrompt: ctx.systemPrompt, + maxIterations: ctx.maxIterations, + llmistLogger, + trackingContext, + logWriter, + llmCallLogger, + repoDir, + gadgets: getRespondToReviewGadgets(), + }); } async function injectReviewSyntheticCalls( @@ -378,10 +268,8 @@ async function injectReviewSyntheticCalls( prNumber: number, ctx: ReviewContextData, trackingContext: TrackingContext, - auEnabled: boolean, + repoDir: string, ): Promise { - let builder = initialBuilder; - // Post initial "getting to work" comment on the PR const initialCommentBody = '🤖 Working on addressing the review feedback...'; const initialComment = await githubClient.createPRComment( @@ -391,59 +279,40 @@ async function injectReviewSyntheticCalls( initialCommentBody, ); recordInitialComment(initialComment.id); - recordSyntheticInvocationId(trackingContext, 'gc_initial_comment'); - builder = builder.withSyntheticGadgetCall( + let builder = injectSyntheticCall( + initialBuilder, + trackingContext, 'PostPRComment', - { - comment: 'Acknowledge review feedback', - owner, - repo, - prNumber, - body: initialCommentBody, - }, + { comment: 'Acknowledge review feedback', owner, repo, prNumber, body: initialCommentBody }, `Comment posted (id: ${initialComment.id}): ${initialComment.htmlUrl}`, 'gc_initial_comment', ); - // Inject directory listing as synthetic ListDirectory call (first for codebase orientation) - // Call the actual gadget to generate output (respects .gitignore by default) - const listDirGadget = new ListDirectory(); - const listDirParams = { - comment: 'Pre-fetching codebase structure for context', - directoryPath: '.', - maxDepth: 3, - includeGitIgnored: false, - }; - const listDirResult = listDirGadget.execute(listDirParams); - recordSyntheticInvocationId(trackingContext, 'gc_dir'); - builder = builder.withSyntheticGadgetCall( - 'ListDirectory', - listDirParams, - listDirResult, - 'gc_dir', - ); + // Inject directory listing + builder = injectDirectoryListing(builder, trackingContext); - // Inject PR details - recordSyntheticInvocationId(trackingContext, 'gc_pr_details'); - builder = builder.withSyntheticGadgetCall( + // Inject PR details, comments, reviews, issue comments, and diff + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRDetails', { comment: 'Pre-fetching PR details for context', owner, repo, prNumber }, ctx.prDetailsFormatted, 'gc_pr_details', ); - // Inject PR line-specific comments - recordSyntheticInvocationId(trackingContext, 'gc_pr_comments'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRComments', { comment: 'Pre-fetching line-specific review comments to address', owner, repo, prNumber }, ctx.commentsFormatted, 'gc_pr_comments', ); - // Inject PR reviews (with body text) - recordSyntheticInvocationId(trackingContext, 'gc_pr_reviews'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRReviews', { comment: 'Pre-fetching review submissions (approve/request changes with body text)', @@ -455,9 +324,9 @@ async function injectReviewSyntheticCalls( 'gc_pr_reviews', ); - // Inject PR issue comments (general conversation) - recordSyntheticInvocationId(trackingContext, 'gc_pr_issue_comments'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRIssueComments', { comment: 'Pre-fetching general PR comments (issue-style conversation)', @@ -469,60 +338,18 @@ async function injectReviewSyntheticCalls( 'gc_pr_issue_comments', ); - // Inject PR diff - recordSyntheticInvocationId(trackingContext, 'gc_pr_diff'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRDiff', { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber }, ctx.diffFormatted, 'gc_pr_diff', ); - // Inject context files - for (let i = 0; i < ctx.contextFiles.length; i++) { - const file = ctx.contextFiles[i]; - const invocationId = `gc_init_${i + 1}`; - recordSyntheticInvocationId(trackingContext, invocationId); - builder = builder.withSyntheticGadgetCall( - 'ReadFile', - { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, - file.content, - invocationId, - ); - } - - // Inject AU understanding if enabled (gives agent immediate codebase context) - if (auEnabled) { - const auListResult = (await auList.execute({ - comment: 'Pre-fetching AU entries for context', - path: '.', - })) as string; - // Only inject if there's actual content - if (auListResult && !auListResult.includes('No AU entries found')) { - recordSyntheticInvocationId(trackingContext, 'gc_au_list'); - builder = builder.withSyntheticGadgetCall( - 'AUList', - { comment: 'Pre-fetching AU entries for context', path: '.' }, - auListResult, - 'gc_au_list', - ); - - // Also inject root-level understanding for high-level context - const auReadResult = (await auRead.execute({ - comment: 'Pre-fetching root-level understanding', - paths: '.', - })) as string; - if (auReadResult && !auReadResult.includes('No understanding exists yet')) { - recordSyntheticInvocationId(trackingContext, 'gc_au_read'); - builder = builder.withSyntheticGadgetCall( - 'AURead', - { comment: 'Pre-fetching root-level understanding', paths: '.' }, - auReadResult, - 'gc_au_read', - ); - } - } - } + // Inject context files and AU context + builder = injectContextFiles(builder, trackingContext, ctx.contextFiles); + builder = await injectAUContext(builder, trackingContext, repoDir); return builder; } @@ -542,68 +369,37 @@ export async function executeRespondToReviewAgent( return { success: false, output: '', error: `Invalid repo format: ${repoFullName}` }; } - let repoDir: string | null = null; - - // Create file logger for this agent run - const fileLogger = createFileLogger(`cascade-review-${prNumber}`); - const log = createAgentLogger(fileLogger); + return executeAgentLifecycle({ + loggerIdentifier: `review-${prNumber}`, - // Register cleanup callback for watchdog timeout - setWatchdogCleanup(async () => { - fileLogger.close(); - // For review agent, we post a comment to the PR instead of attaching logs to Trello - await githubClient.createPRComment( - owner, - repo, - prNumber, - '⚠️ Review agent timed out while addressing feedback.', - ); - logger.info('Posted timeout notice to PR', { prNumber }); - }); + onWatchdogTimeout: async () => { + await githubClient.createPRComment( + owner, + repo, + prNumber, + '⚠️ Review agent timed out while addressing feedback.', + ); + logger.info('Posted timeout notice to PR', { prNumber }); + }, - try { - // Setup repository - repoDir = await setupRepository(project, prBranch, log); + setupRepoDir: (log) => + setupRepository({ project, log, agentType: 'respond-to-review', prBranch }), - log.info('Running review agent', { prNumber, repoFullName, repoDir }); + buildContext: (repoDir, log) => + buildReviewContext( + owner, + repo, + prNumber, + prBranch, + repoDir, + project, + config, + log, + input.modelOverride, + ), - // Build context - const ctx = await buildReviewContext( - owner, - repo, - prNumber, - prBranch, - repoDir, - project, - config, - log, - input.modelOverride, - ); - - // Change to repo directory - const originalCwd = process.cwd(); - process.chdir(repoDir); - - log.info('Starting llmist agent', { - model: ctx.model, - maxIterations: ctx.maxIterations, - promptLength: ctx.prompt.length, - }); - - try { - process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; - - const client = new LLMist(); - const llmistLogger = createLogger({ minLevel: getLogLevel() }); - - // Create tracking context for iterations and gadget calls - const trackingContext = createTrackingContext(); - - // Check if AU features should be enabled (repo has .au file at root) - const auEnabled = existsSync(join(repoDir, '.au')); - - // Build agent with gadgets and synthetic calls - let builder = createRespondToReviewAgentBuilder( + createBuilder: ({ client, ctx, llmistLogger, trackingContext, fileLogger, repoDir }) => + createRespondToReviewAgentBuilder( client, ctx, llmistLogger, @@ -611,80 +407,12 @@ export async function executeRespondToReviewAgent( fileLogger.write.bind(fileLogger), fileLogger.llmCallLogger, repoDir, - ); - builder = await injectReviewSyntheticCalls( - builder, - owner, - repo, - prNumber, - ctx, - trackingContext, - auEnabled, - ); + ), - // Run the agent - const agent = builder.ask(ctx.prompt); - const result = await runAgentLoop( - agent, - log, - trackingContext, - interactive === true, - autoAccept === true, - ); + injectSyntheticCalls: ({ builder, ctx, trackingContext, repoDir }) => + injectReviewSyntheticCalls(builder, owner, repo, prNumber, ctx, trackingContext, repoDir), - log.info('Review agent completed', { - prNumber, - iterations: result.iterations, - gadgetCalls: result.gadgetCalls, - cost: result.cost, - }); - - fileLogger.close(); - const logBuffer = await fileLogger.getZippedBuffer(); - - return { - success: true, - output: result.output, - logBuffer, - cost: result.cost, - }; - } finally { - process.chdir(originalCwd); - } - } catch (err) { - logger.error('Review agent execution failed', { prNumber, error: String(err) }); - - let logBuffer: Buffer | undefined; - try { - fileLogger.close(); - logBuffer = await fileLogger.getZippedBuffer(); - } catch { - // Ignore log buffer errors - } - - return { - success: false, - output: '', - error: String(err), - logBuffer, - }; - } finally { - clearWatchdogCleanup(); - - // Skip cleanup in local mode to preserve logs for debugging - const isLocalMode = process.env.CASCADE_LOCAL_MODE === 'true'; - - if (repoDir && !isLocalMode) { - try { - cleanupTempDir(repoDir); - } catch (err) { - logger.warn('Failed to cleanup temp directory', { repoDir, error: String(err) }); - } - } - if (!isLocalMode) { - cleanupLogFile(fileLogger.logPath); - cleanupLogFile(fileLogger.llmistLogPath); - cleanupLogDirectory(fileLogger.llmCallLogger.logDir); - } - } + interactive, + autoAccept, + }); } diff --git a/src/agents/review.ts b/src/agents/review.ts index 71a59332..495d0da0 100644 --- a/src/agents/review.ts +++ b/src/agents/review.ts @@ -1,13 +1,7 @@ -import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { auList, auRead } from '@zbigniewsobiecki/au'; -import { AgentBuilder, LLMist, createLogger } from 'llmist'; +import type { createLogger } from 'llmist'; -import { getCompactionConfig } from '../config/compactionConfig.js'; -import { getIterationTrailingMessage } from '../config/hintConfig.js'; -import { getRateLimitForModel } from '../config/rateLimits.js'; -import { getRetryConfig } from '../config/retryConfig.js'; import { REVIEW_FILE_CONTENT_TOKEN_LIMIT, estimateTokens } from '../config/reviewConfig.js'; import { Finish } from '../gadgets/Finish.js'; import { ListDirectory } from '../gadgets/ListDirectory.js'; @@ -24,25 +18,18 @@ import { Tmux } from '../gadgets/tmux.js'; import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; import { githubClient } from '../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { cleanupLogDirectory, cleanupLogFile, createFileLogger } from '../utils/fileLogger.js'; -import { clearWatchdogCleanup, setWatchdogCleanup } from '../utils/lifecycle.js'; import { logger } from '../utils/logging.js'; +import { type BuilderType, createConfiguredBuilder } from './shared/builderFactory.js'; +import { executeAgentLifecycle } from './shared/lifecycle.js'; +import { resolveModelConfig } from './shared/modelResolution.js'; +import { type PRDiff, formatPRDetails, formatPRDiff } from './shared/prFormatting.js'; +import { setupRepository } from './shared/repository.js'; import { - cleanupTempDir, - cloneRepo, - createTempDir, - runCommand as execCommand, -} from '../utils/repo.js'; -import { getSystemPrompt } from './prompts/index.js'; -import { runAgentLoop } from './utils/agentLoop.js'; -import { createObserverHooks } from './utils/hooks.js'; -import { getLogLevel, readContextFiles } from './utils/index.js'; -import { createAgentLogger } from './utils/logging.js'; -import { - type TrackingContext, - createTrackingContext, - recordSyntheticInvocationId, -} from './utils/tracking.js'; + injectAUContext, + injectContextFiles, + injectSyntheticCall, +} from './shared/syntheticCalls.js'; +import type { TrackingContext } from './utils/tracking.js'; interface ReviewAgentInput extends AgentInput { prNumber: number; @@ -52,43 +39,6 @@ interface ReviewAgentInput extends AgentInput { config: CascadeConfig; } -// ============================================================================ -// PR Data Formatting -// ============================================================================ - -type PRDetails = Awaited>; -type PRDiff = Awaited>; - -function formatPRDetails(prDetails: PRDetails): string { - return [ - `PR #${prDetails.number}: ${prDetails.title}`, - `State: ${prDetails.state}`, - `Branch: ${prDetails.headRef} -> ${prDetails.baseRef}`, - `URL: ${prDetails.htmlUrl}`, - '', - 'Description:', - prDetails.body || '(no description)', - ].join('\n'); -} - -function formatPRDiff(prDiff: PRDiff): string { - if (prDiff.length === 0) { - return 'No files changed in this PR.'; - } - - const formatted = prDiff.map((f) => { - const lines = [`## ${f.filename}`, `Status: ${f.status} | +${f.additions} -${f.deletions}`]; - if (f.patch) { - lines.push('```diff', f.patch, '```'); - } else { - lines.push('[Binary file or too large to display]'); - } - return lines.join('\n'); - }); - - return `${prDiff.length} file(s) changed:\n\n${formatted.join('\n\n')}`; -} - // ============================================================================ // PR File Contents Reading // ============================================================================ @@ -134,7 +84,7 @@ interface ReviewContextData { systemPrompt: string; model: string; maxIterations: number; - contextFiles: Awaited>; + contextFiles: Awaited>['contextFiles']; prDetailsFormatted: string; diffFormatted: string; checkStatusFormatted: string; @@ -149,21 +99,16 @@ async function buildReviewContext( repoDir: string, project: ProjectConfig, config: CascadeConfig, - log: ReturnType, + log: { info: (msg: string, ctx?: Record) => void }, modelOverride?: string, ): Promise { - // Get system prompt and model - const systemPrompt = project.prompts?.review || getSystemPrompt('review', {}); - const model = - modelOverride || - project.agentModels?.review || - project.model || - config.defaults.agentModels?.review || - config.defaults.model; - const maxIterations = config.defaults.agentIterations?.review || config.defaults.maxIterations; - - // Read context files from repo - const contextFiles = await readContextFiles(repoDir); + const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ + agentType: 'review', + project, + config, + repoDir, + modelOverride, + }); // Fetch PR details, diff, and check status log.info('Fetching PR details, diff, and check status', { owner, repo, prNumber }); @@ -232,63 +177,46 @@ ${skippedFiles.map((f) => `- ${f}`).join('\n')}`; // Agent Builder // ============================================================================ -type BuilderType = ReturnType; - -function createReviewAgentBuilder( - client: LLMist, - ctx: ReviewContextData, - llmistLogger: ReturnType, - trackingContext: TrackingContext, - logWriter: (level: string, message: string, context?: Record) => void, - llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, - repoDir: string, -): BuilderType { - // Check if AU features should be enabled (repo has .au file at root) - const auEnabled = existsSync(join(repoDir, '.au')); - - // Build gadget list - const baseGadgets = [ - // Filesystem gadgets +function getReviewGadgets() { + return [ new ListDirectory(), new ReadFile(), - // Shell commands via tmux new Tmux(), new Sleep(), - // Task tracking gadgets new TodoUpsert(), new TodoUpdateStatus(), new TodoDelete(), - // GitHub gadgets (read + create review) new GetPRDetails(), new GetPRDiff(), new GetPRChecks(), new CreatePRReview(), - // Session control new Finish(), ]; +} - const allGadgets = auEnabled ? [...baseGadgets, auList, auRead] : baseGadgets; - - return new AgentBuilder(client) - .withModel(ctx.model) - .withTemperature(0) - .withSystem(ctx.systemPrompt) - .withMaxIterations(ctx.maxIterations) - .withLogger(llmistLogger) - .withRateLimits(getRateLimitForModel(ctx.model)) - .withRetry(getRetryConfig(llmistLogger)) - .withCompaction(getCompactionConfig('review')) - .withTrailingMessage(getIterationTrailingMessage('review')) - .withTextOnlyHandler('acknowledge') - .withHooks({ - observers: createObserverHooks({ - model: ctx.model, - logWriter, - trackingContext, - llmCallLogger, - }), - }) - .withGadgets(...allGadgets); +function createReviewAgentBuilder( + client: import('llmist').LLMist, + ctx: ReviewContextData, + llmistLogger: ReturnType, + trackingContext: TrackingContext, + logWriter: (level: string, message: string, context?: Record) => void, + llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, + repoDir: string, +): BuilderType { + return createConfiguredBuilder({ + client, + agentType: 'review', + model: ctx.model, + systemPrompt: ctx.systemPrompt, + maxIterations: ctx.maxIterations, + llmistLogger, + trackingContext, + logWriter, + llmCallLogger, + repoDir, + gadgets: getReviewGadgets(), + skipSessionState: true, + }); } async function injectReviewSyntheticCalls( @@ -298,31 +226,30 @@ async function injectReviewSyntheticCalls( prNumber: number, ctx: ReviewContextData, trackingContext: TrackingContext, - auEnabled: boolean, + repoDir: string, ): Promise { - let builder = initialBuilder; - - // Inject PR details - recordSyntheticInvocationId(trackingContext, 'gc_pr_details'); - builder = builder.withSyntheticGadgetCall( + // Inject PR details, diff, and check status + let builder = injectSyntheticCall( + initialBuilder, + trackingContext, 'GetPRDetails', { comment: 'Pre-fetching PR details for review context', owner, repo, prNumber }, ctx.prDetailsFormatted, 'gc_pr_details', ); - // Inject PR diff - recordSyntheticInvocationId(trackingContext, 'gc_pr_diff'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRDiff', { comment: 'Pre-fetching PR diff for code review', owner, repo, prNumber }, ctx.diffFormatted, 'gc_pr_diff', ); - // Inject PR check status - recordSyntheticInvocationId(trackingContext, 'gc_pr_checks'); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'GetPRChecks', { comment: 'Pre-fetching CI check status for review', owner, repo, prNumber }, ctx.checkStatusFormatted, @@ -330,63 +257,22 @@ async function injectReviewSyntheticCalls( ); // Inject context files (CLAUDE.md, README.md, etc.) - for (let i = 0; i < ctx.contextFiles.length; i++) { - const file = ctx.contextFiles[i]; - const invocationId = `gc_init_${i + 1}`; - recordSyntheticInvocationId(trackingContext, invocationId); - builder = builder.withSyntheticGadgetCall( - 'ReadFile', - { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, - file.content, - invocationId, - ); - } + builder = injectContextFiles(builder, trackingContext, ctx.contextFiles); // Inject full contents of PR changed files (up to token limit) for (let i = 0; i < ctx.fileContents.included.length; i++) { const file = ctx.fileContents.included[i]; - const invocationId = `gc_file_${i + 1}`; - recordSyntheticInvocationId(trackingContext, invocationId); - builder = builder.withSyntheticGadgetCall( + builder = injectSyntheticCall( + builder, + trackingContext, 'ReadFile', { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, `path=${file.path}\n\n${file.content}`, - invocationId, + `gc_file_${i + 1}`, ); } - // Inject AU understanding if enabled (gives agent immediate codebase context) - if (auEnabled) { - const auListResult = (await auList.execute({ - comment: 'Pre-fetching AU entries for context', - path: '.', - })) as string; - // Only inject if there's actual content - if (auListResult && !auListResult.includes('No AU entries found')) { - recordSyntheticInvocationId(trackingContext, 'gc_au_list'); - builder = builder.withSyntheticGadgetCall( - 'AUList', - { comment: 'Pre-fetching AU entries for context', path: '.' }, - auListResult, - 'gc_au_list', - ); - - // Also inject root-level understanding for high-level context - const auReadResult = (await auRead.execute({ - comment: 'Pre-fetching root-level understanding', - paths: '.', - })) as string; - if (auReadResult && !auReadResult.includes('No understanding exists yet')) { - recordSyntheticInvocationId(trackingContext, 'gc_au_read'); - builder = builder.withSyntheticGadgetCall( - 'AURead', - { comment: 'Pre-fetching root-level understanding', paths: '.' }, - auReadResult, - 'gc_au_read', - ); - } - } - } + builder = await injectAUContext(builder, trackingContext, repoDir); return builder; } @@ -404,88 +290,26 @@ export async function executeReviewAgent(input: ReviewAgentInput): Promise({ + loggerIdentifier: `review-${prNumber}`, - // Create file logger for this agent run - const fileLogger = createFileLogger(`cascade-review-${prNumber}`); - const log = createAgentLogger(fileLogger); - - // Register cleanup callback for watchdog timeout - setWatchdogCleanup(async () => { - fileLogger.close(); - await githubClient.createPRComment( - owner, - repo, - prNumber, - '⚠️ Review agent timed out while reviewing the PR.', - ); - logger.info('Posted timeout notice to PR', { prNumber }); - }); - - try { - // Clone the target repository - repoDir = createTempDir(project.id); - cloneRepo(project, repoDir); - - // Checkout the PR branch - log.info('Checking out PR branch', { prBranch }); - await execCommand('git', ['checkout', prBranch], repoDir); - - // Run project-specific setup script if it exists (handles dependency installation) - const setupScriptPath = join(repoDir, '.cascade', 'setup.sh'); - if (existsSync(setupScriptPath)) { - log.info('Running project setup script', { path: '.cascade/setup.sh', agentType: 'review' }); - const setupResult = await execCommand('bash', [setupScriptPath], repoDir, { - AGENT_PROFILE_NAME: 'review', - }); - log.info('Setup script completed', { - exitCode: setupResult.exitCode, - stdout: setupResult.stdout.slice(-500), - stderr: setupResult.stderr.slice(-500), - }); - if (setupResult.exitCode !== 0) { - log.warn('Setup script exited with non-zero code', { exitCode: setupResult.exitCode }); - } - } - - log.info('Running review agent', { prNumber, repoFullName, repoDir }); - - // Build context - const ctx = await buildReviewContext( - owner, - repo, - prNumber, - repoDir, - project, - config, - log, - input.modelOverride, - ); - - // Change to repo directory - const originalCwd = process.cwd(); - process.chdir(repoDir); - - log.info('Starting llmist agent', { - model: ctx.model, - maxIterations: ctx.maxIterations, - promptLength: ctx.prompt.length, - }); - - try { - process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; - - const client = new LLMist(); - const llmistLogger = createLogger({ minLevel: getLogLevel() }); + onWatchdogTimeout: async () => { + await githubClient.createPRComment( + owner, + repo, + prNumber, + '⚠️ Review agent timed out while reviewing the PR.', + ); + logger.info('Posted timeout notice to PR', { prNumber }); + }, - // Create tracking context - const trackingContext = createTrackingContext(); + setupRepoDir: (log) => setupRepository({ project, log, agentType: 'review', prBranch }), - // Check if AU features should be enabled (repo has .au file at root) - const auEnabled = existsSync(join(repoDir, '.au')); + buildContext: (repoDir, log) => + buildReviewContext(owner, repo, prNumber, repoDir, project, config, log, input.modelOverride), - // Build agent with gadgets and synthetic calls - let builder = createReviewAgentBuilder( + createBuilder: ({ client, ctx, llmistLogger, trackingContext, fileLogger, repoDir }) => + createReviewAgentBuilder( client, ctx, llmistLogger, @@ -493,74 +317,11 @@ export async function executeReviewAgent(input: ReviewAgentInput): Promise + injectReviewSyntheticCalls(builder, owner, repo, prNumber, ctx, trackingContext, repoDir), - log.info('Review agent completed', { - prNumber, - iterations: result.iterations, - gadgetCalls: result.gadgetCalls, - cost: result.cost, - }); - - fileLogger.close(); - const logBuffer = await fileLogger.getZippedBuffer(); - - return { - success: true, - output: result.output, - logBuffer, - cost: result.cost, - }; - } finally { - process.chdir(originalCwd); - } - } catch (err) { - logger.error('Review agent execution failed', { prNumber, error: String(err) }); - - let logBuffer: Buffer | undefined; - try { - fileLogger.close(); - logBuffer = await fileLogger.getZippedBuffer(); - } catch { - // Ignore log buffer errors - } - - return { - success: false, - output: '', - error: String(err), - logBuffer, - }; - } finally { - clearWatchdogCleanup(); - - // Skip cleanup in local mode to preserve logs for debugging - const isLocalMode = process.env.CASCADE_LOCAL_MODE === 'true'; - - if (repoDir && !isLocalMode) { - try { - cleanupTempDir(repoDir); - } catch (err) { - logger.warn('Failed to cleanup temp directory', { repoDir, error: String(err) }); - } - } - if (!isLocalMode) { - cleanupLogFile(fileLogger.logPath); - cleanupLogFile(fileLogger.llmistLogPath); - cleanupLogDirectory(fileLogger.llmCallLogger.logDir); - } - } + interactive, + }); } diff --git a/src/agents/shared/builderFactory.ts b/src/agents/shared/builderFactory.ts new file mode 100644 index 00000000..69dfb21e --- /dev/null +++ b/src/agents/shared/builderFactory.ts @@ -0,0 +1,94 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { auList, auRead } from '@zbigniewsobiecki/au'; +import { AgentBuilder, type LLMist, type createLogger } from 'llmist'; + +import { getCompactionConfig } from '../../config/compactionConfig.js'; +import { getIterationTrailingMessage } from '../../config/hintConfig.js'; +import { getRateLimitForModel } from '../../config/rateLimits.js'; +import { getRetryConfig } from '../../config/retryConfig.js'; +import { initSessionState } from '../../gadgets/sessionState.js'; +import type { LLMCallLogger } from '../../utils/llmLogging.js'; +import { type StatusUpdateHooksConfig, createObserverHooks } from '../utils/hooks.js'; +import type { TrackingContext } from '../utils/tracking.js'; + +export type BuilderType = ReturnType; + +export interface CreateBuilderOptions { + client: LLMist; + agentType: string; + model: string; + systemPrompt: string; + maxIterations: number; + llmistLogger: ReturnType; + trackingContext: TrackingContext; + logWriter: (level: string, message: string, context?: Record) => void; + llmCallLogger: LLMCallLogger; + repoDir: string; + gadgets: Parameters; + statusUpdate?: StatusUpdateHooksConfig; + /** Set to true to skip calling initSessionState (review agent doesn't use it) */ + skipSessionState?: boolean; + /** Post-configuration callback for agent-specific builder tweaks */ + postConfigure?: (builder: BuilderType) => BuilderType; +} + +export function isAUEnabled(repoDir: string): boolean { + return existsSync(join(repoDir, '.au')); +} + +export function createConfiguredBuilder(options: CreateBuilderOptions): BuilderType { + const { + client, + agentType, + model, + systemPrompt, + maxIterations, + llmistLogger, + trackingContext, + logWriter, + llmCallLogger, + repoDir, + gadgets, + statusUpdate, + skipSessionState, + postConfigure, + } = options; + + // Initialize session state for gadgets (e.g., Finish checks PR requirement for implementation) + if (!skipSessionState) { + initSessionState(agentType); + } + + // Check if AU features should be enabled (repo has .au file at root) + const auEnabled = isAUEnabled(repoDir); + const allGadgets = auEnabled ? [...gadgets, auList, auRead] : gadgets; + + let builder = new AgentBuilder(client) + .withModel(model) + .withTemperature(0) + .withSystem(systemPrompt) + .withMaxIterations(maxIterations) + .withLogger(llmistLogger) + .withRateLimits(getRateLimitForModel(model)) + .withRetry(getRetryConfig(llmistLogger)) + .withCompaction(getCompactionConfig(agentType)) + .withTrailingMessage(getIterationTrailingMessage(agentType)) + .withTextOnlyHandler('acknowledge') + .withHooks({ + observers: createObserverHooks({ + model, + logWriter, + trackingContext, + llmCallLogger, + statusUpdate, + }), + }) + .withGadgets(...allGadgets); + + if (postConfigure) { + builder = postConfigure(builder); + } + + return builder; +} diff --git a/src/agents/shared/lifecycle.ts b/src/agents/shared/lifecycle.ts new file mode 100644 index 00000000..205db7a9 --- /dev/null +++ b/src/agents/shared/lifecycle.ts @@ -0,0 +1,192 @@ +import { LLMist, type ModelSpec, createLogger } from 'llmist'; + +import type { AgentResult } from '../../types/index.js'; +import { cleanupLogDirectory, cleanupLogFile, createFileLogger } from '../../utils/fileLogger.js'; +import { clearWatchdogCleanup, setWatchdogCleanup } from '../../utils/lifecycle.js'; +import { logger } from '../../utils/logging.js'; +import { cleanupTempDir } from '../../utils/repo.js'; +import { runAgentLoop } from '../utils/agentLoop.js'; +import { getLogLevel } from '../utils/index.js'; +import { createAgentLogger } from '../utils/logging.js'; +import { type TrackingContext, createTrackingContext } from '../utils/tracking.js'; +import type { BuilderType } from './builderFactory.js'; + +type FileLogger = ReturnType; +type AgentLogger = ReturnType; + +export type { FileLogger, AgentLogger }; + +export interface BaseAgentContext { + model: string; + maxIterations: number; + prompt: string; +} + +export interface ExecuteAgentOptions { + /** Identifier for log file naming (e.g., "review-42", "ci-42") */ + loggerIdentifier: string; + + /** Called when the watchdog timer expires. FileLogger is already closed. */ + onWatchdogTimeout: (fileLogger: FileLogger) => Promise; + + /** Set up the working directory (clone repo, etc.) */ + setupRepoDir: (log: AgentLogger) => Promise; + + /** Build agent-specific context (model config, PR data, etc.) */ + buildContext: (repoDir: string, log: AgentLogger) => Promise; + + /** Create the configured agent builder with gadgets */ + createBuilder: (params: { + client: LLMist; + ctx: TContext; + llmistLogger: ReturnType; + trackingContext: TrackingContext; + fileLogger: FileLogger; + repoDir: string; + }) => BuilderType; + + /** Inject pre-fetched data as synthetic gadget calls */ + injectSyntheticCalls: (params: { + builder: BuilderType; + ctx: TContext; + trackingContext: TrackingContext; + repoDir: string; + }) => Promise; + + /** Whether to run in interactive mode */ + interactive?: boolean; + + /** Whether to auto-accept gadget calls */ + autoAccept?: boolean; + + /** Custom model definitions for LLMist */ + customModels?: ModelSpec[]; + + /** Extract additional fields from agent output (e.g., PR URL) */ + postProcess?: (output: string) => Partial; +} + +/** + * Shared agent execution lifecycle handling logger setup, watchdog, + * repository setup, LLMist agent creation, execution, and cleanup. + */ +export async function executeAgentLifecycle( + options: ExecuteAgentOptions, +): Promise { + let repoDir: string | null = null; + + const fileLogger = createFileLogger(`cascade-${options.loggerIdentifier}`); + const log = createAgentLogger(fileLogger); + + setWatchdogCleanup(async () => { + fileLogger.close(); + await options.onWatchdogTimeout(fileLogger); + }); + + try { + repoDir = await options.setupRepoDir(log); + + const ctx = await options.buildContext(repoDir, log); + + const originalCwd = process.cwd(); + process.chdir(repoDir); + + log.info('Starting llmist agent', { + model: ctx.model, + maxIterations: ctx.maxIterations, + promptLength: ctx.prompt.length, + }); + + try { + process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; + + const client = options.customModels + ? new LLMist({ customModels: options.customModels }) + : new LLMist(); + const llmistLogger = createLogger({ minLevel: getLogLevel() }); + const trackingContext = createTrackingContext(); + + let builder = options.createBuilder({ + client, + ctx, + llmistLogger, + trackingContext, + fileLogger, + repoDir, + }); + builder = await options.injectSyntheticCalls({ + builder, + ctx, + trackingContext, + repoDir, + }); + + const agent = builder.ask(ctx.prompt); + const result = await runAgentLoop( + agent, + log, + trackingContext, + options.interactive === true, + options.autoAccept === true, + ); + + log.info('Agent completed', { + iterations: result.iterations, + gadgetCalls: result.gadgetCalls, + cost: result.cost, + }); + + fileLogger.close(); + const logBuffer = await fileLogger.getZippedBuffer(); + + const postProcessed = options.postProcess?.(result.output) ?? {}; + + return { + success: true, + output: result.output, + logBuffer, + cost: result.cost, + ...postProcessed, + }; + } finally { + process.chdir(originalCwd); + } + } catch (err) { + logger.error('Agent execution failed', { + identifier: options.loggerIdentifier, + error: String(err), + }); + + let logBuffer: Buffer | undefined; + try { + fileLogger.close(); + logBuffer = await fileLogger.getZippedBuffer(); + } catch { + // Ignore log buffer errors + } + + return { + success: false, + output: '', + error: String(err), + logBuffer, + }; + } finally { + clearWatchdogCleanup(); + + const isLocalMode = process.env.CASCADE_LOCAL_MODE === 'true'; + + if (repoDir && !isLocalMode) { + try { + cleanupTempDir(repoDir); + } catch (err) { + logger.warn('Failed to cleanup temp directory', { repoDir, error: String(err) }); + } + } + if (!isLocalMode) { + cleanupLogFile(fileLogger.logPath); + cleanupLogFile(fileLogger.llmistLogPath); + cleanupLogDirectory(fileLogger.llmCallLogger.logDir); + } + } +} diff --git a/src/agents/shared/modelResolution.ts b/src/agents/shared/modelResolution.ts new file mode 100644 index 00000000..8463151d --- /dev/null +++ b/src/agents/shared/modelResolution.ts @@ -0,0 +1,44 @@ +import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { type ContextFile, readContextFiles } from '../utils/setup.js'; + +import { type PromptContext, getSystemPrompt } from '../prompts/index.js'; + +export interface ModelConfig { + systemPrompt: string; + model: string; + maxIterations: number; + contextFiles: ContextFile[]; +} + +export interface ResolveModelConfigOptions { + agentType: string; + project: ProjectConfig; + config: CascadeConfig; + repoDir: string; + modelOverride?: string; + promptContext?: PromptContext; + /** Optional key override for model/iteration config lookup (e.g., respond-to-review uses 'review') */ + configKey?: string; +} + +export async function resolveModelConfig(options: ResolveModelConfigOptions): Promise { + const { agentType, project, config, repoDir, modelOverride, promptContext } = options; + const configKey = options.configKey ?? agentType; + + const systemPrompt = + project.prompts?.[agentType] || getSystemPrompt(agentType, promptContext ?? {}); + + const model = + modelOverride || + project.agentModels?.[configKey] || + project.model || + config.defaults.agentModels?.[configKey] || + config.defaults.model; + + const maxIterations = + config.defaults.agentIterations?.[configKey] || config.defaults.maxIterations; + + const contextFiles = await readContextFiles(repoDir); + + return { systemPrompt, model, maxIterations, contextFiles }; +} diff --git a/src/agents/shared/prFormatting.ts b/src/agents/shared/prFormatting.ts new file mode 100644 index 00000000..3bef2bd1 --- /dev/null +++ b/src/agents/shared/prFormatting.ts @@ -0,0 +1,36 @@ +import type { githubClient } from '../../github/client.js'; + +type PRDetails = Awaited>; +type PRDiff = Awaited>; + +export type { PRDetails, PRDiff }; + +export function formatPRDetails(prDetails: PRDetails): string { + return [ + `PR #${prDetails.number}: ${prDetails.title}`, + `State: ${prDetails.state}`, + `Branch: ${prDetails.headRef} -> ${prDetails.baseRef}`, + `URL: ${prDetails.htmlUrl}`, + '', + 'Description:', + prDetails.body || '(no description)', + ].join('\n'); +} + +export function formatPRDiff(prDiff: PRDiff): string { + if (prDiff.length === 0) { + return 'No files changed in this PR.'; + } + + const formatted = prDiff.map((f) => { + const lines = [`## ${f.filename}`, `Status: ${f.status} | +${f.additions} -${f.deletions}`]; + if (f.patch) { + lines.push('```diff', f.patch, '```'); + } else { + lines.push('[Binary file or too large to display]'); + } + return lines.join('\n'); + }); + + return `${prDiff.length} file(s) changed:\n\n${formatted.join('\n\n')}`; +} diff --git a/src/agents/shared/repository.ts b/src/agents/shared/repository.ts new file mode 100644 index 00000000..daf40415 --- /dev/null +++ b/src/agents/shared/repository.ts @@ -0,0 +1,60 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { ProjectConfig } from '../../types/index.js'; +import { cloneRepo, createTempDir, runCommand } from '../../utils/repo.js'; +import type { AgentLogger } from '../utils/logging.js'; +import { warmTypeScriptCache } from '../utils/setup.js'; + +export interface SetupRepositoryOptions { + project: ProjectConfig; + log: AgentLogger; + agentType: string; + prBranch?: string; + warmTsCache?: boolean; +} + +export async function setupRepository(options: SetupRepositoryOptions): Promise { + const { project, log, agentType, prBranch, warmTsCache } = options; + + // Clone repo to temp directory + const repoDir = createTempDir(project.id); + cloneRepo(project, repoDir); + + // Checkout PR branch if provided + if (prBranch) { + log.info('Checking out PR branch', { prBranch }); + await runCommand('git', ['checkout', prBranch], repoDir); + } + + // Run project-specific setup script if it exists (handles dependency installation) + const setupScriptPath = join(repoDir, '.cascade', 'setup.sh'); + if (existsSync(setupScriptPath)) { + log.info('Running project setup script', { path: '.cascade/setup.sh', agentType }); + const setupResult = await runCommand('bash', [setupScriptPath], repoDir, { + AGENT_PROFILE_NAME: agentType, + }); + log.info('Setup script completed', { + exitCode: setupResult.exitCode, + stdout: setupResult.stdout.slice(-500), + stderr: setupResult.stderr.slice(-500), + }); + if (setupResult.exitCode !== 0) { + log.warn('Setup script exited with non-zero code', { exitCode: setupResult.exitCode }); + } + } + + // Warm TypeScript cache to avoid slow first-run compilation during agent execution + if (warmTsCache) { + log.info('Warming TypeScript cache', { repoDir }); + const tscResult = await warmTypeScriptCache(repoDir); + if (tscResult) { + log.info('TypeScript cache warmed', { + durationMs: tscResult.durationMs, + hadErrors: !!tscResult.error, + }); + } + } + + return repoDir; +} diff --git a/src/agents/shared/syntheticCalls.ts b/src/agents/shared/syntheticCalls.ts new file mode 100644 index 00000000..98f57301 --- /dev/null +++ b/src/agents/shared/syntheticCalls.ts @@ -0,0 +1,123 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { auList, auRead } from '@zbigniewsobiecki/au'; + +import { ListDirectory } from '../../gadgets/ListDirectory.js'; +import type { ContextFile } from '../utils/setup.js'; +import { type TrackingContext, recordSyntheticInvocationId } from '../utils/tracking.js'; +import type { BuilderType } from './builderFactory.js'; + +/** + * Helper to inject a single synthetic gadget call with tracking. + */ +export function injectSyntheticCall( + builder: BuilderType, + trackingContext: TrackingContext, + gadgetName: string, + params: Record, + result: string, + invocationId: string, +): BuilderType { + recordSyntheticInvocationId(trackingContext, invocationId); + return builder.withSyntheticGadgetCall(gadgetName, params, result, invocationId); +} + +/** + * Inject directory listing as synthetic ListDirectory call. + */ +export function injectDirectoryListing( + builder: BuilderType, + trackingContext: TrackingContext, + maxDepth = 3, +): BuilderType { + const listDirGadget = new ListDirectory(); + const listDirParams = { + comment: 'Pre-fetching codebase structure for context', + directoryPath: '.', + maxDepth, + includeGitIgnored: false, + }; + const listDirResult = listDirGadget.execute(listDirParams); + return injectSyntheticCall( + builder, + trackingContext, + 'ListDirectory', + listDirParams, + listDirResult, + 'gc_dir', + ); +} + +/** + * Inject context files (CLAUDE.md, AGENTS.md, etc.) as synthetic ReadFile calls. + */ +export function injectContextFiles( + builder: BuilderType, + trackingContext: TrackingContext, + contextFiles: ContextFile[], +): BuilderType { + let result = builder; + for (let i = 0; i < contextFiles.length; i++) { + const file = contextFiles[i]; + const invocationId = `gc_init_${i + 1}`; + result = injectSyntheticCall( + result, + trackingContext, + 'ReadFile', + { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, + file.content, + invocationId, + ); + } + return result; +} + +/** + * Inject AU understanding if enabled (gives agent immediate codebase context). + */ +export async function injectAUContext( + builder: BuilderType, + trackingContext: TrackingContext, + repoDir: string, +): Promise { + const auEnabled = existsSync(join(repoDir, '.au')); + if (!auEnabled) return builder; + + let result = builder; + + const auListResult = (await auList.execute({ + comment: 'Pre-fetching AU entries for context', + path: '.', + })) as string; + + if (!auListResult || auListResult.includes('No AU entries found')) { + return result; + } + + result = injectSyntheticCall( + result, + trackingContext, + 'AUList', + { comment: 'Pre-fetching AU entries for context', path: '.' }, + auListResult, + 'gc_au_list', + ); + + const auReadResult = (await auRead.execute({ + comment: 'Pre-fetching root-level understanding', + paths: '.', + })) as string; + + if (auReadResult && !auReadResult.includes('No understanding exists yet')) { + result = injectSyntheticCall( + result, + trackingContext, + 'AURead', + { comment: 'Pre-fetching root-level understanding', paths: '.' }, + auReadResult, + 'gc_au_read', + ); + } + + return result; +} diff --git a/src/triggers/github/issue-comment.ts b/src/triggers/github/issue-comment.ts index aa7eb26e..8c8ee02b 100644 --- a/src/triggers/github/issue-comment.ts +++ b/src/triggers/github/issue-comment.ts @@ -1,8 +1,8 @@ -import { getAuthenticatedUser, githubClient } from '../../github/client.js'; +import { githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { isGitHubIssueCommentPayload } from './types.js'; -import { extractTrelloCardId, hasTrelloCardUrl } from './utils.js'; +import { isSelfAuthored, requireTrelloCardId } from './utils.js'; export class IssueCommentTrigger implements TriggerHandler { name = 'issue-comment-created'; @@ -32,33 +32,18 @@ export class IssueCommentTrigger implements TriggerHandler { const [owner, repo] = payload.repository.full_name.split('/'); // Skip comments from ourselves to avoid infinite loops - try { - const authenticatedUser = await getAuthenticatedUser(); - if (commentAuthor === authenticatedUser || commentAuthor === `${authenticatedUser}[bot]`) { - logger.info('Skipping self-authored comment', { - prNumber, - commentAuthor, - authenticatedUser, - }); - return null; - } - } catch (err) { - logger.warn('Failed to get authenticated user, proceeding with caution', { - error: String(err), - }); + if (await isSelfAuthored(commentAuthor, { prNumber, authorField: 'commentAuthor' })) { + return null; } // Fetch PR to check for Trello card URL and get branch info const prDetails = await githubClient.getPR(owner, repo, prNumber); - if (!hasTrelloCardUrl(prDetails.body)) { - logger.info('PR does not have Trello card URL, skipping issue comment trigger', { - prNumber, - }); - return null; - } - - const cardId = extractTrelloCardId(prDetails.body); + const cardId = requireTrelloCardId(prDetails.body, { + prNumber, + triggerName: 'issue comment trigger', + }); + if (cardId === null) return null; logger.info('PR issue comment received, triggering respond-to-review agent', { prNumber, diff --git a/src/triggers/github/pr-review-comment.ts b/src/triggers/github/pr-review-comment.ts index c683f271..cab6b63f 100644 --- a/src/triggers/github/pr-review-comment.ts +++ b/src/triggers/github/pr-review-comment.ts @@ -1,8 +1,7 @@ -import { getAuthenticatedUser, githubClient } from '../../github/client.js'; +import { githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; -import { logger } from '../../utils/logging.js'; import { isGitHubPRReviewCommentPayload } from './types.js'; -import { extractTrelloCardId, hasTrelloCardUrl } from './utils.js'; +import { isSelfAuthored, requireTrelloCardId } from './utils.js'; export class PRReviewCommentTrigger implements TriggerHandler { name = 'pr-review-comment-created'; @@ -35,33 +34,18 @@ export class PRReviewCommentTrigger implements TriggerHandler { const commentAuthor = prPayload.comment.user.login; // Skip comments from ourselves to avoid infinite loops - try { - const authenticatedUser = await getAuthenticatedUser(); - if (commentAuthor === authenticatedUser || commentAuthor === `${authenticatedUser}[bot]`) { - logger.info('Skipping self-authored review comment', { - prNumber, - commentAuthor, - authenticatedUser, - }); - return null; - } - } catch (err) { - logger.warn('Failed to get authenticated user, proceeding with caution', { - error: String(err), - }); + if (await isSelfAuthored(commentAuthor, { prNumber, authorField: 'commentAuthor' })) { + return null; } // Fetch PR to check for Trello card URL const prDetails = await githubClient.getPR(owner, repo, prNumber); - if (!hasTrelloCardUrl(prDetails.body)) { - logger.info('PR does not have Trello card URL, skipping review comment trigger', { - prNumber, - }); - return null; - } - - const cardId = extractTrelloCardId(prDetails.body); + const cardId = requireTrelloCardId(prDetails.body, { + prNumber, + triggerName: 'review comment trigger', + }); + if (cardId === null) return null; return { agentType: 'respond-to-review', diff --git a/src/triggers/github/pr-review-submitted.ts b/src/triggers/github/pr-review-submitted.ts index 90151659..d5b94f98 100644 --- a/src/triggers/github/pr-review-submitted.ts +++ b/src/triggers/github/pr-review-submitted.ts @@ -1,8 +1,7 @@ -import { getAuthenticatedUser } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { isGitHubPullRequestReviewPayload } from './types.js'; -import { extractTrelloCardId, hasTrelloCardUrl } from './utils.js'; +import { isSelfAuthored, requireTrelloCardId } from './utils.js'; export class PRReviewSubmittedTrigger implements TriggerHandler { name = 'pr-review-submitted'; @@ -39,33 +38,17 @@ export class PRReviewSubmittedTrigger implements TriggerHandler { const reviewAuthor = reviewPayload.review.user.login; // Skip reviews from ourselves to avoid infinite loops - try { - const authenticatedUser = await getAuthenticatedUser(); - if (reviewAuthor === authenticatedUser || reviewAuthor === `${authenticatedUser}[bot]`) { - logger.info('Skipping self-authored review', { - prNumber, - reviewAuthor, - authenticatedUser, - }); - return null; - } - } catch (err) { - logger.warn('Failed to get authenticated user, proceeding with caution', { - error: String(err), - }); + if (await isSelfAuthored(reviewAuthor, { prNumber, authorField: 'reviewAuthor' })) { + return null; } // Check if PR has Trello card URL in body const prBody = reviewPayload.pull_request.body || ''; - if (!hasTrelloCardUrl(prBody)) { - logger.info('PR does not have Trello card URL, skipping review submission trigger', { - prNumber, - reviewState: reviewPayload.review.state, - }); - return null; - } - - const cardId = extractTrelloCardId(prBody); + const cardId = requireTrelloCardId(prBody, { + prNumber, + triggerName: 'review submission trigger', + }); + if (cardId === null) return null; logger.info('PR review submitted, triggering review agent', { prNumber, diff --git a/src/triggers/github/utils.ts b/src/triggers/github/utils.ts index a1478743..2a71f6ed 100644 --- a/src/triggers/github/utils.ts +++ b/src/triggers/github/utils.ts @@ -1,3 +1,6 @@ +import { getAuthenticatedUser } from '../../github/client.js'; +import { logger } from '../../utils/logging.js'; + // Trello card URL pattern: https://trello.com/c/SHORT_ID/optional-slug const TRELLO_CARD_URL_REGEX = /https:\/\/trello\.com\/c\/([a-zA-Z0-9]+)/; @@ -27,3 +30,46 @@ export function extractTrelloCardUrl(text: string | null): string | null { const match = text.match(TRELLO_CARD_URL_REGEX); return match ? match[0] : null; } + +/** + * Check if a comment/review author is the authenticated GitHub user (self). + * Returns true if self-authored (should skip), false otherwise. + */ +export async function isSelfAuthored( + author: string, + context: { prNumber: number; authorField: string }, +): Promise { + try { + const authenticatedUser = await getAuthenticatedUser(); + if (author === authenticatedUser || author === `${authenticatedUser}[bot]`) { + logger.info(`Skipping self-authored ${context.authorField}`, { + prNumber: context.prNumber, + [context.authorField]: author, + authenticatedUser, + }); + return true; + } + } catch (err) { + logger.warn('Failed to get authenticated user, proceeding with caution', { + error: String(err), + }); + } + return false; +} + +/** + * Validate PR body has Trello card URL and extract card ID. + * Returns card ID or null (with logging) if not found. + */ +export function requireTrelloCardId( + prBody: string | null, + context: { prNumber: number; triggerName: string }, +): string | null { + if (!hasTrelloCardUrl(prBody)) { + logger.info(`PR does not have Trello card URL, skipping ${context.triggerName}`, { + prNumber: context.prNumber, + }); + return null; + } + return extractTrelloCardId(prBody); +} diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 9061886b..56e43a61 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -16,6 +16,7 @@ import { } from '../../utils/index.js'; import { safeOperation } from '../../utils/safeOperation.js'; import type { TriggerRegistry } from '../registry.js'; +import { handleAgentResultArtifacts } from '../shared/agent-result-handler.js'; import type { TriggerResult } from '../types.js'; async function executeGitHubAgent( @@ -31,37 +32,9 @@ async function executeGitHubAgent( config, }); - // Upload zipped log file to card (if available) - if (cardId && agentResult.logBuffer) { - const logBuffer = agentResult.logBuffer; - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logName = `${result.agentType}-${timestamp}.zip`; - await safeOperation(() => trelloClient.addAttachmentFile(cardId, logBuffer, logName), { - action: 'upload agent log', - cardId, - logName, - }); - } - - // Update cost custom field (accumulate with existing) - const costFieldId = project.trello?.customFields?.cost; - if (cardId && costFieldId && agentResult.cost !== undefined && agentResult.cost > 0) { - const sessionCost = agentResult.cost; - await safeOperation( - async () => { - const items = await trelloClient.getCardCustomFieldItems(cardId); - const currentItem = items.find((i) => i.idCustomField === costFieldId); - const currentCost = Number.parseFloat(currentItem?.value?.number ?? '0'); - const newTotal = Math.round((currentCost + sessionCost) * 10000) / 10000; - await trelloClient.updateCardCustomFieldNumber(cardId, costFieldId, newTotal); - logger.info('Updated card cost', { - cardId, - sessionCost, - totalCost: newTotal, - }); - }, - { action: 'update cost field' }, - ); + // Upload log and update cost on Trello card + if (cardId) { + await handleAgentResultArtifacts(cardId, result.agentType, agentResult, project); } // Move to in-review if implementation completed successfully diff --git a/src/triggers/shared/agent-result-handler.ts b/src/triggers/shared/agent-result-handler.ts new file mode 100644 index 00000000..249a546a --- /dev/null +++ b/src/triggers/shared/agent-result-handler.ts @@ -0,0 +1,48 @@ +import { trelloClient } from '../../trello/client.js'; +import type { AgentResult, ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { safeOperation } from '../../utils/safeOperation.js'; + +/** + * Upload agent session log and update cost custom field on the Trello card. + * Shared between GitHub and Trello webhook handlers. + */ +export async function handleAgentResultArtifacts( + cardId: string, + agentType: string, + agentResult: AgentResult, + project: ProjectConfig, +): Promise { + // Upload zipped log file to card (if available) + if (agentResult.logBuffer) { + const logBuffer = agentResult.logBuffer; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logName = `${agentType}-${timestamp}.zip`; + await safeOperation(() => trelloClient.addAttachmentFile(cardId, logBuffer, logName), { + action: 'upload agent log', + cardId, + logName, + }); + } + + // Update cost custom field (accumulate with existing) + const costFieldId = project.trello?.customFields?.cost; + if (costFieldId && agentResult.cost !== undefined && agentResult.cost > 0) { + const sessionCost = agentResult.cost; + await safeOperation( + async () => { + const items = await trelloClient.getCardCustomFieldItems(cardId); + const currentItem = items.find((i) => i.idCustomField === costFieldId); + const currentCost = Number.parseFloat(currentItem?.value?.number ?? '0'); + const newTotal = Math.round((currentCost + sessionCost) * 10000) / 10000; + await trelloClient.updateCardCustomFieldNumber(cardId, costFieldId, newTotal); + logger.info('Updated card cost', { + cardId, + sessionCost, + totalCost: newTotal, + }); + }, + { action: 'update cost field' }, + ); + } +} diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index 81edf881..86571637 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -23,6 +23,7 @@ import { } from '../../utils/index.js'; import { safeOperation, silentOperation } from '../../utils/safeOperation.js'; import type { TriggerRegistry } from '../registry.js'; +import { handleAgentResultArtifacts } from '../shared/agent-result-handler.js'; import type { TrelloWebhookPayload, TriggerResult } from '../types.js'; import { isTrelloWebhookPayload } from '../types.js'; @@ -117,37 +118,9 @@ async function executeAgent( config, }); - // Upload zipped log file to card (if available) - if (cardId && agentResult.logBuffer) { - const logBuffer = agentResult.logBuffer; - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logName = `${result.agentType}-${timestamp}.zip`; - await safeOperation(() => trelloClient.addAttachmentFile(cardId, logBuffer, logName), { - action: 'upload agent log', - cardId, - logName, - }); - } - - // Update cost custom field (accumulate with existing) - const costFieldId = project.trello.customFields?.cost; - if (cardId && costFieldId && agentResult.cost !== undefined && agentResult.cost > 0) { - const sessionCost = agentResult.cost; - await safeOperation( - async () => { - const items = await trelloClient.getCardCustomFieldItems(cardId); - const currentItem = items.find((i) => i.idCustomField === costFieldId); - const currentCost = Number.parseFloat(currentItem?.value?.number ?? '0'); - const newTotal = Math.round((currentCost + sessionCost) * 10000) / 10000; - await trelloClient.updateCardCustomFieldNumber(cardId, costFieldId, newTotal); - logger.info('Updated card cost', { - cardId, - sessionCost, - totalCost: newTotal, - }); - }, - { action: 'update cost field' }, - ); + // Upload log and update cost on Trello card + if (cardId) { + await handleAgentResultArtifacts(cardId, result.agentType, agentResult, project); } if (cardId) {