takt/src/core/piece/judgment/FallbackStrategy.ts
nrslib ed367f27df Phase 3判定ロジックをconductorエージェント+フォールバック戦略に分離
Phase 3でレビュアーエージェントが判定タグを出力せず新しい作業を開始する問題を解決。
判定専用のconductorエージェントと4段階フォールバック戦略(AutoSelect→ReportBased→ResponseBased→AgentConsult)を導入し、
ParallelRunnerのlastResponse未配線問題とJudgmentDetectorのアンダースコア対応も修正。
2026-02-05 11:34:23 +09:00

256 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Fallback strategies for Phase 3 judgment.
*
* Implements Chain of Responsibility pattern to try multiple judgment methods
* when conductor cannot determine the status from report alone.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { PieceMovement, Language } from '../../models/types.js';
import { runAgent } from '../../../agents/runner.js';
import { StatusJudgmentBuilder } from '../instruction/StatusJudgmentBuilder.js';
import { JudgmentDetector, type JudgmentResult } from './JudgmentDetector.js';
import { hasOnlyOneBranch, getAutoSelectedTag, getReportFiles } from '../evaluation/rule-utils.js';
import { createLogger } from '../../../shared/utils/index.js';
const log = createLogger('fallback-strategy');
export interface JudgmentContext {
step: PieceMovement;
cwd: string;
language?: Language;
reportDir?: string;
lastResponse?: string; // Phase 1の最終応答
sessionId?: string;
}
export interface JudgmentStrategy {
readonly name: string;
canApply(context: JudgmentContext): boolean;
execute(context: JudgmentContext): Promise<JudgmentResult>;
}
/**
* Base class for judgment strategies using Template Method Pattern.
*/
abstract class JudgmentStrategyBase implements JudgmentStrategy {
abstract readonly name: string;
abstract canApply(context: JudgmentContext): boolean;
async execute(context: JudgmentContext): Promise<JudgmentResult> {
try {
// 1. 情報収集(サブクラスで実装)
const input = await this.gatherInput(context);
// 2. 指示生成(サブクラスで実装)
const instruction = this.buildInstruction(input, context);
// 3. conductor実行共通
const response = await this.runConductor(instruction, context);
// 4. 結果検出(共通)
return JudgmentDetector.detect(response);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.debug(`Strategy ${this.name} threw error`, { error: errorMsg });
return {
success: false,
reason: `Strategy failed with error: ${errorMsg}`,
};
}
}
protected abstract gatherInput(context: JudgmentContext): Promise<string>;
protected abstract buildInstruction(input: string, context: JudgmentContext): string;
protected async runConductor(instruction: string, context: JudgmentContext): Promise<string> {
const response = await runAgent('conductor', instruction, {
cwd: context.cwd,
allowedTools: [],
maxTurns: 3,
language: context.language,
});
if (response.status !== 'done') {
throw new Error(`Conductor failed: ${response.error || response.content || 'Unknown error'}`);
}
return response.content;
}
}
/**
* Strategy 1: Auto-select when there's only one branch.
* This strategy doesn't use conductor - just returns the single tag.
*/
export class AutoSelectStrategy implements JudgmentStrategy {
readonly name = 'AutoSelect';
canApply(context: JudgmentContext): boolean {
return hasOnlyOneBranch(context.step);
}
async execute(context: JudgmentContext): Promise<JudgmentResult> {
const tag = getAutoSelectedTag(context.step);
log.debug('Auto-selected tag (single branch)', { tag });
return {
success: true,
tag,
};
}
}
/**
* Strategy 2: Report-based judgment.
* Read report files and ask conductor to judge.
*/
export class ReportBasedStrategy extends JudgmentStrategyBase {
readonly name = 'ReportBased';
canApply(context: JudgmentContext): boolean {
return context.reportDir !== undefined && getReportFiles(context.step.report).length > 0;
}
protected async gatherInput(context: JudgmentContext): Promise<string> {
if (!context.reportDir) {
throw new Error('Report directory not provided');
}
const reportFiles = getReportFiles(context.step.report);
if (reportFiles.length === 0) {
throw new Error('No report files configured');
}
const reportContents: string[] = [];
for (const fileName of reportFiles) {
const filePath = resolve(context.reportDir, fileName);
try {
const content = readFileSync(filePath, 'utf-8');
reportContents.push(`# ${fileName}\n\n${content}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to read report file ${fileName}: ${errorMsg}`);
}
}
return reportContents.join('\n\n---\n\n');
}
protected buildInstruction(input: string, context: JudgmentContext): string {
return new StatusJudgmentBuilder(context.step, {
language: context.language,
reportContent: input,
inputSource: 'report',
}).build();
}
}
/**
* Strategy 3: Response-based judgment.
* Use the last response from Phase 1 to judge.
*/
export class ResponseBasedStrategy extends JudgmentStrategyBase {
readonly name = 'ResponseBased';
canApply(context: JudgmentContext): boolean {
return context.lastResponse !== undefined && context.lastResponse.length > 0;
}
protected async gatherInput(context: JudgmentContext): Promise<string> {
if (!context.lastResponse) {
throw new Error('Last response not provided');
}
return context.lastResponse;
}
protected buildInstruction(input: string, context: JudgmentContext): string {
return new StatusJudgmentBuilder(context.step, {
language: context.language,
lastResponse: input,
inputSource: 'response',
}).build();
}
}
/**
* Strategy 4: Agent consult.
* Resume the Phase 1 agent session and ask which tag is appropriate.
*/
export class AgentConsultStrategy implements JudgmentStrategy {
readonly name = 'AgentConsult';
canApply(context: JudgmentContext): boolean {
return context.sessionId !== undefined && context.sessionId.length > 0;
}
async execute(context: JudgmentContext): Promise<JudgmentResult> {
if (!context.sessionId) {
return {
success: false,
reason: 'Session ID not provided',
};
}
try {
const question = this.buildQuestion(context);
const response = await runAgent(context.step.agent ?? context.step.name, question, {
cwd: context.cwd,
sessionId: context.sessionId,
maxTurns: 3,
language: context.language,
});
if (response.status !== 'done') {
return {
success: false,
reason: `Agent consultation failed: ${response.error || 'Unknown error'}`,
};
}
return JudgmentDetector.detect(response.content);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.debug('Agent consult strategy failed', { error: errorMsg });
return {
success: false,
reason: `Agent consultation error: ${errorMsg}`,
};
}
}
private buildQuestion(context: JudgmentContext): string {
const rules = context.step.rules || [];
const ruleDescriptions = rules.map((rule, idx) => {
const tag = `[${context.step.name.toUpperCase()}:${idx + 1}]`;
const desc = rule.condition || `Rule ${idx + 1}`;
return `- ${tag}: ${desc}`;
}).join('\n');
const lang = context.language || 'en';
if (lang === 'ja') {
return `あなたの作業結果に基づいて、以下の判定タグのうちどれが適切か教えてください:\n\n${ruleDescriptions}\n\n該当するタグを1つだけ出力してください例: [${context.step.name.toUpperCase()}:1])。`;
} else {
return `Based on your work, which of the following judgment tags is appropriate?\n\n${ruleDescriptions}\n\nPlease output only one tag (e.g., [${context.step.name.toUpperCase()}:1]).`;
}
}
}
/**
* Factory for creating judgment strategies in order of priority.
*/
export class JudgmentStrategyFactory {
static createStrategies(): JudgmentStrategy[] {
return [
new AutoSelectStrategy(),
new ReportBasedStrategy(),
new ResponseBasedStrategy(),
new AgentConsultStrategy(),
];
}
}