takt/src/core/workflow/instruction/InstructionBuilder.ts

195 lines
6.7 KiB
TypeScript

/**
* Phase 1 instruction builder
*
* Builds the instruction string for main agent execution by:
* 1. Auto-injecting standard sections (Execution Context, Workflow Context,
* User Request, Previous Response, Additional User Inputs, Instructions header,
* Status Output Rules)
* 2. Replacing template placeholders with actual values
*/
import type { WorkflowStep, Language, ReportConfig, ReportObjectConfig } from '../../models/types.js';
import { hasTagBasedRules } from '../evaluation/rule-utils.js';
import type { InstructionContext } from './instruction-context.js';
import { buildExecutionMetadata, renderExecutionMetadata } from './instruction-context.js';
import { generateStatusRulesFromRules } from './status-rules.js';
import { escapeTemplateChars, replaceTemplatePlaceholders } from './escape.js';
import { getPromptObject } from '../../../shared/prompts/index.js';
/**
* Check if a report config is the object form (ReportObjectConfig).
*/
export function isReportObjectConfig(report: string | ReportConfig[] | ReportObjectConfig): report is ReportObjectConfig {
return typeof report === 'object' && !Array.isArray(report) && 'name' in report;
}
/** Shape of localized section strings */
interface SectionStrings {
workflowContext: string;
iteration: string;
iterationWorkflowWide: string;
stepIteration: string;
stepIterationTimes: string;
step: string;
reportDirectory: string;
reportFile: string;
reportFiles: string;
phaseNote: string;
userRequest: string;
previousResponse: string;
additionalUserInputs: string;
instructions: string;
}
/** Shape of localized report output strings */
interface ReportOutputStrings {
singleHeading: string;
multiHeading: string;
createRule: string;
appendRule: string;
}
/**
* Builds Phase 1 instructions for agent execution.
*
* Stateless builder — all data is passed via constructor context.
*/
export class InstructionBuilder {
constructor(
private readonly step: WorkflowStep,
private readonly context: InstructionContext,
) {}
/**
* Build the complete instruction string.
*
* Generates a complete instruction by auto-injecting standard sections
* around the step-specific instruction_template content.
*/
build(): string {
const language = this.context.language ?? 'en';
const s = getPromptObject<SectionStrings>('instruction.sections', language);
const sections: string[] = [];
// 1. Execution context metadata (working directory + rules + edit permission)
const metadata = buildExecutionMetadata(this.context, this.step.edit);
sections.push(renderExecutionMetadata(metadata));
// 2. Workflow Context (iteration, step, report info)
sections.push(this.renderWorkflowContext(language));
// Skip auto-injection for sections whose placeholders exist in the template,
// to avoid duplicate content.
const tmpl = this.step.instructionTemplate;
const hasTaskPlaceholder = tmpl.includes('{task}');
const hasPreviousResponsePlaceholder = tmpl.includes('{previous_response}');
const hasUserInputsPlaceholder = tmpl.includes('{user_inputs}');
// 3. User Request (skip if template embeds {task} directly)
if (!hasTaskPlaceholder) {
sections.push(`${s.userRequest}\n${escapeTemplateChars(this.context.task)}`);
}
// 4. Previous Response (skip if template embeds {previous_response} directly)
if (this.step.passPreviousResponse && this.context.previousOutput && !hasPreviousResponsePlaceholder) {
sections.push(
`${s.previousResponse}\n${escapeTemplateChars(this.context.previousOutput.content)}`,
);
}
// 5. Additional User Inputs (skip if template embeds {user_inputs} directly)
if (!hasUserInputsPlaceholder) {
const userInputsStr = this.context.userInputs.join('\n');
sections.push(`${s.additionalUserInputs}\n${escapeTemplateChars(userInputsStr)}`);
}
// 6. Instructions header + instruction_template content
const processedTemplate = replaceTemplatePlaceholders(
this.step.instructionTemplate,
this.step,
this.context,
);
sections.push(`${s.instructions}\n${processedTemplate}`);
// 7. Status Output Rules (for tag-based detection in Phase 1)
if (hasTagBasedRules(this.step)) {
const statusRulesPrompt = generateStatusRulesFromRules(
this.step.name,
this.step.rules!,
language,
{ interactive: this.context.interactive },
);
sections.push(statusRulesPrompt);
}
return sections.join('\n\n');
}
private renderWorkflowContext(language: Language): string {
const s = getPromptObject<SectionStrings>('instruction.sections', language);
const lines: string[] = [
s.workflowContext,
`- ${s.iteration}: ${this.context.iteration}/${this.context.maxIterations}${s.iterationWorkflowWide}`,
`- ${s.stepIteration}: ${this.context.stepIteration}${s.stepIterationTimes}`,
`- ${s.step}: ${this.step.name}`,
];
// If step has report config, include Report Directory path and phase note
if (this.step.report && this.context.reportDir) {
const reportContext = renderReportContext(this.step.report, this.context.reportDir, language);
lines.push(reportContext);
lines.push('');
lines.push(s.phaseNote);
}
return lines.join('\n');
}
}
/**
* Render report context info for Workflow Context section.
* Used by ReportInstructionBuilder.
*/
export function renderReportContext(
report: string | ReportConfig[] | ReportObjectConfig,
reportDir: string,
language: Language,
): string {
const s = getPromptObject<SectionStrings>('instruction.sections', language);
const lines: string[] = [
`- ${s.reportDirectory}: ${reportDir}/`,
];
if (typeof report === 'string') {
lines.push(`- ${s.reportFile}: ${reportDir}/${report}`);
} else if (isReportObjectConfig(report)) {
lines.push(`- ${s.reportFile}: ${reportDir}/${report.name}`);
} else {
lines.push(`- ${s.reportFiles}:`);
for (const file of report) {
lines.push(` - ${file.label}: ${reportDir}/${file.path}`);
}
}
return lines.join('\n');
}
/**
* Generate report output instructions from step.report config.
* Returns undefined if step has no report or no reportDir.
*/
export function renderReportOutputInstruction(
step: WorkflowStep,
context: InstructionContext,
language: Language,
): string | undefined {
if (!step.report || !context.reportDir) return undefined;
const s = getPromptObject<ReportOutputStrings>('instruction.reportOutput', language);
const isMulti = Array.isArray(step.report);
const heading = isMulti ? s.multiHeading : s.singleHeading;
const appendRule = s.appendRule.replace('{step_iteration}', String(context.stepIteration));
return [heading, s.createRule, appendRule].join('\n');
}