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
17 changes: 16 additions & 1 deletion .cascade/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,22 @@ if pg_isready -q 2>/dev/null; then
fi

# =============================================================================
# 4. Run migrations
# 4. Create .env for local database (npm scripts use --env-file=.env)
# =============================================================================
if pg_isready -q 2>/dev/null && [ ! -f .env ]; then
echo ""
echo "--- Creating .env for local database ---"
if [ "$OS" = "linux" ]; then
echo "DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cascade" > .env
else
echo "DATABASE_URL=postgresql://localhost:5432/cascade" > .env
fi
echo "DATABASE_SSL=false" >> .env
log_info "Created .env with local DATABASE_URL"
fi

# =============================================================================
# 5. Run migrations
# =============================================================================
echo ""
echo "--- Database Migrations ---"
Expand Down
1 change: 1 addition & 0 deletions Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ RUN npm install -g pnpm @zbigniewsobiecki/squint --force
# (niu-browser-base already has: git, curl, ca-certificates, gnupg, postgresql-client)
RUN apt-get update && apt-get install -y \
fd-find \
jq \
ripgrep \
ed \
unzip \
Expand Down
1 change: 1 addition & 0 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default defineConfig({
'./src/db/schema/agentConfigs.ts',
'./src/db/schema/integrations.ts',
'./src/db/schema/runs.ts',
'./src/db/schema/promptPartials.ts',
],
out: './src/db/migrations',
dialect: 'postgresql',
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"db:push": "node --env-file=.env ./node_modules/.bin/drizzle-kit push",
"db:studio": "node --env-file=.env ./node_modules/.bin/drizzle-kit studio",
"db:seed": "node --env-file=.env --import tsx tools/seed-config-from-json.ts",
"db:bootstrap-journal": "node --env-file=.env --import tsx tools/db-bootstrap-journal.ts"
"db:bootstrap-journal": "node --env-file=.env --import tsx tools/db-bootstrap-journal.ts",
"db:seed-prompts": "node --env-file=.env --import tsx tools/seed-prompts.ts"
},
"keywords": [
"trello",
Expand Down
78 changes: 57 additions & 21 deletions src/agents/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { WriteFile } from '../gadgets/WriteFile.js';

import { type ProgressMonitor, createProgressMonitor } from '../backends/progress.js';
import { CUSTOM_MODELS } from '../config/customModels.js';
import { loadPartials } from '../db/repositories/partialsRepository.js';
import { AstGrep } from '../gadgets/AstGrep.js';
import { FileMultiEdit } from '../gadgets/FileMultiEdit.js';
import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js';
Expand Down Expand Up @@ -96,13 +97,18 @@ export async function fetchImplementationSteps(cardId: string): Promise<string[]
}
}

async function buildAgentContext(
agentType: string,
async function loadDbPartials(orgId: string): Promise<Map<string, string> | undefined> {
try {
return await loadPartials(orgId);
} catch {
// DB not available — fall back to disk-only partials
return undefined;
}
}

function buildPromptContext(
cardId: string | undefined,
repoDir: string,
project: ProjectConfig,
config: CascadeConfig,
log: { info: (msg: string, ctx?: Record<string, unknown>) => void },
triggerType?: string,
prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string },
debugContext?: {
Expand All @@ -112,13 +118,10 @@ async function buildAgentContext(
originalCardUrl: string;
detectedAgentType: string;
},
modelOverride?: string,
commentContext?: { text: string; author: string },
): Promise<AgentContextData> {
// Build prompt context for template rendering
): PromptContext {
const pmProvider = getPMProvider();
const isJira = pmProvider.type === 'jira';
const promptContext: PromptContext = {
return {
cardId,
cardUrl: cardId ? pmProvider.getWorkItemUrl(cardId) : undefined,
projectId: project.id,
Expand Down Expand Up @@ -147,6 +150,48 @@ async function buildAgentContext(
debugListId: project.trello?.lists?.debug,
}),
};
}

function selectPrompt(
cardId: string | undefined,
commentContext?: { text: string; author: string },
prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string },
debugContext?: {
logDir: string;
originalCardName: string;
originalCardUrl: string;
detectedAgentType: string;
},
): string {
if (commentContext) {
return buildCommentResponsePrompt(cardId ?? '', commentContext.text, commentContext.author);
}
if (prContext) return buildCheckFailurePrompt(prContext);
if (debugContext) return buildDebugPrompt(debugContext);
return buildPrompt(cardId ?? '');
}

async function buildAgentContext(
agentType: string,
cardId: string | undefined,
repoDir: string,
project: ProjectConfig,
config: CascadeConfig,
log: { info: (msg: string, ctx?: Record<string, unknown>) => void },
triggerType?: string,
prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string },
debugContext?: {
logDir: string;
originalCardId: string;
originalCardName: string;
originalCardUrl: string;
detectedAgentType: string;
},
modelOverride?: string,
commentContext?: { text: string; author: string },
): Promise<AgentContextData> {
const promptContext = buildPromptContext(cardId, project, triggerType, prContext, debugContext);
const dbPartials = await loadDbPartials(project.orgId);

// Some agents share model/iteration config with another agent type
const configKeyOverrides: Record<string, string> = {
Expand All @@ -161,6 +206,7 @@ async function buildAgentContext(
modelOverride,
promptContext,
configKey: configKeyOverrides[agentType],
dbPartials,
});

// Pre-fetch work item data for synthetic gadget call (only if cardId exists and not debug flow)
Expand All @@ -176,17 +222,7 @@ async function buildAgentContext(
implementationSteps = await fetchImplementationSteps(cardId);
}

// Build different prompt based on flow
let prompt: string;
if (commentContext) {
prompt = buildCommentResponsePrompt(cardId ?? '', commentContext.text, commentContext.author);
} else if (prContext) {
prompt = buildCheckFailurePrompt(prContext);
} else if (debugContext) {
prompt = buildDebugPrompt(debugContext);
} else {
prompt = buildPrompt(cardId ?? '');
}
const prompt = selectPrompt(cardId, commentContext, prContext, debugContext);

return {
systemPrompt,
Expand Down
150 changes: 137 additions & 13 deletions src/agents/prompts/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Eta } from 'eta';
Expand All @@ -9,6 +9,19 @@ const templatesDir = join(__dirname, 'templates');
// Initialize Eta with the templates directory
const eta = new Eta({ views: templatesDir, autoEscape: false });

// Valid agent types
const validTypes = [
'briefing',
'planning',
'implementation',
'debug',
'respond-to-review',
'respond-to-ci',
'respond-to-pr-comment',
'respond-to-planning-comment',
'review',
];

// Template context interface
export interface PromptContext {
// Common
Expand Down Expand Up @@ -63,26 +76,137 @@ function loadTemplate(agentType: string): string {
return template;
}

export function getSystemPrompt(agentType: string, context: PromptContext = {}): string {
const validTypes = [
'briefing',
'planning',
'implementation',
'debug',
'respond-to-review',
'respond-to-ci',
'respond-to-pr-comment',
'respond-to-planning-comment',
'review',
];
/**
* Resolve `<%~ include("partials/...") %>` directives by looking up DB partials first,
* falling back to disk. This pre-processing happens before Eta variable interpolation.
*/
export function resolveIncludes(template: string, dbPartials: Map<string, string>): string {
return template.replace(
/<%~\s*include\(\s*"partials\/([^"]+)"\s*\)\s*%>/g,
(_match, name: string) => {
const dbContent = dbPartials.get(name);
if (dbContent !== undefined) return dbContent;
// Fall back to disk
const diskPath = join(templatesDir, 'partials', `${name}.eta`);
try {
return readFileSync(diskPath, 'utf-8');
} catch {
throw new Error(`Partial not found: partials/${name}`);
}
},
);
}

/**
* Render a DB-stored template with include resolution + Eta variable interpolation.
*/
export function renderCustomPrompt(
templateSource: string,
context: PromptContext = {},
dbPartials?: Map<string, string>,
): string {
const expanded = resolveIncludes(templateSource, dbPartials ?? new Map());
return eta.renderString(expanded, context);
}

/**
* Validate a template string for correct Eta syntax and resolvable includes.
*/
export function validateTemplate(
templateSource: string,
dbPartials?: Map<string, string>,
): { valid: true } | { valid: false; error: string } {
try {
const expanded = resolveIncludes(templateSource, dbPartials ?? new Map());
eta.renderString(expanded, {});
return { valid: true };
} catch (err) {
return { valid: false, error: err instanceof Error ? err.message : String(err) };
}
}

export function getSystemPrompt(
agentType: string,
context: PromptContext = {},
dbPartials?: Map<string, string>,
): string {
if (!validTypes.includes(agentType)) {
throw new Error(`Unknown agent type: ${agentType}`);
}

const template = loadTemplate(agentType);
if (dbPartials && dbPartials.size > 0) {
const expanded = resolveIncludes(template, dbPartials);
return eta.renderString(expanded, context);
}
return eta.renderString(template, context);
}

/** Returns the raw .eta template source from disk (before rendering). */
export function getRawTemplate(agentType: string): string {
if (!validTypes.includes(agentType)) {
throw new Error(`Unknown agent type: ${agentType}`);
}
return loadTemplate(agentType);
}

/** Returns the raw partial source from disk. */
export function getRawPartial(name: string): string {
const diskPath = join(templatesDir, 'partials', `${name}.eta`);
return readFileSync(diskPath, 'utf-8');
}

/** Returns the list of valid agent types. */
export function getValidAgentTypes(): string[] {
return [...validTypes];
}

/** Returns the list of available disk-based partial names. */
export function getAvailablePartialNames(): string[] {
try {
const entries = readdirSync(join(templatesDir, 'partials'));
return entries
.filter((f) => f.endsWith('.eta'))
.map((f) => f.replace(/\.eta$/, ''))
.sort();
} catch {
return [];
}
}

/** Returns template variable info for documentation/reference. */
export function getTemplateVariables(): Array<{
name: string;
group: string;
description: string;
}> {
return [
{ name: 'cardId', group: 'Common', description: 'Work item ID' },
{ name: 'cardUrl', group: 'Common', description: 'Work item URL' },
{ name: 'projectId', group: 'Common', description: 'Project identifier' },
{ name: 'baseBranch', group: 'Common', description: 'Base branch name (e.g. main)' },
{ name: 'pmType', group: 'PM', description: 'PM type: trello or jira' },
{ name: 'workItemNoun', group: 'PM', description: 'card or issue' },
{ name: 'workItemNounPlural', group: 'PM', description: 'cards or issues' },
{ name: 'workItemNounCap', group: 'PM', description: 'Card or Issue' },
{ name: 'workItemNounPluralCap', group: 'PM', description: 'Cards or Issues' },
{ name: 'pmName', group: 'PM', description: 'Trello or JIRA' },
{ name: 'storiesListId', group: 'Briefing', description: 'Trello stories list ID' },
{ name: 'processedLabelId', group: 'Briefing', description: 'Trello processed label ID' },
{ name: 'prNumber', group: 'CI', description: 'Pull request number' },
{ name: 'prBranch', group: 'CI', description: 'Pull request branch name' },
{ name: 'repoFullName', group: 'CI', description: 'Repository full name (owner/repo)' },
{ name: 'headSha', group: 'CI', description: 'HEAD commit SHA' },
{ name: 'triggerType', group: 'CI', description: 'Trigger type identifier' },
{ name: 'logDir', group: 'Debug', description: 'Debug log directory path' },
{ name: 'originalCardId', group: 'Debug', description: 'Original card ID being debugged' },
{ name: 'originalCardName', group: 'Debug', description: 'Original card name' },
{ name: 'originalCardUrl', group: 'Debug', description: 'Original card URL' },
{ name: 'detectedAgentType', group: 'Debug', description: 'Agent type from session log' },
{ name: 'debugListId', group: 'Debug', description: 'Debug list ID for output cards' },
];
}

// Export individual prompts for backwards compatibility (rendered without context)
export const BRIEFING_SYSTEM_PROMPT = loadTemplate('briefing');
export const PLANNING_SYSTEM_PROMPT = loadTemplate('planning');
Expand Down
17 changes: 13 additions & 4 deletions src/agents/shared/modelResolution.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CascadeConfig, ProjectConfig } from '../../types/index.js';
import { type ContextFile, readContextFiles } from '../utils/setup.js';

import { type PromptContext, getSystemPrompt } from '../prompts/index.js';
import { type PromptContext, getSystemPrompt, renderCustomPrompt } from '../prompts/index.js';

export interface ModelConfig {
systemPrompt: string;
Expand All @@ -19,14 +19,23 @@ export interface ResolveModelConfigOptions {
promptContext?: PromptContext;
/** Optional key override for model/iteration config lookup (e.g., respond-to-review uses 'review') */
configKey?: string;
/** DB partials for template include resolution */
dbPartials?: Map<string, string>;
}

export async function resolveModelConfig(options: ResolveModelConfigOptions): Promise<ModelConfig> {
const { agentType, project, config, repoDir, modelOverride, promptContext } = options;
const { agentType, project, config, repoDir, modelOverride, promptContext, dbPartials } = options;
const configKey = options.configKey ?? agentType;

const systemPrompt =
project.prompts?.[agentType] || getSystemPrompt(agentType, promptContext ?? {});
// Resolution chain: project prompt → defaults prompt → .eta file
const customPromptSource = project.prompts?.[agentType] ?? config.defaults.prompts?.[agentType];

let systemPrompt: string;
if (customPromptSource) {
systemPrompt = renderCustomPrompt(customPromptSource, promptContext ?? {}, dbPartials);
} else {
systemPrompt = getSystemPrompt(agentType, promptContext ?? {}, dbPartials);
}

const model =
modelOverride ||
Expand Down
Loading