diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard index 0641d5cd..d773f34d 100644 --- a/Dockerfile.dashboard +++ b/Dockerfile.dashboard @@ -27,6 +27,7 @@ COPY --from=builder /app/dist ./dist # Copy .eta prompt templates (loaded at runtime by agents/prompts via readFileSync) COPY --from=builder /app/src/agents/prompts/templates ./dist/agents/prompts/templates +COPY --from=builder /app/src/agents/prompts/task-templates ./dist/agents/prompts/task-templates ENV PORT=3001 EXPOSE 3001 diff --git a/Dockerfile.worker b/Dockerfile.worker index 4e068eeb..be24bef9 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -97,6 +97,7 @@ RUN sudo ln -sf /app/bin/cascade-tools.js /usr/local/bin/cascade-tools # Copy Eta template files (not handled by TypeScript compiler) COPY --chown=node:node src/agents/prompts/templates ./dist/agents/prompts/templates +COPY --chown=node:node src/agents/prompts/task-templates ./dist/agents/prompts/task-templates # Copy config COPY --chown=node:node config ./config diff --git a/package-lock.json b/package-lock.json index 14d6b994..f990cda4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "eta": "^4.5.0", "hono": "^4.6.14", "jira.js": "^5.3.0", + "js-yaml": "^4.1.1", "llmist": "^15.19.0", "pg": "^8.18.0", "trello.js": "^1.2.8", @@ -50,6 +51,7 @@ "@types/bcrypt": "^6.0.0", "@types/diff-match-patch": "^1.0.36", "@types/dockerode": "^3.3.47", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", @@ -3923,6 +3925,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mysql": { "version": "2.15.27", "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", @@ -7310,6 +7319,8 @@ }, "node_modules/js-yaml": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index e9928446..b10a26c0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "scripts": { "dev": "node --env-file=.env --import tsx/esm --watch src/index.ts", "dev:web": "cd web && npx vite", - "build": "tsc", + "build": "tsc && npm run build:copy-yaml && npm run build:copy-task-templates", + "build:copy-yaml": "mkdir -p dist/agents/definitions && cp src/agents/definitions/*.yaml dist/agents/definitions/", + "build:copy-task-templates": "mkdir -p dist/agents/prompts/task-templates && cp src/agents/prompts/task-templates/*.eta dist/agents/prompts/task-templates/", "build:web": "cd web && npm run build", "start": "node dist/index.js", "test": "vitest run --project unit", @@ -68,6 +70,7 @@ "eta": "^4.5.0", "hono": "^4.6.14", "jira.js": "^5.3.0", + "js-yaml": "^4.1.1", "llmist": "^15.19.0", "pg": "^8.18.0", "trello.js": "^1.2.8", @@ -84,6 +87,7 @@ "@types/bcrypt": "^6.0.0", "@types/diff-match-patch": "^1.0.36", "@types/dockerode": "^3.3.47", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", diff --git a/src/agents/definitions/contextSteps.ts b/src/agents/definitions/contextSteps.ts new file mode 100644 index 00000000..4b0e6965 --- /dev/null +++ b/src/agents/definitions/contextSteps.ts @@ -0,0 +1,262 @@ +/** + * Context pipeline step implementations and pre-execute hooks. + * + * Each step function takes a FetchContextParams and returns ContextInjection[]. + * These are the building blocks composed by the YAML contextPipeline arrays. + */ + +import { execFileSync } from 'node:child_process'; + +import type { ContextInjection, LogWriter } from '../../backends/types.js'; +import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; +import { ListDirectory } from '../../gadgets/ListDirectory.js'; +import { formatCheckStatus } from '../../gadgets/github/core/getPRChecks.js'; +import { readWorkItem } from '../../gadgets/pm/core/readWorkItem.js'; +import { githubClient } from '../../github/client.js'; +import type { AgentInput } from '../../types/index.js'; +import { parseRepoFullName } from '../../utils/repo.js'; +import { resolveSquintDbPath } from '../../utils/squintDb.js'; +import { + formatPRComments, + formatPRDetails, + formatPRDiff, + formatPRIssueComments, + formatPRReviews, + readPRFileContents, +} from '../shared/prFormatting.js'; +import type { ContextFile } from '../utils/setup.js'; + +// ============================================================================ +// Shared interfaces +// ============================================================================ + +export interface FetchContextParams { + input: AgentInput; + repoDir: string; + contextFiles: ContextFile[]; + logWriter: LogWriter; +} + +export interface PreExecuteParams { + input: AgentInput; + logWriter: LogWriter; +} + +// ============================================================================ +// Atomic context step functions +// ============================================================================ + +export function fetchDirectoryListingStep(params: FetchContextParams): ContextInjection[] { + const listDirGadget = new ListDirectory(); + const gadgetParams = { + comment: 'Pre-fetching codebase structure for context', + directoryPath: params.repoDir, + maxDepth: 3, + includeGitIgnored: false, + }; + + const result = listDirGadget.execute(gadgetParams); + return [ + { + toolName: 'ListDirectory', + params: gadgetParams, + result, + description: 'Pre-fetched codebase structure', + }, + ]; +} + +export function fetchContextFilesStep(params: FetchContextParams): ContextInjection[] { + return params.contextFiles.map((file) => ({ + toolName: 'ReadFile', + params: { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, + result: file.content, + description: `Pre-fetched ${file.path}`, + })); +} + +export function fetchSquintStep(params: FetchContextParams): ContextInjection[] { + const squintDb = resolveSquintDbPath(params.repoDir); + if (!squintDb) return []; + + try { + const output = execFileSync('squint', ['overview', '-d', squintDb], { + encoding: 'utf-8', + timeout: 30_000, + }); + if (!output?.trim()) return []; + + return [ + { + toolName: 'SquintOverview', + params: { + comment: 'Pre-fetching Squint codebase overview for context', + database: squintDb, + }, + result: output, + description: 'Pre-fetched Squint codebase overview', + }, + ]; + } catch { + return []; + } +} + +export async function fetchWorkItemStep(params: FetchContextParams): Promise { + if (!params.input.cardId) return []; + try { + const cardData = await readWorkItem(params.input.cardId, true); + return [ + { + toolName: 'ReadWorkItem', + params: { workItemId: params.input.cardId, includeComments: true }, + result: cardData, + description: 'Pre-fetched work item data', + }, + ]; + } catch { + return []; + } +} + +export async function fetchPRContextStep(params: FetchContextParams): Promise { + const { repoFullName, prNumber } = params.input; + if (!repoFullName || !prNumber) { + throw new Error('fetchPRContextStep requires repoFullName and prNumber in input'); + } + const injections: ContextInjection[] = []; + const { owner, repo } = parseRepoFullName(repoFullName); + + params.logWriter('INFO', 'Fetching PR details, diff, and check status', { + owner, + repo, + prNumber, + }); + + const prDetails = await githubClient.getPR(owner, repo, prNumber); + const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); + const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, prDetails.headSha); + + const prDetailsFormatted = formatPRDetails(prDetails); + const diffFormatted = formatPRDiff(prDiff); + const checkStatusFormatted = formatCheckStatus(prNumber, checkStatus); + + injections.push({ + toolName: 'GetPRDetails', + params: { comment: 'Pre-fetching PR details for review context', owner, repo, prNumber }, + result: prDetailsFormatted, + description: 'Pre-fetched PR details', + }); + + injections.push({ + toolName: 'GetPRDiff', + params: { comment: 'Pre-fetching PR diff for code review', owner, repo, prNumber }, + result: diffFormatted, + description: 'Pre-fetched PR diff', + }); + + injections.push({ + toolName: 'GetPRChecks', + params: { comment: 'Pre-fetching CI check status for review', owner, repo, prNumber }, + result: checkStatusFormatted, + description: 'Pre-fetched CI check status', + }); + + // Read full contents of changed files + params.logWriter('INFO', 'Reading PR file contents', { fileCount: prDiff.length }); + const fileContents = await readPRFileContents(params.repoDir, prDiff); + params.logWriter('INFO', 'File contents loaded', { + included: fileContents.included.length, + skipped: fileContents.skipped.length, + }); + + for (const file of fileContents.included) { + injections.push({ + toolName: 'ReadFile', + params: { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, + result: `path=${file.path}\n\n${file.content}`, + description: `Pre-fetched ${file.path}`, + }); + } + + return injections; +} + +export async function fetchPRConversationStep( + params: FetchContextParams, +): Promise { + const { repoFullName, prNumber } = params.input; + if (!repoFullName || !prNumber) { + throw new Error('fetchPRConversationStep requires repoFullName and prNumber in input'); + } + const injections: ContextInjection[] = []; + const { owner, repo } = parseRepoFullName(repoFullName); + + params.logWriter('INFO', 'Fetching PR conversation context', { owner, repo, prNumber }); + + const [reviewComments, reviews, issueComments] = await Promise.all([ + githubClient.getPRReviewComments(owner, repo, prNumber), + githubClient.getPRReviews(owner, repo, prNumber), + githubClient.getPRIssueComments(owner, repo, prNumber), + ]); + + injections.push({ + toolName: 'GetPRComments', + params: { + comment: 'Pre-fetching PR review comments for conversation context', + owner, + repo, + prNumber, + }, + result: formatPRComments(reviewComments), + description: 'Pre-fetched PR review comments', + }); + + injections.push({ + toolName: 'GetPRComments', + params: { + comment: 'Pre-fetching PR reviews for conversation context', + owner, + repo, + prNumber, + }, + result: formatPRReviews(reviews), + description: 'Pre-fetched PR reviews', + }); + + injections.push({ + toolName: 'GetPRComments', + params: { + comment: 'Pre-fetching PR issue comments for conversation context', + owner, + repo, + prNumber, + }, + result: formatPRIssueComments(issueComments), + description: 'Pre-fetched PR issue comments', + }); + + return injections; +} + +// ============================================================================ +// Pre-execute hooks +// ============================================================================ + +export async function postInitialPRCommentHook( + agentType: string, + { input, logWriter }: PreExecuteParams, +): Promise { + // Skip if ack comment already posted by router or webhook handler + if (input.ackCommentId) return; + + const { repoFullName, prNumber } = input; + if (!repoFullName || !prNumber) { + throw new Error('postInitialPRCommentHook requires repoFullName and prNumber in input'); + } + const { owner, repo } = parseRepoFullName(repoFullName); + + const message = (input.ackMessage as string | undefined) ?? INITIAL_MESSAGES[agentType]; + logWriter('INFO', `Posting initial ${agentType} comment`, { owner, repo, prNumber }); + await githubClient.createPRComment(owner, repo, prNumber, message); +} diff --git a/src/agents/definitions/debug.yaml b/src/agents/definitions/debug.yaml new file mode 100644 index 00000000..ecccbc44 --- /dev/null +++ b/src/agents/definitions/debug.yaml @@ -0,0 +1,28 @@ +identity: + emoji: "\U0001F41B" + label: Debug Update + roleHint: Analyzes session logs to identify what went wrong + initialMessage: "**\U0001F41B Analyzing session logs** — Reviewing what happened and identifying issues..." + +capabilities: + canEditFiles: true + canCreatePR: true + canUpdateChecklists: true + isReadOnly: false + +tools: + sets: [all] + sdkTools: all + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: workItem + gadgetBuilder: workItem + +backend: + enableStopHooks: true + needsGitHubToken: false + +compaction: default + +hint: Analyze the current issue fully before moving to the next. diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml new file mode 100644 index 00000000..5fa9ed2d --- /dev/null +++ b/src/agents/definitions/implementation.yaml @@ -0,0 +1,40 @@ +identity: + emoji: "\U0001F9D1\u200D\U0001F4BB" + label: Implementation Update + roleHint: Writes code, runs tests, and prepares a pull request + initialMessage: "**\U0001F680 Implementing changes** — Writing code, running tests, and preparing a PR..." + +capabilities: + canEditFiles: true + canCreatePR: true + canUpdateChecklists: true + isReadOnly: false + +tools: + sets: [pm, pm_checklist, session] + sdkTools: all + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: workItem + gadgetBuilder: workItem + +backend: + enableStopHooks: true + needsGitHubToken: true + requiresPR: true + postConfigure: sequentialGadgetExecution + +compaction: implementation + +hint: >- + Complete the current todo in as few iterations as possible. Batch related + edits together. Verify with Tmux after edits. NEVER mark acceptance criteria + complete without passing verification. + +trailingMessage: + includeDiagnostics: true + includeTodoProgress: true + includeGitStatus: true + includePRStatus: true + includeReminder: true diff --git a/src/agents/definitions/index.ts b/src/agents/definitions/index.ts new file mode 100644 index 00000000..9ba4fd86 --- /dev/null +++ b/src/agents/definitions/index.ts @@ -0,0 +1,22 @@ +export { AgentDefinitionSchema, type AgentDefinition } from './schema.js'; +export { + loadAgentDefinition, + loadAllAgentDefinitions, + getKnownAgentTypes, + clearDefinitionCache, +} from './loader.js'; +export { + TOOL_SET_REGISTRY, + SDK_TOOLS_REGISTRY, + GADGET_BUILDER_REGISTRY, + CONTEXT_STEP_REGISTRY, + PRE_EXECUTE_REGISTRY, + PM_TOOLS, + PM_CHECKLIST_TOOL, + GITHUB_REVIEW_TOOLS, + GITHUB_CI_TOOLS, + SESSION_TOOL, + ALL_SDK_TOOLS, + READ_ONLY_SDK_TOOLS, +} from './strategies.js'; +export type { FetchContextParams, PreExecuteParams } from './contextSteps.js'; diff --git a/src/agents/definitions/loader.ts b/src/agents/definitions/loader.ts new file mode 100644 index 00000000..b850c846 --- /dev/null +++ b/src/agents/definitions/loader.ts @@ -0,0 +1,79 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; + +import { type AgentDefinition, AgentDefinitionSchema } from './schema.js'; + +// ============================================================================ +// YAML Loader +// ============================================================================ + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Cache of parsed + validated agent definitions */ +const cache = new Map(); + +/** Lazily discovered set of agent types (from YAML filenames) */ +let knownTypes: string[] | null = null; + +/** + * Load and validate a single agent definition from YAML. + * Results are cached after first load. + */ +export function loadAgentDefinition(agentType: string): AgentDefinition { + const cached = cache.get(agentType); + if (cached) return cached; + + const filePath = join(__dirname, `${agentType}.yaml`); + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch { + throw new Error(`Agent definition not found: ${agentType}.yaml (looked in ${__dirname})`); + } + + const parsed = yaml.load(raw); + const result = AgentDefinitionSchema.safeParse(parsed); + if (!result.success) { + const issues = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); + throw new Error(`Invalid agent definition '${agentType}.yaml':\n${issues}`); + } + + cache.set(agentType, result.data); + return result.data; +} + +/** + * Load all agent definitions discovered from YAML files in the definitions directory. + */ +export function loadAllAgentDefinitions(): Map { + const types = getKnownAgentTypes(); + const result = new Map(); + for (const agentType of types) { + result.set(agentType, loadAgentDefinition(agentType)); + } + return result; +} + +/** + * Return the list of known agent types (derived from YAML filenames). + */ +export function getKnownAgentTypes(): string[] { + if (knownTypes) return knownTypes; + + const entries = readdirSync(__dirname); + knownTypes = entries + .filter((f) => f.endsWith('.yaml')) + .map((f) => f.replace(/\.yaml$/, '')) + .sort(); + return knownTypes; +} + +/** + * Clear the loader cache (useful in tests). + */ +export function clearDefinitionCache(): void { + cache.clear(); + knownTypes = null; +} diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml new file mode 100644 index 00000000..8c065af5 --- /dev/null +++ b/src/agents/definitions/planning.yaml @@ -0,0 +1,28 @@ +identity: + emoji: "\U0001F5FA\uFE0F" + label: Planning Update + roleHint: Studies the codebase and designs a step-by-step implementation plan + initialMessage: "**\U0001F5FA\uFE0F Planning implementation** — Studying the codebase and designing a step-by-step plan..." + +capabilities: + canEditFiles: false + canCreatePR: false + canUpdateChecklists: false + isReadOnly: true + +tools: + sets: [pm, session] + sdkTools: readOnly + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: workItem + gadgetBuilder: workItem + +backend: + enableStopHooks: false + needsGitHubToken: false + +compaction: default + +hint: Complete the current planning step efficiently before moving to the next. diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml new file mode 100644 index 00000000..827955b4 --- /dev/null +++ b/src/agents/definitions/respond-to-ci.yaml @@ -0,0 +1,33 @@ +identity: + emoji: "\U0001F527" + label: CI Fix Update + roleHint: Analyzes failed CI checks and works on a fix + initialMessage: "**\U0001F527 Fixing CI failures** — Analyzing the failed checks and working on a fix..." + +capabilities: + canEditFiles: true + canCreatePR: false + canUpdateChecklists: true + isReadOnly: false + +tools: + sets: [github_ci, pm, pm_checklist, session] + sdkTools: all + +strategies: + contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: ci + gadgetBuilder: prAgent + +backend: + enableStopHooks: true + needsGitHubToken: true + blockGitPush: false + preExecute: postInitialPRComment + +compaction: default + +hint: Fix CI failures with minimal, focused changes. Batch related file edits together. + +trailingMessage: + includeDiagnostics: true diff --git a/src/agents/definitions/respond-to-planning-comment.yaml b/src/agents/definitions/respond-to-planning-comment.yaml new file mode 100644 index 00000000..ca40a7a2 --- /dev/null +++ b/src/agents/definitions/respond-to-planning-comment.yaml @@ -0,0 +1,28 @@ +identity: + emoji: "\U0001F4AC" + label: Planning Response Update + roleHint: Reads user feedback and updates the plan accordingly + initialMessage: "**\U0001F4AC Responding to feedback** — Reading your comment and updating the plan accordingly..." + +capabilities: + canEditFiles: false + canCreatePR: false + canUpdateChecklists: true + isReadOnly: true + +tools: + sets: [pm, pm_checklist, session] + sdkTools: readOnly + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: commentResponse + gadgetBuilder: workItem + +backend: + enableStopHooks: false + needsGitHubToken: false + +compaction: default + +hint: Complete the current task efficiently before moving to the next. diff --git a/src/agents/definitions/respond-to-pr-comment.yaml b/src/agents/definitions/respond-to-pr-comment.yaml new file mode 100644 index 00000000..d804a39e --- /dev/null +++ b/src/agents/definitions/respond-to-pr-comment.yaml @@ -0,0 +1,31 @@ +identity: + emoji: "\U0001F4AC" + label: PR Comment Response Update + roleHint: Reads a PR comment and takes action + initialMessage: "**\U0001F4AC Responding to PR comment** — Reading your comment and taking action..." + +capabilities: + canEditFiles: true + canCreatePR: false + canUpdateChecklists: false + isReadOnly: false + +tools: + sets: [github_review, session] + sdkTools: all + +strategies: + contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] + taskPromptBuilder: prCommentResponse + gadgetBuilder: prAgent + gadgetBuilderOptions: + includeReviewComments: true + +backend: + enableStopHooks: true + needsGitHubToken: true + blockGitPush: false + +compaction: default + +hint: Complete the current task efficiently before moving to the next. diff --git a/src/agents/definitions/respond-to-review.yaml b/src/agents/definitions/respond-to-review.yaml new file mode 100644 index 00000000..557b6729 --- /dev/null +++ b/src/agents/definitions/respond-to-review.yaml @@ -0,0 +1,34 @@ +identity: + emoji: "\U0001F527" + label: Review Response Update + roleHint: Addresses code review feedback by making requested changes + initialMessage: "**\U0001F527 Addressing review feedback** — Making the requested changes from the code review..." + +capabilities: + canEditFiles: false + canCreatePR: false + canUpdateChecklists: false + isReadOnly: true + +tools: + sets: [github_review, session] + sdkTools: all + +strategies: + contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] + taskPromptBuilder: prCommentResponse + gadgetBuilder: prAgent + gadgetBuilderOptions: + includeReviewComments: true + +backend: + enableStopHooks: true + needsGitHubToken: true + blockGitPush: false + +compaction: default + +hint: Address the current review comment fully before moving to the next. Batch related file edits together. + +trailingMessage: + includeDiagnostics: true diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml new file mode 100644 index 00000000..d6ef59a4 --- /dev/null +++ b/src/agents/definitions/review.yaml @@ -0,0 +1,29 @@ +identity: + emoji: "\U0001F50D" + label: Code Review Update + roleHint: Reviews pull request changes for quality and correctness + initialMessage: "**\U0001F50D Reviewing code** — Examining the PR changes for quality and correctness..." + +capabilities: + canEditFiles: false + canCreatePR: false + canUpdateChecklists: false + isReadOnly: true + +tools: + sets: [github_review, session] + sdkTools: readOnly + +strategies: + contextPipeline: [prContext, contextFiles, squint] + taskPromptBuilder: review + gadgetBuilder: review + +backend: + enableStopHooks: false + needsGitHubToken: true + preExecute: postInitialPRComment + +compaction: default + +hint: Focus on the current aspect of review before moving to the next. Read related files together. diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts new file mode 100644 index 00000000..d9e77a0d --- /dev/null +++ b/src/agents/definitions/schema.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +// ============================================================================ +// Agent Definition Schema +// ============================================================================ + +const IdentitySchema = z.object({ + emoji: z.string(), + label: z.string(), + roleHint: z.string(), + initialMessage: z.string(), +}); + +const CapabilitiesSchema = z.object({ + canEditFiles: z.boolean(), + canCreatePR: z.boolean(), + canUpdateChecklists: z.boolean(), + isReadOnly: z.boolean(), +}); + +const ToolsSchema = z.object({ + /** Named tool set references resolved via TOOL_SET_REGISTRY */ + sets: z.array(z.enum(['pm', 'pm_checklist', 'session', 'github_review', 'github_ci', 'all'])), + /** SDK tools preset: "all" or "readOnly" */ + sdkTools: z.enum(['all', 'readOnly']), +}); + +const GadgetBuilderOptionsSchema = z + .object({ + includeReviewComments: z.boolean().optional(), + }) + .optional(); + +const StrategiesSchema = z.object({ + contextPipeline: z.array( + z.enum([ + 'directoryListing', + 'contextFiles', + 'squint', + 'workItem', + 'prContext', + 'prConversation', + ]), + ), + taskPromptBuilder: z.enum(['workItem', 'commentResponse', 'review', 'ci', 'prCommentResponse']), + gadgetBuilder: z.enum(['workItem', 'review', 'prAgent']), + gadgetBuilderOptions: GadgetBuilderOptionsSchema, +}); + +const BackendSchema = z.object({ + enableStopHooks: z.boolean(), + needsGitHubToken: z.boolean(), + blockGitPush: z.boolean().optional(), + requiresPR: z.boolean().optional(), + preExecute: z.enum(['postInitialPRComment']).optional(), + postConfigure: z.enum(['sequentialGadgetExecution']).optional(), +}); + +const TrailingMessageSchema = z + .object({ + includeDiagnostics: z.boolean().optional(), + includeTodoProgress: z.boolean().optional(), + includeGitStatus: z.boolean().optional(), + includePRStatus: z.boolean().optional(), + includeReminder: z.boolean().optional(), + }) + .optional(); + +export const AgentDefinitionSchema = z.object({ + identity: IdentitySchema, + capabilities: CapabilitiesSchema, + tools: ToolsSchema, + strategies: StrategiesSchema, + backend: BackendSchema, + compaction: z.enum(['implementation', 'default']), + hint: z.string(), + trailingMessage: TrailingMessageSchema, +}); + +export type AgentDefinition = z.infer; diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml new file mode 100644 index 00000000..4213d571 --- /dev/null +++ b/src/agents/definitions/splitting.yaml @@ -0,0 +1,28 @@ +identity: + emoji: "\U0001F4CB" + label: Splitting Update + roleHint: Breaks down a feature plan into smaller, ordered work items (subtasks) + initialMessage: "**\U0001F4CB Splitting plan** — Reading the plan and splitting it into ordered work items..." + +capabilities: + canEditFiles: true + canCreatePR: false + canUpdateChecklists: true + isReadOnly: false + +tools: + sets: [pm, pm_checklist, session] + sdkTools: all + +strategies: + contextPipeline: [directoryListing, contextFiles, squint, workItem] + taskPromptBuilder: workItem + gadgetBuilder: workItem + +backend: + enableStopHooks: false + needsGitHubToken: false + +compaction: default + +hint: Gather all context needed for the current step before proceeding. diff --git a/src/agents/definitions/strategies.ts b/src/agents/definitions/strategies.ts new file mode 100644 index 00000000..f6e75646 --- /dev/null +++ b/src/agents/definitions/strategies.ts @@ -0,0 +1,121 @@ +import type { ContextInjection } from '../../backends/types.js'; +import type { AgentCapabilities } from '../shared/capabilities.js'; +import { + buildPRAgentGadgets, + buildReviewGadgets, + buildWorkItemGadgets, +} from '../shared/gadgets.js'; +import { + type FetchContextParams, + type PreExecuteParams, + fetchContextFilesStep, + fetchDirectoryListingStep, + fetchPRContextStep, + fetchPRConversationStep, + fetchSquintStep, + fetchWorkItemStep, + postInitialPRCommentHook, +} from './contextSteps.js'; + +// ============================================================================ +// Tool Set Registry +// ============================================================================ + +/** PM tools available to most agents */ +export const PM_TOOLS = [ + 'ReadWorkItem', + 'PostComment', + 'UpdateWorkItem', + 'CreateWorkItem', + 'ListWorkItems', + 'AddChecklist', +]; + +/** PM checklist update — excluded from planning to prevent premature completion */ +export const PM_CHECKLIST_TOOL = 'UpdateChecklistItem'; + +/** GitHub review tools for code review agents */ +export const GITHUB_REVIEW_TOOLS = [ + 'GetPRDetails', + 'GetPRDiff', + 'GetPRChecks', + 'GetPRComments', + 'PostPRComment', + 'UpdatePRComment', + 'ReplyToReviewComment', + 'CreatePRReview', +]; + +/** GitHub CI tools for respond-to-ci agent (no CreatePR — pushes to existing branch) */ +export const GITHUB_CI_TOOLS = [ + 'GetPRDetails', + 'GetPRDiff', + 'GetPRChecks', + 'PostPRComment', + 'UpdatePRComment', +]; + +export const SESSION_TOOL = 'Finish'; + +export const ALL_SDK_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; +export const READ_ONLY_SDK_TOOLS = ['Read', 'Bash', 'Glob', 'Grep']; + +/** + * Maps YAML tool set names to the actual tool name arrays. + */ +export const TOOL_SET_REGISTRY: Record = { + pm: PM_TOOLS, + pm_checklist: [PM_CHECKLIST_TOOL], + session: [SESSION_TOOL], + github_review: GITHUB_REVIEW_TOOLS, + github_ci: GITHUB_CI_TOOLS, + // 'all' is a sentinel — handled by returning allTools unfiltered +}; + +/** + * Maps YAML sdkTools names to actual SDK tool arrays. + */ +export const SDK_TOOLS_REGISTRY: Record = { + all: ALL_SDK_TOOLS, + readOnly: READ_ONLY_SDK_TOOLS, +}; + +// ============================================================================ +// Context Pipeline Step Registry +// ============================================================================ + +export const CONTEXT_STEP_REGISTRY: Record< + string, + (params: FetchContextParams) => ContextInjection[] | Promise +> = { + directoryListing: fetchDirectoryListingStep, + contextFiles: fetchContextFilesStep, + squint: fetchSquintStep, + workItem: fetchWorkItemStep, + prContext: fetchPRContextStep, + prConversation: fetchPRConversationStep, +}; + +// ============================================================================ +// Pre-Execute Hook Registry +// ============================================================================ + +export const PRE_EXECUTE_REGISTRY: Record< + string, + (agentType: string, params: PreExecuteParams) => Promise +> = { + postInitialPRComment: postInitialPRCommentHook, +}; + +// ============================================================================ +// Gadget Builder Registry +// ============================================================================ + +export const GADGET_BUILDER_REGISTRY: Record< + string, + (caps: AgentCapabilities, options?: { includeReviewComments?: boolean }) => unknown[] +> = { + workItem: (caps) => buildWorkItemGadgets(caps), + review: () => buildReviewGadgets(), + prAgent: (_caps, options) => buildPRAgentGadgets(options), +}; diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 2318c6f0..6419832f 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -3,24 +3,18 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Eta } from 'eta'; +import { getKnownAgentTypes } from '../definitions/index.js'; + const __dirname = dirname(fileURLToPath(import.meta.url)); const templatesDir = join(__dirname, 'templates'); +const taskTemplatesDir = join(__dirname, 'task-templates'); // Initialize Eta with the templates directory const eta = new Eta({ views: templatesDir, autoEscape: false }); +const taskEta = new Eta({ views: taskTemplatesDir, autoEscape: false }); -// Valid agent types -const validTypes = [ - 'splitting', - 'planning', - 'implementation', - 'debug', - 'respond-to-review', - 'respond-to-ci', - 'respond-to-pr-comment', - 'respond-to-planning-comment', - 'review', -]; +// Valid agent types — derived from YAML definition files +const validTypes = getKnownAgentTypes(); // Template context interface export interface PromptContext { @@ -141,6 +135,51 @@ export function getSystemPrompt( return eta.renderString(template, context); } +// ============================================================================ +// Task Prompt Templates +// ============================================================================ + +/** Context for task prompt Eta rendering */ +export interface TaskPromptContext { + cardId?: string; + commentText?: string; + commentAuthor?: string; + prNumber?: number; + prBranch?: string; + commentBody?: string; + commentPath?: string; + [key: string]: unknown; +} + +const taskTemplateCache = new Map(); + +function loadTaskTemplate(templateName: string): string { + const cached = taskTemplateCache.get(templateName); + if (cached) return cached; + + const templatePath = join(taskTemplatesDir, `${templateName}.eta`); + const template = readFileSync(templatePath, 'utf-8'); + taskTemplateCache.set(templateName, template); + return template; +} + +/** + * Render a task prompt from a named `.eta` template in `task-templates/`. + * Supports DB partials via `include()` directives (same pattern as system prompts). + */ +export function renderTaskPrompt( + templateName: string, + context: TaskPromptContext = {}, + dbPartials?: Map, +): string { + const template = loadTaskTemplate(templateName); + if (dbPartials && dbPartials.size > 0) { + const expanded = resolveIncludes(template, dbPartials); + return taskEta.renderString(expanded, context); + } + return taskEta.renderString(template, context); +} + /** Returns the raw .eta template source from disk (before rendering). */ export function getRawTemplate(agentType: string): string { if (!validTypes.includes(agentType)) { @@ -204,16 +243,3 @@ export function getTemplateVariables(): Array<{ { name: 'debugListId', group: 'Debug', description: 'Debug list ID for output cards' }, ]; } - -// Export individual prompts for backwards compatibility (rendered without context) -export const SPLITTING_SYSTEM_PROMPT = loadTemplate('splitting'); -export const PLANNING_SYSTEM_PROMPT = loadTemplate('planning'); -export const IMPLEMENTATION_SYSTEM_PROMPT = loadTemplate('implementation'); -export const DEBUG_SYSTEM_PROMPT = loadTemplate('debug'); -export const RESPOND_TO_REVIEW_SYSTEM_PROMPT = loadTemplate('respond-to-review'); -export const RESPOND_TO_CI_SYSTEM_PROMPT = loadTemplate('respond-to-ci'); -export const RESPOND_TO_PR_COMMENT_SYSTEM_PROMPT = loadTemplate('respond-to-pr-comment'); -export const RESPOND_TO_PLANNING_COMMENT_SYSTEM_PROMPT = loadTemplate( - 'respond-to-planning-comment', -); -export const REVIEW_SYSTEM_PROMPT = loadTemplate('review'); diff --git a/src/agents/prompts/task-templates/ci.eta b/src/agents/prompts/task-templates/ci.eta new file mode 100644 index 00000000..9b9eca5f --- /dev/null +++ b/src/agents/prompts/task-templates/ci.eta @@ -0,0 +1,3 @@ +You are on the branch `<%= it.prBranch %>` for PR #<%= it.prNumber %>. + +CI checks have failed. Analyze the failures and fix them. \ No newline at end of file diff --git a/src/agents/prompts/task-templates/commentResponse.eta b/src/agents/prompts/task-templates/commentResponse.eta new file mode 100644 index 00000000..bf12da6d --- /dev/null +++ b/src/agents/prompts/task-templates/commentResponse.eta @@ -0,0 +1,9 @@ +A user (@<%= it.commentAuthor %>) mentioned you in a comment on work item <%= it.cardId %>. + +Their comment: +--- +<%= it.commentText %> +--- + +The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. +Read the user's comment carefully and classify it: if they ask a question or request clarification, reply with a thorough answer via PostComment (do not modify the plan). If they request plan changes, make surgical, targeted updates. If the comment contains both a question and a change request, do both. Default to plan updates when intent is ambiguous. \ No newline at end of file diff --git a/src/agents/prompts/task-templates/prCommentResponse.eta b/src/agents/prompts/task-templates/prCommentResponse.eta new file mode 100644 index 00000000..e8a421bf --- /dev/null +++ b/src/agents/prompts/task-templates/prCommentResponse.eta @@ -0,0 +1,13 @@ +You are on the branch `<%= it.prBranch %>` for PR #<%= it.prNumber %>. + +A user commented on this PR and mentioned you. Respond to their comment. +<% if (it.commentPath) { -%> +File: <%= it.commentPath %> +<% } -%> + +Their comment: +--- +<%= it.commentBody %> +--- + +Read the comment carefully and respond accordingly. If they ask for code changes, make the changes, commit, and push. If they ask a question, reply with a PR comment. Default to surgical, targeted changes unless they clearly ask for something broader. \ No newline at end of file diff --git a/src/agents/prompts/task-templates/review.eta b/src/agents/prompts/task-templates/review.eta new file mode 100644 index 00000000..830397b8 --- /dev/null +++ b/src/agents/prompts/task-templates/review.eta @@ -0,0 +1,3 @@ +Review PR #<%= it.prNumber %>. + +Examine the code changes carefully and submit your review using CreatePRReview. \ No newline at end of file diff --git a/src/agents/prompts/task-templates/workItem.eta b/src/agents/prompts/task-templates/workItem.eta new file mode 100644 index 00000000..99d28c5e --- /dev/null +++ b/src/agents/prompts/task-templates/workItem.eta @@ -0,0 +1 @@ +Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded. \ No newline at end of file diff --git a/src/agents/shared/capabilities.ts b/src/agents/shared/capabilities.ts index 7c46a47b..fccf9e64 100644 --- a/src/agents/shared/capabilities.ts +++ b/src/agents/shared/capabilities.ts @@ -1,3 +1,5 @@ +import { loadAgentDefinition } from '../definitions/loader.js'; + // ============================================================================ // AgentCapabilities // ============================================================================ @@ -21,10 +23,6 @@ export interface AgentCapabilities { isReadOnly: boolean; } -// ============================================================================ -// Capabilities Registry -// ============================================================================ - /** * Default capabilities for unknown agent types — full access. */ @@ -35,71 +33,15 @@ const DEFAULT_CAPABILITIES: AgentCapabilities = { isReadOnly: false, }; -/** - * Capabilities per agent type — single source of truth. - * AgentProfile in backends/agent-profiles.ts consumes these via getAgentCapabilities(). - */ -const CAPABILITIES_REGISTRY: Record = { - splitting: { - 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. + * Reads from YAML definition; falls back to full-access defaults for unknown types. */ export function getAgentCapabilities(agentType: string): AgentCapabilities { - return CAPABILITIES_REGISTRY[agentType] ?? DEFAULT_CAPABILITIES; + try { + const def = loadAgentDefinition(agentType); + return def.capabilities; + } catch { + return DEFAULT_CAPABILITIES; + } } diff --git a/src/agents/shared/modelResolution.ts b/src/agents/shared/modelResolution.ts index 30760c97..baee9241 100644 --- a/src/agents/shared/modelResolution.ts +++ b/src/agents/shared/modelResolution.ts @@ -1,10 +1,17 @@ -import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; import { type ContextFile, readContextFiles } from '../utils/setup.js'; -import { type PromptContext, getSystemPrompt, renderCustomPrompt } from '../prompts/index.js'; +import { + type PromptContext, + type TaskPromptContext, + getSystemPrompt, + renderCustomPrompt, +} from '../prompts/index.js'; export interface ModelConfig { systemPrompt: string; + /** Resolved task prompt override from DB (undefined = use default .eta template) */ + taskPrompt?: string; model: string; maxIterations: number; contextFiles: ContextFile[]; @@ -21,6 +28,31 @@ export interface ResolveModelConfigOptions { configKey?: string; /** DB partials for template include resolution */ dbPartials?: Map; + /** Agent input for task-specific template variables (commentText, commentAuthor, etc.) */ + agentInput?: AgentInput; +} + +/** + * Build a merged context for DB task prompt overrides. + * Combines PromptContext fields (cardId, prNumber, etc.) with task-specific + * fields from AgentInput (commentText, commentAuthor, commentBody, commentPath). + */ +function buildTaskOverrideContext( + promptContext: PromptContext | undefined, + agentInput: AgentInput | undefined, +): TaskPromptContext { + return { + ...(promptContext ?? {}), + // Common fields from AgentInput + cardId: agentInput?.cardId || (promptContext?.cardId as string | undefined), + prNumber: agentInput?.prNumber ?? (promptContext?.prNumber as number | undefined), + prBranch: agentInput?.prBranch ?? (promptContext?.prBranch as string | undefined), + // Task-specific fields from AgentInput + commentText: agentInput?.triggerCommentText as string | undefined, + commentAuthor: (agentInput?.triggerCommentAuthor as string) || undefined, + commentBody: agentInput?.triggerCommentBody as string | undefined, + commentPath: (agentInput?.triggerCommentPath as string) || undefined, + }; } export async function resolveModelConfig(options: ResolveModelConfigOptions): Promise { @@ -47,7 +79,17 @@ export async function resolveModelConfig(options: ResolveModelConfigOptions): Pr const maxIterations = config.defaults.agentIterations?.[configKey] || config.defaults.maxIterations; + // Resolve task prompt override: project → defaults → undefined (use .eta default) + const customTaskPromptSource = + project.taskPrompts?.[agentType] ?? config.defaults.taskPrompts?.[agentType]; + + let taskPrompt: string | undefined; + if (customTaskPromptSource) { + const taskContext = buildTaskOverrideContext(promptContext, options.agentInput); + taskPrompt = renderCustomPrompt(customTaskPromptSource, taskContext, dbPartials); + } + const contextFiles = await readContextFiles(repoDir); - return { systemPrompt, model, maxIterations, contextFiles }; + return { systemPrompt, taskPrompt, model, maxIterations, contextFiles }; } diff --git a/src/agents/shared/taskPrompts.ts b/src/agents/shared/taskPrompts.ts index 51716282..a9ef419e 100644 --- a/src/agents/shared/taskPrompts.ts +++ b/src/agents/shared/taskPrompts.ts @@ -1,91 +1,16 @@ /** - * Shared task prompt builders used by both backends. + * Shared task prompt builders for prompts NOT managed via the YAML profile system. * - * The llmist backend (agents/base.ts) and the Claude Code backend - * (backends/agent-profiles.ts) both need task-level prompts for each agent type. - * This module is the single source of truth so the two backends produce - * identical instructions for each agent type. + * Task prompts managed through YAML profiles (workItem, commentResponse, review, + * ci, prCommentResponse) are now .eta templates in `src/agents/prompts/task-templates/` + * rendered via `renderTaskPrompt()` in the profile builder. + * + * This module retains only the two prompts called directly by trigger handlers/agents, + * not through the profile system: `buildCheckFailurePrompt` and `buildDebugPrompt`. */ import { parseRepoFullName } from '../../utils/repo.js'; -// ============================================================================ -// Work-item agents -// ============================================================================ - -/** - * Standard prompt for agents whose primary task is processing a work item - * (splitting, planning, implementation, debug). - */ -export function buildWorkItemPrompt(cardId: string): string { - return `Analyze and process the work item with ID: ${cardId}. The work item data has been pre-loaded.`; -} - -/** - * Prompt for agents responding to a PM comment mentioning them. - */ -export function buildCommentResponsePrompt( - cardId: string, - commentText: string, - commentAuthor: string, -): string { - return `A user (@${commentAuthor}) mentioned you in a comment on work item ${cardId}. - -Their comment: ---- -${commentText} ---- - -The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. -Read the user's comment carefully and classify it: if they ask a question or request clarification, reply with a thorough answer via PostComment (do not modify the plan). If they request plan changes, make surgical, targeted updates. If the comment contains both a question and a change request, do both. Default to plan updates when intent is ambiguous.`; -} - -// ============================================================================ -// PR agents -// ============================================================================ - -/** - * Prompt for the review agent. - */ -export function buildReviewPrompt(prNumber: number): string { - return `Review PR #${prNumber}. - -Examine the code changes carefully and submit your review using CreatePRReview.`; -} - -/** - * Prompt for the respond-to-ci agent. - */ -export function buildCIResponsePrompt(prBranch: string, prNumber: number): string { - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -CI checks have failed. Analyze the failures and fix them.`; -} - -/** - * Prompt for PR-comment-response agents (respond-to-review, respond-to-pr-comment). - */ -export function buildPRCommentResponsePrompt( - prBranch: string, - prNumber: number, - commentBody: string, - commentPath?: string, -): string { - const pathContext = commentPath ? `\nFile: ${commentPath}` : ''; - - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -A user commented on this PR and mentioned you. Respond to their comment. -${pathContext} - -Their comment: ---- -${commentBody} ---- - -Read the comment carefully and respond accordingly. If they ask for code changes, make the changes, commit, and push. If they ask a question, reply with a PR comment. Default to surgical, targeted changes unless they clearly ask for something broader.`; -} - /** * Prompt for the respond-to-ci agent (llmist backend format — includes GitHub context). * Used by agents/base.ts when the trigger type is 'check-failure'. diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index efe9af9d..6680b8a8 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -83,13 +83,20 @@ async function buildBackendInput( // DB not available — fall back to disk-only partials } - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ + const { + systemPrompt, + taskPrompt: taskPromptOverride, + model, + maxIterations, + contextFiles, + } = await resolveModelConfig({ agentType, project, config, repoDir, promptContext, dbPartials, + agentInput: input, }); const profile = getAgentProfile(agentType); @@ -123,7 +130,7 @@ async function buildBackendInput( config, repoDir, systemPrompt, - taskPrompt: profile.buildTaskPrompt(input), + taskPrompt: taskPromptOverride ?? profile.buildTaskPrompt(input), cliToolsDir, availableTools: profile.filterTools(getToolManifests()), contextInjections, @@ -263,7 +270,9 @@ export async function executeWithBackend( monitor?.stop(); } - postProcessResult(result, agentType, backend, input, identifier); + postProcessResult(result, agentType, backend, input, identifier, { + requiresPR: profile.requiresPR, + }); return { success: result.success, diff --git a/src/backends/agent-profiles.ts b/src/backends/agent-profiles.ts index 85b0ca83..765e61b1 100644 --- a/src/backends/agent-profiles.ts +++ b/src/backends/agent-profiles.ts @@ -1,97 +1,23 @@ -import { execFileSync } from 'node:child_process'; - import { type AgentCapabilities, getAgentCapabilities } from '../agents/shared/capabilities.js'; export type { AgentCapabilities } from '../agents/shared/capabilities.js'; +import type { FetchContextParams, PreExecuteParams } from '../agents/definitions/contextSteps.js'; import { - buildPRAgentGadgets, - buildReviewGadgets, - buildWorkItemGadgets, -} from '../agents/shared/gadgets.js'; -import { - formatPRComments, - formatPRDetails, - formatPRDiff, - formatPRIssueComments, - formatPRReviews, - readPRFileContents, -} from '../agents/shared/prFormatting.js'; -import { - buildCIResponsePrompt, - buildCommentResponsePrompt, - buildPRCommentResponsePrompt, - buildReviewPrompt, - buildWorkItemPrompt, -} from '../agents/shared/taskPrompts.js'; -import type { ContextFile } from '../agents/utils/setup.js'; -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; -import { ListDirectory } from '../gadgets/ListDirectory.js'; -import { formatCheckStatus } from '../gadgets/github/core/getPRChecks.js'; -import { readWorkItem } from '../gadgets/pm/core/readWorkItem.js'; -import { githubClient } from '../github/client.js'; + type AgentDefinition, + CONTEXT_STEP_REGISTRY, + GADGET_BUILDER_REGISTRY, + PRE_EXECUTE_REGISTRY, + SDK_TOOLS_REGISTRY, + TOOL_SET_REGISTRY, + loadAgentDefinition, +} from '../agents/definitions/index.js'; +import { type TaskPromptContext, renderTaskPrompt } from '../agents/prompts/index.js'; import type { AgentInput } from '../types/index.js'; -import { parseRepoFullName } from '../utils/repo.js'; -import { resolveSquintDbPath } from '../utils/squintDb.js'; -import type { ContextInjection, LogWriter, ToolManifest } from './types.js'; - -// ============================================================================ -// Tool Name Sets -// ============================================================================ - -/** PM tools available to most agents */ -const PM_TOOLS = [ - 'ReadWorkItem', - 'PostComment', - 'UpdateWorkItem', - 'CreateWorkItem', - 'ListWorkItems', - 'AddChecklist', -]; - -/** PM checklist update — excluded from planning to prevent premature completion */ -const PM_CHECKLIST_TOOL = 'UpdateChecklistItem'; - -/** GitHub review tools for code review agents */ -const GITHUB_REVIEW_TOOLS = [ - 'GetPRDetails', - 'GetPRDiff', - 'GetPRChecks', - 'GetPRComments', - 'PostPRComment', - 'UpdatePRComment', - 'ReplyToReviewComment', - 'CreatePRReview', -]; - -/** GitHub CI tools for respond-to-ci agent (no CreatePR — pushes to existing branch) */ -const GITHUB_CI_TOOLS = [ - 'GetPRDetails', - 'GetPRDiff', - 'GetPRChecks', - 'PostPRComment', - 'UpdatePRComment', -]; - -const SESSION_TOOL = 'Finish'; - -const ALL_SDK_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; -const READ_ONLY_SDK_TOOLS = ['Read', 'Bash', 'Glob', 'Grep']; +import type { ContextInjection, ToolManifest } from './types.js'; // ============================================================================ // AgentProfile Interface // ============================================================================ -interface FetchContextParams { - input: AgentInput; - repoDir: string; - contextFiles: ContextFile[]; - logWriter: LogWriter; -} - -interface PreExecuteParams { - input: AgentInput; - logWriter: LogWriter; -} - export interface AgentProfile { /** Filter the full set of tool manifests down to what this agent needs */ filterTools(allTools: ToolManifest[]): ToolManifest[]; @@ -103,6 +29,8 @@ export interface AgentProfile { needsGitHubToken: boolean; /** Whether to block git push in hooks (default: true — set false for agents on existing PR branches) */ blockGitPush?: boolean; + /** Whether the agent must create a PR for success (e.g., implementation) */ + requiresPR?: boolean; /** Fetch context injections for this agent type */ fetchContext(params: FetchContextParams): Promise; /** Build the task prompt for this agent type */ @@ -119,15 +47,7 @@ export interface AgentProfile { } // ============================================================================ -// Llmist Gadget Builders -// ============================================================================ -// All three builder functions below delegate to the shared gadget factories in -// agents/shared/gadgets.ts, which serve as the single source of truth for tool -// sets used by both the llmist backend and the Claude Code backend. -// ============================================================================ - -// ============================================================================ -// Context Fetching Helpers +// Helpers // ============================================================================ function filterToolsByNames(allTools: ToolManifest[], names: string[]): ToolManifest[] { @@ -135,472 +55,97 @@ function filterToolsByNames(allTools: ToolManifest[], names: string[]): ToolMani return allTools.filter((t) => nameSet.has(t.name)); } -function fetchDirectoryListing(repoDir: string): ContextInjection { - const listDirGadget = new ListDirectory(); - // Pass the absolute repoDir path so ListDirectory resolves correctly - // without requiring process.chdir(), which is a dangerous side effect. - const params = { - comment: 'Pre-fetching codebase structure for context', - directoryPath: repoDir, - maxDepth: 3, - includeGitIgnored: false, - }; +function resolveRegistry(registry: Record, key: string, label: string): T { + const value = registry[key]; + if (!value) throw new Error(`${label} '${key}' not found in registry`); + return value; +} - const result = listDirGadget.execute(params); +/** + * Extract all relevant fields from AgentInput into a flat context object + * for Eta task prompt template rendering. + */ +function buildTaskPromptContext(input: AgentInput): TaskPromptContext { return { - toolName: 'ListDirectory', - params, - result, - description: 'Pre-fetched codebase structure', + cardId: input.cardId || 'unknown', + commentText: input.triggerCommentText as string | undefined, + commentAuthor: (input.triggerCommentAuthor as string) || 'unknown', + prNumber: input.prNumber, + prBranch: input.prBranch, + commentBody: input.triggerCommentBody as string | undefined, + commentPath: (input.triggerCommentPath as string) || undefined, }; } -function fetchContextFileInjections(contextFiles: ContextFile[]): ContextInjection[] { - return contextFiles.map((file) => ({ - toolName: 'ReadFile', - params: { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, - result: file.content, - description: `Pre-fetched ${file.path}`, - })); -} - -function fetchSquintOverview(repoDir: string): ContextInjection | null { - const squintDb = resolveSquintDbPath(repoDir); - if (!squintDb) return null; - - try { - const output = execFileSync('squint', ['overview', '-d', squintDb], { - encoding: 'utf-8', - timeout: 30_000, - }); - if (!output?.trim()) return null; - - return { - toolName: 'SquintOverview', - params: { comment: 'Pre-fetching Squint codebase overview for context', database: squintDb }, - result: output, - description: 'Pre-fetched Squint codebase overview', - }; - } catch { - return null; - } -} - -async function fetchWorkItemInjection(cardId: string): Promise { - try { - const cardData = await readWorkItem(cardId, true); - return { - toolName: 'ReadWorkItem', - params: { workItemId: cardId, includeComments: true }, - result: cardData, - description: 'Pre-fetched work item data', - }; - } catch { - return null; - } -} - -/** Fetch PR context injections (ported from review.ts:93-144) */ -async function fetchPRContextInjections( - owner: string, - repo: string, - prNumber: number, - repoDir: string, - logWriter: LogWriter, -): Promise<{ injections: ContextInjection[]; skippedFiles: string[] }> { - const injections: ContextInjection[] = []; - - logWriter('INFO', 'Fetching PR details, diff, and check status', { owner, repo, prNumber }); - - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, prDetails.headSha); - - const prDetailsFormatted = formatPRDetails(prDetails); - const diffFormatted = formatPRDiff(prDiff); - const checkStatusFormatted = formatCheckStatus(prNumber, checkStatus); - - injections.push({ - toolName: 'GetPRDetails', - params: { comment: 'Pre-fetching PR details for review context', owner, repo, prNumber }, - result: prDetailsFormatted, - description: 'Pre-fetched PR details', - }); - - injections.push({ - toolName: 'GetPRDiff', - params: { comment: 'Pre-fetching PR diff for code review', owner, repo, prNumber }, - result: diffFormatted, - description: 'Pre-fetched PR diff', - }); - - injections.push({ - toolName: 'GetPRChecks', - params: { comment: 'Pre-fetching CI check status for review', owner, repo, prNumber }, - result: checkStatusFormatted, - description: 'Pre-fetched CI check status', - }); - - // Read full contents of changed files - logWriter('INFO', 'Reading PR file contents', { fileCount: prDiff.length }); - const fileContents = await readPRFileContents(repoDir, prDiff); - logWriter('INFO', 'File contents loaded', { - included: fileContents.included.length, - skipped: fileContents.skipped.length, - }); - - for (const file of fileContents.included) { - injections.push({ - toolName: 'ReadFile', - params: { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, - result: `path=${file.path}\n\n${file.content}`, - description: `Pre-fetched ${file.path}`, - }); - } - - return { injections, skippedFiles: fileContents.skipped }; -} - // ============================================================================ -// Common Context Builders +// Profile Builder (YAML-driven) // ============================================================================ -/** Standard context for work-item-based agents: dirListing + contextFiles + squint + workItem */ -async function fetchWorkItemContext(params: FetchContextParams): Promise { - const injections: ContextInjection[] = []; - - injections.push(fetchDirectoryListing(params.repoDir)); - injections.push(...fetchContextFileInjections(params.contextFiles)); - - const squint = fetchSquintOverview(params.repoDir); - if (squint) injections.push(squint); - - if (params.input.cardId) { - const workItem = await fetchWorkItemInjection(params.input.cardId); - if (workItem) injections.push(workItem); - } - - return injections; -} - -/** PR review context: PR details + diff + checks + file contents + contextFiles + squint */ -async function fetchReviewContext(params: FetchContextParams): Promise { - const injections: ContextInjection[] = []; - - const repoFullName = params.input.repoFullName as string; - const prNumber = params.input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - // PR context first (most relevant for review) - const { injections: prInjections } = await fetchPRContextInjections( - owner, - repo, - prNumber, - params.repoDir, - params.logWriter, - ); - injections.push(...prInjections); - - // Then context files and squint for codebase understanding - injections.push(...fetchContextFileInjections(params.contextFiles)); - - const squint = fetchSquintOverview(params.repoDir); - if (squint) injections.push(squint); - - return injections; -} - -/** CI context: PR details + diff + checks + dirListing + contextFiles + squint + optional workItem */ -async function fetchCIContext(params: FetchContextParams): Promise { - const injections: ContextInjection[] = []; - const repoFullName = params.input.repoFullName as string; - const prNumber = params.input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - // PR context (details, diff, checks) — most relevant for CI fixing - const { injections: prInjections } = await fetchPRContextInjections( - owner, - repo, - prNumber, - params.repoDir, - params.logWriter, - ); - injections.push(...prInjections); - - // Codebase context - injections.push(fetchDirectoryListing(params.repoDir)); - injections.push(...fetchContextFileInjections(params.contextFiles)); - - const squint = fetchSquintOverview(params.repoDir); - if (squint) injections.push(squint); - - // Work item context (if triggered from a Trello card) - if (params.input.cardId) { - const workItem = await fetchWorkItemInjection(params.input.cardId); - if (workItem) injections.push(workItem); +function buildProfileFromDefinition(agentType: string, def: AgentDefinition): AgentProfile { + // Resolve tool names from YAML set references + const hasAllSet = def.tools.sets.includes('all'); + const toolNames: string[] = []; + if (!hasAllSet) { + for (const setName of def.tools.sets) { + const tools = TOOL_SET_REGISTRY[setName]; + if (tools) toolNames.push(...tools); + } } - return injections; -} - -/** PR comment response context: PR details + diff + conversation + dirListing + contextFiles + squint */ -async function fetchPRCommentResponseContext( - params: FetchContextParams, -): Promise { - const injections: ContextInjection[] = []; - const repoFullName = params.input.repoFullName as string; - const prNumber = params.input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - // PR context (details, diff, checks) - const { injections: prInjections } = await fetchPRContextInjections( - owner, - repo, - prNumber, - params.repoDir, - params.logWriter, + const sdkTools = SDK_TOOLS_REGISTRY[def.tools.sdkTools]; + // taskPromptBuilder YAML value maps directly to the .eta template filename + // (validated by the Zod schema in AgentDefinitionSchema) + const taskTemplateName = def.strategies.taskPromptBuilder; + const caps = getAgentCapabilities(agentType); + const gadgetBuilderFn = resolveRegistry( + GADGET_BUILDER_REGISTRY, + def.strategies.gadgetBuilder, + 'gadgetBuilder', ); - injections.push(...prInjections); - - // Conversation context (review comments, reviews, issue comments) - params.logWriter('INFO', 'Fetching PR conversation context', { owner, repo, prNumber }); - - const [reviewComments, reviews, issueComments] = await Promise.all([ - githubClient.getPRReviewComments(owner, repo, prNumber), - githubClient.getPRReviews(owner, repo, prNumber), - githubClient.getPRIssueComments(owner, repo, prNumber), - ]); - - injections.push({ - toolName: 'GetPRComments', - params: { - comment: 'Pre-fetching PR review comments for conversation context', - owner, - repo, - prNumber, + const gadgetBuilderOptions = def.strategies.gadgetBuilderOptions; + const contextPipeline = def.strategies.contextPipeline; + + const profile: AgentProfile = { + filterTools: hasAllSet + ? (allTools) => allTools + : (allTools) => filterToolsByNames(allTools, toolNames), + sdkTools, + enableStopHooks: def.backend.enableStopHooks, + needsGitHubToken: def.backend.needsGitHubToken, + ...(def.backend.blockGitPush !== undefined && { blockGitPush: def.backend.blockGitPush }), + ...(def.backend.requiresPR && { requiresPR: true }), + fetchContext: async (params) => { + const injections: ContextInjection[] = []; + for (const step of contextPipeline) { + const stepFn = resolveRegistry(CONTEXT_STEP_REGISTRY, step, 'contextPipeline step'); + const result = await stepFn(params); + injections.push(...result); + } + return injections; }, - result: formatPRComments(reviewComments), - description: 'Pre-fetched PR review comments', - }); - - injections.push({ - toolName: 'GetPRComments', - params: { comment: 'Pre-fetching PR reviews for conversation context', owner, repo, prNumber }, - result: formatPRReviews(reviews), - description: 'Pre-fetched PR reviews', - }); - - injections.push({ - toolName: 'GetPRComments', - params: { - comment: 'Pre-fetching PR issue comments for conversation context', - owner, - repo, - prNumber, - }, - result: formatPRIssueComments(issueComments), - description: 'Pre-fetched PR issue comments', - }); - - // Codebase context - injections.push(fetchDirectoryListing(params.repoDir)); - injections.push(...fetchContextFileInjections(params.contextFiles)); - - const squint = fetchSquintOverview(params.repoDir); - if (squint) injections.push(squint); - - return injections; -} - -// ============================================================================ -// Task Prompt Builders (thin wrappers around shared/taskPrompts.ts) -// ============================================================================ - -function buildWorkItemTaskPrompt(input: AgentInput): string { - return buildWorkItemPrompt(input.cardId || 'unknown'); -} - -function buildCommentResponseTaskPrompt(input: AgentInput): string { - const commentText = input.triggerCommentText as string; - const commentAuthor = (input.triggerCommentAuthor as string) || 'unknown'; - return buildCommentResponsePrompt(input.cardId || 'unknown', commentText, commentAuthor); -} - -function buildReviewTaskPrompt(input: AgentInput): string { - return buildReviewPrompt(input.prNumber as number); -} + buildTaskPrompt: (input) => renderTaskPrompt(taskTemplateName, buildTaskPromptContext(input)), + capabilities: caps, + getLlmistGadgets: (at) => gadgetBuilderFn(getAgentCapabilities(at), gadgetBuilderOptions), + }; -function buildCITaskPrompt(input: AgentInput): string { - return buildCIResponsePrompt(input.prBranch as string, input.prNumber as number); -} + if (def.backend.preExecute) { + const preExecFn = resolveRegistry(PRE_EXECUTE_REGISTRY, def.backend.preExecute, 'preExecute'); + profile.preExecute = (params) => preExecFn(agentType, params); + } -function buildPRCommentResponseTaskPrompt(input: AgentInput): string { - return buildPRCommentResponsePrompt( - input.prBranch as string, - input.prNumber as number, - input.triggerCommentBody as string, - (input.triggerCommentPath as string) || undefined, - ); + return profile; } // ============================================================================ -// Agent Profiles -// ============================================================================ - -const splittingProfile: AgentProfile = { - filterTools: (allTools) => - filterToolsByNames(allTools, [...PM_TOOLS, PM_CHECKLIST_TOOL, SESSION_TOOL]), - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: false, - needsGitHubToken: false, - fetchContext: fetchWorkItemContext, - buildTaskPrompt: buildWorkItemTaskPrompt, - capabilities: getAgentCapabilities('splitting'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -const planningProfile: AgentProfile = { - filterTools: (allTools) => filterToolsByNames(allTools, [...PM_TOOLS, SESSION_TOOL]), - sdkTools: READ_ONLY_SDK_TOOLS, - enableStopHooks: false, - needsGitHubToken: false, - fetchContext: fetchWorkItemContext, - buildTaskPrompt: buildWorkItemTaskPrompt, - capabilities: getAgentCapabilities('planning'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -const reviewProfile: AgentProfile = { - filterTools: (allTools) => filterToolsByNames(allTools, [...GITHUB_REVIEW_TOOLS, SESSION_TOOL]), - sdkTools: READ_ONLY_SDK_TOOLS, - enableStopHooks: false, - needsGitHubToken: true, - fetchContext: fetchReviewContext, - buildTaskPrompt: buildReviewTaskPrompt, - capabilities: getAgentCapabilities('review'), - getLlmistGadgets: (_agentType) => buildReviewGadgets(), - - async preExecute({ input, logWriter }: PreExecuteParams): Promise { - // Skip if ack comment already posted by router or webhook handler - if (input.ackCommentId) return; - - const repoFullName = input.repoFullName as string; - const prNumber = input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - const message = (input.ackMessage as string | undefined) ?? INITIAL_MESSAGES.review; - logWriter('INFO', 'Posting initial review comment', { owner, repo, prNumber }); - await githubClient.createPRComment(owner, repo, prNumber, message); - }, -}; - -const respondToPlanningCommentProfile: AgentProfile = { - filterTools: (allTools) => - filterToolsByNames(allTools, [...PM_TOOLS, PM_CHECKLIST_TOOL, SESSION_TOOL]), - sdkTools: READ_ONLY_SDK_TOOLS, - enableStopHooks: false, - needsGitHubToken: false, - fetchContext: fetchWorkItemContext, - buildTaskPrompt: buildCommentResponseTaskPrompt, - capabilities: getAgentCapabilities('respond-to-planning-comment'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -const respondToCIProfile: AgentProfile = { - filterTools: (allTools) => - filterToolsByNames(allTools, [ - ...GITHUB_CI_TOOLS, - ...PM_TOOLS, - PM_CHECKLIST_TOOL, - SESSION_TOOL, - ]), - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: true, - needsGitHubToken: true, - blockGitPush: false, - fetchContext: fetchCIContext, - buildTaskPrompt: buildCITaskPrompt, - capabilities: getAgentCapabilities('respond-to-ci'), - getLlmistGadgets: (_agentType) => buildPRAgentGadgets(), - - async preExecute({ input, logWriter }: PreExecuteParams): Promise { - // Skip if ack comment already posted by router or webhook handler - if (input.ackCommentId) return; - - const repoFullName = input.repoFullName as string; - const prNumber = input.prNumber as number; - const { owner, repo } = parseRepoFullName(repoFullName); - - const message = (input.ackMessage as string | undefined) ?? INITIAL_MESSAGES['respond-to-ci']; - logWriter('INFO', 'Posting initial CI fix comment', { owner, repo, prNumber }); - await githubClient.createPRComment(owner, repo, prNumber, message); - }, -}; - -const respondToReviewProfile: AgentProfile = { - filterTools: (allTools) => filterToolsByNames(allTools, [...GITHUB_REVIEW_TOOLS, SESSION_TOOL]), - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: true, - needsGitHubToken: true, - blockGitPush: false, - fetchContext: fetchPRCommentResponseContext, - buildTaskPrompt: buildPRCommentResponseTaskPrompt, - capabilities: getAgentCapabilities('respond-to-review'), - getLlmistGadgets: (_agentType) => buildPRAgentGadgets({ includeReviewComments: true }), -}; - -const respondToPRCommentProfile: AgentProfile = { - filterTools: (allTools) => filterToolsByNames(allTools, [...GITHUB_REVIEW_TOOLS, SESSION_TOOL]), - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: true, - needsGitHubToken: true, - blockGitPush: false, - fetchContext: fetchPRCommentResponseContext, - buildTaskPrompt: buildPRCommentResponseTaskPrompt, - capabilities: getAgentCapabilities('respond-to-pr-comment'), - getLlmistGadgets: (_agentType) => buildPRAgentGadgets({ includeReviewComments: true }), -}; - -const defaultProfile: AgentProfile = { - filterTools: (allTools) => allTools, - sdkTools: ALL_SDK_TOOLS, - enableStopHooks: true, - needsGitHubToken: false, - fetchContext: fetchWorkItemContext, - buildTaskPrompt: buildWorkItemTaskPrompt, - capabilities: getAgentCapabilities('debug'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -const implementationProfile: AgentProfile = { - ...defaultProfile, - needsGitHubToken: true, - capabilities: getAgentCapabilities('implementation'), - getLlmistGadgets: (agentType) => buildWorkItemGadgets(getAgentCapabilities(agentType)), -}; - -// ============================================================================ -// Profile Registry +// Public API // ============================================================================ -const PROFILE_REGISTRY: Record = { - splitting: splittingProfile, - planning: planningProfile, - implementation: implementationProfile, - review: reviewProfile, - 'respond-to-planning-comment': respondToPlanningCommentProfile, - 'respond-to-review': respondToReviewProfile, - 'respond-to-pr-comment': respondToPRCommentProfile, - 'respond-to-ci': respondToCIProfile, - debug: defaultProfile, -}; - export function getAgentProfile(agentType: string): AgentProfile { - const profile = PROFILE_REGISTRY[agentType]; - if (!profile) { - throw new Error( - `Unknown agent type '${agentType}' — add it to PROFILE_REGISTRY in agent-profiles.ts`, - ); + let def: AgentDefinition; + try { + def = loadAgentDefinition(agentType); + } catch (err) { + throw new Error(`Failed to load agent profile for '${agentType}'`, { cause: err }); } - return profile; + return buildProfileFromDefinition(agentType, def); } diff --git a/src/backends/llmist/index.ts b/src/backends/llmist/index.ts index 2bfa5cdb..1f13d11c 100644 --- a/src/backends/llmist/index.ts +++ b/src/backends/llmist/index.ts @@ -2,6 +2,7 @@ import os from 'node:os'; import { LLMist, type ModelSpec, createLogger } from 'llmist'; +import { loadAgentDefinition } from '../../agents/definitions/index.js'; import { type BuilderType, createConfiguredBuilder } from '../../agents/shared/builderFactory.js'; import { injectSyntheticCall } from '../../agents/shared/syntheticCalls.js'; import { runAgentLoop } from '../../agents/utils/agentLoop.js'; @@ -15,6 +16,11 @@ import { extractPRUrl } from '../../utils/prUrl.js'; import { getAgentProfile } from '../agent-profiles.js'; import type { AgentBackend, AgentBackendInput, AgentBackendResult } from '../types.js'; +/** Post-configure registry: maps YAML string references to builder transform functions */ +const POST_CONFIGURE_REGISTRY: Record BuilderType> = { + sequentialGadgetExecution: (b) => b.withGadgetExecutionMode('sequential'), +}; + /** * llmist backend — executes agents using the llmist SDK. * @@ -107,10 +113,16 @@ export class LlmistBackend implements AgentBackend { progressMonitor: progressReporter as Parameters< typeof createConfiguredBuilder >[0]['progressMonitor'], - // Implementation agent uses sequential execution to ensure file operations - // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) - postConfigure: - agentType === 'implementation' ? (b) => b.withGadgetExecutionMode('sequential') : undefined, + // Post-configure hook from YAML definition (e.g., sequentialGadgetExecution for implementation) + postConfigure: (() => { + try { + const def = loadAgentDefinition(agentType); + const hookName = def.backend.postConfigure; + return hookName ? POST_CONFIGURE_REGISTRY[hookName] : undefined; + } catch { + return undefined; + } + })(), }); // Convert ContextInjection[] from the unified adapter into synthetic gadget calls. diff --git a/src/backends/postProcess.ts b/src/backends/postProcess.ts index 6894fa3b..a30b8663 100644 --- a/src/backends/postProcess.ts +++ b/src/backends/postProcess.ts @@ -3,7 +3,7 @@ import { logger } from '../utils/logging.js'; import type { AgentBackend, AgentBackendResult } from './types.js'; /** - * Post-process a backend result: validate PR creation for implementation agents + * Post-process a backend result: validate PR creation for agents that require it * and zero out cost for subscription-backed Claude Code sessions. */ export function postProcessResult( @@ -12,15 +12,16 @@ export function postProcessResult( backend: AgentBackend, input: AgentInput & { project: ProjectConfig }, identifier: string, + options?: { requiresPR?: boolean }, ): void { - // Validate PR creation for implementation agents - if (agentType === 'implementation' && result.success && !result.prUrl) { - logger.warn('Implementation agent completed without creating a PR', { + // Validate PR creation for agents that require it (e.g., implementation) + if (options?.requiresPR && result.success && !result.prUrl) { + logger.warn(`${agentType} agent completed without creating a PR`, { identifier, backend: backend.name, }); result.success = false; - result.error = 'Implementation completed but no PR was created'; + result.error = 'Agent completed but no PR was created'; } // Zero out cost for subscription-backed Claude Code sessions diff --git a/src/config/agentMessages.ts b/src/config/agentMessages.ts index 127e8e41..74057286 100644 --- a/src/config/agentMessages.ts +++ b/src/config/agentMessages.ts @@ -1,3 +1,38 @@ +import { getKnownAgentTypes, loadAgentDefinition } from '../agents/definitions/index.js'; + +// ============================================================================ +// Agent Labels, Role Hints, and Initial Messages — derived from YAML definitions +// ============================================================================ + +function buildRecords(): { + labels: Record; + roleHints: Record; + initialMessages: Record; +} { + const labels: Record = {}; + const roleHints: Record = {}; + const initialMessages: Record = {}; + + for (const agentType of getKnownAgentTypes()) { + const def = loadAgentDefinition(agentType); + labels[agentType] = { emoji: def.identity.emoji, label: def.identity.label }; + roleHints[agentType] = def.identity.roleHint; + initialMessages[agentType] = def.identity.initialMessage; + } + + return { labels, roleHints, initialMessages }; +} + +// Eager-load at module init (YAML files are on disk, read is fast) +let labels: Record; +let roleHints: Record; +let initialMessages: Record; +try { + ({ labels, roleHints, initialMessages } = buildRecords()); +} catch (err) { + throw new Error('Failed to load agent identity records from YAML definitions', { cause: err }); +} + /** * Agent-specific emoji and label for progress update headers. * @@ -5,17 +40,7 @@ * - progressModel.ts — LLM prompt to produce correct header * - statusUpdateConfig.ts — template fallback header */ -export const AGENT_LABELS: Record = { - splitting: { emoji: '📋', label: 'Splitting Update' }, - planning: { emoji: '🗺️', label: 'Planning Update' }, - implementation: { emoji: '🧑‍💻', label: 'Implementation Update' }, - review: { emoji: '🔍', label: 'Code Review Update' }, - 'respond-to-planning-comment': { emoji: '💬', label: 'Planning Response Update' }, - 'respond-to-review': { emoji: '🔧', label: 'Review Response Update' }, - 'respond-to-pr-comment': { emoji: '💬', label: 'PR Comment Response Update' }, - 'respond-to-ci': { emoji: '🔧', label: 'CI Fix Update' }, - debug: { emoji: '🐛', label: 'Debug Update' }, -}; +export const AGENT_LABELS: Record = labels; /** * Get the emoji and label for a given agent type. @@ -32,17 +57,7 @@ export function getAgentLabel(agentType: string): { emoji: string; label: string * - ackMessageGenerator.ts — contextual acknowledgment messages * - progressModel.ts — progress update generation */ -export const AGENT_ROLE_HINTS: Record = { - splitting: 'Breaks down a feature plan into smaller, ordered work items (subtasks)', - planning: 'Studies the codebase and designs a step-by-step implementation plan', - implementation: 'Writes code, runs tests, and prepares a pull request', - review: 'Reviews pull request changes for quality and correctness', - 'respond-to-planning-comment': 'Reads user feedback and updates the plan accordingly', - 'respond-to-review': 'Addresses code review feedback by making requested changes', - 'respond-to-pr-comment': 'Reads a PR comment and takes action', - 'respond-to-ci': 'Analyzes failed CI checks and works on a fix', - debug: 'Analyzes session logs to identify what went wrong', -}; +export const AGENT_ROLE_HINTS: Record = roleHints; /** * Human-readable initial messages per agent type. @@ -51,20 +66,4 @@ export const AGENT_ROLE_HINTS: Record = { * - ProgressMonitor (worker-side) — initial comment on work item * - Router acknowledgments — immediate ack before worker starts */ -export const INITIAL_MESSAGES: Record = { - splitting: '**📋 Splitting plan** — Reading the plan and splitting it into ordered work items...', - planning: - '**🗺️ Planning implementation** — Studying the codebase and designing a step-by-step plan...', - implementation: - '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', - review: '**🔍 Reviewing code** — Examining the PR changes for quality and correctness...', - 'respond-to-planning-comment': - '**💬 Responding to feedback** — Reading your comment and updating the plan accordingly...', - 'respond-to-review': - '**🔧 Addressing review feedback** — Making the requested changes from the code review...', - 'respond-to-pr-comment': - '**💬 Responding to PR comment** — Reading your comment and taking action...', - 'respond-to-ci': - '**🔧 Fixing CI failures** — Analyzing the failed checks and working on a fix...', - debug: '**🐛 Analyzing session logs** — Reviewing what happened and identifying issues...', -}; +export const INITIAL_MESSAGES: Record = initialMessages; diff --git a/src/config/compactionConfig.ts b/src/config/compactionConfig.ts index 9d67d655..71534a61 100644 --- a/src/config/compactionConfig.ts +++ b/src/config/compactionConfig.ts @@ -1,4 +1,5 @@ import type { CompactionConfig, CompactionEvent } from 'llmist'; +import { loadAgentDefinition } from '../agents/definitions/index.js'; import { clearReadTracking } from '../gadgets/readTracking.js'; import { logger } from '../utils/logging.js'; @@ -62,6 +63,11 @@ Format as a brief narrative, with the failed approaches as a bullet list at the Previous conversation:`, }; +const COMPACTION_PRESET_REGISTRY: Record = { + implementation: IMPLEMENTATION_COMPACTION_BASE, + default: DEFAULT_COMPACTION_BASE, +}; + /** * Handle compaction event: log and clear read tracking. * @@ -89,13 +95,21 @@ function handleCompaction(event: CompactionEvent): void { /** * Get compaction configuration for a given agent type. + * Reads the compaction preset name from the YAML definition. * * @param agentType - Type of agent (e.g., "implementation", "splitting", "planning") * @returns Compaction configuration */ export function getCompactionConfig(agentType: string): CompactionConfig { - const baseConfig = - agentType === 'implementation' ? IMPLEMENTATION_COMPACTION_BASE : DEFAULT_COMPACTION_BASE; + let presetName = 'default'; + try { + const def = loadAgentDefinition(agentType); + presetName = def.compaction; + } catch { + // Unknown agent type — use default preset + } + + const baseConfig = COMPACTION_PRESET_REGISTRY[presetName] ?? DEFAULT_COMPACTION_BASE; return { ...baseConfig, onCompaction: handleCompaction, diff --git a/src/config/hintConfig.ts b/src/config/hintConfig.ts index 367bcee0..be1931ea 100644 --- a/src/config/hintConfig.ts +++ b/src/config/hintConfig.ts @@ -1,5 +1,6 @@ import { execSync } from 'node:child_process'; import type { TrailingMessage } from 'llmist'; +import { loadAgentDefinition } from '../agents/definitions/index.js'; import { formatDiagnosticStatus, getDiagnosticLoopFiles, @@ -7,38 +8,20 @@ import { } from '../gadgets/shared/diagnosticState.js'; import { formatTodoList, loadTodos } from '../gadgets/todo/storage.js'; -/** - * Agent-specific batch hints. - * Each agent type gets guidance relevant to its available gadgets. - */ -const AGENT_HINTS: Record = { - // Agents with file editing capabilities - implementation: - 'Complete the current todo in as few iterations as possible. Batch related edits together. Verify with Tmux after edits. NEVER mark acceptance criteria complete without passing verification.', - 'respond-to-review': - 'Address the current review comment fully before moving to the next. Batch related file edits together.', - 'respond-to-ci': - 'Fix CI failures with minimal, focused changes. Batch related file edits together.', - - // Read-only agents - review: - 'Focus on the current aspect of review before moving to the next. Read related files together.', - splitting: 'Gather all context needed for the current step before proceeding.', - planning: 'Complete the current planning step efficiently before moving to the next.', - debug: 'Analyze the current issue fully before moving to the next.', - - // Default fallback - default: 'Complete the current task efficiently before moving to the next.', -}; - /** * Get the agent-specific hint for batch processing. + * Reads from YAML definition; falls back to a default for unknown types. */ function getAgentHint(agentType?: string): string { - if (agentType && agentType in AGENT_HINTS) { - return AGENT_HINTS[agentType]; + if (agentType) { + try { + const def = loadAgentDefinition(agentType); + return def.hint; + } catch { + // Unknown agent type — fall through to default + } } - return AGENT_HINTS.default; + return 'Complete the current task efficiently before moving to the next.'; } /** @@ -99,59 +82,58 @@ function formatIterationStatus( } /** - * Get trailing message function for iteration tracking. - * - * Injects iteration budget awareness into each LLM call: - * - Always shows current iteration, remaining count, and percentage - * - Adds urgency indicator when running low on iterations - * - Includes agent-specific batch processing hints - * - For implementation agent: includes current todo list for visibility - * - * Note: Loop detection warnings are injected as separate user messages - * (see agentLoop.ts) rather than in trailing messages for higher visibility. - * - * Trailing messages are ephemeral - they appear in each request but don't - * persist to conversation history, keeping context clean. - * - * @param agentType - The type of agent (e.g., 'implementation', 'review') - * @returns Trailing message function - */ -/** - * Build the trailing message for the implementation agent. - * Includes diagnostics, todo progress, git status, PR status, and reminders. + * Build the full trailing message with all optional sections. */ -function buildImplementationTrailingMessage(timestamp: string, iterationStatus: string): string { +function buildFullTrailingMessage( + timestamp: string, + iterationStatus: string, + flags: { + includeDiagnostics?: boolean; + includeTodoProgress?: boolean; + includeGitStatus?: boolean; + includePRStatus?: boolean; + includeReminder?: boolean; + }, +): string { const sections: string[] = [timestamp, iterationStatus]; - if (hasAnyDiagnosticErrors()) { + if (flags.includeDiagnostics && hasAnyDiagnosticErrors()) { sections.push(formatDiagnosticStatus()); const loopWarning = formatDiagnosticLoopWarning(); if (loopWarning) sections.push(loopWarning); } - const todos = loadTodos(); - if (todos.length > 0) { - sections.push(`## Current Progress\n\n${formatTodoList(todos)}`); + if (flags.includeTodoProgress) { + const todos = loadTodos(); + if (todos.length > 0) { + sections.push(`## Current Progress\n\n${formatTodoList(todos)}`); + } } - const gitStatus = getGitStatus(); - sections.push( - gitStatus - ? `## Git Status\n\n\`\`\`\n${gitStatus}\n\`\`\`` - : '## Git Status\n\nNo uncommitted changes.', - ); + if (flags.includeGitStatus) { + const gitStatus = getGitStatus(); + sections.push( + gitStatus + ? `## Git Status\n\n\`\`\`\n${gitStatus}\n\`\`\`` + : '## Git Status\n\nNo uncommitted changes.', + ); + } - const prView = getPRView(); - sections.push( - prView - ? `## PR Status\n\n\`\`\`\n${prView}\n\`\`\`` - : '## PR Status\n\nNo PR exists for current branch.', - ); + if (flags.includePRStatus) { + const prView = getPRView(); + sections.push( + prView + ? `## PR Status\n\n\`\`\`\n${prView}\n\`\`\`` + : '## PR Status\n\nNo PR exists for current branch.', + ); + } - sections.push( - '## Reminder\n\nCall multiple gadgets in a single response when you know which ones you need. ' + - 'For example, read multiple related files at once, or make multiple independent edits together.', - ); + if (flags.includeReminder) { + sections.push( + '## Reminder\n\nCall multiple gadgets in a single response when you know which ones you need. ' + + 'For example, read multiple related files at once, or make multiple independent edits together.', + ); + } return sections.join('\n\n'); } @@ -185,25 +167,53 @@ function formatDiagnosticLoopWarning(): string | null { return lines.join('\n'); } +/** + * Get trailing message function for iteration tracking. + * + * Injects iteration budget awareness into each LLM call: + * - Always shows current iteration, remaining count, and percentage + * - Adds urgency indicator when running low on iterations + * - Includes agent-specific batch processing hints + * - Uses YAML trailingMessage flags to decide which extra sections to include + * + * Note: Loop detection warnings are injected as separate user messages + * (see agentLoop.ts) rather than in trailing messages for higher visibility. + * + * Trailing messages are ephemeral - they appear in each request but don't + * persist to conversation history, keeping context clean. + * + * @param agentType - The type of agent (e.g., 'implementation', 'review') + * @returns Trailing message function + */ export function getIterationTrailingMessage(agentType?: string): TrailingMessage { const batchHint = getAgentHint(agentType); + // Resolve trailing message flags from YAML definition + let flags: { + includeDiagnostics?: boolean; + includeTodoProgress?: boolean; + includeGitStatus?: boolean; + includePRStatus?: boolean; + includeReminder?: boolean; + } = {}; + + if (agentType) { + try { + const def = loadAgentDefinition(agentType); + flags = def.trailingMessage ?? {}; + } catch { + // Unknown agent type — use empty flags (basic message only) + } + } + + const hasAnyFlag = Object.values(flags).some(Boolean); + return (ctx) => { const timestamp = `**Timestamp:** ${getCurrentTimestamp()}`; const iterationStatus = formatIterationStatus(ctx.iteration, ctx.maxIterations, batchHint); - if (agentType === 'implementation') { - return buildImplementationTrailingMessage(timestamp, iterationStatus); - } - - if ( - (agentType === 'respond-to-review' || agentType === 'respond-to-ci') && - hasAnyDiagnosticErrors() - ) { - const sections = [timestamp, iterationStatus, formatDiagnosticStatus()]; - const loopWarning = formatDiagnosticLoopWarning(); - if (loopWarning) sections.push(loopWarning); - return sections.join('\n\n'); + if (hasAnyFlag) { + return buildFullTrailingMessage(timestamp, iterationStatus, flags); } return `${timestamp}\n\n${iterationStatus}`; diff --git a/src/config/schema.ts b/src/config/schema.ts index f4323ab8..2836a4ab 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -73,6 +73,7 @@ export const ProjectConfigSchema = z.object({ .optional(), prompts: z.record(z.string()).optional(), + taskPrompts: z.record(z.string()).optional(), model: z.string().optional(), agentModels: z.record(z.string()).optional(), cardBudgetUsd: z.number().positive().optional(), @@ -97,6 +98,7 @@ export const CascadeConfigSchema = z.object({ progressModel: z.string().default('openrouter:google/gemini-2.5-flash-lite'), progressIntervalMinutes: z.number().positive().default(5), prompts: z.record(z.string()).default({}), + taskPrompts: z.record(z.string()).default({}), }) .default({}), projects: z.array(ProjectConfigSchema).min(1), diff --git a/src/db/migrations/0016_add_task_prompt_column.sql b/src/db/migrations/0016_add_task_prompt_column.sql new file mode 100644 index 00000000..b96c0dbe --- /dev/null +++ b/src/db/migrations/0016_add_task_prompt_column.sql @@ -0,0 +1 @@ +ALTER TABLE "agent_configs" ADD COLUMN IF NOT EXISTS "task_prompt" TEXT; \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 9dd14544..f2e0c398 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1750000000000, "tag": "0015_rename_briefing_to_splitting", "breakpoints": false + }, + { + "idx": 16, + "version": "7", + "when": 1751000000000, + "tag": "0016_add_task_prompt_column", + "breakpoints": false } ] } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index b85ef3d6..398f11fa 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -51,6 +51,7 @@ export interface AgentConfigRow { maxIterations: number | null; agentBackend: string | null; prompt: string | null; + taskPrompt: string | null; } export interface IntegrationRow { @@ -89,6 +90,7 @@ export interface ProjectConfigRaw { branchPrefix: string; pm: { type: string }; prompts?: Record; + taskPrompts?: Record; model?: string; agentModels?: Record; cardBudgetUsd?: number; @@ -139,19 +141,22 @@ export function buildAgentMaps(configs: AgentConfigRow[]): { models: Record; iterations: Record; prompts: Record; + taskPrompts: Record; backends: Record; } { const models: Record = {}; const iterations: Record = {}; const prompts: Record = {}; + const taskPrompts: Record = {}; const backends: Record = {}; for (const ac of configs) { if (ac.model) models[ac.agentType] = ac.model; if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; if (ac.prompt) prompts[ac.agentType] = ac.prompt; + if (ac.taskPrompt) taskPrompts[ac.agentType] = ac.taskPrompt; if (ac.agentBackend) backends[ac.agentType] = ac.agentBackend; } - return { models, iterations, prompts, backends }; + return { models, iterations, prompts, taskPrompts, backends }; } export function orUndefined>(obj: T): T | undefined { @@ -206,7 +211,7 @@ export function mapDefaultsRow( row: DefaultsRow | undefined, globalAgentConfigs: AgentConfigRow[], ): Record { - const { models, iterations, prompts } = buildAgentMaps(globalAgentConfigs); + const { models, iterations, prompts, taskPrompts } = buildAgentMaps(globalAgentConfigs); return { model: row?.model ?? undefined, @@ -221,6 +226,7 @@ export function mapDefaultsRow( ? Number(row.progressIntervalMinutes) : undefined, prompts: orUndefined(prompts), + taskPrompts: orUndefined(taskPrompts), }; } @@ -255,7 +261,7 @@ export function mapProjectRow({ jiraTriggers, githubTriggers, }: MapProjectInput): ProjectConfigRaw { - const { models, prompts, backends } = buildAgentMaps(projectAgentConfigs); + const { models, prompts, taskPrompts, backends } = buildAgentMaps(projectAgentConfigs); // Derive PM type from integration config const pmType = jiraConfig ? 'jira' : 'trello'; @@ -269,6 +275,7 @@ export function mapProjectRow({ branchPrefix: row.branchPrefix ?? 'feature/', pm: { type: pmType }, prompts: orUndefined(prompts), + taskPrompts: orUndefined(taskPrompts), model: row.model ?? undefined, agentModels: orUndefined(models), cardBudgetUsd: row.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, diff --git a/src/db/schema/agentConfigs.ts b/src/db/schema/agentConfigs.ts index 4bcbf9ea..3ae16368 100644 --- a/src/db/schema/agentConfigs.ts +++ b/src/db/schema/agentConfigs.ts @@ -13,6 +13,7 @@ export const agentConfigs = pgTable( maxIterations: integer('max_iterations'), agentBackend: text('agent_backend'), prompt: text('prompt'), + taskPrompt: text('task_prompt'), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') .defaultNow() diff --git a/tests/unit/agents/definitions/loader.test.ts b/tests/unit/agents/definitions/loader.test.ts new file mode 100644 index 00000000..e8ecfe37 --- /dev/null +++ b/tests/unit/agents/definitions/loader.test.ts @@ -0,0 +1,362 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + clearDefinitionCache, + getKnownAgentTypes, + loadAgentDefinition, + loadAllAgentDefinitions, +} from '../../../../src/agents/definitions/loader.js'; +import { + CONTEXT_STEP_REGISTRY, + GADGET_BUILDER_REGISTRY, + SDK_TOOLS_REGISTRY, + TOOL_SET_REGISTRY, +} from '../../../../src/agents/definitions/strategies.js'; +import { getAgentCapabilities } from '../../../../src/agents/shared/capabilities.js'; + +const ALL_AGENT_TYPES = [ + 'debug', + 'implementation', + 'planning', + 'respond-to-ci', + 'respond-to-planning-comment', + 'respond-to-pr-comment', + 'respond-to-review', + 'review', + 'splitting', +]; + +describe('YAML agent definitions loader', () => { + afterEach(() => { + clearDefinitionCache(); + }); + + describe('getKnownAgentTypes', () => { + it('discovers all 9 agent types from YAML files', () => { + const types = getKnownAgentTypes(); + expect(types).toEqual(ALL_AGENT_TYPES); + }); + }); + + describe('loadAgentDefinition', () => { + it('loads and parses each agent definition without error', () => { + for (const agentType of ALL_AGENT_TYPES) { + expect(() => loadAgentDefinition(agentType)).not.toThrow(); + } + }); + + it('throws for unknown agent type', () => { + expect(() => loadAgentDefinition('nonexistent-agent')).toThrow('Agent definition not found'); + }); + + it('caches parsed definitions', () => { + const first = loadAgentDefinition('implementation'); + const second = loadAgentDefinition('implementation'); + expect(first).toBe(second); + }); + + it('returns fresh results after cache clear', () => { + const first = loadAgentDefinition('implementation'); + clearDefinitionCache(); + const second = loadAgentDefinition('implementation'); + expect(first).not.toBe(second); + expect(first).toEqual(second); + }); + }); + + describe('loadAllAgentDefinitions', () => { + it('returns a map with all 9 agent types', () => { + const all = loadAllAgentDefinitions(); + expect(all.size).toBe(9); + for (const agentType of ALL_AGENT_TYPES) { + expect(all.has(agentType)).toBe(true); + } + }); + }); + + describe('strategy references resolve correctly', () => { + it('all tool set references exist in TOOL_SET_REGISTRY', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + for (const setName of def.tools.sets) { + expect( + setName === 'all' || setName in TOOL_SET_REGISTRY, + `${agentType}: tool set '${setName}' not in TOOL_SET_REGISTRY`, + ).toBe(true); + } + } + }); + + it('all sdkTools references exist in SDK_TOOLS_REGISTRY', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect( + def.tools.sdkTools in SDK_TOOLS_REGISTRY, + `${agentType}: sdkTools '${def.tools.sdkTools}' not in SDK_TOOLS_REGISTRY`, + ).toBe(true); + } + }); + + it('all gadgetBuilder references exist in GADGET_BUILDER_REGISTRY', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect( + def.strategies.gadgetBuilder in GADGET_BUILDER_REGISTRY, + `${agentType}: gadgetBuilder '${def.strategies.gadgetBuilder}' not in GADGET_BUILDER_REGISTRY`, + ).toBe(true); + } + }); + + it('all contextPipeline step references exist in CONTEXT_STEP_REGISTRY', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + for (const step of def.strategies.contextPipeline) { + expect( + step in CONTEXT_STEP_REGISTRY, + `${agentType}: contextPipeline step '${step}' not in CONTEXT_STEP_REGISTRY`, + ).toBe(true); + } + } + }); + + it('all taskPromptBuilder values correspond to .eta template files', () => { + const { readdirSync } = require('node:fs'); + const { join, dirname } = require('node:path'); + const { fileURLToPath } = require('node:url'); + const taskTemplatesDir = join( + dirname(fileURLToPath(import.meta.url)), + '../../../../src/agents/prompts/task-templates', + ); + const templateFiles = new Set( + readdirSync(taskTemplatesDir) + .filter((f: string) => f.endsWith('.eta')) + .map((f: string) => f.replace(/\.eta$/, '')), + ); + + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect( + templateFiles.has(def.strategies.taskPromptBuilder), + `${agentType}: taskPromptBuilder '${def.strategies.taskPromptBuilder}' has no matching .eta template file`, + ).toBe(true); + } + }); + }); + + describe('definition content spot checks', () => { + it('implementation has implementation compaction preset', () => { + const def = loadAgentDefinition('implementation'); + expect(def.compaction).toBe('implementation'); + }); + + it('implementation has postConfigure hook', () => { + const def = loadAgentDefinition('implementation'); + expect(def.backend.postConfigure).toBe('sequentialGadgetExecution'); + }); + + it('implementation has requiresPR flag', () => { + const def = loadAgentDefinition('implementation'); + expect(def.backend.requiresPR).toBe(true); + }); + + it('non-implementation agents do not have requiresPR', () => { + for (const agentType of ALL_AGENT_TYPES.filter((t) => t !== 'implementation')) { + const def = loadAgentDefinition(agentType); + expect(def.backend.requiresPR).toBeUndefined(); + } + }); + + it('work-item agents use standard context pipeline', () => { + const workItemAgents = ['implementation', 'splitting', 'planning', 'debug']; + for (const agentType of workItemAgents) { + const def = loadAgentDefinition(agentType); + expect(def.strategies.contextPipeline).toEqual([ + 'directoryListing', + 'contextFiles', + 'squint', + 'workItem', + ]); + } + }); + + it('review agent uses PR context pipeline without directoryListing', () => { + const def = loadAgentDefinition('review'); + expect(def.strategies.contextPipeline).toEqual(['prContext', 'contextFiles', 'squint']); + }); + + it('respond-to-ci uses combined PR + work-item pipeline', () => { + const def = loadAgentDefinition('respond-to-ci'); + expect(def.strategies.contextPipeline).toEqual([ + 'prContext', + 'directoryListing', + 'contextFiles', + 'squint', + 'workItem', + ]); + }); + + it('PR comment agents use conversation pipeline', () => { + const prCommentAgents = ['respond-to-review', 'respond-to-pr-comment']; + for (const agentType of prCommentAgents) { + const def = loadAgentDefinition(agentType); + expect(def.strategies.contextPipeline).toEqual([ + 'prContext', + 'prConversation', + 'directoryListing', + 'contextFiles', + 'squint', + ]); + } + }); + + it('review has preExecute hook', () => { + const def = loadAgentDefinition('review'); + expect(def.backend.preExecute).toBe('postInitialPRComment'); + }); + + it('respond-to-ci has preExecute hook', () => { + const def = loadAgentDefinition('respond-to-ci'); + expect(def.backend.preExecute).toBe('postInitialPRComment'); + }); + + it('planning is readOnly', () => { + const def = loadAgentDefinition('planning'); + expect(def.capabilities.isReadOnly).toBe(true); + expect(def.capabilities.canEditFiles).toBe(false); + expect(def.tools.sdkTools).toBe('readOnly'); + }); + + it('implementation has trailingMessage with all flags', () => { + const def = loadAgentDefinition('implementation'); + expect(def.trailingMessage).toEqual({ + includeDiagnostics: true, + includeTodoProgress: true, + includeGitStatus: true, + includePRStatus: true, + includeReminder: true, + }); + }); + + it('respond-to-review has diagnostics-only trailingMessage', () => { + const def = loadAgentDefinition('respond-to-review'); + expect(def.trailingMessage).toEqual({ + includeDiagnostics: true, + }); + }); + + it('respond-to-ci has diagnostics-only trailingMessage', () => { + const def = loadAgentDefinition('respond-to-ci'); + expect(def.trailingMessage).toEqual({ + includeDiagnostics: true, + }); + }); + + it('splitting has no trailingMessage', () => { + const def = loadAgentDefinition('splitting'); + expect(def.trailingMessage).toBeUndefined(); + }); + + it('respond-to-review includes review comment gadget options', () => { + const def = loadAgentDefinition('respond-to-review'); + expect(def.strategies.gadgetBuilderOptions).toEqual({ includeReviewComments: true }); + }); + + it('respond-to-pr-comment includes review comment gadget options', () => { + const def = loadAgentDefinition('respond-to-pr-comment'); + expect(def.strategies.gadgetBuilderOptions).toEqual({ includeReviewComments: true }); + }); + + it('debug uses "all" tool set', () => { + const def = loadAgentDefinition('debug'); + expect(def.tools.sets).toContain('all'); + }); + + it('all agents have non-empty identity fields', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect(def.identity.emoji.length).toBeGreaterThan(0); + expect(def.identity.label.length).toBeGreaterThan(0); + expect(def.identity.roleHint.length).toBeGreaterThan(0); + expect(def.identity.initialMessage.length).toBeGreaterThan(0); + } + }); + + it('all agents have non-empty hints', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect(def.hint.length).toBeGreaterThan(0); + } + }); + }); + + describe('roundtrip: YAML definition → profile properties', () => { + it('implementation agent has full capabilities and stop hooks', () => { + const def = loadAgentDefinition('implementation'); + const caps = getAgentCapabilities('implementation'); + + expect(caps.canEditFiles).toBe(true); + expect(caps.canCreatePR).toBe(true); + expect(caps.canUpdateChecklists).toBe(true); + expect(caps.isReadOnly).toBe(false); + expect(def.backend.enableStopHooks).toBe(true); + expect(def.backend.needsGitHubToken).toBe(true); + expect(def.backend.preExecute).toBeUndefined(); + expect(def.backend.postConfigure).toBe('sequentialGadgetExecution'); + expect(SDK_TOOLS_REGISTRY[def.tools.sdkTools]).toBeDefined(); + }); + + it('review agent is read-only with preExecute hook', () => { + const def = loadAgentDefinition('review'); + const caps = getAgentCapabilities('review'); + + expect(caps.canEditFiles).toBe(false); + expect(caps.isReadOnly).toBe(true); + expect(def.backend.enableStopHooks).toBe(false); + expect(def.backend.needsGitHubToken).toBe(true); + expect(def.backend.preExecute).toBe('postInitialPRComment'); + }); + + it('respond-to-ci agent has preExecute and needsGitHubToken', () => { + const def = loadAgentDefinition('respond-to-ci'); + const caps = getAgentCapabilities('respond-to-ci'); + + expect(caps.canEditFiles).toBe(true); + expect(def.backend.needsGitHubToken).toBe(true); + expect(def.backend.preExecute).toBe('postInitialPRComment'); + }); + + it('all agent sdkTools references resolve to non-empty arrays', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + const sdkTools = SDK_TOOLS_REGISTRY[def.tools.sdkTools]; + expect( + Array.isArray(sdkTools) && sdkTools.length > 0, + `${agentType}: sdkTools '${def.tools.sdkTools}' resolved to empty or non-array`, + ).toBe(true); + } + }); + + it('capabilities from getAgentCapabilities match YAML definition for all agents', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + const caps = getAgentCapabilities(agentType); + + expect(caps.canEditFiles).toBe(def.capabilities.canEditFiles); + expect(caps.canCreatePR).toBe(def.capabilities.canCreatePR); + expect(caps.canUpdateChecklists).toBe(def.capabilities.canUpdateChecklists); + expect(caps.isReadOnly).toBe(def.capabilities.isReadOnly); + } + }); + }); + + describe('unknown agent type fallbacks', () => { + it('getAgentCapabilities returns full-access defaults for unknown type', () => { + const caps = getAgentCapabilities('nonexistent-agent-type'); + expect(caps).toEqual({ + canEditFiles: true, + canCreatePR: true, + canUpdateChecklists: true, + isReadOnly: false, + }); + }); + }); +}); diff --git a/tests/unit/agents/definitions/schema.test.ts b/tests/unit/agents/definitions/schema.test.ts new file mode 100644 index 00000000..a7e4327b --- /dev/null +++ b/tests/unit/agents/definitions/schema.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; +import { AgentDefinitionSchema } from '../../../../src/agents/definitions/schema.js'; + +describe('AgentDefinitionSchema', () => { + const validDefinition = { + identity: { + emoji: '🔧', + label: 'Test Agent', + roleHint: 'Does test things', + initialMessage: '**🔧 Testing** — Running tests...', + }, + capabilities: { + canEditFiles: true, + canCreatePR: false, + canUpdateChecklists: true, + isReadOnly: false, + }, + tools: { + sets: ['pm', 'session'], + sdkTools: 'all', + }, + strategies: { + contextPipeline: ['directoryListing', 'contextFiles', 'squint', 'workItem'], + taskPromptBuilder: 'workItem', + gadgetBuilder: 'workItem', + }, + backend: { + enableStopHooks: false, + needsGitHubToken: false, + }, + compaction: 'default', + hint: 'Do the thing efficiently.', + }; + + it('parses a valid minimal definition', () => { + const result = AgentDefinitionSchema.safeParse(validDefinition); + expect(result.success).toBe(true); + }); + + it('parses a definition with all optional fields', () => { + const full = { + ...validDefinition, + strategies: { + ...validDefinition.strategies, + gadgetBuilderOptions: { includeReviewComments: true }, + }, + backend: { + ...validDefinition.backend, + blockGitPush: false, + preExecute: 'postInitialPRComment', + postConfigure: 'sequentialGadgetExecution', + }, + trailingMessage: { + includeDiagnostics: true, + includeTodoProgress: true, + includeGitStatus: true, + includePRStatus: true, + includeReminder: true, + }, + }; + + const result = AgentDefinitionSchema.safeParse(full); + expect(result.success).toBe(true); + }); + + it('rejects missing required fields', () => { + const { identity: _, ...missing } = validDefinition; + const result = AgentDefinitionSchema.safeParse(missing); + expect(result.success).toBe(false); + }); + + it('rejects invalid tool set names', () => { + const bad = { + ...validDefinition, + tools: { sets: ['invalid_set'], sdkTools: 'all' }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('rejects invalid sdkTools values', () => { + const bad = { + ...validDefinition, + tools: { sets: ['pm'], sdkTools: 'invalid' }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('rejects invalid strategy names', () => { + const bad = { + ...validDefinition, + strategies: { ...validDefinition.strategies, contextPipeline: ['nonexistentStep'] }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('rejects invalid compaction preset names', () => { + const bad = { ...validDefinition, compaction: 'aggressive' }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('allows trailingMessage to be omitted', () => { + const result = AgentDefinitionSchema.safeParse(validDefinition); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.trailingMessage).toBeUndefined(); + } + }); + + it('rejects invalid preExecute hook names', () => { + const bad = { + ...validDefinition, + backend: { ...validDefinition.backend, preExecute: 'typoInHookName' }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('accepts valid preExecute hook name', () => { + const good = { + ...validDefinition, + backend: { ...validDefinition.backend, preExecute: 'postInitialPRComment' }, + }; + const result = AgentDefinitionSchema.safeParse(good); + expect(result.success).toBe(true); + }); + + it('rejects invalid postConfigure hook names', () => { + const bad = { + ...validDefinition, + backend: { ...validDefinition.backend, postConfigure: 'nonexistentHook' }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + }); + + it('accepts valid postConfigure hook name', () => { + const good = { + ...validDefinition, + backend: { ...validDefinition.backend, postConfigure: 'sequentialGadgetExecution' }, + }; + const result = AgentDefinitionSchema.safeParse(good); + expect(result.success).toBe(true); + }); + + it('accepts requiresPR boolean', () => { + const good = { + ...validDefinition, + backend: { ...validDefinition.backend, requiresPR: true }, + }; + const result = AgentDefinitionSchema.safeParse(good); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.backend.requiresPR).toBe(true); + } + }); + + it('allows requiresPR to be omitted', () => { + const result = AgentDefinitionSchema.safeParse(validDefinition); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.backend.requiresPR).toBeUndefined(); + } + }); + + it('validates contextPipeline step names', () => { + const good = { + ...validDefinition, + strategies: { + ...validDefinition.strategies, + contextPipeline: ['prContext', 'prConversation', 'directoryListing'], + }, + }; + const result = AgentDefinitionSchema.safeParse(good); + expect(result.success).toBe(true); + }); +}); diff --git a/tests/unit/agents/shared/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts index e22da634..e968d539 100644 --- a/tests/unit/agents/shared/modelResolution.test.ts +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -210,6 +210,117 @@ describe('resolveModelConfig', () => { }); }); + describe('task prompt override resolution', () => { + it('returns undefined taskPrompt when no override configured', async () => { + const result = await resolveModelConfig({ + agentType: 'splitting', + project: makeProject(), + config: makeConfig(), + repoDir: '/tmp/test', + }); + + expect(result.taskPrompt).toBeUndefined(); + }); + + it('renders project-level task prompt override', async () => { + const project = makeProject({ + taskPrompts: { splitting: 'Custom task for <%= it.cardId %>.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'splitting', + project, + config: makeConfig(), + repoDir: '/tmp/test', + agentInput: { cardId: 'card-42' }, + }); + + expect(result.taskPrompt).toBe('Custom task for card-42.'); + }); + + it('renders task-specific variables from agentInput', async () => { + const project = makeProject({ + taskPrompts: { + 'respond-to-planning-comment': + 'Comment by @<%= it.commentAuthor %>: <%= it.commentText %>', + }, + }); + + const result = await resolveModelConfig({ + agentType: 'respond-to-planning-comment', + project, + config: makeConfig(), + repoDir: '/tmp/test', + agentInput: { + triggerCommentText: 'Add more tests', + triggerCommentAuthor: 'alice', + }, + }); + + expect(result.taskPrompt).toBe('Comment by @alice: Add more tests'); + }); + + it('renders PR-specific variables from agentInput in task prompt override', async () => { + const project = makeProject({ + taskPrompts: { + 'respond-to-pr-comment': + 'PR #<%= it.prNumber %>, file: <%= it.commentPath %>, body: <%= it.commentBody %>', + }, + }); + + const result = await resolveModelConfig({ + agentType: 'respond-to-pr-comment', + project, + config: makeConfig(), + repoDir: '/tmp/test', + agentInput: { + prNumber: 55, + triggerCommentBody: 'Fix this line', + triggerCommentPath: 'src/utils.ts', + }, + promptContext: { prNumber: 55 }, + }); + + expect(result.taskPrompt).toContain('PR #55'); + expect(result.taskPrompt).toContain('src/utils.ts'); + expect(result.taskPrompt).toContain('Fix this line'); + }); + + it('uses defaults-level task prompt when no project override', async () => { + const config = makeConfig({ + taskPrompts: { splitting: 'Default task prompt for <%= it.cardId %>.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'splitting', + project: makeProject(), + config, + repoDir: '/tmp/test', + agentInput: { cardId: 'card-99' }, + }); + + expect(result.taskPrompt).toBe('Default task prompt for card-99.'); + }); + + it('prefers project task prompt over defaults', async () => { + const project = makeProject({ + taskPrompts: { splitting: 'Project task prompt.' }, + }); + const config = makeConfig({ + taskPrompts: { splitting: 'Defaults task prompt.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'splitting', + project, + config, + repoDir: '/tmp/test', + }); + + expect(result.taskPrompt).toBe('Project task prompt.'); + }); + }); + describe('iterations resolution', () => { it('uses default maxIterations', async () => { const result = await resolveModelConfig({ diff --git a/tests/unit/agents/shared/taskPrompts.test.ts b/tests/unit/agents/shared/taskPrompts.test.ts index 7a484e18..a82435c4 100644 --- a/tests/unit/agents/shared/taskPrompts.test.ts +++ b/tests/unit/agents/shared/taskPrompts.test.ts @@ -1,117 +1,183 @@ import { describe, expect, it } from 'vitest'; +import { renderCustomPrompt, renderTaskPrompt } from '../../../../src/agents/prompts/index.js'; import { - buildCIResponsePrompt, buildCheckFailurePrompt, - buildCommentResponsePrompt, buildDebugPrompt, - buildPRCommentResponsePrompt, - buildReviewPrompt, - buildWorkItemPrompt, } from '../../../../src/agents/shared/taskPrompts.js'; -describe('buildWorkItemPrompt', () => { +// ============================================================================ +// .eta task prompt template tests (replaces the old TS function tests) +// ============================================================================ + +describe('workItem task template', () => { it('includes the card ID', () => { - const prompt = buildWorkItemPrompt('abc123'); + const prompt = renderTaskPrompt('workItem', { cardId: 'abc123' }); expect(prompt).toContain('abc123'); }); it('asks the agent to process the work item', () => { - const prompt = buildWorkItemPrompt('card-99'); + const prompt = renderTaskPrompt('workItem', { cardId: 'card-99' }); expect(prompt).toContain('work item'); }); }); -describe('buildCommentResponsePrompt', () => { +describe('commentResponse task template', () => { it('includes card ID, comment text, and author', () => { - const prompt = buildCommentResponsePrompt('card-42', 'Please add tests', 'alice'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-42', + commentText: 'Please add tests', + commentAuthor: 'alice', + }); expect(prompt).toContain('card-42'); expect(prompt).toContain('Please add tests'); expect(prompt).toContain('@alice'); }); it('instructs surgical updates for plan changes', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Fix the typo', 'bob'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Fix the typo', + commentAuthor: 'bob', + }); expect(prompt).toContain('surgical'); }); it('mentions that work item data is pre-loaded', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Update docs', 'carol'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Update docs', + commentAuthor: 'carol', + }); expect(prompt).toContain('pre-loaded'); }); it('instructs to classify the comment', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Why this approach?', 'dave'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Why this approach?', + commentAuthor: 'dave', + }); expect(prompt).toContain('classify'); }); it('instructs question-only replies via PostComment without plan modification', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Why this approach?', 'dave'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Why this approach?', + commentAuthor: 'dave', + }); expect(prompt).toContain('question'); expect(prompt).toContain('PostComment'); expect(prompt).toContain('do not modify the plan'); }); it('defaults to plan updates when intent is ambiguous', () => { - const prompt = buildCommentResponsePrompt('card-1', 'Some comment', 'eve'); + const prompt = renderTaskPrompt('commentResponse', { + cardId: 'card-1', + commentText: 'Some comment', + commentAuthor: 'eve', + }); expect(prompt).toContain('Default to plan updates when intent is ambiguous'); }); }); -describe('buildReviewPrompt', () => { +describe('review task template', () => { it('includes the PR number', () => { - const prompt = buildReviewPrompt(42); + const prompt = renderTaskPrompt('review', { prNumber: 42 }); expect(prompt).toContain('PR #42'); }); it('instructs to use CreatePRReview', () => { - const prompt = buildReviewPrompt(7); + const prompt = renderTaskPrompt('review', { prNumber: 7 }); expect(prompt).toContain('CreatePRReview'); }); }); -describe('buildCIResponsePrompt', () => { +describe('ci task template', () => { it('includes branch and PR number', () => { - const prompt = buildCIResponsePrompt('fix/ci-errors', 99); + const prompt = renderTaskPrompt('ci', { prBranch: 'fix/ci-errors', prNumber: 99 }); expect(prompt).toContain('fix/ci-errors'); expect(prompt).toContain('PR #99'); }); it('mentions CI checks have failed', () => { - const prompt = buildCIResponsePrompt('main', 1); + const prompt = renderTaskPrompt('ci', { prBranch: 'main', prNumber: 1 }); expect(prompt).toContain('CI checks have failed'); }); }); -describe('buildPRCommentResponsePrompt', () => { +describe('prCommentResponse task template', () => { it('includes PR number, branch, and comment body', () => { - const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Can you fix the typo?'); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'feat/new', + prNumber: 55, + commentBody: 'Can you fix the typo?', + }); expect(prompt).toContain('PR #55'); expect(prompt).toContain('feat/new'); expect(prompt).toContain('Can you fix the typo?'); }); it('includes file path when provided', () => { - const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Fix this line', 'src/utils.ts'); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'feat/new', + prNumber: 55, + commentBody: 'Fix this line', + commentPath: 'src/utils.ts', + }); expect(prompt).toContain('src/utils.ts'); }); it('omits file path when not provided', () => { - const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'Looks good overall!'); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'feat/new', + prNumber: 55, + commentBody: 'Looks good overall!', + }); expect(prompt).not.toContain('File:'); }); it('omits file path when empty string provided', () => { - const prompt = buildPRCommentResponsePrompt('feat/new', 55, 'LGTM', ''); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'feat/new', + prNumber: 55, + commentBody: 'LGTM', + commentPath: '', + }); expect(prompt).not.toContain('File:'); }); it('instructs surgical changes by default', () => { - const prompt = buildPRCommentResponsePrompt('main', 1, 'Please refactor'); + const prompt = renderTaskPrompt('prCommentResponse', { + prBranch: 'main', + prNumber: 1, + commentBody: 'Please refactor', + }); expect(prompt).toContain('surgical'); }); }); +// ============================================================================ +// Edge cases: DB partials and error handling +// ============================================================================ + +describe('renderTaskPrompt edge cases', () => { + it('renders DB task prompt override with partials via renderCustomPrompt', () => { + const dbPartials = new Map([['custom', 'DB partial content']]); + const result = renderCustomPrompt('Task: <%~ include("partials/custom") %>', {}, dbPartials); + expect(result).toContain('DB partial content'); + }); + + it('throws for nonexistent template name', () => { + expect(() => renderTaskPrompt('nonexistent-template', {})).toThrow(); + }); +}); + +// ============================================================================ +// Direct-call prompts (not part of YAML profile system) +// ============================================================================ + describe('buildCheckFailurePrompt', () => { const prContext = { prNumber: 33, diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 2dedc413..bea8ec96 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -424,6 +424,7 @@ describe('executeWithBackend', () => { it('marks implementation agent as failed when no PR was created', async () => { setupMocks(); + mockGetAgentProfile.mockReturnValue(makeMockProfile({ requiresPR: true })); const backend = makeMockBackend(); vi.mocked(backend.execute).mockResolvedValue({ success: true, @@ -435,9 +436,9 @@ describe('executeWithBackend', () => { const result = await executeWithBackend(backend, 'implementation', input); expect(result.success).toBe(false); - expect(result.error).toBe('Implementation completed but no PR was created'); + expect(result.error).toBe('Agent completed but no PR was created'); expect(logger.warn).toHaveBeenCalledWith( - 'Implementation agent completed without creating a PR', + 'implementation agent completed without creating a PR', expect.objectContaining({ backend: 'test-backend' }), ); }); diff --git a/tests/unit/backends/agent-profiles.test.ts b/tests/unit/backends/agent-profiles.test.ts index 733c111f..0815d094 100644 --- a/tests/unit/backends/agent-profiles.test.ts +++ b/tests/unit/backends/agent-profiles.test.ts @@ -424,7 +424,7 @@ describe('getAgentProfile', () => { it('throws for unknown agent types', () => { expect(() => getAgentProfile('nonexistent-agent')).toThrow( - "Unknown agent type 'nonexistent-agent'", + "Failed to load agent profile for 'nonexistent-agent'", ); }); diff --git a/tests/unit/backends/llmist.test.ts b/tests/unit/backends/llmist.test.ts index 49416fc8..ea7ece72 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -4,7 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('llmist', () => ({ LLMist: vi.fn().mockImplementation(() => ({})), createLogger: vi.fn(() => ({})), - type: undefined, +})); + +// Mock agents/definitions to break the circular dependency chain: +// backends/llmist → definitions → strategies → gadgets → pm/ → webhook-handler +// → triggers/agent-execution → agents/registry → new LlmistBackend() (still loading) +vi.mock('../../../src/agents/definitions/index.js', () => ({ + loadAgentDefinition: vi.fn(() => ({ backend: {} })), })); vi.mock('../../../src/backends/agent-profiles.js', () => ({ diff --git a/tests/unit/backends/postProcess.test.ts b/tests/unit/backends/postProcess.test.ts index 263c3d28..61ddc086 100644 --- a/tests/unit/backends/postProcess.test.ts +++ b/tests/unit/backends/postProcess.test.ts @@ -49,55 +49,63 @@ function makeInput(overrides?: Partial): AgentInput & { project: } describe('postProcessResult', () => { - describe('PR validation for implementation agents', () => { - it('marks as failed when implementation agent succeeds without prUrl', () => { + describe('PR validation for agents with requiresPR', () => { + it('marks as failed when requiresPR agent succeeds without prUrl', () => { const result = makeResult({ success: true, prUrl: undefined }); const backend = makeBackend(); const input = makeInput(); - postProcessResult(result, 'implementation', backend, input, 'implementation-card-123'); + postProcessResult(result, 'implementation', backend, input, 'implementation-card-123', { + requiresPR: true, + }); expect(result.success).toBe(false); - expect(result.error).toBe('Implementation completed but no PR was created'); + expect(result.error).toBe('Agent completed but no PR was created'); }); - it('logs warning when implementation agent succeeds without prUrl', () => { + it('logs warning when requiresPR agent succeeds without prUrl', () => { const result = makeResult({ success: true, prUrl: undefined }); const backend = makeBackend('my-backend'); const input = makeInput(); - postProcessResult(result, 'implementation', backend, input, 'impl-id'); + postProcessResult(result, 'implementation', backend, input, 'impl-id', { + requiresPR: true, + }); expect(logger.warn).toHaveBeenCalledWith( - 'Implementation agent completed without creating a PR', + 'implementation agent completed without creating a PR', { identifier: 'impl-id', backend: 'my-backend' }, ); }); - it('passes through when implementation agent succeeds with prUrl', () => { + it('passes through when requiresPR agent succeeds with prUrl', () => { const result = makeResult({ success: true, prUrl: 'https://github.com/o/r/pull/1' }); const backend = makeBackend(); const input = makeInput(); - postProcessResult(result, 'implementation', backend, input, 'impl-id'); + postProcessResult(result, 'implementation', backend, input, 'impl-id', { + requiresPR: true, + }); expect(result.success).toBe(true); expect(result.error).toBeUndefined(); }); - it('passes through when implementation agent already failed', () => { + it('passes through when requiresPR agent already failed', () => { const result = makeResult({ success: false, error: 'Budget exceeded' }); const backend = makeBackend(); const input = makeInput(); - postProcessResult(result, 'implementation', backend, input, 'impl-id'); + postProcessResult(result, 'implementation', backend, input, 'impl-id', { + requiresPR: true, + }); expect(result.success).toBe(false); expect(result.error).toBe('Budget exceeded'); expect(logger.warn).not.toHaveBeenCalled(); }); - it('does not validate PR creation for non-implementation agents', () => { + it('does not validate PR creation when requiresPR is not set', () => { const result = makeResult({ success: true, prUrl: undefined }); const backend = makeBackend(); const input = makeInput(); diff --git a/tests/unit/backends/progressModel.test.ts b/tests/unit/backends/progressModel.test.ts index c6b90b9f..288f53cf 100644 --- a/tests/unit/backends/progressModel.test.ts +++ b/tests/unit/backends/progressModel.test.ts @@ -1,13 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockTextComplete = vi.fn(); -vi.mock('llmist', () => { - return { - LLMist: vi.fn().mockImplementation(() => ({ - text: { complete: mockTextComplete }, - })), - }; -}); +vi.mock('llmist', async (importOriginal) => ({ + ...(await importOriginal()), + LLMist: vi.fn().mockImplementation(() => ({ + text: { complete: mockTextComplete }, + })), +})); import { LLMist } from 'llmist'; import { type ProgressContext, callProgressModel } from '../../../src/backends/progressModel.js'; diff --git a/tests/unit/config/compactionConfig.test.ts b/tests/unit/config/compactionConfig.test.ts index 6f489108..8f7892ef 100644 --- a/tests/unit/config/compactionConfig.test.ts +++ b/tests/unit/config/compactionConfig.test.ts @@ -217,6 +217,17 @@ describe('config/compactionConfig', () => { } }); + it('returns default config for unknown agent type', () => { + const config = getCompactionConfig('nonexistent-agent-type'); + + expect(config.enabled).toBe(true); + expect(config.strategy).toBe('hybrid'); + expect(config.triggerThresholdPercent).toBe(80); + expect(config.targetPercent).toBe(50); + expect(config.preserveRecentTurns).toBe(5); + expect(config.onCompaction).toBeTypeOf('function'); + }); + it('target percent is less than trigger threshold', () => { const agentTypes = ['implementation', 'splitting', 'planning'];