Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
430 changes: 111 additions & 319 deletions src/agents/base.ts

Large diffs are not rendered by default.

482 changes: 112 additions & 370 deletions src/agents/respond-to-ci.ts

Large diffs are not rendered by default.

488 changes: 108 additions & 380 deletions src/agents/respond-to-review.ts

Large diffs are not rendered by default.

407 changes: 84 additions & 323 deletions src/agents/review.ts

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions src/agents/shared/builderFactory.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AgentBuilder.prototype.withGadgets>;

export interface CreateBuilderOptions {
client: LLMist;
agentType: string;
model: string;
systemPrompt: string;
maxIterations: number;
llmistLogger: ReturnType<typeof createLogger>;
trackingContext: TrackingContext;
logWriter: (level: string, message: string, context?: Record<string, unknown>) => void;
llmCallLogger: LLMCallLogger;
repoDir: string;
gadgets: Parameters<typeof AgentBuilder.prototype.withGadgets>;
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;
}
192 changes: 192 additions & 0 deletions src/agents/shared/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createFileLogger>;
type AgentLogger = ReturnType<typeof createAgentLogger>;

export type { FileLogger, AgentLogger };

export interface BaseAgentContext {
model: string;
maxIterations: number;
prompt: string;
}

export interface ExecuteAgentOptions<TContext extends BaseAgentContext> {
/** 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<void>;

/** Set up the working directory (clone repo, etc.) */
setupRepoDir: (log: AgentLogger) => Promise<string>;

/** Build agent-specific context (model config, PR data, etc.) */
buildContext: (repoDir: string, log: AgentLogger) => Promise<TContext>;

/** Create the configured agent builder with gadgets */
createBuilder: (params: {
client: LLMist;
ctx: TContext;
llmistLogger: ReturnType<typeof createLogger>;
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<BuilderType>;

/** 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<AgentResult>;
}

/**
* Shared agent execution lifecycle handling logger setup, watchdog,
* repository setup, LLMist agent creation, execution, and cleanup.
*/
export async function executeAgentLifecycle<TContext extends BaseAgentContext>(
options: ExecuteAgentOptions<TContext>,
): Promise<AgentResult> {
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);
}
}
}
44 changes: 44 additions & 0 deletions src/agents/shared/modelResolution.ts
Original file line number Diff line number Diff line change
@@ -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<ModelConfig> {
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 };
}
36 changes: 36 additions & 0 deletions src/agents/shared/prFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { githubClient } from '../../github/client.js';

type PRDetails = Awaited<ReturnType<typeof githubClient.getPR>>;
type PRDiff = Awaited<ReturnType<typeof githubClient.getPRDiff>>;

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')}`;
}
Loading