From f1231bc3d356df1672827dda436d0f82b82b313c Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Wed, 25 Feb 2026 13:16:42 +0000 Subject: [PATCH 1/2] refactor(agents): configuration-driven agent definitions with YAML + review fixes Phase 2 of the configuration-driven agents architecture. Extracts hardcoded agent profiles into declarative YAML definitions with typed schemas, registry-based strategy resolution, and Eta task prompt templates. Key changes: - Add YAML agent definitions (src/agents/definitions/*.yaml) with Zod-validated schema for identity, capabilities, tools, strategies, backend config, compaction, hints, and trailing messages - Extract context pipeline steps into composable functions (contextSteps.ts) wired via YAML contextPipeline arrays - Move task prompts from TS functions to Eta templates (src/agents/prompts/task-templates/*.eta) - Derive agent capabilities from YAML instead of hardcoded switch - Add DB column for per-agent task prompt overrides (migration 0016) - Wire task prompt override rendering through resolveModelConfig with full AgentInput context (fixes commentText/commentAuthor rendering in DB overrides) - Drive compaction, hint, and initial message configs from YAML definitions instead of hardcoded maps - Add guard clauses to PR context steps (fetchPRContextStep, fetchPRConversationStep, postInitialPRCommentHook) replacing unsafe `as` casts - Fix prCommentResponse.eta whitespace with Eta trimming tags - Remove identity-mapping TASK_PROMPT_TEMPLATE_REGISTRY (Zod schema validates allowed values directly) - Remove duplicate section header in strategies.ts Net: -905 lines removed, +2078 added (much of the addition is YAML definitions and comprehensive tests). 3259 tests pass. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 11 + package.json | 5 +- src/agents/definitions/contextSteps.ts | 262 ++++++++ src/agents/definitions/debug.yaml | 28 + src/agents/definitions/implementation.yaml | 40 ++ src/agents/definitions/index.ts | 22 + src/agents/definitions/loader.ts | 79 +++ src/agents/definitions/planning.yaml | 28 + src/agents/definitions/respond-to-ci.yaml | 33 + .../respond-to-planning-comment.yaml | 28 + .../definitions/respond-to-pr-comment.yaml | 31 + src/agents/definitions/respond-to-review.yaml | 34 + src/agents/definitions/review.yaml | 29 + src/agents/definitions/schema.ts | 80 +++ src/agents/definitions/splitting.yaml | 28 + src/agents/definitions/strategies.ts | 121 ++++ src/agents/prompts/index.ts | 76 ++- src/agents/prompts/task-templates/ci.eta | 3 + .../task-templates/commentResponse.eta | 9 + .../task-templates/prCommentResponse.eta | 13 + src/agents/prompts/task-templates/review.eta | 3 + .../prompts/task-templates/workItem.eta | 1 + src/agents/shared/capabilities.ts | 76 +-- src/agents/shared/modelResolution.ts | 48 +- src/agents/shared/taskPrompts.ts | 89 +-- src/backends/adapter.ts | 15 +- src/backends/agent-profiles.ts | 627 +++--------------- src/backends/llmist/index.ts | 20 +- src/backends/postProcess.ts | 11 +- src/config/agentMessages.ts | 77 ++- src/config/compactionConfig.ts | 18 +- src/config/hintConfig.ts | 170 ++--- src/config/schema.ts | 2 + .../0016_add_task_prompt_column.sql | 1 + src/db/migrations/meta/_journal.json | 7 + src/db/repositories/configMapper.ts | 13 +- src/db/schema/agentConfigs.ts | 1 + tests/unit/agents/definitions/loader.test.ts | 362 ++++++++++ tests/unit/agents/definitions/schema.test.ts | 180 +++++ .../agents/shared/modelResolution.test.ts | 111 ++++ tests/unit/agents/shared/taskPrompts.test.ts | 120 +++- tests/unit/backends/adapter.test.ts | 5 +- tests/unit/backends/agent-profiles.test.ts | 2 +- tests/unit/backends/llmist.test.ts | 8 +- tests/unit/backends/postProcess.test.ts | 32 +- tests/unit/backends/progressModel.test.ts | 13 +- tests/unit/config/compactionConfig.test.ts | 11 + 47 files changed, 2078 insertions(+), 905 deletions(-) create mode 100644 src/agents/definitions/contextSteps.ts create mode 100644 src/agents/definitions/debug.yaml create mode 100644 src/agents/definitions/implementation.yaml create mode 100644 src/agents/definitions/index.ts create mode 100644 src/agents/definitions/loader.ts create mode 100644 src/agents/definitions/planning.yaml create mode 100644 src/agents/definitions/respond-to-ci.yaml create mode 100644 src/agents/definitions/respond-to-planning-comment.yaml create mode 100644 src/agents/definitions/respond-to-pr-comment.yaml create mode 100644 src/agents/definitions/respond-to-review.yaml create mode 100644 src/agents/definitions/review.yaml create mode 100644 src/agents/definitions/schema.ts create mode 100644 src/agents/definitions/splitting.yaml create mode 100644 src/agents/definitions/strategies.ts create mode 100644 src/agents/prompts/task-templates/ci.eta create mode 100644 src/agents/prompts/task-templates/commentResponse.eta create mode 100644 src/agents/prompts/task-templates/prCommentResponse.eta create mode 100644 src/agents/prompts/task-templates/review.eta create mode 100644 src/agents/prompts/task-templates/workItem.eta create mode 100644 src/db/migrations/0016_add_task_prompt_column.sql create mode 100644 tests/unit/agents/definitions/loader.test.ts create mode 100644 tests/unit/agents/definitions/schema.test.ts 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..e1f91276 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "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", + "build:copy-yaml": "mkdir -p dist/agents/definitions && cp src/agents/definitions/*.yaml dist/agents/definitions/", "build:web": "cd web && npm run build", "start": "node dist/index.js", "test": "vitest run --project unit", @@ -68,6 +69,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 +86,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']; From 5f68f75d56ab59d19a56e5f8c0d5d73273e48bd1 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 25 Feb 2026 13:26:05 +0000 Subject: [PATCH 2/2] fix: add task-templates to build and Docker images The new task prompt templates (ci.eta, commentResponse.eta, etc.) were missing from both the npm build script and the Dockerfiles. At runtime, renderTaskPrompt() uses readFileSync with __dirname-relative paths, which would throw ENOENT in production containers. Changes: - Add build:copy-task-templates script to package.json - Copy src/agents/prompts/task-templates/*.eta to dist/ during build - Add task-templates COPY directive to Dockerfile.worker and Dockerfile.dashboard Co-Authored-By: Claude Sonnet 4.5 --- Dockerfile.dashboard | 1 + Dockerfile.worker | 1 + package.json | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) 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.json b/package.json index e1f91276..b10a26c0 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "scripts": { "dev": "node --env-file=.env --import tsx/esm --watch src/index.ts", "dev:web": "cd web && npx vite", - "build": "tsc && npm run build:copy-yaml", + "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",