- REPORT_OUTPUT_STRINGS (en/ja) と renderReportOutputInstruction() を追加 - 全8ワークフローYAMLから手動の order フィールドとレポート出力指示を削除 - ReportObjectConfig に明示的 order がある場合は後方互換として優先 - .envrc を .gitignore に追加 ref #29
505 lines
19 KiB
TypeScript
505 lines
19 KiB
TypeScript
/**
|
||
* 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');
|
||
}
|