From 19241392b9c55a401c48f81acb3fed7e75deed68 Mon Sep 17 00:00:00 2001 From: zbigniew sobiecki Date: Sun, 1 Mar 2026 18:00:54 +0100 Subject: [PATCH 1/2] fix(agents): address inline task prompts code review findings Phase 1 - Critical Backend Fixes: - Add missing AgentInput fields (triggerCommentBody, triggerCommentPath) for PR comment triggers - Create unified buildTaskPromptContext() in prompts/index.ts with consistent null handling - Fix modelResolution.ts to use renderInlineTaskPrompt() instead of renderCustomPrompt() - Add schema validation requiring prompts.taskPrompt (PromptsSchema no longer optional) - Add early Eta syntax validation in profile builder to catch errors at build time Phase 2 - Dashboard Fixes: - Fix save logic to always send both prompts (prevents losing inactive section) - Fix reset button: rename to "Reset All Prompts" and add confirmation dialog - Show variable descriptions in ReferencePanel - Add loading/error states in PromptsPanel - Refactor PromptsPanel to reduce cognitive complexity (extract helper components) Phase 3 - Consistency Cleanup: - Standardize YAML taskPrompt format to pipe style (debug, planning, splitting, implementation) - Remove unnecessary dbPartials size check in getSystemPrompt() and renderInlineTaskPrompt() - Add placeholder text for system prompt textarea Co-Authored-By: Claude Opus 4.5 --- src/agents/definitions/debug.yaml | 5 +- src/agents/definitions/email-joke.yaml | 20 +- src/agents/definitions/implementation.yaml | 5 +- src/agents/definitions/planning.yaml | 5 +- src/agents/definitions/profiles.ts | 37 +- src/agents/definitions/respond-to-ci.yaml | 7 +- .../respond-to-planning-comment.yaml | 13 +- .../definitions/respond-to-pr-comment.yaml | 17 +- src/agents/definitions/respond-to-review.yaml | 17 +- src/agents/definitions/review.yaml | 7 +- src/agents/definitions/schema.ts | 24 +- src/agents/definitions/splitting.yaml | 5 +- src/agents/prompts/index.ts | 96 +++-- src/agents/prompts/task-templates/ci.eta | 3 - .../task-templates/commentResponse.eta | 9 - .../prompts/task-templates/emailJoke.eta | 16 - .../task-templates/prCommentResponse.eta | 13 - src/agents/prompts/task-templates/review.eta | 3 - .../prompts/task-templates/workItem.eta | 1 - src/agents/shared/modelResolution.ts | 40 +-- src/api/routers/agentDefinitions.ts | 51 +-- src/api/routers/prompts.ts | 5 + src/types/index.ts | 4 + tests/unit/agents/definitions/loader.test.ts | 19 +- tests/unit/agents/definitions/schema.test.ts | 4 +- .../agents/shared/modelResolution.test.ts | 10 +- tests/unit/agents/shared/taskPrompts.test.ts | 88 +++-- .../unit/api/routers/agentDefinitions.test.ts | 6 +- .../agentDefinitionsRepository.test.ts | 5 +- .../settings/agent-definition-editor.tsx | 334 ++++++++++++------ web/src/components/settings/prompt-editor.tsx | 3 +- 31 files changed, 554 insertions(+), 318 deletions(-) delete mode 100644 src/agents/prompts/task-templates/ci.eta delete mode 100644 src/agents/prompts/task-templates/commentResponse.eta delete mode 100644 src/agents/prompts/task-templates/emailJoke.eta delete mode 100644 src/agents/prompts/task-templates/prCommentResponse.eta delete mode 100644 src/agents/prompts/task-templates/review.eta delete mode 100644 src/agents/prompts/task-templates/workItem.eta diff --git a/src/agents/definitions/debug.yaml b/src/agents/definitions/debug.yaml index 4b8f7aaa..bd3fc516 100644 --- a/src/agents/definitions/debug.yaml +++ b/src/agents/definitions/debug.yaml @@ -17,7 +17,10 @@ capabilities: strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] - taskPromptBuilder: workItem + +prompts: + taskPrompt: | + Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded. backend: enableStopHooks: true diff --git a/src/agents/definitions/email-joke.yaml b/src/agents/definitions/email-joke.yaml index f604ce62..e18957fd 100644 --- a/src/agents/definitions/email-joke.yaml +++ b/src/agents/definitions/email-joke.yaml @@ -15,7 +15,25 @@ capabilities: strategies: contextPipeline: [prefetchedEmails] - taskPromptBuilder: emailJoke + +prompts: + taskPrompt: | + ## Your Task + + Your initial email search has already been completed — see the **SearchEmails** result above. + + For each email found: + 1. Use **ReadEmail** to read the full content + 2. Compose a friendly, funny response that relates to the email content + 3. Use **ReplyToEmail** to send your joke response + 4. Use **MarkEmailAsSeen** to mark the email as read (prevents re-processing) + + Once all emails have been processed, call **Finish**. + + <% if (it.senderEmail) { %> + ## Sender Filter + Only emails from **<%= it.senderEmail %>** were included in the search. + <% } %> backend: enableStopHooks: false diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml index 7fc6a223..eb20be6f 100644 --- a/src/agents/definitions/implementation.yaml +++ b/src/agents/definitions/implementation.yaml @@ -20,7 +20,10 @@ capabilities: strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] - taskPromptBuilder: workItem + +prompts: + taskPrompt: | + Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded. backend: enableStopHooks: true diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml index a6200d78..9cb17a4a 100644 --- a/src/agents/definitions/planning.yaml +++ b/src/agents/definitions/planning.yaml @@ -17,7 +17,10 @@ capabilities: strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] - taskPromptBuilder: workItem + +prompts: + taskPrompt: | + Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded. backend: enableStopHooks: false diff --git a/src/agents/definitions/profiles.ts b/src/agents/definitions/profiles.ts index 3349ca18..90c1eeeb 100644 --- a/src/agents/definitions/profiles.ts +++ b/src/agents/definitions/profiles.ts @@ -13,7 +13,11 @@ import { resolveEffectiveCapabilities, } from '../capabilities/resolver.js'; import type { ContextInjection, ToolManifest } from '../contracts/index.js'; -import { type TaskPromptContext, renderTaskPrompt } from '../prompts/index.js'; +import { + buildTaskPromptContext, + renderInlineTaskPrompt, + validateTemplate, +} from '../prompts/index.js'; import { buildGadgetsForAgent } from '../shared/gadgets.js'; import type { FetchContextParams, PreExecuteParams } from './contextSteps.js'; import { resolveAgentDefinition } from './loader.js'; @@ -69,24 +73,6 @@ function resolveRegistry(registry: Record, key: string, label: str return value; } -/** - * Extract all relevant fields from AgentInput into a flat context object - * for Eta task prompt template rendering. - */ -function buildTaskPromptContext(input: AgentInput): TaskPromptContext { - return { - 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, - // Email-joke agent fields - senderEmail: input.senderEmail as string | undefined, - }; -} - /** * Merge required and optional capabilities into a single list. * In runtime, we use all declared capabilities (validation happens separately). @@ -114,8 +100,14 @@ function buildProfileFromDefinition(def: AgentDefinition, agentType: string): Ag // Get context pipeline from strategies const contextPipeline = def.strategies.contextPipeline; - // Task prompt template name (maps to .eta file) - const taskTemplateName = def.strategies.taskPromptBuilder; + // Get task prompt template from prompts (required by schema) + const taskPromptTemplate = def.prompts.taskPrompt; + + // Validate Eta syntax early to catch errors at profile build time + const validationResult = validateTemplate(taskPromptTemplate); + if (!validationResult.valid) { + throw new Error(`Agent '${agentType}' has invalid taskPrompt: ${validationResult.error}`); + } const profile: AgentProfile = { filterTools: (allTools: ToolManifest[]) => { @@ -137,7 +129,8 @@ function buildProfileFromDefinition(def: AgentDefinition, agentType: string): Ag } return injections; }, - buildTaskPrompt: (input) => renderTaskPrompt(taskTemplateName, buildTaskPromptContext(input)), + buildTaskPrompt: (input) => + renderInlineTaskPrompt(taskPromptTemplate, buildTaskPromptContext(input)), capabilities: def.capabilities, getLlmistGadgets: (integrationChecker?: IntegrationChecker) => { // Resolve effective capabilities based on integration availability diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml index 951f94aa..7ea1ef14 100644 --- a/src/agents/definitions/respond-to-ci.yaml +++ b/src/agents/definitions/respond-to-ci.yaml @@ -20,7 +20,12 @@ capabilities: strategies: contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] - taskPromptBuilder: ci + +prompts: + taskPrompt: | + You are on the branch `<%= it.prBranch %>` for PR #<%= it.prNumber %>. + + CI checks have failed. Analyze the failures and fix them. backend: enableStopHooks: true diff --git a/src/agents/definitions/respond-to-planning-comment.yaml b/src/agents/definitions/respond-to-planning-comment.yaml index 79b0ec6d..ce67a612 100644 --- a/src/agents/definitions/respond-to-planning-comment.yaml +++ b/src/agents/definitions/respond-to-planning-comment.yaml @@ -17,7 +17,18 @@ capabilities: strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] - taskPromptBuilder: commentResponse + +prompts: + taskPrompt: | + 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. backend: enableStopHooks: false diff --git a/src/agents/definitions/respond-to-pr-comment.yaml b/src/agents/definitions/respond-to-pr-comment.yaml index 9e2cf001..f802be3b 100644 --- a/src/agents/definitions/respond-to-pr-comment.yaml +++ b/src/agents/definitions/respond-to-pr-comment.yaml @@ -19,10 +19,25 @@ capabilities: strategies: contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] - taskPromptBuilder: prCommentResponse gadgetOptions: includeReviewComments: true +prompts: + taskPrompt: | + 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. + backend: enableStopHooks: true needsGitHubToken: true diff --git a/src/agents/definitions/respond-to-review.yaml b/src/agents/definitions/respond-to-review.yaml index a0736a82..53770376 100644 --- a/src/agents/definitions/respond-to-review.yaml +++ b/src/agents/definitions/respond-to-review.yaml @@ -20,10 +20,25 @@ capabilities: strategies: contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] - taskPromptBuilder: prCommentResponse gadgetOptions: includeReviewComments: true +prompts: + taskPrompt: | + 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. + backend: enableStopHooks: true needsGitHubToken: true diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml index 8bf358f8..92e18f64 100644 --- a/src/agents/definitions/review.yaml +++ b/src/agents/definitions/review.yaml @@ -19,7 +19,12 @@ capabilities: strategies: contextPipeline: [prContext, contextFiles, squint] - taskPromptBuilder: review + +prompts: + taskPrompt: | + Review PR #<%= it.prNumber %>. + + Examine the code changes carefully and submit your review using CreatePRReview. backend: enableStopHooks: false diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 25859e74..5d94d23e 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -69,26 +69,16 @@ export const CONTEXT_STEP_NAMES = [ 'prefetchedEmails', ] as const; -export const TASK_PROMPT_BUILDER_NAMES = [ - 'workItem', - 'commentResponse', - 'review', - 'ci', - 'prCommentResponse', - 'emailJoke', -] as const; - export const COMPACTION_NAMES = ['implementation', 'default'] as const; /** * Strategies schema - context and prompt configuration. * Note: gadgetBuilder removed - gadgets are now derived from capabilities. + * Note: taskPromptBuilder removed - task prompts are now stored in prompts.taskPrompt. */ const StrategiesSchema = z.object({ /** Pipeline of context fetching steps */ contextPipeline: z.array(z.enum(CONTEXT_STEP_NAMES)), - /** Task prompt template name (maps to .eta file) */ - taskPromptBuilder: z.enum(TASK_PROMPT_BUILDER_NAMES), /** Optional gadget configuration for special cases */ gadgetOptions: GadgetOptionsSchema, }); @@ -112,12 +102,10 @@ const TrailingMessageSchema = z }) .optional(); -const PromptsSchema = z - .object({ - systemPrompt: z.string().optional(), - taskPrompt: z.string().optional(), - }) - .optional(); +const PromptsSchema = z.object({ + systemPrompt: z.string().optional(), + taskPrompt: z.string().min(1, 'taskPrompt is required and must be non-empty'), +}); /** * Complete agent definition schema. @@ -145,7 +133,7 @@ export const AgentDefinitionSchema = z.object({ hint: z.string(), /** Trailing message configuration */ trailingMessage: TrailingMessageSchema, - /** Custom prompts (optional) */ + /** Custom prompts (taskPrompt required, systemPrompt optional) */ prompts: PromptsSchema, }); diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml index f49dcb49..9ed61fe0 100644 --- a/src/agents/definitions/splitting.yaml +++ b/src/agents/definitions/splitting.yaml @@ -18,7 +18,10 @@ capabilities: strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] - taskPromptBuilder: workItem + +prompts: + taskPrompt: | + Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded. backend: enableStopHooks: false diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index c3b794a3..35b73ac4 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -7,11 +7,12 @@ import { resolveKnownAgentTypes } 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 }); + +// Standalone Eta instance for inline task prompts (no views directory needed) +const taskEta = new Eta({ autoEscape: false }); // Valid agent types — lazily resolved from DB (with YAML fallback), populated by initPrompts() let validTypes: string[] = []; @@ -149,11 +150,9 @@ export function getSystemPrompt( } const template = loadTemplate(agentType); - if (dbPartials && dbPartials.size > 0) { - const expanded = resolveIncludes(template, dbPartials); - return eta.renderString(expanded, context); - } - return eta.renderString(template, context); + // Always resolve includes - resolveIncludes handles empty maps gracefully + const expanded = resolveIncludes(template, dbPartials ?? new Map()); + return eta.renderString(expanded, context); } // ============================================================================ @@ -174,33 +173,58 @@ export interface TaskPromptContext { [key: string]: unknown; } -const taskTemplateCache = new Map(); - -function loadTaskTemplate(templateName: string): string { - const cached = taskTemplateCache.get(templateName); - if (cached) return cached; +/** + * Input interface for buildTaskPromptContext - accepts both AgentInput fields + * and PromptContext fields for maximum flexibility. + */ +export interface TaskPromptInput { + // Common fields + cardId?: string; + prNumber?: number; + prBranch?: string; + // PM comment trigger fields + triggerCommentText?: string; + triggerCommentAuthor?: string; + // PR comment trigger fields + triggerCommentBody?: string; + triggerCommentPath?: string; + // Email agent fields + senderEmail?: string; + // Allow extra fields for future extensibility + [key: string]: unknown; +} - const templatePath = join(taskTemplatesDir, `${templateName}.eta`); - const template = readFileSync(templatePath, 'utf-8'); - taskTemplateCache.set(templateName, template); - return template; +/** + * Build a TaskPromptContext from AgentInput or combined PromptContext + AgentInput. + * This is the canonical builder used by both profile.buildTaskPrompt() and resolveModelConfig(). + * + * Null handling: all optional fields remain undefined when not present (no 'unknown' defaults). + */ +export function buildTaskPromptContext(input: TaskPromptInput): TaskPromptContext { + return { + cardId: input.cardId, + prNumber: input.prNumber, + prBranch: input.prBranch, + commentText: input.triggerCommentText, + commentAuthor: input.triggerCommentAuthor, + commentBody: input.triggerCommentBody, + commentPath: input.triggerCommentPath, + senderEmail: input.senderEmail, + }; } /** - * Render a task prompt from a named `.eta` template in `task-templates/`. - * Supports DB partials via `include()` directives (same pattern as system prompts). + * Render an inline task prompt template with Eta variable interpolation. + * Used for task prompts stored directly in agent definitions (prompts.taskPrompt). */ -export function renderTaskPrompt( - templateName: string, +export function renderInlineTaskPrompt( + template: 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); + // Always resolve includes - resolveIncludes handles empty maps gracefully + const expanded = resolveIncludes(template, dbPartials ?? new Map()); + return taskEta.renderString(expanded, context); } /** Returns the raw .eta template source from disk (before rendering). */ @@ -241,7 +265,7 @@ export function getAvailablePartialNames(): string[] { } } -/** Returns template variable info for documentation/reference. */ +/** Returns template variable info for system prompts documentation/reference. */ export function getTemplateVariables(): Array<{ name: string; group: string; @@ -272,3 +296,21 @@ export function getTemplateVariables(): Array<{ { name: 'debugListId', group: 'Debug', description: 'Debug list ID for output cards' }, ]; } + +/** Returns task prompt variable info for documentation/reference. */ +export function getTaskTemplateVariables(): Array<{ + name: string; + group: string; + description: string; +}> { + return [ + { name: 'cardId', group: 'Work Item', description: 'Work item ID (card or issue)' }, + { name: 'commentText', group: 'Comment', description: 'Comment text content (PM comments)' }, + { name: 'commentAuthor', group: 'Comment', description: 'Comment author username' }, + { name: 'prNumber', group: 'PR', description: 'Pull request number' }, + { name: 'prBranch', group: 'PR', description: 'Pull request branch name' }, + { name: 'commentBody', group: 'PR Comment', description: 'PR comment body text' }, + { name: 'commentPath', group: 'PR Comment', description: 'File path for inline PR comments' }, + { name: 'senderEmail', group: 'Email', description: 'Email sender address (email-joke agent)' }, + ]; +} diff --git a/src/agents/prompts/task-templates/ci.eta b/src/agents/prompts/task-templates/ci.eta deleted file mode 100644 index 9b9eca5f..00000000 --- a/src/agents/prompts/task-templates/ci.eta +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index bf12da6d..00000000 --- a/src/agents/prompts/task-templates/commentResponse.eta +++ /dev/null @@ -1,9 +0,0 @@ -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/emailJoke.eta b/src/agents/prompts/task-templates/emailJoke.eta deleted file mode 100644 index 8e7987e3..00000000 --- a/src/agents/prompts/task-templates/emailJoke.eta +++ /dev/null @@ -1,16 +0,0 @@ -## Your Task - -Your initial email search has already been completed — see the **SearchEmails** result above. - -For each email found: -1. Use **ReadEmail** to read the full content -2. Compose a friendly, funny response that relates to the email content -3. Use **ReplyToEmail** to send your joke response -4. Use **MarkEmailAsSeen** to mark the email as read (prevents re-processing) - -Once all emails have been processed, call **Finish**. - -<% if (it.senderEmail) { %> -## Sender Filter -Only emails from **<%= it.senderEmail %>** were included in the search. -<% } %> diff --git a/src/agents/prompts/task-templates/prCommentResponse.eta b/src/agents/prompts/task-templates/prCommentResponse.eta deleted file mode 100644 index e8a421bf..00000000 --- a/src/agents/prompts/task-templates/prCommentResponse.eta +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 830397b8..00000000 --- a/src/agents/prompts/task-templates/review.eta +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 99d28c5e..00000000 --- a/src/agents/prompts/task-templates/workItem.eta +++ /dev/null @@ -1 +0,0 @@ -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/modelResolution.ts b/src/agents/shared/modelResolution.ts index 66b05c83..b1feba9a 100644 --- a/src/agents/shared/modelResolution.ts +++ b/src/agents/shared/modelResolution.ts @@ -3,9 +3,10 @@ import { logger } from '../../utils/logging.js'; import { resolveAgentDefinition } from '../definitions/loader.js'; import { type PromptContext, - type TaskPromptContext, + buildTaskPromptContext, getSystemPrompt, renderCustomPrompt, + renderInlineTaskPrompt, } from '../prompts/index.js'; import { type ContextFile, readContextFiles } from '../utils/setup.js'; @@ -33,29 +34,6 @@ export interface ResolveModelConfigOptions { 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 { const { agentType, project, config, repoDir, modelOverride, promptContext, dbPartials } = options; const configKey = options.configKey ?? agentType; @@ -93,8 +71,18 @@ export async function resolveModelConfig(options: ResolveModelConfigOptions): Pr // Resolve task prompt override from definition → undefined (use .eta default) let taskPrompt: string | undefined; if (definitionTaskPrompt) { - const taskContext = buildTaskOverrideContext(promptContext, options.agentInput); - taskPrompt = renderCustomPrompt(definitionTaskPrompt, taskContext, dbPartials); + // Build task context from agentInput, falling back to promptContext for common fields + const taskContext = buildTaskPromptContext({ + cardId: options.agentInput?.cardId ?? promptContext?.cardId, + prNumber: options.agentInput?.prNumber ?? (promptContext?.prNumber as number | undefined), + prBranch: options.agentInput?.prBranch ?? (promptContext?.prBranch as string | undefined), + triggerCommentText: options.agentInput?.triggerCommentText, + triggerCommentAuthor: options.agentInput?.triggerCommentAuthor, + triggerCommentBody: options.agentInput?.triggerCommentBody, + triggerCommentPath: options.agentInput?.triggerCommentPath, + senderEmail: options.agentInput?.senderEmail, + }); + taskPrompt = renderInlineTaskPrompt(definitionTaskPrompt, taskContext, dbPartials); } const contextFiles = await readContextFiles(repoDir); diff --git a/src/api/routers/agentDefinitions.ts b/src/api/routers/agentDefinitions.ts index 5d4ef7e6..311dc383 100644 --- a/src/api/routers/agentDefinitions.ts +++ b/src/api/routers/agentDefinitions.ts @@ -14,7 +14,6 @@ import { COMPACTION_NAMES, CONTEXT_STEP_NAMES, DefinitionPatchSchema, - TASK_PROMPT_BUILDER_NAMES, } from '../../agents/definitions/schema.js'; import { validateTemplate } from '../../agents/prompts/index.js'; import { @@ -275,21 +274,23 @@ export const agentDefinitionsRouter = router({ } // Build updated prompts section - // Merge with existing prompts: omitting a field preserves it, passing null clears it, passing a string sets it - const systemPrompt = - input.systemPrompt !== undefined - ? (input.systemPrompt ?? undefined) - : current.prompts?.systemPrompt; - const taskPrompt = - input.taskPrompt !== undefined - ? (input.taskPrompt ?? undefined) - : current.prompts?.taskPrompt; - const prompts = - systemPrompt !== undefined || taskPrompt !== undefined - ? { systemPrompt, taskPrompt } - : undefined; + // Merge with existing prompts: undefined preserves current, null clears (for systemPrompt only), string sets + // Note: taskPrompt is required by schema, so null is treated as "keep current" rather than "clear" + const systemPrompt: string | undefined = + input.systemPrompt === null + ? undefined + : input.systemPrompt !== undefined + ? input.systemPrompt + : current.prompts.systemPrompt; + const taskPrompt: string = + input.taskPrompt && input.taskPrompt !== null + ? input.taskPrompt + : current.prompts.taskPrompt; - const updated: AgentDefinition = { ...current, prompts }; + const updated: AgentDefinition = { + ...current, + prompts: { systemPrompt, taskPrompt }, + }; const validated = AgentDefinitionSchema.parse(updated); const isBuiltin = getKnownAgentTypes().includes(input.agentType); @@ -300,7 +301,7 @@ export const agentDefinitionsRouter = router({ /** * Reset prompt overrides to YAML defaults for an agent type (superadmin only). - * Removes the prompts section from the stored definition. + * Restores the prompts section from the YAML definition. */ resetPrompt: superAdminProcedure .input(z.object({ agentType: z.string().min(1) })) @@ -315,9 +316,20 @@ export const agentDefinitionsRouter = router({ }); } - // Remove the prompts section - const { prompts: _removed, ...withoutPrompts } = current; - const validated = AgentDefinitionSchema.parse({ ...withoutPrompts, prompts: undefined }); + // Load YAML defaults and use its prompts section + let yamlDefault: AgentDefinition; + try { + yamlDefault = loadAgentDefinition(input.agentType); + } catch { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `YAML default not found for agent: ${input.agentType}`, + }); + } + + // Replace prompts with YAML defaults + const updated: AgentDefinition = { ...current, prompts: yamlDefault.prompts }; + const validated = AgentDefinitionSchema.parse(updated); const isBuiltin = getKnownAgentTypes().includes(input.agentType); await upsertAgentDefinition(input.agentType, validated, isBuiltin); @@ -339,7 +351,6 @@ export const agentDefinitionsRouter = router({ return { capabilities: [...CAPABILITIES], contextStepNames: [...CONTEXT_STEP_NAMES], - taskPromptBuilderNames: [...TASK_PROMPT_BUILDER_NAMES], compactionNames: [...COMPACTION_NAMES], }; }), diff --git a/src/api/routers/prompts.ts b/src/api/routers/prompts.ts index 68f9ff96..f2f08791 100644 --- a/src/api/routers/prompts.ts +++ b/src/api/routers/prompts.ts @@ -4,6 +4,7 @@ import { getAvailablePartialNames, getRawPartial, getRawTemplate, + getTaskTemplateVariables, getTemplateVariables, getValidAgentTypes, validateTemplate, @@ -43,6 +44,10 @@ export const promptsRouter = router({ return getTemplateVariables(); }), + taskVariables: protectedProcedure.query(() => { + return getTaskTemplateVariables(); + }), + validate: protectedProcedure .input(z.object({ template: z.string() })) .mutation(async ({ input }) => { diff --git a/src/types/index.ts b/src/types/index.ts index 4fe7a61b..31ee0211 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -34,6 +34,10 @@ export interface AgentInput { triggerCommentText?: string; triggerCommentAuthor?: string; + // PR comment trigger fields (for respond-to-pr-comment and similar agents) + triggerCommentBody?: string; + triggerCommentPath?: string; + // Email-joke agent fields senderEmail?: string; preFoundEmails?: EmailSummary[]; // pre-fetched before agent start to skip if empty diff --git a/tests/unit/agents/definitions/loader.test.ts b/tests/unit/agents/definitions/loader.test.ts index a5e3b3f9..8ef55f1a 100644 --- a/tests/unit/agents/definitions/loader.test.ts +++ b/tests/unit/agents/definitions/loader.test.ts @@ -117,25 +117,12 @@ describe('YAML agent definitions loader', () => { } }); - 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$/, '')), - ); - + it('all agents have prompts.taskPrompt defined', () => { 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`, + typeof def.prompts?.taskPrompt === 'string' && def.prompts.taskPrompt.length > 0, + `${agentType}: prompts.taskPrompt is missing or empty`, ).toBe(true); } }); diff --git a/tests/unit/agents/definitions/schema.test.ts b/tests/unit/agents/definitions/schema.test.ts index fb41c7fd..39c121bf 100644 --- a/tests/unit/agents/definitions/schema.test.ts +++ b/tests/unit/agents/definitions/schema.test.ts @@ -15,7 +15,6 @@ describe('AgentDefinitionSchema', () => { }, strategies: { contextPipeline: ['directoryListing', 'contextFiles', 'squint', 'workItem'], - taskPromptBuilder: 'workItem', }, backend: { enableStopHooks: false, @@ -23,6 +22,9 @@ describe('AgentDefinitionSchema', () => { }, compaction: 'default', hint: 'Do the thing efficiently.', + prompts: { + taskPrompt: 'Analyze and process the work item with ID: <%= it.cardId %>.', + }, }; it('parses a valid minimal definition', () => { diff --git a/tests/unit/agents/shared/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts index af89c39f..558206f1 100644 --- a/tests/unit/agents/shared/modelResolution.test.ts +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -10,22 +10,16 @@ function mockAgentDefinition(prompts?: AgentDefinition['prompts']): AgentDefinit return { identity: { emoji: '🤖', label: 'Test', roleHint: 'test', initialMessage: 'Hi' }, capabilities: { - canEditFiles: false, - canCreatePR: false, - canUpdateChecklists: false, - isReadOnly: true, + required: ['fs:read', 'session:ctrl'], + optional: [], }, - tools: { sets: [], sdkTools: 'readOnly' }, strategies: { contextPipeline: [], - taskPromptBuilder: 'workItem', - gadgetBuilder: 'workItem', }, backend: { enableStopHooks: false, needsGitHubToken: false }, compaction: 'default', hint: 'test', trailingMessage: undefined, - integrations: { required: [], optional: [] }, prompts, }; } diff --git a/tests/unit/agents/shared/taskPrompts.test.ts b/tests/unit/agents/shared/taskPrompts.test.ts index a82435c4..319e9141 100644 --- a/tests/unit/agents/shared/taskPrompts.test.ts +++ b/tests/unit/agents/shared/taskPrompts.test.ts @@ -1,30 +1,72 @@ import { describe, expect, it } from 'vitest'; -import { renderCustomPrompt, renderTaskPrompt } from '../../../../src/agents/prompts/index.js'; +import { + renderCustomPrompt, + renderInlineTaskPrompt, +} from '../../../../src/agents/prompts/index.js'; import { buildCheckFailurePrompt, buildDebugPrompt, } from '../../../../src/agents/shared/taskPrompts.js'; // ============================================================================ -// .eta task prompt template tests (replaces the old TS function tests) +// Inline task prompt template tests (task prompts are now in YAML definitions) // ============================================================================ +// Task prompts that were previously in .eta files are now inline in agent definitions. +// These tests verify renderInlineTaskPrompt works correctly with the new inline format. + +// Sample task prompt templates (matching what's in the YAML files) +const WORK_ITEM_TEMPLATE = + 'Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded.'; + +const COMMENT_RESPONSE_TEMPLATE = `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.`; + +const REVIEW_TEMPLATE = `Review PR #<%= it.prNumber %>. + +Examine the code changes carefully and submit your review using CreatePRReview.`; + +const CI_TEMPLATE = `You are on the branch \`<%= it.prBranch %>\` for PR #<%= it.prNumber %>. + +CI checks have failed. Analyze the failures and fix them.`; + +const PR_COMMENT_RESPONSE_TEMPLATE = `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.`; + describe('workItem task template', () => { it('includes the card ID', () => { - const prompt = renderTaskPrompt('workItem', { cardId: 'abc123' }); + const prompt = renderInlineTaskPrompt(WORK_ITEM_TEMPLATE, { cardId: 'abc123' }); expect(prompt).toContain('abc123'); }); it('asks the agent to process the work item', () => { - const prompt = renderTaskPrompt('workItem', { cardId: 'card-99' }); + const prompt = renderInlineTaskPrompt(WORK_ITEM_TEMPLATE, { cardId: 'card-99' }); expect(prompt).toContain('work item'); }); }); describe('commentResponse task template', () => { it('includes card ID, comment text, and author', () => { - const prompt = renderTaskPrompt('commentResponse', { + const prompt = renderInlineTaskPrompt(COMMENT_RESPONSE_TEMPLATE, { cardId: 'card-42', commentText: 'Please add tests', commentAuthor: 'alice', @@ -35,7 +77,7 @@ describe('commentResponse task template', () => { }); it('instructs surgical updates for plan changes', () => { - const prompt = renderTaskPrompt('commentResponse', { + const prompt = renderInlineTaskPrompt(COMMENT_RESPONSE_TEMPLATE, { cardId: 'card-1', commentText: 'Fix the typo', commentAuthor: 'bob', @@ -44,7 +86,7 @@ describe('commentResponse task template', () => { }); it('mentions that work item data is pre-loaded', () => { - const prompt = renderTaskPrompt('commentResponse', { + const prompt = renderInlineTaskPrompt(COMMENT_RESPONSE_TEMPLATE, { cardId: 'card-1', commentText: 'Update docs', commentAuthor: 'carol', @@ -53,7 +95,7 @@ describe('commentResponse task template', () => { }); it('instructs to classify the comment', () => { - const prompt = renderTaskPrompt('commentResponse', { + const prompt = renderInlineTaskPrompt(COMMENT_RESPONSE_TEMPLATE, { cardId: 'card-1', commentText: 'Why this approach?', commentAuthor: 'dave', @@ -62,7 +104,7 @@ describe('commentResponse task template', () => { }); it('instructs question-only replies via PostComment without plan modification', () => { - const prompt = renderTaskPrompt('commentResponse', { + const prompt = renderInlineTaskPrompt(COMMENT_RESPONSE_TEMPLATE, { cardId: 'card-1', commentText: 'Why this approach?', commentAuthor: 'dave', @@ -73,7 +115,7 @@ describe('commentResponse task template', () => { }); it('defaults to plan updates when intent is ambiguous', () => { - const prompt = renderTaskPrompt('commentResponse', { + const prompt = renderInlineTaskPrompt(COMMENT_RESPONSE_TEMPLATE, { cardId: 'card-1', commentText: 'Some comment', commentAuthor: 'eve', @@ -84,32 +126,32 @@ describe('commentResponse task template', () => { describe('review task template', () => { it('includes the PR number', () => { - const prompt = renderTaskPrompt('review', { prNumber: 42 }); + const prompt = renderInlineTaskPrompt(REVIEW_TEMPLATE, { prNumber: 42 }); expect(prompt).toContain('PR #42'); }); it('instructs to use CreatePRReview', () => { - const prompt = renderTaskPrompt('review', { prNumber: 7 }); + const prompt = renderInlineTaskPrompt(REVIEW_TEMPLATE, { prNumber: 7 }); expect(prompt).toContain('CreatePRReview'); }); }); describe('ci task template', () => { it('includes branch and PR number', () => { - const prompt = renderTaskPrompt('ci', { prBranch: 'fix/ci-errors', prNumber: 99 }); + const prompt = renderInlineTaskPrompt(CI_TEMPLATE, { 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 = renderTaskPrompt('ci', { prBranch: 'main', prNumber: 1 }); + const prompt = renderInlineTaskPrompt(CI_TEMPLATE, { prBranch: 'main', prNumber: 1 }); expect(prompt).toContain('CI checks have failed'); }); }); describe('prCommentResponse task template', () => { it('includes PR number, branch, and comment body', () => { - const prompt = renderTaskPrompt('prCommentResponse', { + const prompt = renderInlineTaskPrompt(PR_COMMENT_RESPONSE_TEMPLATE, { prBranch: 'feat/new', prNumber: 55, commentBody: 'Can you fix the typo?', @@ -120,7 +162,7 @@ describe('prCommentResponse task template', () => { }); it('includes file path when provided', () => { - const prompt = renderTaskPrompt('prCommentResponse', { + const prompt = renderInlineTaskPrompt(PR_COMMENT_RESPONSE_TEMPLATE, { prBranch: 'feat/new', prNumber: 55, commentBody: 'Fix this line', @@ -130,7 +172,7 @@ describe('prCommentResponse task template', () => { }); it('omits file path when not provided', () => { - const prompt = renderTaskPrompt('prCommentResponse', { + const prompt = renderInlineTaskPrompt(PR_COMMENT_RESPONSE_TEMPLATE, { prBranch: 'feat/new', prNumber: 55, commentBody: 'Looks good overall!', @@ -139,7 +181,7 @@ describe('prCommentResponse task template', () => { }); it('omits file path when empty string provided', () => { - const prompt = renderTaskPrompt('prCommentResponse', { + const prompt = renderInlineTaskPrompt(PR_COMMENT_RESPONSE_TEMPLATE, { prBranch: 'feat/new', prNumber: 55, commentBody: 'LGTM', @@ -149,7 +191,7 @@ describe('prCommentResponse task template', () => { }); it('instructs surgical changes by default', () => { - const prompt = renderTaskPrompt('prCommentResponse', { + const prompt = renderInlineTaskPrompt(PR_COMMENT_RESPONSE_TEMPLATE, { prBranch: 'main', prNumber: 1, commentBody: 'Please refactor', @@ -162,15 +204,17 @@ describe('prCommentResponse task template', () => { // Edge cases: DB partials and error handling // ============================================================================ -describe('renderTaskPrompt edge cases', () => { +describe('renderInlineTaskPrompt 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(); + it('renders basic template without partials', () => { + const template = 'Process card <%= it.cardId %>'; + const prompt = renderInlineTaskPrompt(template, { cardId: 'test-123' }); + expect(prompt).toBe('Process card test-123'); }); }); diff --git a/tests/unit/api/routers/agentDefinitions.test.ts b/tests/unit/api/routers/agentDefinitions.test.ts index adb875e6..844033ea 100644 --- a/tests/unit/api/routers/agentDefinitions.test.ts +++ b/tests/unit/api/routers/agentDefinitions.test.ts @@ -69,7 +69,6 @@ function createMockDefinition(overrides?: Partial): AgentDefini }, strategies: { contextPipeline: ['directoryListing'], - taskPromptBuilder: 'workItem', }, backend: { enableStopHooks: true, @@ -78,6 +77,10 @@ function createMockDefinition(overrides?: Partial): AgentDefini compaction: 'default', hint: 'A test agent', trailingMessage: undefined, + prompts: { + taskPrompt: + 'Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded.', + }, ...overrides, } as AgentDefinition; } @@ -434,7 +437,6 @@ describe('agentDefinitionsRouter', () => { expect(result).toHaveProperty('capabilities'); expect(result).toHaveProperty('contextStepNames'); - expect(result).toHaveProperty('taskPromptBuilderNames'); expect(result).toHaveProperty('compactionNames'); // Verify they're arrays expect(Array.isArray(result.capabilities)).toBe(true); diff --git a/tests/unit/db/repositories/agentDefinitionsRepository.test.ts b/tests/unit/db/repositories/agentDefinitionsRepository.test.ts index a325bc82..076c005d 100644 --- a/tests/unit/db/repositories/agentDefinitionsRepository.test.ts +++ b/tests/unit/db/repositories/agentDefinitionsRepository.test.ts @@ -28,7 +28,6 @@ const mockDefinition: AgentDefinition = { }, strategies: { contextPipeline: ['workItem'], - taskPromptBuilder: 'workItem', }, backend: { enableStopHooks: false, @@ -38,6 +37,10 @@ const mockDefinition: AgentDefinition = { compaction: 'default', hint: 'test hint', trailingMessage: undefined, + prompts: { + taskPrompt: + 'Analyze and process the work item with ID: <%= it.cardId %>. The work item data has been pre-loaded.', + }, }; describe('agentDefinitionsRepository', () => { diff --git a/web/src/components/settings/agent-definition-editor.tsx b/web/src/components/settings/agent-definition-editor.tsx index e2ba7b3f..b9a5e3da 100644 --- a/web/src/components/settings/agent-definition-editor.tsx +++ b/web/src/components/settings/agent-definition-editor.tsx @@ -35,7 +35,6 @@ export interface AgentDefinitionEditorProps { interface SchemaData { capabilities: readonly string[]; contextStepNames: readonly string[]; - taskPromptBuilderNames: readonly string[]; compactionNames: readonly string[]; } @@ -338,32 +337,6 @@ function StrategiesSection({
Loading...
)} -
- - -
{def.strategies.gadgetOptions && (
@@ -503,35 +476,156 @@ function TrailingMessageSection({ } // ───────────────────────────────────────────────────────────────────────────── -// System Prompt panel (edit mode only) +// Combined Prompts panel (edit mode only) - shows both system and task prompts // ───────────────────────────────────────────────────────────────────────────── -function SystemPromptPanel({ agentType }: { agentType: string }) { +function PromptSectionTab({ + label, + isActive, + hasCustom, + onClick, +}: { + label: string; + isActive: boolean; + hasCustom: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function ValidationStatus({ + status, + saveError, +}: { + status: string | null; + saveError: string | undefined; +}) { + if (!status && !saveError) return null; + const isInvalid = status?.startsWith('Invalid'); + return ( + <> + {status && ( + + {status} + + )} + {saveError && {saveError}} + + ); +} + +function usePromptSync( + definition: { prompts?: { systemPrompt?: string; taskPrompt?: string } } | undefined, + defaultContent: string | undefined, + setSystemPrompt: (v: string) => void, + setTaskPrompt: (v: string) => void, +) { + useEffect(() => { + const customSystem = definition?.prompts?.systemPrompt; + setSystemPrompt(customSystem || defaultContent || ''); + }, [definition?.prompts?.systemPrompt, defaultContent, setSystemPrompt]); + + useEffect(() => { + const customTask = definition?.prompts?.taskPrompt; + if (customTask) setTaskPrompt(customTask); + }, [definition?.prompts?.taskPrompt, setTaskPrompt]); +} + +function PromptEditorHeader({ + sectionLabel, + agentType, + hasCustom, + hasAnyCustom, + onReset, + onSave, + resetPending, + savePending, +}: { + sectionLabel: string; + agentType: string; + hasCustom: boolean; + hasAnyCustom: boolean; + onReset: () => void; + onSave: () => void; + resetPending: boolean; + savePending: boolean; +}) { + return ( +
+
+ + {sectionLabel} prompt for {agentType} + + {hasCustom && custom} +
+
+ + +
+
+ ); +} + +function PromptsPanel({ agentType }: { agentType: string }) { const queryClient = useQueryClient(); - const [content, setContent] = useState(''); + const [systemPrompt, setSystemPrompt] = useState(''); + const [taskPrompt, setTaskPrompt] = useState(''); + const [activeSection, setActiveSection] = useState<'system' | 'task'>('system'); const [validationStatus, setValidationStatus] = useState(null); const definitionQuery = useQuery(trpc.agentDefinitions.get.queryOptions({ agentType })); const defaultQuery = useQuery(trpc.prompts.getDefault.queryOptions({ agentType })); - const variablesQuery = useQuery(trpc.prompts.variables.queryOptions()); + const systemVariablesQuery = useQuery(trpc.prompts.variables.queryOptions()); + const taskVariablesQuery = useQuery(trpc.prompts.taskVariables.queryOptions()); const partialsQuery = useQuery(trpc.prompts.listPartials.queryOptions()); const definition = definitionQuery.data?.definition; - const hasCustom = !!definition?.prompts?.systemPrompt; + const hasCustomSystemPrompt = !!definition?.prompts?.systemPrompt; + const hasCustomTaskPrompt = !!definition?.prompts?.taskPrompt; - useEffect(() => { - if (definition?.prompts?.systemPrompt) { - setContent(definition.prompts.systemPrompt); - } else if (defaultQuery.data) { - setContent(defaultQuery.data.content); - } - }, [definition?.prompts?.systemPrompt, defaultQuery.data]); + // Sync prompt state with definition/defaults + usePromptSync(definition, defaultQuery.data?.content, setSystemPrompt, setTaskPrompt); const saveMutation = useMutation({ mutationFn: async () => { + // Always send both prompts to prevent losing the inactive section await trpcClient.agentDefinitions.updatePrompt.mutate({ agentType, - systemPrompt: content, + systemPrompt, + taskPrompt, }); }, onSuccess: () => { @@ -557,14 +651,18 @@ function SystemPromptPanel({ agentType }: { agentType: string }) { queryKey: trpc.agentDefinitions.list.queryOptions().queryKey, }); if (defaultQuery.data) { - setContent(defaultQuery.data.content); + setSystemPrompt(defaultQuery.data.content); } + setTaskPrompt(''); setValidationStatus('Reset to default.'); }, }); const validateMutation = useMutation({ - mutationFn: () => trpcClient.prompts.validate.mutate({ template: content }), + mutationFn: () => + trpcClient.prompts.validate.mutate({ + template: activeSection === 'system' ? systemPrompt : taskPrompt, + }), onSuccess: (result) => { if (result.valid) { setValidationStatus('Valid.'); @@ -574,61 +672,106 @@ function SystemPromptPanel({ agentType }: { agentType: string }) { }, }); - function loadDefault() { + function loadDefaultSystemPrompt() { if (defaultQuery.data) { - setContent(defaultQuery.data.content); + setSystemPrompt(defaultQuery.data.content); setValidationStatus(null); } } + const isSystemSection = activeSection === 'system'; + const currentContent = isSystemSection ? systemPrompt : taskPrompt; + const setCurrentContent = isSystemSection ? setSystemPrompt : setTaskPrompt; + const hasCustom = isSystemSection ? hasCustomSystemPrompt : hasCustomTaskPrompt; + const variables = isSystemSection ? systemVariablesQuery.data : taskVariablesQuery.data; + const sectionLabel = isSystemSection ? 'System' : 'Task'; + const placeholder = isSystemSection + ? 'Enter the system prompt template with Eta variables and <%~ include("partials/...") %> directives' + : 'Enter the task prompt template with Eta variables like <%= it.cardId %>'; + + // Loading state + const isLoading = definitionQuery.isLoading || defaultQuery.isLoading; + // Error state + const queryError = definitionQuery.error || defaultQuery.error; + + if (isLoading) { + return ( +
+
Loading prompts...
+
+ ); + } + + if (queryError) { + return ( +
+
Failed to load prompts: {queryError.message}
+
+ ); + } + + const handleSectionChange = (section: 'system' | 'task') => { + setActiveSection(section); + setValidationStatus(null); + }; + + const handleReset = () => { + if (!confirm('Reset both system and task prompts to their defaults?')) return; + resetMutation.mutate(); + }; + return (
-
-
- - System prompt for {agentType} - - {hasCustom && custom} -
-
- - -
+ {/* Section tabs */} +
+ handleSectionChange('system')} + /> + handleSectionChange('task')} + />
+ {/* Header with actions */} + saveMutation.mutate()} + resetPending={resetMutation.isPending} + savePending={saveMutation.isPending} + /> +