takt/src/workflow/instruction-builder.ts
nrslib cb37baec2c レポート出力指示を自動生成に統一し、YAML の手動 order/instruction_template を削除
- REPORT_OUTPUT_STRINGS (en/ja) と renderReportOutputInstruction() を追加
- 全8ワークフローYAMLから手動の order フィールドとレポート出力指示を削除
- ReportObjectConfig に明示的 order がある場合は後方互換として優先
- .envrc を .gitignore に追加

ref #29
2026-01-30 11:59:06 +09:00

505 lines
19 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.

/**
* Instruction template builder for workflow steps
*
* Builds the instruction string for agent execution by:
* 1. Auto-injecting standard sections (Execution Context, Workflow Context,
* User Request, Previous Response, Additional User Inputs, Instructions header)
* 2. Replacing template placeholders with actual values
* 3. Appending auto-generated status rules from workflow rules
*/
import type { WorkflowStep, WorkflowRule, AgentResponse, Language, ReportConfig, ReportObjectConfig } from '../models/types.js';
/**
* Context for building instruction from template.
*/
export interface InstructionContext {
/** The main task/prompt */
task: string;
/** Current iteration number (workflow-wide turn count) */
iteration: number;
/** Maximum iterations allowed */
maxIterations: number;
/** Current step's iteration number (how many times this step has been executed) */
stepIteration: number;
/** Working directory (agent work dir, may be a clone) */
cwd: string;
/** Project root directory (where .takt/ lives). Defaults to cwd. */
projectCwd?: string;
/** User inputs accumulated during workflow */
userInputs: string[];
/** Previous step output if available */
previousOutput?: AgentResponse;
/** Report directory path */
reportDir?: string;
/** Language for metadata rendering. Defaults to 'en'. */
language?: Language;
}
/** Execution environment metadata prepended to agent instructions */
export interface ExecutionMetadata {
/** The agent's working directory (may be a clone) */
readonly workingDirectory: string;
/** Language for metadata rendering */
readonly language: Language;
/** Whether file editing is allowed for this step (undefined = no prompt) */
readonly edit?: boolean;
}
/**
* Build execution metadata from instruction context and step config.
*
* Pure function: (InstructionContext, edit?) → ExecutionMetadata.
*/
export function buildExecutionMetadata(context: InstructionContext, edit?: boolean): ExecutionMetadata {
return {
workingDirectory: context.cwd,
language: context.language ?? 'en',
edit,
};
}
/** Localized strings for status rules header */
const STATUS_RULES_HEADER_STRINGS = {
en: {
heading: '# ⚠️ Required: Status Output Rules ⚠️',
warning: '**The workflow will stop without this tag.**',
instruction: 'Your final output MUST include a status tag following the rules below.',
},
ja: {
heading: '# ⚠️ 必須: ステータス出力ルール ⚠️',
warning: '**このタグがないとワークフローが停止します。**',
instruction: '最終出力には必ず以下のルールに従ったステータスタグを含めてください。',
},
} as const;
/**
* Render status rules header.
* Prepended to auto-generated status rules from workflow rules.
*/
export function renderStatusRulesHeader(language: Language): string {
const strings = STATUS_RULES_HEADER_STRINGS[language];
return [strings.heading, '', strings.warning, strings.instruction, ''].join('\n');
}
/** Localized strings for rules-based status prompt */
const RULES_PROMPT_STRINGS = {
en: {
criteriaHeading: '## Decision Criteria',
headerNum: '#',
headerCondition: 'Condition',
headerTag: 'Tag',
outputHeading: '## Output Format',
outputInstruction: 'Output the tag corresponding to your decision:',
appendixHeading: '### Appendix Template',
appendixInstruction: 'When outputting `[{tag}]`, append the following:',
},
ja: {
criteriaHeading: '## 判定基準',
headerNum: '#',
headerCondition: '状況',
headerTag: 'タグ',
outputHeading: '## 出力フォーマット',
outputInstruction: '判定に対応するタグを出力してください:',
appendixHeading: '### 追加出力テンプレート',
appendixInstruction: '`[{tag}]` を出力する場合、以下を追記してください:',
},
} as const;
/**
* Generate status rules prompt from rules configuration.
* Creates a structured prompt that tells the agent which numbered tags to output.
*
* Example output for step "plan" with 3 rules:
* ## 判定基準
* | # | 状況 | タグ |
* |---|------|------|
* | 1 | 要件が明確で実装可能 | `[PLAN:1]` |
* | 2 | ユーザーが質問をしている | `[PLAN:2]` |
* | 3 | 要件が不明確、情報不足 | `[PLAN:3]` |
*/
export function generateStatusRulesFromRules(
stepName: string,
rules: WorkflowRule[],
language: Language,
): string {
const tag = stepName.toUpperCase();
const strings = RULES_PROMPT_STRINGS[language];
const lines: string[] = [];
// Criteria table
lines.push(strings.criteriaHeading);
lines.push('');
lines.push(`| ${strings.headerNum} | ${strings.headerCondition} | ${strings.headerTag} |`);
lines.push('|---|------|------|');
for (const [i, rule] of rules.entries()) {
lines.push(`| ${i + 1} | ${rule.condition} | \`[${tag}:${i + 1}]\` |`);
}
lines.push('');
// Output format
lines.push(strings.outputHeading);
lines.push('');
lines.push(strings.outputInstruction);
lines.push('');
for (const [i, rule] of rules.entries()) {
lines.push(`- \`[${tag}:${i + 1}]\`${rule.condition}`);
}
// Appendix templates (if any rules have appendix)
const rulesWithAppendix = rules.filter((r) => r.appendix);
if (rulesWithAppendix.length > 0) {
lines.push('');
lines.push(strings.appendixHeading);
for (const [i, rule] of rules.entries()) {
if (!rule.appendix) continue;
const tagStr = `[${tag}:${i + 1}]`;
lines.push('');
lines.push(strings.appendixInstruction.replace('{tag}', tagStr));
lines.push('```');
lines.push(rule.appendix.trimEnd());
lines.push('```');
}
}
return lines.join('\n');
}
/** Localized strings for execution metadata rendering */
const METADATA_STRINGS = {
en: {
heading: '## Execution Context',
workingDirectory: 'Working Directory',
rulesHeading: '## Execution Rules',
noCommit: '**Do NOT run git commit.** Commits are handled automatically by the system after workflow completion.',
noCd: '**Do NOT use `cd` in Bash commands.** Your working directory is already set correctly. Run commands directly without changing directories.',
editEnabled: '**Editing is ENABLED for this step.** You may create, modify, and delete files as needed to fulfill the user\'s request.',
editDisabled: '**Editing is DISABLED for this step.** Do NOT create, modify, or delete any project source files. You may only read/search code and write to report files in the Report Directory.',
note: 'Note: This section is metadata. Follow the language used in the rest of the prompt.',
},
ja: {
heading: '## 実行コンテキスト',
workingDirectory: '作業ディレクトリ',
rulesHeading: '## 実行ルール',
noCommit: '**git commit を実行しないでください。** コミットはワークフロー完了後にシステムが自動で行います。',
noCd: '**Bashコマンドで `cd` を使用しないでください。** 作業ディレクトリは既に正しく設定されています。ディレクトリを変更せずにコマンドを実行してください。',
editEnabled: '**このステップでは編集が許可されています。** ユーザーの要求に応じて、ファイルの作成・変更・削除を行ってください。',
editDisabled: '**このステップでは編集が禁止されています。** プロジェクトのソースファイルを作成・変更・削除しないでください。コードの読み取り・検索と、Report Directoryへのレポート出力のみ行えます。',
note: '',
},
} as const;
/**
* Render execution metadata as a markdown string.
*
* Pure function: ExecutionMetadata → string.
* Always includes heading + Working Directory + Execution Rules.
* Language determines the output language; 'en' includes a note about language consistency.
*/
export function renderExecutionMetadata(metadata: ExecutionMetadata): string {
const strings = METADATA_STRINGS[metadata.language];
const lines = [
strings.heading,
`- ${strings.workingDirectory}: ${metadata.workingDirectory}`,
'',
strings.rulesHeading,
`- ${strings.noCommit}`,
`- ${strings.noCd}`,
];
if (metadata.edit === true) {
lines.push(`- ${strings.editEnabled}`);
} else if (metadata.edit === false) {
lines.push(`- ${strings.editDisabled}`);
}
if (strings.note) {
lines.push('');
lines.push(strings.note);
}
lines.push('');
return lines.join('\n');
}
/**
* Escape special characters in dynamic content to prevent template injection.
*/
function escapeTemplateChars(str: string): string {
return str.replace(/\{/g, '').replace(/\}/g, '');
}
/**
* 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',
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',
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;
/**
* Generate report output instructions from step.report config.
* Returns undefined if step has no report or no reportDir.
*
* This replaces the manual `order:` fields and instruction_template
* report output blocks that were previously hand-written in each YAML.
*/
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');
}
/**
* Render the Workflow Context section.
*/
function renderWorkflowContext(
step: WorkflowStep,
context: InstructionContext,
language: Language,
): string {
const s = SECTION_STRINGS[language];
const lines: string[] = [
s.workflowContext,
`- ${s.iteration}: ${context.iteration}/${context.maxIterations}${s.iterationWorkflowWide}`,
`- ${s.stepIteration}: ${context.stepIteration}${s.stepIterationTimes}`,
`- ${s.step}: ${step.name}`,
];
// Report info (only if step has report config AND reportDir is available)
if (step.report && context.reportDir) {
lines.push(`- ${s.reportDirectory}: ${context.reportDir}/`);
if (typeof step.report === 'string') {
// Single file (string form)
lines.push(`- ${s.reportFile}: ${context.reportDir}/${step.report}`);
} else if (isReportObjectConfig(step.report)) {
// Object form (name + order + format)
lines.push(`- ${s.reportFile}: ${context.reportDir}/${step.report.name}`);
} else {
// Multiple files (ReportConfig[] form)
lines.push(`- ${s.reportFiles}:`);
for (const file of step.report as ReportConfig[]) {
lines.push(` - ${file.label}: ${context.reportDir}/${file.path}`);
}
}
}
return lines.join('\n');
}
/**
* Replace template placeholders in the instruction_template body.
*
* These placeholders may still be used in instruction_template for
* backward compatibility or special cases.
*/
function replaceTemplatePlaceholders(
template: string,
step: WorkflowStep,
context: InstructionContext,
): string {
let result = template;
// These placeholders are also covered by auto-injected sections
// (User Request, Previous Response, Additional User Inputs), but kept here
// for backward compatibility with workflows that still embed them in
// instruction_template (e.g., research.yaml, magi.yaml).
// New workflows should NOT use {task} or {user_inputs} in instruction_template
// since they are auto-injected as separate sections.
// Replace {task}
result = result.replace(/\{task\}/g, escapeTemplateChars(context.task));
// Replace {iteration}, {max_iterations}, and {step_iteration}
result = result.replace(/\{iteration\}/g, String(context.iteration));
result = result.replace(/\{max_iterations\}/g, String(context.maxIterations));
result = result.replace(/\{step_iteration\}/g, String(context.stepIteration));
// Replace {previous_response}
if (step.passPreviousResponse) {
if (context.previousOutput) {
result = result.replace(
/\{previous_response\}/g,
escapeTemplateChars(context.previousOutput.content),
);
} else {
result = result.replace(/\{previous_response\}/g, '');
}
}
// Replace {user_inputs}
const userInputsStr = context.userInputs.join('\n');
result = result.replace(
/\{user_inputs\}/g,
escapeTemplateChars(userInputsStr),
);
// Replace {report_dir}
if (context.reportDir) {
result = result.replace(/\{report_dir\}/g, context.reportDir);
}
// Replace {report:filename} with reportDir/filename
if (context.reportDir) {
result = result.replace(/\{report:([^}]+)\}/g, (_match, filename: string) => {
return `${context.reportDir}/${filename}`;
});
}
return result;
}
/**
* Build instruction from template with context values.
*
* Generates a complete instruction by auto-injecting standard sections
* around the step-specific instruction_template content:
*
* 1. Execution Context (working directory, rules) — always
* 2. Workflow Context (iteration, step, report info) — always
* 3. User Request ({task}) — unless template contains {task}
* 4. Previous Response — if passPreviousResponse and has content, unless template contains {previous_response}
* 5. Additional User Inputs — unless template contains {user_inputs}
* 6. Instructions header + instruction_template content — always
* 7. Status Output Rules — if rules exist
*
* Template placeholders ({task}, {previous_response}, etc.) are still replaced
* within the instruction_template body for backward compatibility.
* When a placeholder is present in the template, the corresponding
* auto-injected section is skipped to avoid duplication.
*/
export function buildInstruction(
step: WorkflowStep,
context: InstructionContext,
): string {
const language = context.language ?? 'en';
const s = SECTION_STRINGS[language];
const sections: string[] = [];
// 1. Execution context metadata (working directory + rules + edit permission)
const metadata = buildExecutionMetadata(context, step.edit);
sections.push(renderExecutionMetadata(metadata));
// 2. Workflow Context (iteration, step, report info)
sections.push(renderWorkflowContext(step, context, language));
// Skip auto-injection for sections whose placeholders exist in the template,
// to avoid duplicate content. Templates using placeholders handle their own layout.
const tmpl = 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(context.task)}`);
}
// 4. Previous Response (skip if template embeds {previous_response} directly)
if (step.passPreviousResponse && context.previousOutput && !hasPreviousResponsePlaceholder) {
sections.push(
`${s.previousResponse}\n${escapeTemplateChars(context.previousOutput.content)}`,
);
}
// 5. Additional User Inputs (skip if template embeds {user_inputs} directly)
if (!hasUserInputsPlaceholder) {
const userInputsStr = context.userInputs.join('\n');
sections.push(`${s.additionalUserInputs}\n${escapeTemplateChars(userInputsStr)}`);
}
// 6a. Report output instruction (auto-generated from step.report)
// If ReportObjectConfig has an explicit `order:`, use that (backward compat).
// Otherwise, auto-generate from the report declaration.
if (step.report && isReportObjectConfig(step.report) && step.report.order) {
const processedOrder = replaceTemplatePlaceholders(step.report.order.trimEnd(), step, context);
sections.push(processedOrder);
} else {
const reportInstruction = renderReportOutputInstruction(step, context, language);
if (reportInstruction) {
sections.push(reportInstruction);
}
}
// 6b. Instructions header + instruction_template content
const processedTemplate = replaceTemplatePlaceholders(
step.instructionTemplate,
step,
context,
);
sections.push(`${s.instructions}\n${processedTemplate}`);
// 6c. Report format (appended after instruction_template, from ReportObjectConfig)
if (step.report && isReportObjectConfig(step.report) && step.report.format) {
const processedFormat = replaceTemplatePlaceholders(step.report.format.trimEnd(), step, context);
sections.push(processedFormat);
}
// 7. Status rules (auto-generated from rules)
if (step.rules && step.rules.length > 0) {
const statusHeader = renderStatusRulesHeader(language);
const generatedPrompt = generateStatusRulesFromRules(step.name, step.rules, language);
sections.push(`${statusHeader}\n${generatedPrompt}`);
}
return sections.join('\n\n');
}