Skip to content
Merged
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ test-results/
# Claude Code — commit settings.json but exclude local settings (may contain secrets)
.claude/settings.local.json

# Progress comment state file (ephemeral, written by ProgressMonitor)
.cascade-progress-comment-id

93 changes: 16 additions & 77 deletions src/agents/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ import { type Todo, formatTodoList, initTodoSession, saveTodos } from '../gadget
import { getPMProvider } from '../pm/index.js';
import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js';
import { logger } from '../utils/logging.js';
import type { PromptContext } from './prompts/index.js';
import { extractPRUrl } from '../utils/prUrl.js';
import { type BuilderType, createConfiguredBuilder } from './shared/builderFactory.js';
import { getAgentCapabilities } from './shared/capabilities.js';
import { type FileLogger, executeAgentLifecycle } from './shared/lifecycle.js';
import { resolveModelConfig } from './shared/modelResolution.js';
import { buildPromptContext } from './shared/promptContext.js';
import { setupRepository as setupRepo } from './shared/repository.js';
import {
injectContextFiles,
Expand All @@ -57,19 +59,6 @@ export interface AgentRunner {
run: (ctx: AgentContext) => Promise<AgentResult>;
}

// ============================================================================
// Repository Setup
// ============================================================================

async function setupRepository(
project: ProjectConfig,
log: AgentLogger,
agentType: string,
prBranch?: string,
): Promise<string> {
return setupRepo({ project, log, agentType, prBranch, warmTsCache: true });
}

// ============================================================================
// Agent Context Building
// ============================================================================
Expand Down Expand Up @@ -106,51 +95,6 @@ async function loadDbPartials(orgId: string): Promise<Map<string, string> | unde
}
}

function buildPromptContext(
cardId: string | undefined,
project: ProjectConfig,
triggerType?: string,
prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string },
debugContext?: {
logDir: string;
originalCardId: string;
originalCardName: string;
originalCardUrl: string;
detectedAgentType: string;
},
): PromptContext {
const pmProvider = getPMProvider();
const isJira = pmProvider.type === 'jira';
return {
cardId,
cardUrl: cardId ? pmProvider.getWorkItemUrl(cardId) : undefined,
projectId: project.id,
storiesListId: project.trello?.lists?.stories,
processedLabelId: project.trello?.labels?.processed,
pmType: pmProvider.type,
workItemNoun: isJira ? 'issue' : 'card',
workItemNounPlural: isJira ? 'issues' : 'cards',
workItemNounCap: isJira ? 'Issue' : 'Card',
workItemNounPluralCap: isJira ? 'Issues' : 'Cards',
pmName: isJira ? 'JIRA' : 'Trello',
...(prContext && {
prNumber: prContext.prNumber,
prBranch: prContext.prBranch,
repoFullName: prContext.repoFullName,
headSha: prContext.headSha,
triggerType,
}),
...(debugContext && {
logDir: debugContext.logDir,
originalCardId: debugContext.originalCardId,
originalCardName: debugContext.originalCardName,
originalCardUrl: debugContext.originalCardUrl,
detectedAgentType: debugContext.detectedAgentType,
debugListId: project.trello?.lists?.debug,
}),
};
}

function selectPrompt(
cardId: string | undefined,
commentContext?: { text: string; author: string },
Expand Down Expand Up @@ -319,37 +263,36 @@ Start by listing the contents of the log directory, then read and analyze the lo
// ============================================================================

function getBaseAgentGadgets(agentType: string) {
// Planning agents are read-only - no file editing capabilities
const isReadOnlyAgent = agentType === 'planning' || agentType === 'respond-to-planning-comment';
const caps = getAgentCapabilities(agentType);

return [
// Filesystem gadgets (read-only for planning)
// Filesystem gadgets (read-only when canEditFiles is false)
new ListDirectory(),
new ReadFile(),
new RipGrep(),
new AstGrep(),
...(isReadOnlyAgent
? []
: [new FileSearchAndReplace(), new FileMultiEdit(), new WriteFile(), new VerifyChanges()]),
...(caps.canEditFiles
? [new FileSearchAndReplace(), new FileMultiEdit(), new WriteFile(), new VerifyChanges()]
: []),
// Shell commands via tmux (no timeout issues)
new Tmux(),
new Sleep(),
// Task tracking gadgets
new TodoUpsert(),
new TodoUpdateStatus(),
new TodoDelete(),
// GitHub gadgets (no PR creation for planning)
...(isReadOnlyAgent ? [] : [new CreatePR()]),
// GitHub gadgets (PR creation gated by capability)
...(caps.canCreatePR ? [new CreatePR()] : []),
// PM gadgets (work items, comments, checklists — PM-agnostic)
new ReadWorkItem(),
new PostComment(),
new UpdateWorkItem(),
new CreateWorkItem(),
new ListWorkItems(),
new AddChecklist(),
// UpdateChecklistItem not available for planning - prevents marking items complete prematurely
// But respond-to-planning-comment CAN update checklist items (user may ask to check/uncheck steps)
...(agentType === 'planning' ? [] : [new PMUpdateChecklistItem()]),
// UpdateChecklistItem gated by capability — prevents planning from marking items complete
// prematurely, while respond-to-planning-comment CAN update them
...(caps.canUpdateChecklists ? [new PMUpdateChecklistItem()] : []),
// Session control
new Finish(),
];
Expand Down Expand Up @@ -511,12 +454,7 @@ async function setupWorkingDirectory(
return input.logDir;
}

return setupRepository(project, log, agentType, prBranch);
}

function extractPRUrl(output: string): string | undefined {
const match = output.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/);
return match ? match[0] : undefined;
return setupRepo({ project, log, agentType, prBranch, warmTsCache: true });
}

export async function executeAgent(
Expand Down Expand Up @@ -613,14 +551,15 @@ export async function executeAgent(
ctx.implementationSteps,
),

createProgressMonitor: (fileLogger) =>
createProgressMonitor: (fileLogger, repoDir) =>
createProgressMonitor({
logWriter: fileLogger.write.bind(fileLogger),
agentType,
taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task',
progressModel: config.defaults.progressModel,
intervalMinutes: config.defaults.progressIntervalMinutes,
customModels: CUSTOM_MODELS as ModelSpec[],
repoDir,
trello: cardId ? { cardId } : undefined,
}),

Expand Down
48 changes: 6 additions & 42 deletions src/agents/review.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

import { REVIEW_FILE_CONTENT_TOKEN_LIMIT, estimateTokens } from '../config/reviewConfig.js';
import { Finish } from '../gadgets/Finish.js';
import { ListDirectory } from '../gadgets/ListDirectory.js';
import { ReadFile } from '../gadgets/ReadFile.js';
Expand All @@ -26,7 +22,12 @@ import {
executeGitHubAgent,
} from './shared/githubAgent.js';
import { resolveModelConfig } from './shared/modelResolution.js';
import { type PRDiff, formatPRDetails, formatPRDiff } from './shared/prFormatting.js';
import {
type PRFileContents,
formatPRDetails,
formatPRDiff,
readPRFileContents,
} from './shared/prFormatting.js';
import {
injectContextFiles,
injectSquintContext,
Expand All @@ -41,43 +42,6 @@ interface ReviewAgentInput extends GitHubAgentInput {
config: CascadeConfig;
}

// ============================================================================
// PR File Contents Reading
// ============================================================================

interface PRFileContents {
included: Array<{ path: string; content: string }>;
skipped: string[];
}

async function readPRFileContents(repoDir: string, prDiff: PRDiff): Promise<PRFileContents> {
const included: Array<{ path: string; content: string }> = [];
const skipped: string[] = [];
let totalTokens = 0;

for (const file of prDiff) {
// Skip deleted/binary files
if (file.status === 'removed' || !file.patch) continue;

const filePath = join(repoDir, file.filename);
try {
const content = await readFile(filePath, 'utf-8');
const tokens = estimateTokens(content);

if (totalTokens + tokens <= REVIEW_FILE_CONTENT_TOKEN_LIMIT) {
included.push({ path: file.filename, content });
totalTokens += tokens;
} else {
skipped.push(file.filename);
}
} catch {
// File might not exist (renamed from), skip
}
}

return { included, skipped };
}

// ============================================================================
// Context Building
// ============================================================================
Expand Down
105 changes: 105 additions & 0 deletions src/agents/shared/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// ============================================================================
// AgentCapabilities
// ============================================================================

/**
* Describes what a particular agent type is allowed to do.
*
* Consumed by the llmist backend (agents/base.ts) to gate gadget inclusion
* and by the Claude Code backend (backends/agent-profiles.ts) for tool filtering.
*
* Keeping this in agents/shared/ avoids circular imports between agents/ and backends/.
*/
export interface AgentCapabilities {
/** Can the agent read and write files? (false = read-only) */
canEditFiles: boolean;
/** Can the agent create GitHub pull requests? */
canCreatePR: boolean;
/** Can the agent update PM checklist items? */
canUpdateChecklists: boolean;
/** True for agents that only interact with the PM system (no repo changes) */
isReadOnly: boolean;
}

// ============================================================================
// Capabilities Registry
// ============================================================================

/**
* Default capabilities for unknown agent types — full access.
*/
const DEFAULT_CAPABILITIES: AgentCapabilities = {
canEditFiles: true,
canCreatePR: true,
canUpdateChecklists: true,
isReadOnly: false,
};

/**
* Capabilities per agent type — single source of truth.
* AgentProfile in backends/agent-profiles.ts consumes these via getAgentCapabilities().
*/
const CAPABILITIES_REGISTRY: Record<string, AgentCapabilities> = {
briefing: {
canEditFiles: true,
canCreatePR: false,
canUpdateChecklists: true,
isReadOnly: false,
},
planning: {
canEditFiles: false,
canCreatePR: false,
canUpdateChecklists: false,
isReadOnly: true,
},
implementation: {
canEditFiles: true,
canCreatePR: true,
canUpdateChecklists: true,
isReadOnly: false,
},
review: {
canEditFiles: false,
canCreatePR: false,
canUpdateChecklists: false,
isReadOnly: true,
},
'respond-to-planning-comment': {
canEditFiles: false,
canCreatePR: false,
canUpdateChecklists: true,
isReadOnly: true,
},
'respond-to-review': {
canEditFiles: false,
canCreatePR: false,
canUpdateChecklists: false,
isReadOnly: true,
},
'respond-to-ci': {
canEditFiles: true,
canCreatePR: false,
canUpdateChecklists: true,
isReadOnly: false,
},
'respond-to-pr-comment': {
canEditFiles: true,
canCreatePR: false,
canUpdateChecklists: false,
isReadOnly: false,
},
debug: {
canEditFiles: true,
canCreatePR: true,
canUpdateChecklists: true,
isReadOnly: false,
},
};

/**
* Look up capabilities for a given agent type.
* Falls back to full-access defaults for unknown types.
*/
export function getAgentCapabilities(agentType: string): AgentCapabilities {
return CAPABILITIES_REGISTRY[agentType] ?? DEFAULT_CAPABILITIES;
}
38 changes: 38 additions & 0 deletions src/agents/shared/cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { cleanupLogDirectory, cleanupLogFile } from '../../utils/fileLogger.js';
import { clearWatchdogCleanup } from '../../utils/lifecycle.js';
import { logger } from '../../utils/logging.js';
import { cleanupTempDir } from '../../utils/repo.js';
import type { FileLogger } from './lifecycle.js';

/**
* Clean up temporary resources after agent execution.
*
* Shared by both the llmist lifecycle (agents/shared/lifecycle.ts) and the
* adapter-based backends (backends/adapter.ts).
*
* @param repoDir - The temp repo directory to remove (null if not set up)
* @param fileLogger - The file logger whose log files should be cleaned up
* @param skipRepoDeletion - When true, skip removing the repoDir (e.g., for debug agents using a pre-existing logDir)
*/
export function cleanupAgentResources(
repoDir: string | null,
fileLogger: FileLogger,
skipRepoDeletion = false,
): void {
clearWatchdogCleanup();

const isLocalMode = process.env.CASCADE_LOCAL_MODE === 'true';

if (repoDir && !isLocalMode && !skipRepoDeletion) {
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);
}
}
2 changes: 1 addition & 1 deletion src/agents/shared/githubAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export async function executeGitHubAgent<
});
},

createProgressMonitor: (fileLogger) =>
createProgressMonitor: (fileLogger, _repoDir) =>
createProgressMonitor({
logWriter: fileLogger.write.bind(fileLogger),
agentType: definition.agentType,
Expand Down
Loading