ステータス判定をPhase 3に分離し、デッドコードを整理

- buildInstruction からステータスルール注入を除去(Phase 1はステータスタグなし)
- buildStatusJudgmentInstruction を新設(Phase 3: セッション再開でステータスタグ出力)
- detectMatchedRule のシグネチャを (agentContent, tagContent) に変更
- ルール存在時にマッチなしなら即座にthrow(Fail Fast)
- runReportPhase / runStatusJudgmentPhase の共通部分を buildResumeOptions に抽出
- sessionId 欠落時のサイレントフォールバックをエラーに変更
- renderStatusRulesHeader / STATUS_RULES_HEADER_STRINGS を削除(デッドコード)
- StatusJudgmentContext から未使用の cwd を削除
- Status 型および StatusSchema から未使用の in_progress を削除
This commit is contained in:
nrslib 2026-01-30 16:29:54 +09:00
parent b969f5a7f4
commit b10773d310
7 changed files with 636 additions and 113 deletions

View File

@ -6,13 +6,14 @@ import { describe, it, expect } from 'vitest';
import { import {
buildInstruction, buildInstruction,
buildReportInstruction, buildReportInstruction,
buildStatusJudgmentInstruction,
buildExecutionMetadata, buildExecutionMetadata,
renderExecutionMetadata, renderExecutionMetadata,
renderStatusRulesHeader,
generateStatusRulesFromRules, generateStatusRulesFromRules,
isReportObjectConfig, isReportObjectConfig,
type InstructionContext, type InstructionContext,
type ReportInstructionContext, type ReportInstructionContext,
type StatusJudgmentContext,
} from '../workflow/instruction-builder.js'; } from '../workflow/instruction-builder.js';
import type { WorkflowStep, WorkflowRule } from '../models/types.js'; import type { WorkflowStep, WorkflowRule } from '../models/types.js';
@ -296,30 +297,6 @@ describe('instruction-builder', () => {
}); });
}); });
describe('renderStatusRulesHeader', () => {
it('should render Japanese header when language is ja', () => {
const header = renderStatusRulesHeader('ja');
expect(header).toContain('# ⚠️ 必須: ステータス出力ルール ⚠️');
expect(header).toContain('このタグがないとワークフローが停止します');
expect(header).toContain('最終出力には必ず以下のルールに従ったステータスタグを含めてください');
});
it('should render English header when language is en', () => {
const header = renderStatusRulesHeader('en');
expect(header).toContain('# ⚠️ Required: Status Output Rules ⚠️');
expect(header).toContain('The workflow will stop without this tag');
expect(header).toContain('Your final output MUST include a status tag');
});
it('should end with trailing empty line', () => {
const header = renderStatusRulesHeader('en');
expect(header).toMatch(/\n$/);
});
});
describe('generateStatusRulesFromRules', () => { describe('generateStatusRulesFromRules', () => {
const rules: WorkflowRule[] = [ const rules: WorkflowRule[] = [
{ condition: '要件が明確で実装可能', next: 'implement' }, { condition: '要件が明確で実装可能', next: 'implement' },
@ -385,8 +362,8 @@ describe('instruction-builder', () => {
}); });
}); });
describe('buildInstruction with rules', () => { describe('buildInstruction with rules (Phase 1 — no status tags)', () => {
it('should auto-generate status rules from rules', () => { it('should NOT include status rules even when rules exist (phase separation)', () => {
const step = createMinimalStep('Do work'); const step = createMinimalStep('Do work');
step.name = 'plan'; step.name = 'plan';
step.rules = [ step.rules = [
@ -397,12 +374,10 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context); const result = buildInstruction(step, context);
// Should contain status header // Phase 1 should NOT contain status header or criteria
expect(result).toContain('⚠️ Required: Status Output Rules ⚠️'); expect(result).not.toContain('Status Output Rules');
// Should contain auto-generated criteria table expect(result).not.toContain('Decision Criteria');
expect(result).toContain('## Decision Criteria'); expect(result).not.toContain('[PLAN:');
expect(result).toContain('`[PLAN:1]`');
expect(result).toContain('`[PLAN:2]`');
}); });
it('should not add status rules when rules do not exist', () => { it('should not add status rules when rules do not exist', () => {
@ -411,7 +386,7 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context); const result = buildInstruction(step, context);
expect(result).not.toContain('⚠️ Required'); expect(result).not.toContain('Status Output Rules');
expect(result).not.toContain('Decision Criteria'); expect(result).not.toContain('Decision Criteria');
}); });
@ -422,7 +397,7 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context); const result = buildInstruction(step, context);
expect(result).not.toContain('⚠️ Required'); expect(result).not.toContain('Status Output Rules');
expect(result).not.toContain('Decision Criteria'); expect(result).not.toContain('Decision Criteria');
}); });
}); });
@ -884,8 +859,8 @@ describe('instruction-builder', () => {
}); });
}); });
describe('ai() condition status tag skip', () => { describe('phase separation — buildInstruction never includes status rules', () => {
it('should skip status rules when ALL rules are ai() conditions', () => { it('should NOT include status rules even with ai() conditions', () => {
const step = createMinimalStep('Do work'); const step = createMinimalStep('Do work');
step.rules = [ step.rules = [
{ condition: 'ai("No issues")', next: 'COMPLETE', isAiCondition: true, aiConditionText: 'No issues' }, { condition: 'ai("No issues")', next: 'COMPLETE', isAiCondition: true, aiConditionText: 'No issues' },
@ -899,7 +874,7 @@ describe('instruction-builder', () => {
expect(result).not.toContain('[TEST-STEP:'); expect(result).not.toContain('[TEST-STEP:');
}); });
it('should include status rules when some rules are NOT ai() conditions', () => { it('should NOT include status rules with mixed regular and ai() conditions', () => {
const step = createMinimalStep('Do work'); const step = createMinimalStep('Do work');
step.rules = [ step.rules = [
{ condition: 'Error occurred', next: 'ABORT' }, { condition: 'Error occurred', next: 'ABORT' },
@ -909,10 +884,10 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context); const result = buildInstruction(step, context);
expect(result).toContain('Status Output Rules'); expect(result).not.toContain('Status Output Rules');
}); });
it('should include status rules when no rules are ai() conditions', () => { it('should NOT include status rules with regular conditions only', () => {
const step = createMinimalStep('Do work'); const step = createMinimalStep('Do work');
step.rules = [ step.rules = [
{ condition: 'Done', next: 'COMPLETE' }, { condition: 'Done', next: 'COMPLETE' },
@ -922,7 +897,47 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context); const result = buildInstruction(step, context);
expect(result).toContain('Status Output Rules'); expect(result).not.toContain('Status Output Rules');
});
it('should NOT include status rules with aggregate conditions', () => {
const step = createMinimalStep('Do work');
step.rules = [
{ condition: 'all("approved")', next: 'COMPLETE', isAggregateCondition: true, aggregateType: 'all' as const, aggregateConditionText: 'approved' },
{ condition: 'any("rejected")', next: 'fix', isAggregateCondition: true, aggregateType: 'any' as const, aggregateConditionText: 'rejected' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Status Output Rules');
});
it('should NOT include status rules with mixed ai() and aggregate conditions', () => {
const step = createMinimalStep('Do work');
step.rules = [
{ condition: 'all("approved")', next: 'COMPLETE', isAggregateCondition: true, aggregateType: 'all' as const, aggregateConditionText: 'approved' },
{ condition: 'any("rejected")', next: 'fix', isAggregateCondition: true, aggregateType: 'any' as const, aggregateConditionText: 'rejected' },
{ condition: 'ai("Judgment needed")', next: 'manual', isAiCondition: true, aiConditionText: 'Judgment needed' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Status Output Rules');
});
it('should NOT include status rules with mixed aggregate and regular conditions', () => {
const step = createMinimalStep('Do work');
step.rules = [
{ condition: 'all("approved")', next: 'COMPLETE', isAggregateCondition: true, aggregateType: 'all' as const, aggregateConditionText: 'approved' },
{ condition: 'Error occurred', next: 'ABORT' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Status Output Rules');
}); });
}); });
@ -943,4 +958,117 @@ describe('instruction-builder', () => {
expect(isReportObjectConfig([{ label: 'Scope', path: '01-scope.md' }])).toBe(false); expect(isReportObjectConfig([{ label: 'Scope', path: '01-scope.md' }])).toBe(false);
}); });
}); });
describe('buildStatusJudgmentInstruction (Phase 3)', () => {
function createJudgmentContext(overrides: Partial<StatusJudgmentContext> = {}): StatusJudgmentContext {
return {
language: 'en',
...overrides,
};
}
it('should include header instruction (en)', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: 'Clear requirements', next: 'implement' },
{ condition: 'Unclear', next: 'ABORT' },
];
const ctx = createJudgmentContext();
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('Review your work results and determine the status');
expect(result).toContain('Do NOT perform any additional work');
});
it('should include header instruction (ja)', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: '要件が明確', next: 'implement' },
{ condition: '不明確', next: 'ABORT' },
];
const ctx = createJudgmentContext({ language: 'ja' });
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('作業結果を振り返り、ステータスを判定してください');
expect(result).toContain('追加の作業は行わないでください');
});
it('should include criteria table with tags', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: 'Clear requirements', next: 'implement' },
{ condition: 'Unclear', next: 'ABORT' },
];
const ctx = createJudgmentContext();
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('## Decision Criteria');
expect(result).toContain('`[PLAN:1]`');
expect(result).toContain('`[PLAN:2]`');
});
it('should include output format section', () => {
const step = createMinimalStep('Do work');
step.name = 'review';
step.rules = [
{ condition: 'Approved', next: 'COMPLETE' },
{ condition: 'Rejected', next: 'fix' },
];
const ctx = createJudgmentContext();
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('## Output Format');
expect(result).toContain('`[REVIEW:1]` — Approved');
expect(result).toContain('`[REVIEW:2]` — Rejected');
});
it('should throw error when step has no rules', () => {
const step = createMinimalStep('Do work');
const ctx = createJudgmentContext();
expect(() => buildStatusJudgmentInstruction(step, ctx)).toThrow('no rules');
});
it('should throw error when step has empty rules', () => {
const step = createMinimalStep('Do work');
step.rules = [];
const ctx = createJudgmentContext();
expect(() => buildStatusJudgmentInstruction(step, ctx)).toThrow('no rules');
});
it('should default language to en', () => {
const step = createMinimalStep('Do work');
step.name = 'test';
step.rules = [{ condition: 'Done', next: 'COMPLETE' }];
const ctx: StatusJudgmentContext = {};
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('Review your work results');
expect(result).toContain('## Decision Criteria');
});
it('should include appendix template when rules have appendix', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: 'Done', next: 'COMPLETE' },
{ condition: 'Blocked', next: 'ABORT', appendix: '確認事項:\n- {質問1}' },
];
const ctx = createJudgmentContext();
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('Appendix Template');
expect(result).toContain('確認事項:');
});
});
}); });

View File

@ -252,6 +252,227 @@ describe('ai() condition regex parsing', () => {
}); });
}); });
describe('all()/any() aggregate condition regex parsing', () => {
const AGGREGATE_CONDITION_REGEX = /^(all|any)\("(.+)"\)$/;
it('should match all() condition', () => {
const match = 'all("approved")'.match(AGGREGATE_CONDITION_REGEX);
expect(match).not.toBeNull();
expect(match![1]).toBe('all');
expect(match![2]).toBe('approved');
});
it('should match any() condition', () => {
const match = 'any("rejected")'.match(AGGREGATE_CONDITION_REGEX);
expect(match).not.toBeNull();
expect(match![1]).toBe('any');
expect(match![2]).toBe('rejected');
});
it('should match with Japanese text', () => {
const match = 'all("承認済み")'.match(AGGREGATE_CONDITION_REGEX);
expect(match).not.toBeNull();
expect(match![1]).toBe('all');
expect(match![2]).toBe('承認済み');
});
it('should not match regular condition text', () => {
expect('approved'.match(AGGREGATE_CONDITION_REGEX)).toBeNull();
});
it('should not match ai() condition', () => {
expect('ai("something")'.match(AGGREGATE_CONDITION_REGEX)).toBeNull();
});
it('should not match invalid patterns', () => {
expect('all(missing quotes)'.match(AGGREGATE_CONDITION_REGEX)).toBeNull();
expect('all("")'.match(AGGREGATE_CONDITION_REGEX)).toBeNull();
expect('not all("text")'.match(AGGREGATE_CONDITION_REGEX)).toBeNull();
expect('all("text") extra'.match(AGGREGATE_CONDITION_REGEX)).toBeNull();
expect('ALL("text")'.match(AGGREGATE_CONDITION_REGEX)).toBeNull();
});
it('should match with special characters in text', () => {
const match = 'any("issues found (critical)")'.match(AGGREGATE_CONDITION_REGEX);
expect(match).not.toBeNull();
expect(match![2]).toBe('issues found (critical)');
});
});
describe('all()/any() condition in WorkflowStepRawSchema', () => {
it('should accept all() condition as a string', () => {
const raw = {
name: 'parallel-review',
parallel: [
{ name: 'arch-review', agent: 'reviewer.md', instruction_template: 'Review' },
],
rules: [
{ condition: 'all("approved")', next: 'COMPLETE' },
{ condition: 'any("rejected")', next: 'fix' },
],
};
const result = WorkflowStepRawSchema.safeParse(raw);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.rules?.[0].condition).toBe('all("approved")');
expect(result.data.rules?.[1].condition).toBe('any("rejected")');
}
});
it('should accept mixed regular, ai(), and all()/any() conditions', () => {
const raw = {
name: 'mixed-rules',
parallel: [
{ name: 'sub', agent: 'agent.md' },
],
rules: [
{ condition: 'all("approved")', next: 'COMPLETE' },
{ condition: 'any("rejected")', next: 'fix' },
{ condition: 'ai("Difficult judgment")', next: 'manual-review' },
],
};
const result = WorkflowStepRawSchema.safeParse(raw);
expect(result.success).toBe(true);
});
});
describe('aggregate condition evaluation logic', () => {
// Simulate the evaluation logic from engine.ts
type SubResult = { name: string; matchedRuleIndex?: number; rules?: { condition: string }[] };
function evaluateAggregate(
aggregateType: 'all' | 'any',
targetCondition: string,
subSteps: SubResult[],
): boolean {
if (subSteps.length === 0) return false;
if (aggregateType === 'all') {
return subSteps.every((sub) => {
if (sub.matchedRuleIndex == null || !sub.rules) return false;
const matchedRule = sub.rules[sub.matchedRuleIndex];
return matchedRule?.condition === targetCondition;
});
}
// 'any'
return subSteps.some((sub) => {
if (sub.matchedRuleIndex == null || !sub.rules) return false;
const matchedRule = sub.rules[sub.matchedRuleIndex];
return matchedRule?.condition === targetCondition;
});
}
const rules = [
{ condition: 'approved' },
{ condition: 'rejected' },
];
it('all(): true when all sub-steps match', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 0, rules },
];
expect(evaluateAggregate('all', 'approved', subs)).toBe(true);
});
it('all(): false when some sub-steps do not match', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 1, rules },
];
expect(evaluateAggregate('all', 'approved', subs)).toBe(false);
});
it('all(): false when sub-step has no matched rule', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: undefined, rules },
];
expect(evaluateAggregate('all', 'approved', subs)).toBe(false);
});
it('all(): false when sub-step has no rules', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 0, rules: undefined },
];
expect(evaluateAggregate('all', 'approved', subs)).toBe(false);
});
it('all(): false with zero sub-steps', () => {
expect(evaluateAggregate('all', 'approved', [])).toBe(false);
});
it('any(): true when one sub-step matches', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 1, rules },
];
expect(evaluateAggregate('any', 'rejected', subs)).toBe(true);
});
it('any(): true when all sub-steps match', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 1, rules },
{ name: 'b', matchedRuleIndex: 1, rules },
];
expect(evaluateAggregate('any', 'rejected', subs)).toBe(true);
});
it('any(): false when no sub-steps match', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 0, rules },
];
expect(evaluateAggregate('any', 'rejected', subs)).toBe(false);
});
it('any(): false with zero sub-steps', () => {
expect(evaluateAggregate('any', 'rejected', [])).toBe(false);
});
it('any(): skips sub-steps without matched rule (does not count as match)', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: undefined, rules },
{ name: 'b', matchedRuleIndex: 1, rules },
];
expect(evaluateAggregate('any', 'rejected', subs)).toBe(true);
});
it('any(): false when only unmatched sub-steps exist', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: undefined, rules },
{ name: 'b', matchedRuleIndex: undefined, rules },
];
expect(evaluateAggregate('any', 'rejected', subs)).toBe(false);
});
it('evaluation priority: first matching aggregate rule wins', () => {
const parentRules = [
{ type: 'all' as const, condition: 'approved' },
{ type: 'any' as const, condition: 'rejected' },
];
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 0, rules },
];
// Find the first matching rule
let matchedIndex = -1;
for (let i = 0; i < parentRules.length; i++) {
const r = parentRules[i]!;
if (evaluateAggregate(r.type, r.condition, subs)) {
matchedIndex = i;
break;
}
}
expect(matchedIndex).toBe(0); // all("approved") matches first
});
});
describe('parallel step aggregation format', () => { describe('parallel step aggregation format', () => {
it('should aggregate sub-step outputs in the expected format', () => { it('should aggregate sub-step outputs in the expected format', () => {
// Mirror the aggregation logic from engine.ts // Mirror the aggregation logic from engine.ts

View File

@ -123,21 +123,38 @@ function normalizeReport(
/** Regex to detect ai("...") condition expressions */ /** Regex to detect ai("...") condition expressions */
const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/; const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/;
/** Regex to detect all("...")/any("...") aggregate condition expressions */
const AGGREGATE_CONDITION_REGEX = /^(all|any)\("(.+)"\)$/;
/** /**
* Parse a rule's condition for ai() expressions. * Parse a rule's condition for ai() and all()/any() expressions.
* If condition is `ai("some text")`, sets isAiCondition and aiConditionText. * - `ai("text")` sets isAiCondition and aiConditionText
* - `all("text")` / `any("text")` sets isAggregateCondition, aggregateType, aggregateConditionText
*/ */
function normalizeRule(r: { condition: string; next: string; appendix?: string }): WorkflowRule { function normalizeRule(r: { condition: string; next: string; appendix?: string }): WorkflowRule {
const match = r.condition.match(AI_CONDITION_REGEX); const aiMatch = r.condition.match(AI_CONDITION_REGEX);
if (match?.[1]) { if (aiMatch?.[1]) {
return { return {
condition: r.condition, condition: r.condition,
next: r.next, next: r.next,
appendix: r.appendix, appendix: r.appendix,
isAiCondition: true, isAiCondition: true,
aiConditionText: match[1], aiConditionText: aiMatch[1],
}; };
} }
const aggMatch = r.condition.match(AGGREGATE_CONDITION_REGEX);
if (aggMatch?.[1] && aggMatch[2]) {
return {
condition: r.condition,
next: r.next,
appendix: r.appendix,
isAggregateCondition: true,
aggregateType: aggMatch[1] as 'all' | 'any',
aggregateConditionText: aggMatch[2],
};
}
return { return {
condition: r.condition, condition: r.condition,
next: r.next, next: r.next,

View File

@ -13,7 +13,6 @@ export const AgentTypeSchema = z.enum(['coder', 'architect', 'supervisor', 'cust
/** Status schema */ /** Status schema */
export const StatusSchema = z.enum([ export const StatusSchema = z.enum([
'pending', 'pending',
'in_progress',
'done', 'done',
'blocked', 'blocked',
'approved', 'approved',

View File

@ -8,7 +8,6 @@ export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom';
/** Execution status for agents and workflows */ /** Execution status for agents and workflows */
export type Status = export type Status =
| 'pending' | 'pending'
| 'in_progress'
| 'done' | 'done'
| 'blocked' | 'blocked'
| 'approved' | 'approved'
@ -52,6 +51,12 @@ export interface WorkflowRule {
isAiCondition?: boolean; isAiCondition?: boolean;
/** The condition text inside ai("...") for AI judge evaluation (set by loader) */ /** The condition text inside ai("...") for AI judge evaluation (set by loader) */
aiConditionText?: string; aiConditionText?: string;
/** Whether this condition uses all()/any() aggregate expression (set by loader) */
isAggregateCondition?: boolean;
/** Aggregate type: 'all' requires all sub-steps match, 'any' requires at least one (set by loader) */
aggregateType?: 'all' | 'any';
/** The condition text inside all("...")/any("...") to match against sub-step results (set by loader) */
aggregateConditionText?: string;
} }
/** Report file configuration for a workflow step (label: path pair) */ /** Report file configuration for a workflow step (label: path pair) */

View File

@ -16,7 +16,7 @@ import { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js';
import type { WorkflowEngineOptions } from './types.js'; import type { WorkflowEngineOptions } from './types.js';
import { determineNextStepByRules } from './transitions.js'; import { determineNextStepByRules } from './transitions.js';
import { detectRuleIndex, callAiJudge } from '../claude/client.js'; import { detectRuleIndex, callAiJudge } from '../claude/client.js';
import { buildInstruction as buildInstructionFromTemplate, buildReportInstruction as buildReportInstructionFromTemplate, isReportObjectConfig } from './instruction-builder.js'; import { buildInstruction as buildInstructionFromTemplate, buildReportInstruction as buildReportInstructionFromTemplate, buildStatusJudgmentInstruction as buildStatusJudgmentInstructionFromTemplate, isReportObjectConfig } from './instruction-builder.js';
import { LoopDetector } from './loop-detector.js'; import { LoopDetector } from './loop-detector.js';
import { handleBlocked } from './blocked-handler.js'; import { handleBlocked } from './blocked-handler.js';
import { import {
@ -226,6 +226,27 @@ export class WorkflowEngine extends EventEmitter {
}; };
} }
/**
* Build RunAgentOptions for session-resume phases (Phase 2, Phase 3).
* Shares common fields with the original step's agent config.
*/
private buildResumeOptions(step: WorkflowStep, sessionId: string, overrides: Pick<RunAgentOptions, 'allowedTools' | 'maxTurns'>): RunAgentOptions {
return {
cwd: this.cwd,
sessionId,
agentPath: step.agentPath,
allowedTools: overrides.allowedTools,
maxTurns: overrides.maxTurns,
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,
};
}
/** Update agent session and notify via callback if session changed */ /** Update agent session and notify via callback if session changed */
private updateAgentSession(agent: string, sessionId: string | undefined): void { private updateAgentSession(agent: string, sessionId: string | undefined): void {
if (!sessionId) return; if (!sessionId) return;
@ -240,23 +261,96 @@ export class WorkflowEngine extends EventEmitter {
/** /**
* Detect matched rule for a step's response. * Detect matched rule for a step's response.
* 1. Try standard [STEP:N] tag detection * Evaluation order (first match wins):
* 2. Fallback to ai() condition evaluation via AI judge * 1. Aggregate conditions: all()/any() evaluate sub-step results
* 2. Standard [STEP:N] tag detection (from tagContent, i.e. Phase 3 output)
* 3. ai() condition evaluation via AI judge (from agentContent, i.e. Phase 1 output)
*
* Returns undefined for steps without rules.
* Throws if rules exist but no rule matched (Fail Fast).
*
* @param step - The workflow step
* @param agentContent - Phase 1 output (main execution)
* @param tagContent - Phase 3 output (status judgment); empty string skips tag detection
*/ */
private async detectMatchedRule(step: WorkflowStep, content: string): Promise<number | undefined> { private async detectMatchedRule(step: WorkflowStep, agentContent: string, tagContent: string): Promise<number | undefined> {
if (!step.rules || step.rules.length === 0) return undefined; if (!step.rules || step.rules.length === 0) return undefined;
const ruleIndex = detectRuleIndex(content, step.name); // 1. Aggregate conditions (all/any) — only meaningful for parallel parent steps
const aggIndex = this.evaluateAggregateConditions(step);
if (aggIndex >= 0) {
return aggIndex;
}
// 2. Standard tag detection (from Phase 3 output)
if (tagContent) {
const ruleIndex = detectRuleIndex(tagContent, step.name);
if (ruleIndex >= 0 && ruleIndex < step.rules.length) { if (ruleIndex >= 0 && ruleIndex < step.rules.length) {
return ruleIndex; return ruleIndex;
} }
}
const aiRuleIndex = await this.evaluateAiConditions(step, content); // 3. AI judge fallback (from Phase 1 output)
const aiRuleIndex = await this.evaluateAiConditions(step, agentContent);
if (aiRuleIndex >= 0) { if (aiRuleIndex >= 0) {
return aiRuleIndex; return aiRuleIndex;
} }
return undefined; throw new Error(`Status not found for step "${step.name}": no rule matched after all detection phases`);
}
/**
* Evaluate aggregate conditions (all()/any()) against sub-step results.
* Returns the 0-based rule index in the step's rules array, or -1 if no match.
*
* For each aggregate rule, checks the matched condition text of sub-steps:
* - all("X"): true when ALL sub-steps have matched condition === X
* - any("X"): true when at least ONE sub-step has matched condition === X
*
* Edge cases per spec:
* - Sub-step with no matched rule: all() false, any() skip that sub-step
* - No sub-steps (0 ): both false
* - Non-parallel step: both false
*/
private evaluateAggregateConditions(step: WorkflowStep): number {
if (!step.rules || !step.parallel || step.parallel.length === 0) return -1;
for (let i = 0; i < step.rules.length; i++) {
const rule = step.rules[i]!;
if (!rule.isAggregateCondition || !rule.aggregateType || !rule.aggregateConditionText) {
continue;
}
const subSteps = step.parallel;
const targetCondition = rule.aggregateConditionText;
if (rule.aggregateType === 'all') {
const allMatch = subSteps.every((sub) => {
const output = this.state.stepOutputs.get(sub.name);
if (!output || output.matchedRuleIndex == null || !sub.rules) return false;
const matchedRule = sub.rules[output.matchedRuleIndex];
return matchedRule?.condition === targetCondition;
});
if (allMatch) {
log.debug('Aggregate all() matched', { step: step.name, condition: targetCondition, ruleIndex: i });
return i;
}
} else {
// 'any'
const anyMatch = subSteps.some((sub) => {
const output = this.state.stepOutputs.get(sub.name);
if (!output || output.matchedRuleIndex == null || !sub.rules) return false;
const matchedRule = sub.rules[output.matchedRuleIndex];
return matchedRule?.condition === targetCondition;
});
if (anyMatch) {
log.debug('Aggregate any() matched', { step: step.name, condition: targetCondition, ruleIndex: i });
return i;
}
}
}
return -1;
} }
/** Run a normal (non-parallel) step */ /** Run a normal (non-parallel) step */
@ -281,8 +375,13 @@ export class WorkflowEngine extends EventEmitter {
await this.runReportPhase(step, stepIteration); await this.runReportPhase(step, stepIteration);
} }
// Status detection uses phase 1 response // Phase 3: status judgment (resume session, no tools, output status tag)
const matchedRuleIndex = await this.detectMatchedRule(step, response.content); let tagContent = '';
if (this.needsStatusJudgmentPhase(step)) {
tagContent = await this.runStatusJudgmentPhase(step);
}
const matchedRuleIndex = await this.detectMatchedRule(step, response.content, tagContent);
if (matchedRuleIndex != null) { if (matchedRuleIndex != null) {
response = { ...response, matchedRuleIndex }; response = { ...response, matchedRuleIndex };
} }
@ -300,8 +399,7 @@ export class WorkflowEngine extends EventEmitter {
private async runReportPhase(step: WorkflowStep, stepIteration: number): Promise<void> { private async runReportPhase(step: WorkflowStep, stepIteration: number): Promise<void> {
const sessionId = this.state.agentSessions.get(step.agent); const sessionId = this.state.agentSessions.get(step.agent);
if (!sessionId) { if (!sessionId) {
log.debug('Skipping report phase: no sessionId to resume', { step: step.name }); throw new Error(`Report phase requires a session to resume, but no sessionId found for agent "${step.agent}" in step "${step.name}"`);
return;
} }
log.debug('Running report phase', { step: step.name, sessionId }); log.debug('Running report phase', { step: step.name, sessionId });
@ -313,20 +411,10 @@ export class WorkflowEngine extends EventEmitter {
language: this.language, language: this.language,
}); });
const reportOptions: RunAgentOptions = { const reportOptions = this.buildResumeOptions(step, sessionId, {
cwd: this.cwd,
sessionId,
agentPath: step.agentPath,
allowedTools: ['Write'], allowedTools: ['Write'],
maxTurns: 3, 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); const reportResponse = await runAgent(step.agent, reportInstruction, reportOptions);
@ -336,6 +424,48 @@ export class WorkflowEngine extends EventEmitter {
log.debug('Report phase complete', { step: step.name, status: reportResponse.status }); log.debug('Report phase complete', { step: step.name, status: reportResponse.status });
} }
/**
* Check if a step needs Phase 3 (status judgment).
* Returns true when at least one rule requires tag-based detection
* (i.e., not all rules are ai() or aggregate conditions).
*/
private needsStatusJudgmentPhase(step: WorkflowStep): boolean {
if (!step.rules || step.rules.length === 0) return false;
const allNonTagConditions = step.rules.every((r) => r.isAiCondition || r.isAggregateCondition);
return !allNonTagConditions;
}
/**
* Phase 3: Status judgment.
* Resumes the agent session with no tools to ask the agent to output a status tag.
* Returns the Phase 3 response content (containing the status tag).
*/
private async runStatusJudgmentPhase(step: WorkflowStep): Promise<string> {
const sessionId = this.state.agentSessions.get(step.agent);
if (!sessionId) {
throw new Error(`Status judgment phase requires a session to resume, but no sessionId found for agent "${step.agent}" in step "${step.name}"`);
}
log.debug('Running status judgment phase', { step: step.name, sessionId });
const judgmentInstruction = buildStatusJudgmentInstructionFromTemplate(step, {
language: this.language,
});
const judgmentOptions = this.buildResumeOptions(step, sessionId, {
allowedTools: [],
maxTurns: 1,
});
const judgmentResponse = await runAgent(step.agent, judgmentInstruction, judgmentOptions);
// Update session (phase 3 may update it)
this.updateAgentSession(step.agent, judgmentResponse.sessionId);
log.debug('Status judgment phase complete', { step: step.name, status: judgmentResponse.status });
return judgmentResponse.content;
}
/** /**
* Run a parallel step: execute all sub-steps concurrently, then aggregate results. * Run a parallel step: execute all sub-steps concurrently, then aggregate results.
* The aggregated output becomes the parent step's response for rules evaluation. * The aggregated output becomes the parent step's response for rules evaluation.
@ -365,8 +495,13 @@ export class WorkflowEngine extends EventEmitter {
await this.runReportPhase(subStep, subIteration); await this.runReportPhase(subStep, subIteration);
} }
// Detect sub-step rule matches (tag detection + ai() fallback) // Phase 3: status judgment for sub-step
const matchedRuleIndex = await this.detectMatchedRule(subStep, subResponse.content); let subTagContent = '';
if (this.needsStatusJudgmentPhase(subStep)) {
subTagContent = await this.runStatusJudgmentPhase(subStep);
}
const matchedRuleIndex = await this.detectMatchedRule(subStep, subResponse.content, subTagContent);
const finalResponse = matchedRuleIndex != null const finalResponse = matchedRuleIndex != null
? { ...subResponse, matchedRuleIndex } ? { ...subResponse, matchedRuleIndex }
: subResponse; : subResponse;
@ -387,8 +522,8 @@ export class WorkflowEngine extends EventEmitter {
.map((r) => r.instruction) .map((r) => r.instruction)
.join('\n\n'); .join('\n\n');
// Evaluate parent step's rules against aggregated output // Parent step uses aggregate conditions, so tagContent is empty
const matchedRuleIndex = await this.detectMatchedRule(step, aggregatedContent); const matchedRuleIndex = await this.detectMatchedRule(step, aggregatedContent, '');
const aggregatedResponse: AgentResponse = { const aggregatedResponse: AgentResponse = {
agent: step.name, agent: step.name,

View File

@ -5,7 +5,8 @@
* 1. Auto-injecting standard sections (Execution Context, Workflow Context, * 1. Auto-injecting standard sections (Execution Context, Workflow Context,
* User Request, Previous Response, Additional User Inputs, Instructions header) * User Request, Previous Response, Additional User Inputs, Instructions header)
* 2. Replacing template placeholders with actual values * 2. Replacing template placeholders with actual values
* 3. Appending auto-generated status rules from workflow rules *
* Status judgment is handled separately in Phase 3 (buildStatusJudgmentInstruction).
*/ */
import type { WorkflowStep, WorkflowRule, AgentResponse, Language, ReportConfig, ReportObjectConfig } from '../models/types.js'; import type { WorkflowStep, WorkflowRule, AgentResponse, Language, ReportConfig, ReportObjectConfig } from '../models/types.js';
@ -60,29 +61,6 @@ export function buildExecutionMetadata(context: InstructionContext, edit?: boole
}; };
} }
/** 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 */ /** Localized strings for rules-based status prompt */
const RULES_PROMPT_STRINGS = { const RULES_PROMPT_STRINGS = {
en: { en: {
@ -428,7 +406,8 @@ function replaceTemplatePlaceholders(
* 4. Previous Response if passPreviousResponse and has content, unless template contains {previous_response} * 4. Previous Response if passPreviousResponse and has content, unless template contains {previous_response}
* 5. Additional User Inputs unless template contains {user_inputs} * 5. Additional User Inputs unless template contains {user_inputs}
* 6. Instructions header + instruction_template content always * 6. Instructions header + instruction_template content always
* 7. Status Output Rules if rules exist *
* Status judgment is handled separately in Phase 3 (buildStatusJudgmentInstruction).
* *
* Template placeholders ({task}, {previous_response}, etc.) are still replaced * Template placeholders ({task}, {previous_response}, etc.) are still replaced
* within the instruction_template body for backward compatibility. * within the instruction_template body for backward compatibility.
@ -483,17 +462,6 @@ export function buildInstruction(
); );
sections.push(`${s.instructions}\n${processedTemplate}`); sections.push(`${s.instructions}\n${processedTemplate}`);
// 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) {
const allAiConditions = step.rules.every((r) => r.isAiCondition);
if (!allAiConditions) {
const statusHeader = renderStatusRulesHeader(language);
const generatedPrompt = generateStatusRulesFromRules(step.name, step.rules, language);
sections.push(`${statusHeader}\n${generatedPrompt}`);
}
}
return sections.join('\n\n'); return sections.join('\n\n');
} }
@ -613,3 +581,53 @@ export function buildReportInstruction(
return sections.join('\n\n'); return sections.join('\n\n');
} }
/** Localized strings for status judgment phase (Phase 3) */
const STATUS_JUDGMENT_STRINGS = {
en: {
header: 'Review your work results and determine the status. Do NOT perform any additional work.',
},
ja: {
header: '作業結果を振り返り、ステータスを判定してください。追加の作業は行わないでください。',
},
} as const;
/**
* Context for building status judgment instruction (Phase 3).
*/
export interface StatusJudgmentContext {
/** Language */
language?: Language;
}
/**
* Build instruction for Phase 3 (status judgment).
*
* Resumes the agent session and asks it to evaluate its work
* and output the appropriate status tag. No tools are allowed.
*
* Includes:
* - Header instruction (review and determine status)
* - Status rules (criteria table + output format) from generateStatusRulesFromRules()
*/
export function buildStatusJudgmentInstruction(
step: WorkflowStep,
context: StatusJudgmentContext,
): string {
if (!step.rules || step.rules.length === 0) {
throw new Error(`buildStatusJudgmentInstruction called for step "${step.name}" which has no rules`);
}
const language = context.language ?? 'en';
const s = STATUS_JUDGMENT_STRINGS[language];
const sections: string[] = [];
// Header
sections.push(s.header);
// Status rules (criteria table + output format)
const generatedPrompt = generateStatusRulesFromRules(step.name, step.rules, language);
sections.push(generatedPrompt);
return sections.join('\n\n');
}