takt: 257/add-agent-usecase-structured-o

This commit is contained in:
nrslib 2026-02-12 15:03:28 +09:00
parent bf4196d3b3
commit bcf38a5530
16 changed files with 158 additions and 281 deletions

View File

@ -86,6 +86,22 @@ describe('agent-usecases', () => {
expect(detectJudgeIndex).toHaveBeenCalledWith('[JUDGE:2]'); expect(detectJudgeIndex).toHaveBeenCalledWith('[JUDGE:2]');
}); });
it('evaluateCondition は runAgent が done 以外なら -1 を返す', async () => {
vi.mocked(runAgent).mockResolvedValue({
persona: 'tester',
status: 'error',
content: 'failed',
timestamp: new Date('2026-02-12T00:00:00Z'),
});
const result = await evaluateCondition('agent output', [
{ index: 0, text: 'first' },
], { cwd: '/repo' });
expect(result).toBe(-1);
expect(detectJudgeIndex).not.toHaveBeenCalled();
});
it('judgeStatus は単一ルール時に auto_select を返す', async () => { it('judgeStatus は単一ルール時に auto_select を返す', async () => {
const result = await judgeStatus('instruction', [{ condition: 'always', next: 'done' }], { const result = await judgeStatus('instruction', [{ condition: 'always', next: 'done' }], {
cwd: '/repo', cwd: '/repo',
@ -96,6 +112,13 @@ describe('agent-usecases', () => {
expect(runAgent).not.toHaveBeenCalled(); expect(runAgent).not.toHaveBeenCalled();
}); });
it('judgeStatus はルールが空ならエラー', async () => {
await expect(judgeStatus('instruction', [], {
cwd: '/repo',
movementName: 'review',
})).rejects.toThrow('judgeStatus requires at least one rule');
});
it('judgeStatus は構造化出力 step を採用する', async () => { it('judgeStatus は構造化出力 step を採用する', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('x', { step: 2 })); vi.mocked(runAgent).mockResolvedValue(doneResponse('x', { step: 2 }));
@ -141,6 +164,21 @@ describe('agent-usecases', () => {
expect(runAgent).toHaveBeenCalledTimes(2); expect(runAgent).toHaveBeenCalledTimes(2);
}); });
it('judgeStatus は全ての判定に失敗したらエラー', async () => {
vi.mocked(runAgent)
.mockResolvedValueOnce(doneResponse('no match'))
.mockResolvedValueOnce(doneResponse('still no match'));
vi.mocked(detectJudgeIndex).mockReturnValue(-1);
await expect(judgeStatus('instruction', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], {
cwd: '/repo',
movementName: 'review',
})).rejects.toThrow('Status not found for movement "review"');
});
it('decomposeTask は構造化出力 parts を返す', async () => { it('decomposeTask は構造化出力 parts を返す', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('x', { vi.mocked(runAgent).mockResolvedValue(doneResponse('x', {
parts: [ parts: [

View File

@ -1,70 +0,0 @@
/**
* Test for JudgmentDetector
*/
import { describe, it, expect } from 'vitest';
import { JudgmentDetector } from '../core/piece/judgment/JudgmentDetector.js';
describe('JudgmentDetector', () => {
describe('detect', () => {
it('should detect tag in simple response', () => {
const result = JudgmentDetector.detect('[ARCH-REVIEW:1]');
expect(result.success).toBe(true);
expect(result.tag).toBe('[ARCH-REVIEW:1]');
});
it('should detect tag with surrounding text', () => {
const result = JudgmentDetector.detect('Based on the review, I choose [MOVEMENT:2] because...');
expect(result.success).toBe(true);
expect(result.tag).toBe('[MOVEMENT:2]');
});
it('should detect tag with hyphenated movement name', () => {
const result = JudgmentDetector.detect('[AI-ANTIPATTERN-REVIEW:1]');
expect(result.success).toBe(true);
expect(result.tag).toBe('[AI-ANTIPATTERN-REVIEW:1]');
});
it('should detect tag with underscored movement name', () => {
const result = JudgmentDetector.detect('[AI_REVIEW:1]');
expect(result.success).toBe(true);
expect(result.tag).toBe('[AI_REVIEW:1]');
});
it('should detect "判断できない" (Japanese)', () => {
const result = JudgmentDetector.detect('判断できない:情報が不足しています');
expect(result.success).toBe(false);
expect(result.reason).toBe('Conductor explicitly stated it cannot judge');
});
it('should detect "Cannot determine" (English)', () => {
const result = JudgmentDetector.detect('Cannot determine: Insufficient information');
expect(result.success).toBe(false);
expect(result.reason).toBe('Conductor explicitly stated it cannot judge');
});
it('should detect "unable to judge"', () => {
const result = JudgmentDetector.detect('I am unable to judge based on the provided information.');
expect(result.success).toBe(false);
expect(result.reason).toBe('Conductor explicitly stated it cannot judge');
});
it('should fail when no tag and no explicit "cannot judge"', () => {
const result = JudgmentDetector.detect('This is a response without a tag or explicit statement.');
expect(result.success).toBe(false);
expect(result.reason).toBe('No tag found and no explicit "cannot judge" statement');
});
it('should fail on empty response', () => {
const result = JudgmentDetector.detect('');
expect(result.success).toBe(false);
expect(result.reason).toBe('No tag found and no explicit "cannot judge" statement');
});
it('should detect first tag when multiple tags exist', () => {
const result = JudgmentDetector.detect('[MOVEMENT:1] or [MOVEMENT:2]');
expect(result.success).toBe(true);
expect(result.tag).toBe('[MOVEMENT:1]');
});
});
});

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
describe('public API exports', () => {
it('should expose piece usecases and engine public APIs', async () => {
// Given: パッケージの公開API
const api = await import('../index.js');
// When: 主要なユースケース関数とエンジン公開APIを参照する
// Then: 必要な公開シンボルが利用できる
expect(typeof api.executeAgent).toBe('function');
expect(typeof api.generateReport).toBe('function');
expect(typeof api.executePart).toBe('function');
expect(typeof api.judgeStatus).toBe('function');
expect(typeof api.evaluateCondition).toBe('function');
expect(typeof api.decomposeTask).toBe('function');
expect(typeof api.PieceEngine).toBe('function');
expect(typeof api.createInitialState).toBe('function');
expect(typeof api.addUserInput).toBe('function');
expect(typeof api.getPreviousOutput).toBe('function');
expect(api.COMPLETE_MOVEMENT).toBeDefined();
expect(api.ABORT_MOVEMENT).toBeDefined();
});
it('should not expose internal engine implementation details', async () => {
// Given: パッケージの公開API
const api = await import('../index.js');
// When: 非公開にすべき内部シンボルの有無を確認する
// Then: 内部実装詳細は公開されていない
expect('AgentRunner' in api).toBe(false);
expect('RuleEvaluator' in api).toBe(false);
expect('AggregateEvaluator' in api).toBe(false);
expect('evaluateAggregateConditions' in api).toBe(false);
expect('needsStatusJudgmentPhase' in api).toBe(false);
expect('StatusJudgmentBuilder' in api).toBe(false);
expect('buildEditRule' in api).toBe(false);
expect('detectRuleIndex' in api).toBe(false);
});
});

View File

@ -3,8 +3,8 @@ import { runAgent, type RunAgentOptions } from '../../agents/runner.js';
import { detectJudgeIndex, buildJudgePrompt } from '../../agents/judge-utils.js'; import { detectJudgeIndex, buildJudgePrompt } from '../../agents/judge-utils.js';
import { parseParts } from './engine/task-decomposer.js'; import { parseParts } from './engine/task-decomposer.js';
import { loadJudgmentSchema, loadEvaluationSchema, loadDecompositionSchema } from './schema-loader.js'; import { loadJudgmentSchema, loadEvaluationSchema, loadDecompositionSchema } from './schema-loader.js';
import { detectRuleIndex } from '../../shared/utils/ruleIndex.js';
export type UsecaseOptions = RunAgentOptions; import { ensureUniquePartIds, parsePartDefinitionEntry } from './part-definition-validator.js';
export interface JudgeStatusOptions { export interface JudgeStatusOptions {
cwd: string; cwd: string;
@ -29,18 +29,6 @@ export interface DecomposeTaskOptions {
provider?: 'claude' | 'codex' | 'opencode' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'mock';
} }
function detectRuleIndex(content: string, movementName: string): number {
const tag = movementName.toUpperCase();
const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi');
const matches = [...content.matchAll(regex)];
const match = matches.at(-1);
if (match?.[1]) {
const index = Number.parseInt(match[1], 10) - 1;
return index >= 0 ? index : -1;
}
return -1;
}
function toPartDefinitions(raw: unknown, maxParts: number): PartDefinition[] { function toPartDefinitions(raw: unknown, maxParts: number): PartDefinition[] {
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
throw new Error('Structured output "parts" must be an array'); throw new Error('Structured output "parts" must be an array');
@ -52,47 +40,8 @@ function toPartDefinitions(raw: unknown, maxParts: number): PartDefinition[] {
throw new Error(`Structured output produced too many parts: ${raw.length} > ${maxParts}`); throw new Error(`Structured output produced too many parts: ${raw.length} > ${maxParts}`);
} }
const parts: PartDefinition[] = raw.map((entry, index) => { const parts: PartDefinition[] = raw.map((entry, index) => parsePartDefinitionEntry(entry, index));
if (typeof entry !== 'object' || entry == null || Array.isArray(entry)) { ensureUniquePartIds(parts);
throw new Error(`Part[${index}] must be an object`);
}
const row = entry as Record<string, unknown>;
const id = row.id;
const title = row.title;
const instruction = row.instruction;
const timeoutMs = row.timeout_ms;
if (typeof id !== 'string' || id.trim().length === 0) {
throw new Error(`Part[${index}] "id" must be a non-empty string`);
}
if (typeof title !== 'string' || title.trim().length === 0) {
throw new Error(`Part[${index}] "title" must be a non-empty string`);
}
if (typeof instruction !== 'string' || instruction.trim().length === 0) {
throw new Error(`Part[${index}] "instruction" must be a non-empty string`);
}
if (
timeoutMs != null
&& (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0)
) {
throw new Error(`Part[${index}] "timeout_ms" must be a positive integer`);
}
return {
id,
title,
instruction,
timeoutMs: timeoutMs as number | undefined,
};
});
const seen = new Set<string>();
for (const part of parts) {
if (seen.has(part.id)) {
throw new Error(`Duplicate part id: ${part.id}`);
}
seen.add(part.id);
}
return parts; return parts;
} }
@ -100,26 +49,12 @@ function toPartDefinitions(raw: unknown, maxParts: number): PartDefinition[] {
export async function executeAgent( export async function executeAgent(
persona: string | undefined, persona: string | undefined,
instruction: string, instruction: string,
options: UsecaseOptions, options: RunAgentOptions,
): Promise<AgentResponse> {
return runAgent(persona, instruction, options);
}
export async function generateReport(
persona: string | undefined,
instruction: string,
options: UsecaseOptions,
): Promise<AgentResponse> {
return runAgent(persona, instruction, options);
}
export async function executePart(
persona: string | undefined,
instruction: string,
options: UsecaseOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
return runAgent(persona, instruction, options); return runAgent(persona, instruction, options);
} }
export const generateReport = executeAgent;
export const executePart = executeAgent;
export async function evaluateCondition( export async function evaluateCondition(
agentOutput: string, agentOutput: string,

View File

@ -1,10 +1,3 @@
/**
* Builds RunAgentOptions for different execution phases.
*
* Centralizes the option construction logic that was previously
* scattered across PieceEngine methods.
*/
import { join } from 'node:path'; import { join } from 'node:path';
import type { PieceMovement, PieceState, Language } from '../../models/types.js'; import type { PieceMovement, PieceState, Language } from '../../models/types.js';
import type { RunAgentOptions } from '../../../agents/runner.js'; import type { RunAgentOptions } from '../../../agents/runner.js';

View File

@ -5,7 +5,7 @@ import type {
PartDefinition, PartDefinition,
PartResult, PartResult,
} from '../../models/types.js'; } from '../../models/types.js';
import { decomposeTask, executePart } from '../agent-usecases.js'; import { decomposeTask, executeAgent } from '../agent-usecases.js';
import { detectMatchedRule } from '../evaluation/index.js'; import { detectMatchedRule } from '../evaluation/index.js';
import { buildSessionKey } from '../session-key.js'; import { buildSessionKey } from '../session-key.js';
import { ParallelLogger } from './parallel-logger.js'; import { ParallelLogger } from './parallel-logger.js';
@ -232,7 +232,7 @@ export class TeamLeaderRunner {
: { ...baseOptions, abortSignal: signal }; : { ...baseOptions, abortSignal: signal };
try { try {
const response = await executePart(partMovement.persona, part.instruction, options); const response = await executeAgent(partMovement.persona, part.instruction, options);
updatePersonaSession(buildSessionKey(partMovement), response.sessionId); updatePersonaSession(buildSessionKey(partMovement), response.sessionId);
return { return {
part, part,

View File

@ -1,4 +1,5 @@
import type { PartDefinition } from '../../models/part.js'; import type { PartDefinition } from '../../models/part.js';
import { ensureUniquePartIds, parsePartDefinitionEntry } from '../part-definition-validator.js';
const JSON_CODE_BLOCK_REGEX = /```json\s*([\s\S]*?)```/g; const JSON_CODE_BLOCK_REGEX = /```json\s*([\s\S]*?)```/g;
@ -24,36 +25,6 @@ function parseJsonBlock(content: string): unknown {
} }
} }
function assertString(value: unknown, fieldName: string, index: number): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Part[${index}] "${fieldName}" must be a non-empty string`);
}
return value;
}
function parsePartEntry(entry: unknown, index: number): PartDefinition {
if (typeof entry !== 'object' || entry == null || Array.isArray(entry)) {
throw new Error(`Part[${index}] must be an object`);
}
const raw = entry as Record<string, unknown>;
const id = assertString(raw.id, 'id', index);
const title = assertString(raw.title, 'title', index);
const instruction = assertString(raw.instruction, 'instruction', index);
const timeoutMs = raw.timeout_ms;
if (timeoutMs != null && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0)) {
throw new Error(`Part[${index}] "timeout_ms" must be a positive integer`);
}
return {
id,
title,
instruction,
timeoutMs: timeoutMs as number | undefined,
};
}
export function parseParts(content: string, maxParts: number): PartDefinition[] { export function parseParts(content: string, maxParts: number): PartDefinition[] {
const parsed = parseJsonBlock(content); const parsed = parseJsonBlock(content);
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
@ -66,14 +37,8 @@ export function parseParts(content: string, maxParts: number): PartDefinition[]
throw new Error(`Team leader produced too many parts: ${parsed.length} > ${maxParts}`); throw new Error(`Team leader produced too many parts: ${parsed.length} > ${maxParts}`);
} }
const parts = parsed.map((entry, index) => parsePartEntry(entry, index)); const parts = parsed.map((entry, index) => parsePartDefinitionEntry(entry, index));
const ids = new Set<string>(); ensureUniquePartIds(parts);
for (const part of parts) {
if (ids.has(part.id)) {
throw new Error(`Duplicate part id: ${part.id}`);
}
ids.add(part.id);
}
return parts; return parts;
} }

View File

@ -1,45 +0,0 @@
/**
* Detect judgment result from conductor's response.
*/
export interface JudgmentResult {
success: boolean;
tag?: string; // e.g., "[ARCH-REVIEW:1]"
reason?: string;
}
export class JudgmentDetector {
private static readonly TAG_PATTERN = /\[([A-Z_-]+):(\d+)\]/;
private static readonly CANNOT_JUDGE_PATTERNS = [
/判断できない/i,
/cannot\s+determine/i,
/unable\s+to\s+judge/i,
/insufficient\s+information/i,
];
static detect(response: string): JudgmentResult {
// 1. タグ検出
const tagMatch = response.match(this.TAG_PATTERN);
if (tagMatch) {
return {
success: true,
tag: tagMatch[0], // e.g., "[ARCH-REVIEW:1]"
};
}
// 2. 「判断できない」検出
for (const pattern of this.CANNOT_JUDGE_PATTERNS) {
if (pattern.test(response)) {
return {
success: false,
reason: 'Conductor explicitly stated it cannot judge',
};
}
}
// 3. タグも「判断できない」もない → 失敗
return {
success: false,
reason: 'No tag found and no explicit "cannot judge" statement',
};
}
}

View File

@ -1,8 +0,0 @@
/**
* Judgment module exports
*/
export {
JudgmentDetector,
type JudgmentResult,
} from './JudgmentDetector.js';

View File

@ -0,0 +1,41 @@
import type { PartDefinition } from '../models/part.js';
function assertNonEmptyString(value: unknown, fieldName: string, index: number): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Part[${index}] "${fieldName}" must be a non-empty string`);
}
return value;
}
export function parsePartDefinitionEntry(entry: unknown, index: number): PartDefinition {
if (typeof entry !== 'object' || entry == null || Array.isArray(entry)) {
throw new Error(`Part[${index}] must be an object`);
}
const raw = entry as Record<string, unknown>;
const id = assertNonEmptyString(raw.id, 'id', index);
const title = assertNonEmptyString(raw.title, 'title', index);
const instruction = assertNonEmptyString(raw.instruction, 'instruction', index);
const timeoutMs = raw.timeout_ms;
if (timeoutMs != null && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0)) {
throw new Error(`Part[${index}] "timeout_ms" must be a positive integer`);
}
return {
id,
title,
instruction,
timeoutMs: timeoutMs as number | undefined,
};
}
export function ensureUniquePartIds(parts: PartDefinition[]): void {
const ids = new Set<string>();
for (const part of parts) {
if (ids.has(part.id)) {
throw new Error(`Duplicate part id: ${part.id}`);
}
ids.add(part.id);
}
}

View File

@ -12,7 +12,7 @@ import type { PhaseName } from './types.js';
import type { RunAgentOptions } from '../../agents/runner.js'; import type { RunAgentOptions } from '../../agents/runner.js';
import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js'; import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js';
import { hasTagBasedRules, getReportFiles } from './evaluation/rule-utils.js'; import { hasTagBasedRules, getReportFiles } from './evaluation/rule-utils.js';
import { generateReport } from './agent-usecases.js'; import { executeAgent } from './agent-usecases.js';
import { createLogger } from '../../shared/utils/index.js'; import { createLogger } from '../../shared/utils/index.js';
import { buildSessionKey } from './session-key.js'; import { buildSessionKey } from './session-key.js';
export { runStatusJudgmentPhase } from './status-judgment-phase.js'; export { runStatusJudgmentPhase } from './status-judgment-phase.js';
@ -214,7 +214,7 @@ async function runSingleReportAttempt(
let response: AgentResponse; let response: AgentResponse;
try { try {
response = await generateReport(step.persona, instruction, options); response = await executeAgent(step.persona, instruction, options);
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
ctx.onPhaseComplete?.(step, 2, 'report', '', 'error', errorMsg); ctx.onPhaseComplete?.(step, 2, 'report', '', 'error', errorMsg);

View File

@ -87,9 +87,6 @@ export type {
export { CodexClient, mapToCodexSandboxMode } from './infra/codex/index.js'; export { CodexClient, mapToCodexSandboxMode } from './infra/codex/index.js';
export type { CodexCallOptions, CodexSandboxMode } from './infra/codex/index.js'; export type { CodexCallOptions, CodexSandboxMode } from './infra/codex/index.js';
// Agent execution
export * from './agents/index.js';
// Piece engine // Piece engine
export { export {
PieceEngine, PieceEngine,
@ -107,12 +104,6 @@ export {
InstructionBuilder, InstructionBuilder,
isOutputContractItem, isOutputContractItem,
ReportInstructionBuilder, ReportInstructionBuilder,
StatusJudgmentBuilder,
buildEditRule,
RuleEvaluator,
evaluateAggregateConditions,
AggregateEvaluator,
needsStatusJudgmentPhase,
executeAgent, executeAgent,
generateReport, generateReport,
executePart, executePart,
@ -129,13 +120,7 @@ export type {
PieceEngineOptions, PieceEngineOptions,
LoopCheckResult, LoopCheckResult,
ProviderType, ProviderType,
RuleMatch,
RuleEvaluatorContext,
JudgeStatusResult, JudgeStatusResult,
ReportInstructionContext,
StatusJudgmentContext,
InstructionContext,
StatusRulesComponents,
BlockedHandlerResult, BlockedHandlerResult,
} from './core/piece/index.js'; } from './core/piece/index.js';

View File

@ -3,24 +3,9 @@
* *
* Stateless helpers for rule detection and regex safety validation. * Stateless helpers for rule detection and regex safety validation.
*/ */
import { detectRuleIndex } from '../../shared/utils/ruleIndex.js';
/** export { detectRuleIndex };
* Detect rule index from numbered tag pattern [STEP_NAME:N].
* Returns 0-based rule index, or -1 if no match.
*
* Example: detectRuleIndex("... [PLAN:2] ...", "plan") 1
*/
export function detectRuleIndex(content: string, movementName: string): number {
const tag = movementName.toUpperCase();
const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi');
const matches = [...content.matchAll(regex)];
const match = matches.at(-1);
if (match?.[1]) {
const index = Number.parseInt(match[1], 10) - 1;
return index >= 0 ? index : -1;
}
return -1;
}
/** Validate regex pattern for ReDoS safety */ /** Validate regex pattern for ReDoS safety */
export function isRegexSafe(pattern: string): boolean { export function isRegexSafe(pattern: string): boolean {

View File

@ -156,7 +156,9 @@ export class CodexClient {
if (options.outputSchema) { if (options.outputSchema) {
runOptions.outputSchema = options.outputSchema; runOptions.outputSchema = options.outputSchema;
} }
const { events } = await thread.runStreamed(fullPrompt, runOptions as never); // Codex SDK types do not yet expose outputSchema even though runtime accepts it.
const runStreamedOptions = runOptions as unknown as Parameters<typeof thread.runStreamed>[1];
const { events } = await thread.runStreamed(fullPrompt, runStreamedOptions);
resetIdleTimeout(); resetIdleTimeout();
diag.onConnected(); diag.onConnected();

View File

@ -343,10 +343,11 @@ export class OpenCodeClient {
}; };
} }
await client.session.promptAsync( // OpenCode SDK types do not yet expose outputFormat even though runtime accepts it.
promptPayload as never, const promptPayloadForSdk = promptPayload as unknown as Parameters<typeof client.session.promptAsync>[0];
{ signal: streamAbortController.signal }, await client.session.promptAsync(promptPayloadForSdk, {
); signal: streamAbortController.signal,
});
emitInit(options.onStream, options.model, sessionId); emitInit(options.onStream, options.model, sessionId);

View File

@ -0,0 +1,15 @@
/**
* Detect rule index from numbered tag pattern [STEP_NAME:N].
* Returns 0-based rule index, or -1 if no match.
*/
export function detectRuleIndex(content: string, movementName: string): number {
const tag = movementName.toUpperCase();
const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi');
const matches = [...content.matchAll(regex)];
const match = matches.at(-1);
if (match?.[1]) {
const index = Number.parseInt(match[1], 10) - 1;
return index >= 0 ? index : -1;
}
return -1;
}