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

220 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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';
/**
* 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;
}
/** Localized strings for auto-injected sections */
const SECTION_STRINGS = {
en: {
workflowContext: '## Workflow Context',
iteration: 'Iteration',
iterationWorkflowWide: '(workflow-wide)',
stepIteration: 'Step Iteration',
stepIterationTimes: '(times this step has run)',
step: 'Step',
reportDirectory: 'Report Directory',
reportFile: 'Report File',
reportFiles: 'Report Files',
phaseNote: '**Note:** This is Phase 1 (main work). After you complete your work, Phase 2 will automatically generate the report based on your findings.',
userRequest: '## User Request',
previousResponse: '## Previous Response',
additionalUserInputs: '## Additional User Inputs',
instructions: '## Instructions',
},
ja: {
workflowContext: '## Workflow Context',
iteration: 'Iteration',
iterationWorkflowWide: '(ワークフロー全体)',
stepIteration: 'Step Iteration',
stepIterationTimes: '(このステップの実行回数)',
step: 'Step',
reportDirectory: 'Report Directory',
reportFile: 'Report File',
reportFiles: 'Report Files',
phaseNote: '**注意:** これはPhase 1本来の作業です。作業完了後、Phase 2で自動的にレポートを生成します。',
userRequest: '## User Request',
previousResponse: '## Previous Response',
additionalUserInputs: '## Additional User Inputs',
instructions: '## Instructions',
},
} as const;
/** Localized strings for auto-generated report output instructions */
const REPORT_OUTPUT_STRINGS = {
en: {
singleHeading: '**Report output:** Output to the `Report File` specified above.',
multiHeading: '**Report output:** Output to the `Report Files` specified above.',
createRule: '- If file does not exist: Create new file',
appendRule: '- If file exists: Append with `## Iteration {step_iteration}` section',
},
ja: {
singleHeading: '**レポート出力:** `Report File` に出力してください。',
multiHeading: '**レポート出力:** Report Files に出力してください。',
createRule: '- ファイルが存在しない場合: 新規作成',
appendRule: '- ファイルが存在する場合: `## Iteration {step_iteration}` セクションを追記',
},
} as const;
/**
* 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 = SECTION_STRINGS[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 = SECTION_STRINGS[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 = SECTION_STRINGS[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 = REPORT_OUTPUT_STRINGS[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');
}