レポート出力をフェーズ2に分離し、本体実行からWriteを除外
ステップ実行を2フェーズに分離: - フェーズ1(本体): allowed_toolsからWriteを除外、レポート情報を注入しない - フェーズ2(レポート出力): 同一セッションresume、Writeのみ付与、ステータス検出なし buildInstruction()からレポート関連コードを削除し、 buildReportInstruction()を新設してレポート出力の責務を完全分離。
This commit is contained in:
parent
70651f8dd8
commit
9c597a9b0d
@ -5,12 +5,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildInstruction,
|
||||
buildReportInstruction,
|
||||
buildExecutionMetadata,
|
||||
renderExecutionMetadata,
|
||||
renderStatusRulesHeader,
|
||||
generateStatusRulesFromRules,
|
||||
isReportObjectConfig,
|
||||
type InstructionContext,
|
||||
type ReportInstructionContext,
|
||||
} from '../workflow/instruction-builder.js';
|
||||
import type { WorkflowStep, WorkflowRule } from '../models/types.js';
|
||||
|
||||
@ -444,7 +446,7 @@ describe('instruction-builder', () => {
|
||||
expect(result).toContain('- Step: implement');
|
||||
});
|
||||
|
||||
it('should include single report file when report is a string', () => {
|
||||
it('should NOT include report info even when step has report (phase separation)', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.name = 'plan';
|
||||
step.report = '00-plan.md';
|
||||
@ -455,14 +457,13 @@ describe('instruction-builder', () => {
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('- Report Directory: 20260129-test/');
|
||||
expect(result).toContain('- Report File: 20260129-test/00-plan.md');
|
||||
expect(result).not.toContain('Report Files:');
|
||||
expect(result).toContain('## Workflow Context');
|
||||
expect(result).not.toContain('Report Directory');
|
||||
expect(result).not.toContain('Report File');
|
||||
});
|
||||
|
||||
it('should include multiple report files when report is ReportConfig[]', () => {
|
||||
it('should NOT include report info for ReportConfig[] (phase separation)', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.name = 'implement';
|
||||
step.report = [
|
||||
{ label: 'Scope', path: '01-scope.md' },
|
||||
{ label: 'Decisions', path: '02-decisions.md' },
|
||||
@ -474,16 +475,12 @@ describe('instruction-builder', () => {
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('- Report Directory: 20260129-test/');
|
||||
expect(result).toContain('- Report Files:');
|
||||
expect(result).toContain(' - Scope: 20260129-test/01-scope.md');
|
||||
expect(result).toContain(' - Decisions: 20260129-test/02-decisions.md');
|
||||
expect(result).not.toContain('Report File:');
|
||||
expect(result).not.toContain('Report Directory');
|
||||
expect(result).not.toContain('Report Files');
|
||||
});
|
||||
|
||||
it('should include report file when report is ReportObjectConfig', () => {
|
||||
it('should NOT include report info for ReportObjectConfig (phase separation)', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.name = 'plan';
|
||||
step.report = { name: '00-plan.md' };
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
@ -492,33 +489,6 @@ describe('instruction-builder', () => {
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('- Report Directory: 20260129-test/');
|
||||
expect(result).toContain('- Report File: 20260129-test/00-plan.md');
|
||||
expect(result).not.toContain('Report Files:');
|
||||
});
|
||||
|
||||
it('should NOT include report info when reportDir is undefined', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const context = createMinimalContext({ language: 'en' });
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('## Workflow Context');
|
||||
expect(result).not.toContain('Report Directory');
|
||||
expect(result).not.toContain('Report File');
|
||||
});
|
||||
|
||||
it('should NOT include report info when step has no report', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('## Workflow Context');
|
||||
expect(result).not.toContain('Report Directory');
|
||||
expect(result).not.toContain('Report File');
|
||||
});
|
||||
@ -534,8 +504,10 @@ describe('instruction-builder', () => {
|
||||
|
||||
expect(result).toContain('- Step Iteration: 3(このステップの実行回数)');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT include .takt/reports/ prefix in report paths', () => {
|
||||
describe('buildInstruction report-free (phase separation)', () => {
|
||||
it('should NOT include report output instruction in buildInstruction', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const context = createMinimalContext({
|
||||
@ -545,17 +517,14 @@ describe('instruction-builder', () => {
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).not.toContain('.takt/reports/');
|
||||
});
|
||||
expect(result).not.toContain('**Report output:**');
|
||||
expect(result).not.toContain('Report File');
|
||||
expect(result).not.toContain('Report Directory');
|
||||
});
|
||||
|
||||
describe('ReportObjectConfig order/format injection', () => {
|
||||
it('should inject order before instruction_template', () => {
|
||||
it('should NOT include report format in buildInstruction', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = {
|
||||
name: '00-plan.md',
|
||||
order: '**Output:** Write to {report:00-plan.md}',
|
||||
};
|
||||
step.report = { name: '00-plan.md', format: '**Format:**\n# Plan' };
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
@ -563,178 +532,10 @@ describe('instruction-builder', () => {
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
const orderIdx = result.indexOf('**Output:** Write to 20260129-test/00-plan.md');
|
||||
const instructionsIdx = result.indexOf('## Instructions');
|
||||
expect(orderIdx).toBeGreaterThan(-1);
|
||||
expect(instructionsIdx).toBeGreaterThan(orderIdx);
|
||||
expect(result).not.toContain('**Format:**');
|
||||
});
|
||||
|
||||
it('should inject format after instruction_template', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = {
|
||||
name: '00-plan.md',
|
||||
format: '**Format:**\n```markdown\n# Plan\n```',
|
||||
};
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
const instructionsIdx = result.indexOf('## Instructions');
|
||||
const formatIdx = result.indexOf('**Format:**');
|
||||
expect(formatIdx).toBeGreaterThan(instructionsIdx);
|
||||
});
|
||||
|
||||
it('should inject both order before and format after instruction_template', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = {
|
||||
name: '00-plan.md',
|
||||
order: '**Output:** Write to {report:00-plan.md}',
|
||||
format: '**Format:**\n```markdown\n# Plan\n```',
|
||||
};
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
const orderIdx = result.indexOf('**Output:** Write to 20260129-test/00-plan.md');
|
||||
const instructionsIdx = result.indexOf('## Instructions');
|
||||
const formatIdx = result.indexOf('**Format:**');
|
||||
expect(orderIdx).toBeGreaterThan(-1);
|
||||
expect(instructionsIdx).toBeGreaterThan(orderIdx);
|
||||
expect(formatIdx).toBeGreaterThan(instructionsIdx);
|
||||
});
|
||||
|
||||
it('should replace {report:filename} in order text', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = {
|
||||
name: '00-plan.md',
|
||||
order: 'Output to {report:00-plan.md} file.',
|
||||
};
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('Output to 20260129-test/00-plan.md file.');
|
||||
expect(result).not.toContain('{report:00-plan.md}');
|
||||
});
|
||||
|
||||
it('should auto-inject report output instruction when report is a simple string', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
// Auto-generated report output instruction should be injected before ## Instructions
|
||||
expect(result).toContain('**Report output:** Output to the `Report File` specified above.');
|
||||
expect(result).toContain('- If file does not exist: Create new file');
|
||||
const reportIdx = result.indexOf('**Report output:**');
|
||||
const instructionsIdx = result.indexOf('## Instructions');
|
||||
expect(reportIdx).toBeGreaterThan(-1);
|
||||
expect(instructionsIdx).toBeGreaterThan(reportIdx);
|
||||
});
|
||||
|
||||
it('should auto-inject report output instruction when report is ReportConfig[]', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = [
|
||||
{ label: 'Scope', path: '01-scope.md' },
|
||||
];
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
// Auto-generated multi-file report output instruction
|
||||
expect(result).toContain('**Report output:** Output to the `Report Files` specified above.');
|
||||
expect(result).toContain('- If file does not exist: Create new file');
|
||||
});
|
||||
|
||||
it('should replace {report:filename} in instruction_template too', () => {
|
||||
const step = createMinimalStep('Write to {report:00-plan.md}');
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('Write to 20260129-test/00-plan.md');
|
||||
expect(result).not.toContain('{report:00-plan.md}');
|
||||
});
|
||||
|
||||
it('should replace {step_iteration} in order/format text', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = {
|
||||
name: '00-plan.md',
|
||||
order: 'Append ## Iteration {step_iteration} section',
|
||||
};
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
stepIteration: 3,
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('Append ## Iteration 3 section');
|
||||
});
|
||||
|
||||
it('should auto-inject Japanese report output instruction for ja language', () => {
|
||||
const step = createMinimalStep('作業する');
|
||||
step.report = { name: '00-plan.md' };
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'ja',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('**レポート出力:** `Report File` に出力してください。');
|
||||
expect(result).toContain('- ファイルが存在しない場合: 新規作成');
|
||||
expect(result).toContain('- ファイルが存在する場合: `## Iteration 1` セクションを追記');
|
||||
});
|
||||
|
||||
it('should auto-inject Japanese multi-file report output instruction', () => {
|
||||
const step = createMinimalStep('作業する');
|
||||
step.report = [{ label: 'Scope', path: '01-scope.md' }];
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'ja',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('**レポート出力:** Report Files に出力してください。');
|
||||
});
|
||||
|
||||
it('should replace {step_iteration} in auto-generated report output instruction', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
stepIteration: 5,
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('Append with `## Iteration 5` section');
|
||||
});
|
||||
|
||||
it('should prefer explicit order over auto-generated report instruction', () => {
|
||||
it('should NOT include report order in buildInstruction', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = {
|
||||
name: '00-plan.md',
|
||||
@ -747,13 +548,11 @@ describe('instruction-builder', () => {
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('Custom order instruction');
|
||||
expect(result).not.toContain('**Report output:**');
|
||||
expect(result).not.toContain('Custom order instruction');
|
||||
});
|
||||
|
||||
it('should auto-inject report output for ReportObjectConfig without order', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = { name: '00-plan.md', format: '# Plan' };
|
||||
it('should still replace {report:filename} in instruction_template', () => {
|
||||
const step = createMinimalStep('Write to {report:00-plan.md}');
|
||||
const context = createMinimalContext({
|
||||
reportDir: '20260129-test',
|
||||
language: 'en',
|
||||
@ -761,20 +560,195 @@ describe('instruction-builder', () => {
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
|
||||
expect(result).toContain('**Report output:** Output to the `Report File` specified above.');
|
||||
expect(result).toContain('Write to 20260129-test/00-plan.md');
|
||||
expect(result).not.toContain('{report:00-plan.md}');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT inject report output when no reportDir', () => {
|
||||
describe('buildReportInstruction (phase 2)', () => {
|
||||
function createReportContext(overrides: Partial<ReportInstructionContext> = {}): ReportInstructionContext {
|
||||
return {
|
||||
cwd: '/project',
|
||||
reportDir: '20260129-test',
|
||||
stepIteration: 1,
|
||||
language: 'en',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('should include execution context with working directory', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const context = createMinimalContext({
|
||||
language: 'en',
|
||||
const ctx = createReportContext({ cwd: '/my/project' });
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('Working Directory: /my/project');
|
||||
});
|
||||
|
||||
const result = buildInstruction(step, context);
|
||||
it('should include no-source-edit rule in execution rules', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('Do NOT modify project source files');
|
||||
});
|
||||
|
||||
it('should include no-commit and no-cd rules', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('Do NOT run git commit');
|
||||
expect(result).toContain('Do NOT use `cd`');
|
||||
});
|
||||
|
||||
it('should include report directory and file for string report', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const ctx = createReportContext({ reportDir: '20260130-test' });
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('- Report Directory: 20260130-test/');
|
||||
expect(result).toContain('- Report File: 20260130-test/00-plan.md');
|
||||
});
|
||||
|
||||
it('should include report files for ReportConfig[] report', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = [
|
||||
{ label: 'Scope', path: '01-scope.md' },
|
||||
{ label: 'Decisions', path: '02-decisions.md' },
|
||||
];
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('- Report Directory: 20260129-test/');
|
||||
expect(result).toContain('- Report Files:');
|
||||
expect(result).toContain(' - Scope: 20260129-test/01-scope.md');
|
||||
expect(result).toContain(' - Decisions: 20260129-test/02-decisions.md');
|
||||
});
|
||||
|
||||
it('should include report file for ReportObjectConfig report', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = { name: '00-plan.md' };
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('- Report File: 20260129-test/00-plan.md');
|
||||
});
|
||||
|
||||
it('should include auto-generated report output instruction', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('**Report output:** Output to the `Report File` specified above.');
|
||||
expect(result).toContain('- If file does not exist: Create new file');
|
||||
expect(result).toContain('Append with `## Iteration 1` section');
|
||||
});
|
||||
|
||||
it('should include explicit order instead of auto-generated', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = {
|
||||
name: '00-plan.md',
|
||||
order: 'Output to {report:00-plan.md} file.',
|
||||
};
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('Output to 20260129-test/00-plan.md file.');
|
||||
expect(result).not.toContain('**Report output:**');
|
||||
});
|
||||
|
||||
it('should include format from ReportObjectConfig', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = {
|
||||
name: '00-plan.md',
|
||||
format: '**Format:**\n```markdown\n# Plan\n```',
|
||||
};
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('**Format:**');
|
||||
expect(result).toContain('# Plan');
|
||||
});
|
||||
|
||||
it('should replace {step_iteration} in report output instruction', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const ctx = createReportContext({ stepIteration: 5 });
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('Append with `## Iteration 5` section');
|
||||
});
|
||||
|
||||
it('should include instruction body text', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('## Instructions');
|
||||
expect(result).toContain('Output the results of your previous work as a report');
|
||||
});
|
||||
|
||||
it('should NOT include user request, previous response, or status rules', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = '00-plan.md';
|
||||
step.rules = [
|
||||
{ condition: 'Done', next: 'COMPLETE' },
|
||||
];
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).not.toContain('User Request');
|
||||
expect(result).not.toContain('Previous Response');
|
||||
expect(result).not.toContain('Additional User Inputs');
|
||||
expect(result).not.toContain('Status Output Rules');
|
||||
});
|
||||
|
||||
it('should render Japanese report instruction', () => {
|
||||
const step = createMinimalStep('作業する');
|
||||
step.report = { name: '00-plan.md' };
|
||||
const ctx = createReportContext({ language: 'ja' });
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('前のステップの作業結果をレポートとして出力してください');
|
||||
expect(result).toContain('プロジェクトのソースファイルを変更しないでください');
|
||||
expect(result).toContain('**レポート出力:** `Report File` に出力してください。');
|
||||
});
|
||||
|
||||
it('should throw error when step has no report config', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
const ctx = createReportContext();
|
||||
|
||||
expect(() => buildReportInstruction(step, ctx)).toThrow('no report config');
|
||||
});
|
||||
|
||||
it('should include multi-file report output instruction for ReportConfig[]', () => {
|
||||
const step = createMinimalStep('Do work');
|
||||
step.report = [{ label: 'Scope', path: '01-scope.md' }];
|
||||
const ctx = createReportContext();
|
||||
|
||||
const result = buildReportInstruction(step, ctx);
|
||||
|
||||
expect(result).toContain('**Report output:** Output to the `Report Files` specified above.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-injected User Request and Additional User Inputs sections', () => {
|
||||
|
||||
@ -31,6 +31,8 @@ export interface RunAgentOptions {
|
||||
agentPath?: string;
|
||||
/** Allowed tools for this agent run */
|
||||
allowedTools?: string[];
|
||||
/** Maximum number of agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
onStream?: StreamCallback;
|
||||
@ -82,6 +84,7 @@ export async function runCustomAgent(
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
@ -98,6 +101,7 @@ export async function runCustomAgent(
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
@ -118,6 +122,7 @@ export async function runCustomAgent(
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
@ -195,6 +200,7 @@ export async function runAgent(
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: resolveModel(options.cwd, options),
|
||||
systemPrompt,
|
||||
permissionMode: options.permissionMode,
|
||||
|
||||
@ -14,6 +14,7 @@ export class ClaudeProvider implements Provider {
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
systemPrompt: options.systemPrompt,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
@ -31,6 +32,7 @@ export class ClaudeProvider implements Provider {
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
|
||||
@ -18,6 +18,8 @@ export interface ProviderCallOptions {
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
allowedTools?: string[];
|
||||
/** Maximum number of agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
onStream?: StreamCallback;
|
||||
|
||||
@ -16,7 +16,7 @@ import { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js';
|
||||
import type { WorkflowEngineOptions } from './types.js';
|
||||
import { determineNextStepByRules } from './transitions.js';
|
||||
import { detectRuleIndex, callAiJudge } from '../claude/client.js';
|
||||
import { buildInstruction as buildInstructionFromTemplate, isReportObjectConfig } from './instruction-builder.js';
|
||||
import { buildInstruction as buildInstructionFromTemplate, buildReportInstruction as buildReportInstructionFromTemplate, isReportObjectConfig } from './instruction-builder.js';
|
||||
import { LoopDetector } from './loop-detector.js';
|
||||
import { handleBlocked } from './blocked-handler.js';
|
||||
import {
|
||||
@ -206,11 +206,16 @@ export class WorkflowEngine extends EventEmitter {
|
||||
|
||||
/** Build RunAgentOptions from a step's configuration */
|
||||
private buildAgentOptions(step: WorkflowStep): RunAgentOptions {
|
||||
// Phase 1: exclude Write from allowedTools when step has report config
|
||||
const allowedTools = step.report
|
||||
? step.allowedTools?.filter((t) => t !== 'Write')
|
||||
: step.allowedTools;
|
||||
|
||||
return {
|
||||
cwd: this.cwd,
|
||||
sessionId: this.state.agentSessions.get(step.agent),
|
||||
agentPath: step.agentPath,
|
||||
allowedTools: step.allowedTools,
|
||||
allowedTools,
|
||||
provider: step.provider,
|
||||
model: step.model,
|
||||
permissionMode: step.permissionMode,
|
||||
@ -266,11 +271,17 @@ export class WorkflowEngine extends EventEmitter {
|
||||
sessionId: this.state.agentSessions.get(step.agent) ?? 'new',
|
||||
});
|
||||
|
||||
// Phase 1: main execution (Write excluded if step has report)
|
||||
const agentOptions = this.buildAgentOptions(step);
|
||||
let response = await runAgent(step.agent, instruction, agentOptions);
|
||||
|
||||
this.updateAgentSession(step.agent, response.sessionId);
|
||||
|
||||
// Phase 2: report output (resume same session, Write only)
|
||||
if (step.report) {
|
||||
await this.runReportPhase(step, stepIteration);
|
||||
}
|
||||
|
||||
// Status detection uses phase 1 response
|
||||
const matchedRuleIndex = await this.detectMatchedRule(step, response.content);
|
||||
if (matchedRuleIndex != null) {
|
||||
response = { ...response, matchedRuleIndex };
|
||||
@ -281,6 +292,50 @@ export class WorkflowEngine extends EventEmitter {
|
||||
return { response, instruction };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Report output.
|
||||
* Resumes the agent session with Write-only tools to output reports.
|
||||
* The response is discarded — only sessionId is updated.
|
||||
*/
|
||||
private async runReportPhase(step: WorkflowStep, stepIteration: number): Promise<void> {
|
||||
const sessionId = this.state.agentSessions.get(step.agent);
|
||||
if (!sessionId) {
|
||||
log.debug('Skipping report phase: no sessionId to resume', { step: step.name });
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('Running report phase', { step: step.name, sessionId });
|
||||
|
||||
const reportInstruction = buildReportInstructionFromTemplate(step, {
|
||||
cwd: this.cwd,
|
||||
reportDir: this.reportDir,
|
||||
stepIteration,
|
||||
language: this.language,
|
||||
});
|
||||
|
||||
const reportOptions: RunAgentOptions = {
|
||||
cwd: this.cwd,
|
||||
sessionId,
|
||||
agentPath: step.agentPath,
|
||||
allowedTools: ['Write'],
|
||||
maxTurns: 3,
|
||||
provider: step.provider,
|
||||
model: step.model,
|
||||
permissionMode: step.permissionMode,
|
||||
onStream: this.options.onStream,
|
||||
onPermissionRequest: this.options.onPermissionRequest,
|
||||
onAskUserQuestion: this.options.onAskUserQuestion,
|
||||
bypassPermissions: this.options.bypassPermissions,
|
||||
};
|
||||
|
||||
const reportResponse = await runAgent(step.agent, reportInstruction, reportOptions);
|
||||
|
||||
// Update session (phase 2 may update it)
|
||||
this.updateAgentSession(step.agent, reportResponse.sessionId);
|
||||
|
||||
log.debug('Report phase complete', { step: step.name, status: reportResponse.status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a parallel step: execute all sub-steps concurrently, then aggregate results.
|
||||
* The aggregated output becomes the parent step's response for rules evaluation.
|
||||
@ -300,11 +355,16 @@ export class WorkflowEngine extends EventEmitter {
|
||||
const subIteration = incrementStepIteration(this.state, subStep.name);
|
||||
const subInstruction = this.buildInstruction(subStep, subIteration);
|
||||
|
||||
// Phase 1: main execution (Write excluded if sub-step has report)
|
||||
const agentOptions = this.buildAgentOptions(subStep);
|
||||
const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions);
|
||||
|
||||
this.updateAgentSession(subStep.agent, subResponse.sessionId);
|
||||
|
||||
// Phase 2: report output for sub-step
|
||||
if (subStep.report) {
|
||||
await this.runReportPhase(subStep, subIteration);
|
||||
}
|
||||
|
||||
// Detect sub-step rule matches (tag detection + ai() fallback)
|
||||
const matchedRuleIndex = await this.detectMatchedRule(subStep, subResponse.content);
|
||||
const finalResponse = matchedRuleIndex != null
|
||||
|
||||
@ -323,22 +323,31 @@ function renderWorkflowContext(
|
||||
`- ${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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render report info for the Workflow Context section.
|
||||
* Used only by buildReportInstruction() (phase 2).
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -466,20 +475,7 @@ export function buildInstruction(
|
||||
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
|
||||
// 6. Instructions header + instruction_template content
|
||||
const processedTemplate = replaceTemplatePlaceholders(
|
||||
step.instructionTemplate,
|
||||
step,
|
||||
@ -487,12 +483,6 @@ export function buildInstruction(
|
||||
);
|
||||
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)
|
||||
// Skip when ALL rules are ai() conditions — agent doesn't need to output status tags
|
||||
if (step.rules && step.rules.length > 0) {
|
||||
@ -506,3 +496,120 @@ export function buildInstruction(
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
/** Localized strings for report phase execution rules */
|
||||
const REPORT_PHASE_STRINGS = {
|
||||
en: {
|
||||
noSourceEdit: '**Do NOT modify project source files.** Only output report files.',
|
||||
instructionBody: 'Output the results of your previous work as a report.',
|
||||
},
|
||||
ja: {
|
||||
noSourceEdit: '**プロジェクトのソースファイルを変更しないでください。** レポートファイルのみ出力してください。',
|
||||
instructionBody: '前のステップの作業結果をレポートとして出力してください。',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Context for building report phase instruction.
|
||||
*/
|
||||
export interface ReportInstructionContext {
|
||||
/** Working directory */
|
||||
cwd: string;
|
||||
/** Report directory path */
|
||||
reportDir: string;
|
||||
/** Step iteration (for {step_iteration} replacement) */
|
||||
stepIteration: number;
|
||||
/** Language */
|
||||
language?: Language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build instruction for phase 2 (report output).
|
||||
*
|
||||
* Separate from buildInstruction() — only includes:
|
||||
* - Execution Context (cwd + rules)
|
||||
* - Workflow Context (report info only)
|
||||
* - Report output instruction + format
|
||||
*
|
||||
* Does NOT include: User Request, Previous Response, User Inputs,
|
||||
* Status rules, instruction_template.
|
||||
*/
|
||||
export function buildReportInstruction(
|
||||
step: WorkflowStep,
|
||||
context: ReportInstructionContext,
|
||||
): string {
|
||||
if (!step.report) {
|
||||
throw new Error(`buildReportInstruction called for step "${step.name}" which has no report config`);
|
||||
}
|
||||
|
||||
const language = context.language ?? 'en';
|
||||
const s = SECTION_STRINGS[language];
|
||||
const r = REPORT_PHASE_STRINGS[language];
|
||||
const m = METADATA_STRINGS[language];
|
||||
const sections: string[] = [];
|
||||
|
||||
// 1. Execution Context
|
||||
const execLines = [
|
||||
m.heading,
|
||||
`- ${m.workingDirectory}: ${context.cwd}`,
|
||||
'',
|
||||
m.rulesHeading,
|
||||
`- ${m.noCommit}`,
|
||||
`- ${m.noCd}`,
|
||||
`- ${r.noSourceEdit}`,
|
||||
];
|
||||
if (m.note) {
|
||||
execLines.push('');
|
||||
execLines.push(m.note);
|
||||
}
|
||||
execLines.push('');
|
||||
sections.push(execLines.join('\n'));
|
||||
|
||||
// 2. Workflow Context (report info only)
|
||||
const workflowLines = [
|
||||
s.workflowContext,
|
||||
renderReportContext(step.report, context.reportDir, language),
|
||||
];
|
||||
sections.push(workflowLines.join('\n'));
|
||||
|
||||
// 3. Instructions + report output instruction + format
|
||||
const instrParts: string[] = [
|
||||
`${s.instructions}`,
|
||||
r.instructionBody,
|
||||
];
|
||||
|
||||
// Report output instruction (auto-generated or explicit order)
|
||||
const reportContext: InstructionContext = {
|
||||
task: '',
|
||||
iteration: 0,
|
||||
maxIterations: 0,
|
||||
stepIteration: context.stepIteration,
|
||||
cwd: context.cwd,
|
||||
userInputs: [],
|
||||
reportDir: context.reportDir,
|
||||
language,
|
||||
};
|
||||
|
||||
if (isReportObjectConfig(step.report) && step.report.order) {
|
||||
const processedOrder = replaceTemplatePlaceholders(step.report.order.trimEnd(), step, reportContext);
|
||||
instrParts.push('');
|
||||
instrParts.push(processedOrder);
|
||||
} else {
|
||||
const reportInstruction = renderReportOutputInstruction(step, reportContext, language);
|
||||
if (reportInstruction) {
|
||||
instrParts.push('');
|
||||
instrParts.push(reportInstruction);
|
||||
}
|
||||
}
|
||||
|
||||
// Report format
|
||||
if (isReportObjectConfig(step.report) && step.report.format) {
|
||||
const processedFormat = replaceTemplatePlaceholders(step.report.format.trimEnd(), step, reportContext);
|
||||
instrParts.push('');
|
||||
instrParts.push(processedFormat);
|
||||
}
|
||||
|
||||
sections.push(instrParts.join('\n'));
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user