resolved ai_review ↔ ai_fix ループの健全性チェックと修正不要時の裁定ステップを追加 #102
This commit is contained in:
parent
68b45abbf6
commit
3e54c80ba2
190
e2e/specs/eject.e2e.ts
Normal file
190
e2e/specs/eject.e2e.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
/**
|
||||
* Create a minimal local git repository for eject tests.
|
||||
* No GitHub access needed — just a local git init.
|
||||
*/
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-eject-e2e-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
// Create initial commit so branch exists
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try {
|
||||
rmSync(repoPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Eject builtin pieces (takt eject)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
repo.cleanup();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
isolatedEnv.cleanup();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
|
||||
it('should list available builtin pieces when no name given', () => {
|
||||
const result = runTakt({
|
||||
args: ['eject'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('default');
|
||||
expect(result.stdout).toContain('Available builtin pieces');
|
||||
});
|
||||
|
||||
it('should eject piece to project .takt/ by default', () => {
|
||||
const result = runTakt({
|
||||
args: ['eject', 'default'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
// Piece YAML should be in project .takt/pieces/
|
||||
const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml');
|
||||
expect(existsSync(piecePath)).toBe(true);
|
||||
|
||||
// Agents should be in project .takt/agents/
|
||||
const agentsDir = join(repo.path, '.takt', 'agents', 'default');
|
||||
expect(existsSync(agentsDir)).toBe(true);
|
||||
expect(existsSync(join(agentsDir, 'coder.md'))).toBe(true);
|
||||
expect(existsSync(join(agentsDir, 'planner.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve relative agent paths in ejected piece (no rewriting)', () => {
|
||||
runTakt({
|
||||
args: ['eject', 'default'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml');
|
||||
const content = readFileSync(piecePath, 'utf-8');
|
||||
|
||||
// Relative paths should be preserved as ../agents/
|
||||
expect(content).toContain('agent: ../agents/default/');
|
||||
// Should NOT contain rewritten absolute paths
|
||||
expect(content).not.toContain('agent: ~/.takt/agents/');
|
||||
});
|
||||
|
||||
it('should eject piece to global ~/.takt/ with --global flag', () => {
|
||||
const result = runTakt({
|
||||
args: ['eject', 'default', '--global'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
// Piece YAML should be in global dir (TAKT_CONFIG_DIR from isolated env)
|
||||
const piecePath = join(isolatedEnv.taktDir, 'pieces', 'default.yaml');
|
||||
expect(existsSync(piecePath)).toBe(true);
|
||||
|
||||
// Agents should be in global agents dir
|
||||
const agentsDir = join(isolatedEnv.taktDir, 'agents', 'default');
|
||||
expect(existsSync(agentsDir)).toBe(true);
|
||||
expect(existsSync(join(agentsDir, 'coder.md'))).toBe(true);
|
||||
|
||||
// Should NOT be in project dir
|
||||
const projectPiecePath = join(repo.path, '.takt', 'pieces', 'default.yaml');
|
||||
expect(existsSync(projectPiecePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should warn and skip when piece already exists', () => {
|
||||
// First eject
|
||||
runTakt({
|
||||
args: ['eject', 'default'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
// Second eject — should skip
|
||||
const result = runTakt({
|
||||
args: ['eject', 'default'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('already exists');
|
||||
});
|
||||
|
||||
it('should report error for non-existent builtin', () => {
|
||||
const result = runTakt({
|
||||
args: ['eject', 'nonexistent-piece-xyz'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('not found');
|
||||
});
|
||||
|
||||
it('should correctly eject agents for pieces with unique agents', () => {
|
||||
const result = runTakt({
|
||||
args: ['eject', 'magi'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
// MAGI piece should have its own agents
|
||||
const magiDir = join(repo.path, '.takt', 'agents', 'magi');
|
||||
expect(existsSync(join(magiDir, 'melchior.md'))).toBe(true);
|
||||
expect(existsSync(join(magiDir, 'balthasar.md'))).toBe(true);
|
||||
expect(existsSync(join(magiDir, 'casper.md'))).toBe(true);
|
||||
|
||||
// Should NOT have default agents mixed in
|
||||
expect(existsSync(join(repo.path, '.takt', 'agents', 'default'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve relative paths for global eject too', () => {
|
||||
runTakt({
|
||||
args: ['eject', 'magi', '--global'],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
});
|
||||
|
||||
const piecePath = join(isolatedEnv.taktDir, 'pieces', 'magi.yaml');
|
||||
const content = readFileSync(piecePath, 'utf-8');
|
||||
|
||||
expect(content).toContain('agent: ../agents/magi/');
|
||||
expect(content).not.toContain('agent: ~/.takt/agents/');
|
||||
});
|
||||
});
|
||||
@ -26,6 +26,30 @@ max_iterations: 30
|
||||
|
||||
initial_movement: plan
|
||||
|
||||
loop_monitors:
|
||||
- cycle: [ai_review, ai_fix]
|
||||
threshold: 3
|
||||
judge:
|
||||
agent: ../agents/default/supervisor.md
|
||||
instruction_template: |
|
||||
The ai_review ↔ ai_fix loop has repeated {cycle_count} times.
|
||||
|
||||
Review the reports from each cycle and determine whether this loop
|
||||
is healthy (making progress) or unproductive (repeating the same issues).
|
||||
|
||||
**Reports to reference:**
|
||||
- AI Review results: {report:04-ai-review.md}
|
||||
|
||||
**Judgment criteria:**
|
||||
- Are new issues being found/fixed in each cycle?
|
||||
- Are the same findings being repeated?
|
||||
- Are fixes actually being applied?
|
||||
rules:
|
||||
- condition: Healthy (making progress)
|
||||
next: ai_review
|
||||
- condition: Unproductive (no improvement)
|
||||
next: reviewers
|
||||
|
||||
movements:
|
||||
- name: plan
|
||||
edit: false
|
||||
|
||||
@ -17,6 +17,30 @@ max_iterations: 30
|
||||
|
||||
initial_movement: plan
|
||||
|
||||
loop_monitors:
|
||||
- cycle: [ai_review, ai_fix]
|
||||
threshold: 3
|
||||
judge:
|
||||
agent: ../agents/default/supervisor.md
|
||||
instruction_template: |
|
||||
ai_review と ai_fix のループが {cycle_count} 回繰り返されました。
|
||||
|
||||
各サイクルのレポートを確認し、このループが健全(進捗がある)か、
|
||||
非生産的(同じ問題を繰り返している)かを判断してください。
|
||||
|
||||
**参照するレポート:**
|
||||
- AIレビュー結果: {report:04-ai-review.md}
|
||||
|
||||
**判断基準:**
|
||||
- 各サイクルで新しい問題が発見・修正されているか
|
||||
- 同じ指摘が繰り返されていないか
|
||||
- 修正が実際に反映されているか
|
||||
rules:
|
||||
- condition: 健全(進捗あり)
|
||||
next: ai_review
|
||||
- condition: 非生産的(改善なし)
|
||||
next: reviewers
|
||||
|
||||
movements:
|
||||
- name: plan
|
||||
edit: false
|
||||
|
||||
218
src/__tests__/cycle-detector.test.ts
Normal file
218
src/__tests__/cycle-detector.test.ts
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* CycleDetector unit tests
|
||||
*
|
||||
* Tests cycle detection logic for loop_monitors.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CycleDetector } from '../core/piece/engine/cycle-detector.js';
|
||||
import type { LoopMonitorConfig } from '../core/models/index.js';
|
||||
|
||||
function makeMonitor(
|
||||
cycle: string[],
|
||||
threshold: number,
|
||||
rules = [
|
||||
{ condition: 'healthy', next: 'ai_review' },
|
||||
{ condition: 'unproductive', next: 'reviewers' },
|
||||
],
|
||||
): LoopMonitorConfig {
|
||||
return {
|
||||
cycle,
|
||||
threshold,
|
||||
judge: { rules },
|
||||
};
|
||||
}
|
||||
|
||||
describe('CycleDetector', () => {
|
||||
describe('2-step cycle detection', () => {
|
||||
let detector: CycleDetector;
|
||||
const monitor = makeMonitor(['ai_review', 'ai_fix'], 3);
|
||||
|
||||
beforeEach(() => {
|
||||
detector = new CycleDetector([monitor]);
|
||||
});
|
||||
|
||||
it('should not trigger before threshold is reached', () => {
|
||||
// 2 complete cycles (4 movements)
|
||||
expect(detector.recordAndCheck('ai_review').triggered).toBe(false);
|
||||
expect(detector.recordAndCheck('ai_fix').triggered).toBe(false);
|
||||
expect(detector.recordAndCheck('ai_review').triggered).toBe(false);
|
||||
expect(detector.recordAndCheck('ai_fix').triggered).toBe(false);
|
||||
});
|
||||
|
||||
it('should trigger when threshold (3 cycles) is reached', () => {
|
||||
// 3 complete cycles (6 movements)
|
||||
detector.recordAndCheck('ai_review');
|
||||
detector.recordAndCheck('ai_fix');
|
||||
detector.recordAndCheck('ai_review');
|
||||
detector.recordAndCheck('ai_fix');
|
||||
detector.recordAndCheck('ai_review');
|
||||
const result = detector.recordAndCheck('ai_fix');
|
||||
|
||||
expect(result.triggered).toBe(true);
|
||||
expect(result.cycleCount).toBe(3);
|
||||
expect(result.monitor).toBe(monitor);
|
||||
});
|
||||
|
||||
it('should not trigger when cycle is interrupted by another movement', () => {
|
||||
detector.recordAndCheck('ai_review');
|
||||
detector.recordAndCheck('ai_fix');
|
||||
detector.recordAndCheck('ai_review');
|
||||
detector.recordAndCheck('ai_fix');
|
||||
// Interrupt the cycle with a different movement
|
||||
detector.recordAndCheck('plan');
|
||||
detector.recordAndCheck('ai_review');
|
||||
const result = detector.recordAndCheck('ai_fix');
|
||||
|
||||
// Only 1 complete cycle since the interruption
|
||||
expect(result.triggered).toBe(false);
|
||||
});
|
||||
|
||||
it('should not trigger when only the cycle end matches', () => {
|
||||
// History doesn't form a valid cycle pattern
|
||||
detector.recordAndCheck('plan');
|
||||
detector.recordAndCheck('implement');
|
||||
detector.recordAndCheck('ai_fix');
|
||||
|
||||
expect(detector.recordAndCheck('ai_fix').triggered).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset correctly and not trigger after reset', () => {
|
||||
// Build up 2 cycles
|
||||
detector.recordAndCheck('ai_review');
|
||||
detector.recordAndCheck('ai_fix');
|
||||
detector.recordAndCheck('ai_review');
|
||||
detector.recordAndCheck('ai_fix');
|
||||
|
||||
// Reset
|
||||
detector.reset();
|
||||
|
||||
// Now only 1 cycle after reset
|
||||
detector.recordAndCheck('ai_review');
|
||||
const result = detector.recordAndCheck('ai_fix');
|
||||
expect(result.triggered).toBe(false);
|
||||
expect(result.cycleCount).toBe(0); // Less than threshold
|
||||
});
|
||||
|
||||
it('should trigger exactly at threshold, not before', () => {
|
||||
// Threshold is 3
|
||||
// Cycle 1
|
||||
expect(detector.recordAndCheck('ai_review').triggered).toBe(false);
|
||||
expect(detector.recordAndCheck('ai_fix').triggered).toBe(false);
|
||||
|
||||
// Cycle 2
|
||||
expect(detector.recordAndCheck('ai_review').triggered).toBe(false);
|
||||
expect(detector.recordAndCheck('ai_fix').triggered).toBe(false);
|
||||
|
||||
// Cycle 3 (threshold reached)
|
||||
expect(detector.recordAndCheck('ai_review').triggered).toBe(false);
|
||||
expect(detector.recordAndCheck('ai_fix').triggered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3-step cycle detection', () => {
|
||||
it('should detect 3-step cycles', () => {
|
||||
const monitor = makeMonitor(['A', 'B', 'C'], 2);
|
||||
const detector = new CycleDetector([monitor]);
|
||||
|
||||
// Cycle 1
|
||||
detector.recordAndCheck('A');
|
||||
detector.recordAndCheck('B');
|
||||
detector.recordAndCheck('C');
|
||||
|
||||
// Cycle 2
|
||||
detector.recordAndCheck('A');
|
||||
detector.recordAndCheck('B');
|
||||
const result = detector.recordAndCheck('C');
|
||||
|
||||
expect(result.triggered).toBe(true);
|
||||
expect(result.cycleCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple monitors', () => {
|
||||
it('should check all monitors and trigger the first matching one', () => {
|
||||
const monitor1 = makeMonitor(['A', 'B'], 3);
|
||||
const monitor2 = makeMonitor(['X', 'Y'], 2);
|
||||
const detector = new CycleDetector([monitor1, monitor2]);
|
||||
|
||||
// 2 cycles of X → Y (threshold for monitor2 is 2)
|
||||
detector.recordAndCheck('X');
|
||||
detector.recordAndCheck('Y');
|
||||
detector.recordAndCheck('X');
|
||||
const result = detector.recordAndCheck('Y');
|
||||
|
||||
expect(result.triggered).toBe(true);
|
||||
expect(result.cycleCount).toBe(2);
|
||||
expect(result.monitor).toBe(monitor2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no monitors', () => {
|
||||
it('should never trigger with empty monitors', () => {
|
||||
const detector = new CycleDetector([]);
|
||||
detector.recordAndCheck('ai_review');
|
||||
detector.recordAndCheck('ai_fix');
|
||||
detector.recordAndCheck('ai_review');
|
||||
const result = detector.recordAndCheck('ai_fix');
|
||||
|
||||
expect(result.triggered).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistory', () => {
|
||||
it('should return the full movement history', () => {
|
||||
const detector = new CycleDetector([]);
|
||||
detector.recordAndCheck('plan');
|
||||
detector.recordAndCheck('implement');
|
||||
detector.recordAndCheck('ai_review');
|
||||
|
||||
expect(detector.getHistory()).toEqual(['plan', 'implement', 'ai_review']);
|
||||
});
|
||||
|
||||
it('should return empty after reset', () => {
|
||||
const detector = new CycleDetector([]);
|
||||
detector.recordAndCheck('plan');
|
||||
detector.reset();
|
||||
|
||||
expect(detector.getHistory()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threshold of 1', () => {
|
||||
it('should trigger after first complete cycle', () => {
|
||||
const monitor = makeMonitor(['A', 'B'], 1);
|
||||
const detector = new CycleDetector([monitor]);
|
||||
|
||||
detector.recordAndCheck('A');
|
||||
const result = detector.recordAndCheck('B');
|
||||
|
||||
expect(result.triggered).toBe(true);
|
||||
expect(result.cycleCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beyond threshold', () => {
|
||||
it('should also trigger at threshold + N (consecutive cycles)', () => {
|
||||
const monitor = makeMonitor(['A', 'B'], 2);
|
||||
const detector = new CycleDetector([monitor]);
|
||||
|
||||
// 2 cycles → threshold met
|
||||
detector.recordAndCheck('A');
|
||||
detector.recordAndCheck('B');
|
||||
detector.recordAndCheck('A');
|
||||
const result1 = detector.recordAndCheck('B');
|
||||
expect(result1.triggered).toBe(true);
|
||||
expect(result1.cycleCount).toBe(2);
|
||||
|
||||
// After reset + 3 more cycles → triggers at 2 again
|
||||
detector.reset();
|
||||
detector.recordAndCheck('A');
|
||||
detector.recordAndCheck('B');
|
||||
detector.recordAndCheck('A');
|
||||
const result2 = detector.recordAndCheck('B');
|
||||
expect(result2.triggered).toBe(true);
|
||||
expect(result2.cycleCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
315
src/__tests__/engine-loop-monitors.test.ts
Normal file
315
src/__tests__/engine-loop-monitors.test.ts
Normal file
@ -0,0 +1,315 @@
|
||||
/**
|
||||
* PieceEngine integration tests: loop_monitors (cycle detection + judge)
|
||||
*
|
||||
* Covers:
|
||||
* - Loop monitor triggers judge when cycle threshold reached
|
||||
* - Judge decision overrides normal next movement
|
||||
* - Cycle detector resets after judge intervention
|
||||
* - No trigger when threshold not reached
|
||||
* - Validation of loop_monitors config
|
||||
* - movement:cycle_detected event emission
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import type { PieceConfig, PieceMovement, LoopMonitorConfig } from '../core/models/index.js';
|
||||
|
||||
// --- Mock setup (must be before imports that use these modules) ---
|
||||
|
||||
vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { PieceEngine } from '../core/piece/index.js';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import {
|
||||
makeResponse,
|
||||
makeMovement,
|
||||
makeRule,
|
||||
mockRunAgentSequence,
|
||||
mockDetectMatchedRuleSequence,
|
||||
createTestTmpDir,
|
||||
applyDefaultMocks,
|
||||
cleanupPieceEngine,
|
||||
} from './engine-test-helpers.js';
|
||||
|
||||
/**
|
||||
* Build a piece config with ai_review ↔ ai_fix loop and loop_monitors.
|
||||
*/
|
||||
function buildConfigWithLoopMonitor(
|
||||
threshold = 3,
|
||||
monitorOverrides: Partial<LoopMonitorConfig> = {},
|
||||
): PieceConfig {
|
||||
return {
|
||||
name: 'test-loop-monitor',
|
||||
description: 'Test piece with loop monitors',
|
||||
maxIterations: 30,
|
||||
initialMovement: 'implement',
|
||||
loopMonitors: [
|
||||
{
|
||||
cycle: ['ai_review', 'ai_fix'],
|
||||
threshold,
|
||||
judge: {
|
||||
rules: [
|
||||
{ condition: 'Healthy', next: 'ai_review' },
|
||||
{ condition: 'Unproductive', next: 'reviewers' },
|
||||
],
|
||||
},
|
||||
...monitorOverrides,
|
||||
},
|
||||
],
|
||||
movements: [
|
||||
makeMovement('implement', {
|
||||
rules: [makeRule('done', 'ai_review')],
|
||||
}),
|
||||
makeMovement('ai_review', {
|
||||
rules: [
|
||||
makeRule('No issues', 'reviewers'),
|
||||
makeRule('Issues found', 'ai_fix'),
|
||||
],
|
||||
}),
|
||||
makeMovement('ai_fix', {
|
||||
rules: [
|
||||
makeRule('Fixed', 'ai_review'),
|
||||
makeRule('No fix needed', 'reviewers'),
|
||||
],
|
||||
}),
|
||||
makeMovement('reviewers', {
|
||||
rules: [makeRule('All approved', 'COMPLETE')],
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('PieceEngine Integration: Loop Monitors', () => {
|
||||
let tmpDir: string;
|
||||
let engine: PieceEngine | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
applyDefaultMocks();
|
||||
tmpDir = createTestTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (engine) {
|
||||
cleanupPieceEngine(engine);
|
||||
engine = null;
|
||||
}
|
||||
if (existsSync(tmpDir)) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// 1. Cycle triggers judge → unproductive → skip to reviewers
|
||||
// =====================================================
|
||||
describe('Judge triggered on cycle threshold', () => {
|
||||
it('should run judge and redirect to reviewers when cycle is unproductive', async () => {
|
||||
const config = buildConfigWithLoopMonitor(2);
|
||||
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
||||
|
||||
mockRunAgentSequence([
|
||||
// implement
|
||||
makeResponse({ agent: 'implement', content: 'Implementation done' }),
|
||||
// ai_review → issues found
|
||||
makeResponse({ agent: 'ai_review', content: 'Issues found: X' }),
|
||||
// ai_fix → fixed → ai_review
|
||||
makeResponse({ agent: 'ai_fix', content: 'Fixed X' }),
|
||||
// ai_review → issues found again
|
||||
makeResponse({ agent: 'ai_review', content: 'Issues found: Y' }),
|
||||
// ai_fix → fixed → cycle threshold reached (2 cycles complete)
|
||||
makeResponse({ agent: 'ai_fix', content: 'Fixed Y' }),
|
||||
// Judge runs (synthetic movement)
|
||||
makeResponse({ agent: 'supervisor', content: 'Unproductive loop detected' }),
|
||||
// reviewers (after judge redirects here)
|
||||
makeResponse({ agent: 'reviewers', content: 'All approved' }),
|
||||
]);
|
||||
|
||||
mockDetectMatchedRuleSequence([
|
||||
{ index: 0, method: 'phase1_tag' }, // implement → ai_review
|
||||
{ index: 1, method: 'phase1_tag' }, // ai_review → ai_fix (issues found)
|
||||
{ index: 0, method: 'phase1_tag' }, // ai_fix → ai_review (fixed)
|
||||
{ index: 1, method: 'phase1_tag' }, // ai_review → ai_fix (issues found again)
|
||||
{ index: 0, method: 'phase1_tag' }, // ai_fix → ai_review (fixed) — but cycle detected!
|
||||
// Judge rule match: Unproductive (index 1) → reviewers
|
||||
{ index: 1, method: 'ai_judge_fallback' },
|
||||
// reviewers → COMPLETE
|
||||
{ index: 0, method: 'phase1_tag' },
|
||||
]);
|
||||
|
||||
const cycleDetectedFn = vi.fn();
|
||||
engine.on('movement:cycle_detected', cycleDetectedFn);
|
||||
|
||||
const state = await engine.run();
|
||||
|
||||
expect(state.status).toBe('completed');
|
||||
expect(cycleDetectedFn).toHaveBeenCalledOnce();
|
||||
expect(cycleDetectedFn.mock.calls[0][1]).toBe(2); // cycleCount
|
||||
// 7 iterations: implement + ai_review + ai_fix + ai_review + ai_fix + judge + reviewers
|
||||
expect(state.iteration).toBe(7);
|
||||
});
|
||||
|
||||
it('should run judge and continue loop when cycle is healthy', async () => {
|
||||
const config = buildConfigWithLoopMonitor(2);
|
||||
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
||||
|
||||
mockRunAgentSequence([
|
||||
// implement
|
||||
makeResponse({ agent: 'implement', content: 'Implementation done' }),
|
||||
// Cycle 1: ai_review → ai_fix
|
||||
makeResponse({ agent: 'ai_review', content: 'Issues found: A' }),
|
||||
makeResponse({ agent: 'ai_fix', content: 'Fixed A' }),
|
||||
// Cycle 2: ai_review → ai_fix (threshold reached)
|
||||
makeResponse({ agent: 'ai_review', content: 'Issues found: B' }),
|
||||
makeResponse({ agent: 'ai_fix', content: 'Fixed B' }),
|
||||
// Judge says healthy → continue to ai_review
|
||||
makeResponse({ agent: 'supervisor', content: 'Loop is healthy, making progress' }),
|
||||
// ai_review → no issues
|
||||
makeResponse({ agent: 'ai_review', content: 'No issues remaining' }),
|
||||
// reviewers → COMPLETE
|
||||
makeResponse({ agent: 'reviewers', content: 'All approved' }),
|
||||
]);
|
||||
|
||||
mockDetectMatchedRuleSequence([
|
||||
{ index: 0, method: 'phase1_tag' }, // implement → ai_review
|
||||
{ index: 1, method: 'phase1_tag' }, // ai_review → ai_fix
|
||||
{ index: 0, method: 'phase1_tag' }, // ai_fix → ai_review
|
||||
{ index: 1, method: 'phase1_tag' }, // ai_review → ai_fix
|
||||
{ index: 0, method: 'phase1_tag' }, // ai_fix → ai_review — cycle detected!
|
||||
// Judge: Healthy (index 0) → ai_review
|
||||
{ index: 0, method: 'ai_judge_fallback' },
|
||||
// ai_review → reviewers (no issues)
|
||||
{ index: 0, method: 'phase1_tag' },
|
||||
// reviewers → COMPLETE
|
||||
{ index: 0, method: 'phase1_tag' },
|
||||
]);
|
||||
|
||||
const state = await engine.run();
|
||||
|
||||
expect(state.status).toBe('completed');
|
||||
// 8 iterations: impl + ai_review*3 + ai_fix*2 + judge + reviewers
|
||||
expect(state.iteration).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// 2. No trigger when threshold not reached
|
||||
// =====================================================
|
||||
describe('No trigger before threshold', () => {
|
||||
it('should not trigger judge when fewer cycles than threshold', async () => {
|
||||
const config = buildConfigWithLoopMonitor(3); // threshold = 3, only do 1 cycle
|
||||
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
||||
|
||||
mockRunAgentSequence([
|
||||
makeResponse({ agent: 'implement', content: 'Implementation done' }),
|
||||
makeResponse({ agent: 'ai_review', content: 'Issues found' }),
|
||||
makeResponse({ agent: 'ai_fix', content: 'Fixed' }),
|
||||
makeResponse({ agent: 'ai_review', content: 'No issues' }),
|
||||
makeResponse({ agent: 'reviewers', content: 'All approved' }),
|
||||
]);
|
||||
|
||||
mockDetectMatchedRuleSequence([
|
||||
{ index: 0, method: 'phase1_tag' }, // implement → ai_review
|
||||
{ index: 1, method: 'phase1_tag' }, // ai_review → ai_fix
|
||||
{ index: 0, method: 'phase1_tag' }, // ai_fix → ai_review
|
||||
{ index: 0, method: 'phase1_tag' }, // ai_review → reviewers (no issues)
|
||||
{ index: 0, method: 'phase1_tag' }, // reviewers → COMPLETE
|
||||
]);
|
||||
|
||||
const cycleDetectedFn = vi.fn();
|
||||
engine.on('movement:cycle_detected', cycleDetectedFn);
|
||||
|
||||
const state = await engine.run();
|
||||
|
||||
expect(state.status).toBe('completed');
|
||||
expect(cycleDetectedFn).not.toHaveBeenCalled();
|
||||
// No judge was called, so only 5 iterations
|
||||
expect(state.iteration).toBe(5);
|
||||
expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// 3. Validation errors
|
||||
// =====================================================
|
||||
describe('Config validation', () => {
|
||||
it('should throw when loop_monitor cycle references nonexistent movement', () => {
|
||||
const config = buildConfigWithLoopMonitor(3);
|
||||
config.loopMonitors = [
|
||||
{
|
||||
cycle: ['ai_review', 'nonexistent'],
|
||||
threshold: 3,
|
||||
judge: {
|
||||
rules: [{ condition: 'test', next: 'ai_review' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => {
|
||||
new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
||||
}).toThrow('nonexistent');
|
||||
});
|
||||
|
||||
it('should throw when loop_monitor judge rule references nonexistent movement', () => {
|
||||
const config = buildConfigWithLoopMonitor(3);
|
||||
config.loopMonitors = [
|
||||
{
|
||||
cycle: ['ai_review', 'ai_fix'],
|
||||
threshold: 3,
|
||||
judge: {
|
||||
rules: [{ condition: 'test', next: 'nonexistent_target' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => {
|
||||
new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
||||
}).toThrow('nonexistent_target');
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// 4. No loop monitors configured
|
||||
// =====================================================
|
||||
describe('No loop monitors', () => {
|
||||
it('should work normally without loop_monitors configured', async () => {
|
||||
const config = buildConfigWithLoopMonitor(3);
|
||||
config.loopMonitors = undefined;
|
||||
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
||||
|
||||
mockRunAgentSequence([
|
||||
makeResponse({ agent: 'implement', content: 'Done' }),
|
||||
makeResponse({ agent: 'ai_review', content: 'No issues' }),
|
||||
makeResponse({ agent: 'reviewers', content: 'All approved' }),
|
||||
]);
|
||||
|
||||
mockDetectMatchedRuleSequence([
|
||||
{ index: 0, method: 'phase1_tag' },
|
||||
{ index: 0, method: 'phase1_tag' },
|
||||
{ index: 0, method: 'phase1_tag' },
|
||||
]);
|
||||
|
||||
const state = await engine.run();
|
||||
expect(state.status).toBe('completed');
|
||||
expect(state.iteration).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -75,10 +75,11 @@ program
|
||||
|
||||
program
|
||||
.command('eject')
|
||||
.description('Copy builtin piece/agents to ~/.takt/ for customization')
|
||||
.description('Copy builtin piece/agents for customization (default: project .takt/)')
|
||||
.argument('[name]', 'Specific builtin to eject')
|
||||
.action(async (name?: string) => {
|
||||
await ejectBuiltin(name);
|
||||
.option('--global', 'Eject to ~/.takt/ instead of project .takt/')
|
||||
.action(async (name: string | undefined, opts: { global?: boolean }) => {
|
||||
await ejectBuiltin(name, { global: opts.global, projectDir: resolvedCwd });
|
||||
});
|
||||
|
||||
program
|
||||
|
||||
@ -11,6 +11,9 @@ export type {
|
||||
PieceRule,
|
||||
PieceMovement,
|
||||
LoopDetectionConfig,
|
||||
LoopMonitorConfig,
|
||||
LoopMonitorJudge,
|
||||
LoopMonitorRule,
|
||||
PieceConfig,
|
||||
PieceState,
|
||||
CustomAgentConfig,
|
||||
|
||||
@ -91,6 +91,36 @@ export interface LoopDetectionConfig {
|
||||
action?: 'abort' | 'warn' | 'ignore';
|
||||
}
|
||||
|
||||
/** Rule for loop monitor judge decision */
|
||||
export interface LoopMonitorRule {
|
||||
/** Human-readable condition text */
|
||||
condition: string;
|
||||
/** Next movement name to transition to */
|
||||
next: string;
|
||||
}
|
||||
|
||||
/** Judge configuration for loop monitor */
|
||||
export interface LoopMonitorJudge {
|
||||
/** Agent path, inline prompt, or undefined (uses default) */
|
||||
agent?: string;
|
||||
/** Resolved absolute path to agent prompt file (set by loader) */
|
||||
agentPath?: string;
|
||||
/** Custom instruction template for the judge (uses default if omitted) */
|
||||
instructionTemplate?: string;
|
||||
/** Rules for the judge's decision */
|
||||
rules: LoopMonitorRule[];
|
||||
}
|
||||
|
||||
/** Loop monitor configuration for detecting cyclic patterns between movements */
|
||||
export interface LoopMonitorConfig {
|
||||
/** Ordered list of movement names forming the cycle to detect */
|
||||
cycle: string[];
|
||||
/** Number of complete cycles before triggering the judge (default: 3) */
|
||||
threshold: number;
|
||||
/** Judge configuration for deciding what to do when threshold is reached */
|
||||
judge: LoopMonitorJudge;
|
||||
}
|
||||
|
||||
/** Piece configuration */
|
||||
export interface PieceConfig {
|
||||
name: string;
|
||||
@ -100,6 +130,8 @@ export interface PieceConfig {
|
||||
maxIterations: number;
|
||||
/** Loop detection settings */
|
||||
loopDetection?: LoopDetectionConfig;
|
||||
/** Loop monitors for detecting cyclic patterns between movements */
|
||||
loopMonitors?: LoopMonitorConfig[];
|
||||
/**
|
||||
* Agent to use for answering AskUserQuestion prompts automatically.
|
||||
* When specified, questions from Claude Code are routed to this agent
|
||||
|
||||
@ -158,6 +158,34 @@ export const PieceMovementRawSchema = z.object({
|
||||
parallel: z.array(ParallelSubMovementRawSchema).optional(),
|
||||
});
|
||||
|
||||
/** Loop monitor rule schema */
|
||||
export const LoopMonitorRuleSchema = z.object({
|
||||
/** Human-readable condition text */
|
||||
condition: z.string().min(1),
|
||||
/** Next movement name to transition to */
|
||||
next: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Loop monitor judge schema */
|
||||
export const LoopMonitorJudgeSchema = z.object({
|
||||
/** Agent path, inline prompt, or omitted (uses default) */
|
||||
agent: z.string().optional(),
|
||||
/** Custom instruction template for the judge */
|
||||
instruction_template: z.string().optional(),
|
||||
/** Rules for the judge's decision */
|
||||
rules: z.array(LoopMonitorRuleSchema).min(1),
|
||||
});
|
||||
|
||||
/** Loop monitor configuration schema */
|
||||
export const LoopMonitorSchema = z.object({
|
||||
/** Ordered list of movement names forming the cycle to detect */
|
||||
cycle: z.array(z.string().min(1)).min(2),
|
||||
/** Number of complete cycles before triggering the judge (default: 3) */
|
||||
threshold: z.number().int().positive().optional().default(3),
|
||||
/** Judge configuration */
|
||||
judge: LoopMonitorJudgeSchema,
|
||||
});
|
||||
|
||||
/** Piece configuration schema - raw YAML format */
|
||||
export const PieceConfigRawSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@ -165,6 +193,7 @@ export const PieceConfigRawSchema = z.object({
|
||||
movements: z.array(PieceMovementRawSchema).min(1),
|
||||
initial_movement: z.string().optional(),
|
||||
max_iterations: z.number().int().positive().optional().default(10),
|
||||
loop_monitors: z.array(LoopMonitorSchema).optional(),
|
||||
answer_agent: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@ -30,6 +30,9 @@ export type {
|
||||
ReportObjectConfig,
|
||||
PieceMovement,
|
||||
LoopDetectionConfig,
|
||||
LoopMonitorConfig,
|
||||
LoopMonitorJudge,
|
||||
LoopMonitorRule,
|
||||
PieceConfig,
|
||||
PieceState,
|
||||
} from './piece-types.js';
|
||||
|
||||
@ -14,11 +14,13 @@ import type {
|
||||
PieceState,
|
||||
PieceMovement,
|
||||
AgentResponse,
|
||||
LoopMonitorConfig,
|
||||
} from '../../models/types.js';
|
||||
import { COMPLETE_MOVEMENT, ABORT_MOVEMENT, ERROR_MESSAGES } from '../constants.js';
|
||||
import type { PieceEngineOptions } from '../types.js';
|
||||
import { determineNextMovementByRules } from './transitions.js';
|
||||
import { LoopDetector } from './loop-detector.js';
|
||||
import { CycleDetector } from './cycle-detector.js';
|
||||
import { handleBlocked } from './blocked-handler.js';
|
||||
import {
|
||||
createInitialState,
|
||||
@ -51,6 +53,7 @@ export class PieceEngine extends EventEmitter {
|
||||
private task: string;
|
||||
private options: PieceEngineOptions;
|
||||
private loopDetector: LoopDetector;
|
||||
private cycleDetector: CycleDetector;
|
||||
private reportDir: string;
|
||||
private abortRequested = false;
|
||||
|
||||
@ -72,6 +75,7 @@ export class PieceEngine extends EventEmitter {
|
||||
this.task = task;
|
||||
this.options = options;
|
||||
this.loopDetector = new LoopDetector(config.loopDetection);
|
||||
this.cycleDetector = new CycleDetector(config.loopMonitors ?? []);
|
||||
this.reportDir = `.takt/reports/${generateReportDir(task)}`;
|
||||
this.ensureReportDirExists();
|
||||
this.validateConfig();
|
||||
@ -183,6 +187,26 @@ export class PieceEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate loop_monitors
|
||||
if (this.config.loopMonitors) {
|
||||
for (const monitor of this.config.loopMonitors) {
|
||||
for (const cycleName of monitor.cycle) {
|
||||
if (!movementNames.has(cycleName)) {
|
||||
throw new Error(
|
||||
`Invalid loop_monitor: cycle references unknown movement "${cycleName}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const rule of monitor.judge.rules) {
|
||||
if (!movementNames.has(rule.next)) {
|
||||
throw new Error(
|
||||
`Invalid loop_monitor judge rule: target movement "${rule.next}" does not exist`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get current piece state */
|
||||
@ -300,6 +324,120 @@ export class PieceEngine extends EventEmitter {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default instruction template for a loop monitor judge.
|
||||
* Used when the monitor config does not specify a custom instruction_template.
|
||||
*/
|
||||
private buildDefaultJudgeInstructionTemplate(
|
||||
monitor: LoopMonitorConfig,
|
||||
cycleCount: number,
|
||||
language: string,
|
||||
): string {
|
||||
const cycleNames = monitor.cycle.join(' → ');
|
||||
const rulesDesc = monitor.judge.rules.map((r) => `- ${r.condition} → ${r.next}`).join('\n');
|
||||
|
||||
if (language === 'ja') {
|
||||
return [
|
||||
`ムーブメントのサイクル [${cycleNames}] が ${cycleCount} 回繰り返されました。`,
|
||||
'',
|
||||
'このループが健全(進捗がある)か、非生産的(同じ問題を繰り返している)かを判断してください。',
|
||||
'',
|
||||
'**判断の選択肢:**',
|
||||
rulesDesc,
|
||||
'',
|
||||
'**判断基準:**',
|
||||
'- 各サイクルで新しい問題が発見・修正されているか',
|
||||
'- 同じ指摘が繰り返されていないか',
|
||||
'- 全体的な進捗があるか',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
`The movement cycle [${cycleNames}] has repeated ${cycleCount} times.`,
|
||||
'',
|
||||
'Determine whether this loop is healthy (making progress) or unproductive (repeating the same issues).',
|
||||
'',
|
||||
'**Decision options:**',
|
||||
rulesDesc,
|
||||
'',
|
||||
'**Judgment criteria:**',
|
||||
'- Are new issues being found/fixed in each cycle?',
|
||||
'- Are the same findings being repeated?',
|
||||
'- Is there overall progress?',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a loop monitor judge as a synthetic movement.
|
||||
* Returns the next movement name determined by the judge.
|
||||
*/
|
||||
private async runLoopMonitorJudge(
|
||||
monitor: LoopMonitorConfig,
|
||||
cycleCount: number,
|
||||
): Promise<string> {
|
||||
const language = this.options.language ?? 'en';
|
||||
const instructionTemplate = monitor.judge.instructionTemplate
|
||||
?? this.buildDefaultJudgeInstructionTemplate(monitor, cycleCount, language);
|
||||
|
||||
// Replace {cycle_count} in custom templates
|
||||
const processedTemplate = instructionTemplate.replace(/\{cycle_count\}/g, String(cycleCount));
|
||||
|
||||
// Build a synthetic PieceMovement for the judge
|
||||
const judgeMovement: PieceMovement = {
|
||||
name: `_loop_judge_${monitor.cycle.join('_')}`,
|
||||
agent: monitor.judge.agent,
|
||||
agentPath: monitor.judge.agentPath,
|
||||
agentDisplayName: 'loop-judge',
|
||||
edit: false,
|
||||
instructionTemplate: processedTemplate,
|
||||
rules: monitor.judge.rules.map((r) => ({
|
||||
condition: r.condition,
|
||||
next: r.next,
|
||||
})),
|
||||
passPreviousResponse: true,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
};
|
||||
|
||||
log.info('Running loop monitor judge', {
|
||||
cycle: monitor.cycle,
|
||||
cycleCount,
|
||||
threshold: monitor.threshold,
|
||||
});
|
||||
|
||||
this.state.iteration++;
|
||||
const movementIteration = incrementMovementIteration(this.state, judgeMovement.name);
|
||||
const prebuiltInstruction = this.movementExecutor.buildInstruction(
|
||||
judgeMovement, movementIteration, this.state, this.task, this.config.maxIterations,
|
||||
);
|
||||
|
||||
this.emit('movement:start', judgeMovement, this.state.iteration, prebuiltInstruction);
|
||||
|
||||
const { response, instruction } = await this.movementExecutor.runNormalMovement(
|
||||
judgeMovement,
|
||||
this.state,
|
||||
this.task,
|
||||
this.config.maxIterations,
|
||||
this.updateAgentSession.bind(this),
|
||||
prebuiltInstruction,
|
||||
);
|
||||
this.emitCollectedReports();
|
||||
this.emit('movement:complete', judgeMovement, response, instruction);
|
||||
|
||||
// Resolve next movement from the judge's rules
|
||||
const nextMovement = this.resolveNextMovement(judgeMovement, response);
|
||||
|
||||
log.info('Loop monitor judge decision', {
|
||||
cycle: monitor.cycle,
|
||||
nextMovement,
|
||||
matchedRuleIndex: response.matchedRuleIndex,
|
||||
});
|
||||
|
||||
// Reset cycle detector to prevent re-triggering immediately
|
||||
this.cycleDetector.reset();
|
||||
|
||||
return nextMovement;
|
||||
}
|
||||
|
||||
/** Run the piece to completion */
|
||||
async run(): Promise<PieceState> {
|
||||
while (this.state.status === 'running') {
|
||||
@ -378,7 +516,7 @@ export class PieceEngine extends EventEmitter {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextMovement = this.resolveNextMovement(movement, response);
|
||||
let nextMovement = this.resolveNextMovement(movement, response);
|
||||
log.debug('Movement transition', {
|
||||
from: movement.name,
|
||||
status: response.status,
|
||||
@ -411,6 +549,23 @@ export class PieceEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check loop monitors (cycle detection) after movement completion
|
||||
const cycleCheck = this.cycleDetector.recordAndCheck(movement.name);
|
||||
if (cycleCheck.triggered && cycleCheck.monitor) {
|
||||
log.info('Loop monitor cycle threshold reached', {
|
||||
cycle: cycleCheck.monitor.cycle,
|
||||
cycleCount: cycleCheck.cycleCount,
|
||||
threshold: cycleCheck.monitor.threshold,
|
||||
});
|
||||
this.emit('movement:cycle_detected', cycleCheck.monitor, cycleCheck.cycleCount);
|
||||
|
||||
// Run the judge to decide what to do
|
||||
nextMovement = await this.runLoopMonitorJudge(
|
||||
cycleCheck.monitor,
|
||||
cycleCheck.cycleCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (nextMovement === COMPLETE_MOVEMENT) {
|
||||
this.state.status = 'completed';
|
||||
this.emit('piece:complete', this.state);
|
||||
|
||||
131
src/core/piece/engine/cycle-detector.ts
Normal file
131
src/core/piece/engine/cycle-detector.ts
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Cycle detection for loop monitors.
|
||||
*
|
||||
* Tracks movement execution history and detects when a specific cycle
|
||||
* of movements has been repeated a configured number of times (threshold).
|
||||
*
|
||||
* Example:
|
||||
* cycle: [ai_review, ai_fix], threshold: 3
|
||||
* History: ai_review → ai_fix → ai_review → ai_fix → ai_review → ai_fix
|
||||
* ↑
|
||||
* 3 cycles → trigger
|
||||
*/
|
||||
|
||||
import type { LoopMonitorConfig } from '../../models/types.js';
|
||||
|
||||
/** Result of checking a single loop monitor */
|
||||
export interface CycleCheckResult {
|
||||
/** Whether the threshold has been reached */
|
||||
triggered: boolean;
|
||||
/** Current number of completed cycles */
|
||||
cycleCount: number;
|
||||
/** The loop monitor config that was triggered (if triggered) */
|
||||
monitor?: LoopMonitorConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks movement execution history and detects cyclic patterns
|
||||
* as defined by loop_monitors configuration.
|
||||
*/
|
||||
export class CycleDetector {
|
||||
/** Movement execution history (names in order) */
|
||||
private history: string[] = [];
|
||||
private monitors: LoopMonitorConfig[];
|
||||
|
||||
constructor(monitors: LoopMonitorConfig[] = []) {
|
||||
this.monitors = monitors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a movement completion and check if any cycle threshold is reached.
|
||||
*
|
||||
* The detection logic works as follows:
|
||||
* 1. The movement name is appended to the history
|
||||
* 2. For each monitor, we check if the cycle pattern has been completed
|
||||
* by looking at the tail of the history
|
||||
* 3. A cycle is "completed" when the last N entries in history match
|
||||
* the cycle pattern repeated `threshold` times
|
||||
*
|
||||
* @param movementName The name of the movement that just completed
|
||||
* @returns CycleCheckResult indicating if any monitor was triggered
|
||||
*/
|
||||
recordAndCheck(movementName: string): CycleCheckResult {
|
||||
this.history.push(movementName);
|
||||
|
||||
for (const monitor of this.monitors) {
|
||||
const result = this.checkMonitor(monitor);
|
||||
if (result.triggered) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return { triggered: false, cycleCount: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single monitor against the current history.
|
||||
*
|
||||
* A cycle is detected when the last element of the history matches the
|
||||
* last element of the cycle, and looking backwards we can find exactly
|
||||
* `threshold` complete cycles.
|
||||
*/
|
||||
private checkMonitor(monitor: LoopMonitorConfig): CycleCheckResult {
|
||||
const { cycle, threshold } = monitor;
|
||||
const cycleLen = cycle.length;
|
||||
|
||||
// The cycle's last step must match the most recent movement
|
||||
const lastStep = cycle[cycleLen - 1];
|
||||
if (this.history[this.history.length - 1] !== lastStep) {
|
||||
return { triggered: false, cycleCount: 0 };
|
||||
}
|
||||
|
||||
// Need at least threshold * cycleLen entries to check
|
||||
const requiredLen = threshold * cycleLen;
|
||||
if (this.history.length < requiredLen) {
|
||||
return { triggered: false, cycleCount: 0 };
|
||||
}
|
||||
|
||||
// Count complete cycles from the end of history backwards
|
||||
let cycleCount = 0;
|
||||
let pos = this.history.length;
|
||||
|
||||
while (pos >= cycleLen) {
|
||||
// Check if the last cycleLen entries match the cycle pattern
|
||||
let matches = true;
|
||||
for (let i = 0; i < cycleLen; i++) {
|
||||
if (this.history[pos - cycleLen + i] !== cycle[i]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
cycleCount++;
|
||||
pos -= cycleLen;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cycleCount >= threshold) {
|
||||
return { triggered: true, cycleCount, monitor };
|
||||
}
|
||||
|
||||
return { triggered: false, cycleCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the history after a judge intervention.
|
||||
* This prevents the same cycle from immediately triggering again.
|
||||
*/
|
||||
reset(): void {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current movement history (for debugging/testing).
|
||||
*/
|
||||
getHistory(): readonly string[] {
|
||||
return this.history;
|
||||
}
|
||||
}
|
||||
@ -9,3 +9,5 @@ export { MovementExecutor } from './MovementExecutor.js';
|
||||
export type { MovementExecutorDeps } from './MovementExecutor.js';
|
||||
export { ParallelRunner } from './ParallelRunner.js';
|
||||
export { OptionsBuilder } from './OptionsBuilder.js';
|
||||
export { CycleDetector } from './cycle-detector.js';
|
||||
export type { CycleCheckResult } from './cycle-detector.js';
|
||||
|
||||
@ -35,6 +35,9 @@ export { determineNextMovementByRules, extractBlockedPrompt } from './engine/tra
|
||||
// Loop detection (engine/)
|
||||
export { LoopDetector } from './engine/loop-detector.js';
|
||||
|
||||
// Cycle detection (engine/)
|
||||
export { CycleDetector, type CycleCheckResult } from './engine/cycle-detector.js';
|
||||
|
||||
// State management (engine/)
|
||||
export {
|
||||
createInitialState,
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { PieceMovement, AgentResponse, PieceState, Language } from '../models/types.js';
|
||||
import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js';
|
||||
|
||||
export type ProviderType = 'claude' | 'codex' | 'mock';
|
||||
|
||||
@ -119,6 +119,7 @@ export interface PieceEvents {
|
||||
'piece:abort': (state: PieceState, reason: string) => void;
|
||||
'iteration:limit': (iteration: number, maxIterations: number) => void;
|
||||
'movement:loop_detected': (step: PieceMovement, consecutiveCount: number) => void;
|
||||
'movement:cycle_detected': (monitor: LoopMonitorConfig, cycleCount: number) => void;
|
||||
}
|
||||
|
||||
/** User input request for blocked state */
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
/**
|
||||
* /eject command implementation
|
||||
*
|
||||
* Copies a builtin piece (and its agents) to ~/.takt/ for user customization.
|
||||
* Once ejected, the user copy takes priority over the builtin version.
|
||||
* Copies a builtin piece (and its agents) for user customization.
|
||||
* Directory structure is mirrored so relative agent paths work as-is.
|
||||
*
|
||||
* Default target: project-local (.takt/)
|
||||
* With --global: user global (~/.takt/)
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
@ -10,18 +13,25 @@ import { join, dirname } from 'node:path';
|
||||
import {
|
||||
getGlobalPiecesDir,
|
||||
getGlobalAgentsDir,
|
||||
getProjectPiecesDir,
|
||||
getProjectAgentsDir,
|
||||
getBuiltinPiecesDir,
|
||||
getBuiltinAgentsDir,
|
||||
getLanguage,
|
||||
} from '../../infra/config/index.js';
|
||||
import { header, success, info, warn, error, blankLine } from '../../shared/ui/index.js';
|
||||
|
||||
export interface EjectOptions {
|
||||
global?: boolean;
|
||||
projectDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eject a builtin piece to user space for customization.
|
||||
* Copies the piece YAML and related agent .md files to ~/.takt/.
|
||||
* Agent paths in the ejected piece are rewritten from ../agents/ to ~/.takt/agents/.
|
||||
* Eject a builtin piece to project or global space for customization.
|
||||
* Copies the piece YAML and related agent .md files, preserving
|
||||
* the directory structure so relative paths continue to work.
|
||||
*/
|
||||
export async function ejectBuiltin(name?: string): Promise<void> {
|
||||
export async function ejectBuiltin(name?: string, options: EjectOptions = {}): Promise<void> {
|
||||
header('Eject Builtin');
|
||||
|
||||
const lang = getLanguage();
|
||||
@ -29,7 +39,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
||||
|
||||
if (!name) {
|
||||
// List available builtins
|
||||
listAvailableBuiltins(builtinPiecesDir);
|
||||
listAvailableBuiltins(builtinPiecesDir, options.global);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -40,24 +50,24 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const userPiecesDir = getGlobalPiecesDir();
|
||||
const userAgentsDir = getGlobalAgentsDir();
|
||||
const projectDir = options.projectDir || process.cwd();
|
||||
const targetPiecesDir = options.global ? getGlobalPiecesDir() : getProjectPiecesDir(projectDir);
|
||||
const targetAgentsDir = options.global ? getGlobalAgentsDir() : getProjectAgentsDir(projectDir);
|
||||
const builtinAgentsDir = getBuiltinAgentsDir(lang);
|
||||
const targetLabel = options.global ? 'global (~/.takt/)' : 'project (.takt/)';
|
||||
|
||||
// Copy piece YAML (rewrite agent paths)
|
||||
const pieceDest = join(userPiecesDir, `${name}.yaml`);
|
||||
info(`Ejecting to ${targetLabel}`);
|
||||
blankLine();
|
||||
|
||||
// Copy piece YAML as-is (no path rewriting — directory structure mirrors builtin)
|
||||
const pieceDest = join(targetPiecesDir, `${name}.yaml`);
|
||||
if (existsSync(pieceDest)) {
|
||||
warn(`User piece already exists: ${pieceDest}`);
|
||||
warn('Skipping piece copy (user version takes priority).');
|
||||
} else {
|
||||
mkdirSync(dirname(pieceDest), { recursive: true });
|
||||
const content = readFileSync(builtinPath, 'utf-8');
|
||||
// Rewrite relative agent paths to ~/.takt/agents/
|
||||
const rewritten = content.replace(
|
||||
/agent:\s*\.\.\/agents\//g,
|
||||
'agent: ~/.takt/agents/',
|
||||
);
|
||||
writeFileSync(pieceDest, rewritten, 'utf-8');
|
||||
writeFileSync(pieceDest, content, 'utf-8');
|
||||
success(`Ejected piece: ${pieceDest}`);
|
||||
}
|
||||
|
||||
@ -67,7 +77,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
||||
|
||||
for (const relPath of agentPaths) {
|
||||
const srcPath = join(builtinAgentsDir, relPath);
|
||||
const destPath = join(userAgentsDir, relPath);
|
||||
const destPath = join(targetAgentsDir, relPath);
|
||||
|
||||
if (!existsSync(srcPath)) continue;
|
||||
|
||||
@ -88,7 +98,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
||||
}
|
||||
|
||||
/** List available builtin pieces for ejection */
|
||||
function listAvailableBuiltins(builtinPiecesDir: string): void {
|
||||
function listAvailableBuiltins(builtinPiecesDir: string, isGlobal?: boolean): void {
|
||||
if (!existsSync(builtinPiecesDir)) {
|
||||
warn('No builtin pieces found.');
|
||||
return;
|
||||
@ -106,7 +116,11 @@ function listAvailableBuiltins(builtinPiecesDir: string): void {
|
||||
}
|
||||
|
||||
blankLine();
|
||||
info('Usage: takt eject {name}');
|
||||
const globalFlag = isGlobal ? ' --global' : '';
|
||||
info(`Usage: takt eject {name}${globalFlag}`);
|
||||
if (!isGlobal) {
|
||||
info(' Add --global to eject to ~/.takt/ instead of .takt/');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -10,7 +10,7 @@ import { join, dirname, basename } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import type { z } from 'zod';
|
||||
import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js';
|
||||
import type { PieceConfig, PieceMovement, PieceRule, ReportConfig, ReportObjectConfig } from '../../../core/models/index.js';
|
||||
import type { PieceConfig, PieceMovement, PieceRule, ReportConfig, ReportObjectConfig, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js';
|
||||
|
||||
/** Parsed movement type from Zod schema (replaces `any`) */
|
||||
type RawStep = z.output<typeof PieceMovementRawSchema>;
|
||||
@ -210,6 +210,47 @@ function normalizeStepFromRaw(step: RawStep, pieceDir: string): PieceMovement {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw loop monitor judge from YAML into internal format.
|
||||
* Resolves agent paths and instruction_template content paths.
|
||||
*/
|
||||
function normalizeLoopMonitorJudge(
|
||||
raw: { agent?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> },
|
||||
pieceDir: string,
|
||||
): LoopMonitorJudge {
|
||||
const agentSpec = raw.agent || undefined;
|
||||
|
||||
let agentPath: string | undefined;
|
||||
if (agentSpec) {
|
||||
const resolved = resolveAgentPathForPiece(agentSpec, pieceDir);
|
||||
if (existsSync(resolved)) {
|
||||
agentPath = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: agentSpec,
|
||||
agentPath,
|
||||
instructionTemplate: resolveContentPath(raw.instruction_template, pieceDir),
|
||||
rules: raw.rules.map((r) => ({ condition: r.condition, next: r.next })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw loop monitors from YAML into internal format.
|
||||
*/
|
||||
function normalizeLoopMonitors(
|
||||
raw: Array<{ cycle: string[]; threshold: number; judge: { agent?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> } }> | undefined,
|
||||
pieceDir: string,
|
||||
): LoopMonitorConfig[] | undefined {
|
||||
if (!raw || raw.length === 0) return undefined;
|
||||
return raw.map((monitor) => ({
|
||||
cycle: monitor.cycle,
|
||||
threshold: monitor.threshold,
|
||||
judge: normalizeLoopMonitorJudge(monitor.judge, pieceDir),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw YAML piece config to internal format.
|
||||
* Agent paths are resolved relative to the piece directory.
|
||||
@ -229,6 +270,7 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi
|
||||
movements,
|
||||
initialMovement,
|
||||
maxIterations: parsed.max_iterations,
|
||||
loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir),
|
||||
answerAgent: parsed.answer_agent,
|
||||
};
|
||||
}
|
||||
|
||||
@ -51,6 +51,16 @@ export function getProjectConfigDir(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.takt');
|
||||
}
|
||||
|
||||
/** Get project pieces directory (.takt/pieces in project) */
|
||||
export function getProjectPiecesDir(projectDir: string): string {
|
||||
return join(getProjectConfigDir(projectDir), 'pieces');
|
||||
}
|
||||
|
||||
/** Get project agents directory (.takt/agents in project) */
|
||||
export function getProjectAgentsDir(projectDir: string): string {
|
||||
return join(getProjectConfigDir(projectDir), 'agents');
|
||||
}
|
||||
|
||||
/** Get project config file path */
|
||||
export function getProjectConfigPath(projectDir: string): string {
|
||||
return join(getProjectConfigDir(projectDir), 'config.yaml');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user