takt/src/core/piece/engine/PieceEngine.ts
nrs 2f268f6d43
[#320] move-allowed-tools-claude (#469)
* takt: move-allowed-tools-claude

* fix: E2Eフィクスチャの allowed_tools を provider_options.claude に移行

PR #469 で allowed_tools がムーブメント直下から provider_options.claude.allowed_tools に
移動されたが、E2Eフィクスチャとインラインピース定義が旧形式のままだった。
2026-03-05 11:27:48 +09:00

802 lines
30 KiB
TypeScript

/**
* Piece execution engine.
*
* Orchestrates the main execution loop: movement transitions, abort handling,
* loop detection, and iteration limits. Delegates movement execution to
* MovementExecutor (normal movements) and ParallelRunner (parallel movements).
*/
import { EventEmitter } from 'node:events';
import { mkdirSync, existsSync } from 'node:fs';
import type {
PieceConfig,
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,
addUserInput as addUserInputToState,
incrementMovementIteration,
} from './state-manager.js';
import { generateReportDir, getErrorMessage, createLogger, isValidReportDirName } from '../../../shared/utils/index.js';
import { OptionsBuilder } from './OptionsBuilder.js';
import { MovementExecutor } from './MovementExecutor.js';
import { ParallelRunner } from './ParallelRunner.js';
import { ArpeggioRunner } from './ArpeggioRunner.js';
import { TeamLeaderRunner } from './TeamLeaderRunner.js';
import { buildRunPaths, type RunPaths } from '../run/run-paths.js';
import { prepareRuntimeEnvironment } from '../../runtime/runtime-environment.js';
const log = createLogger('engine');
export type {
PieceEvents,
MovementProviderInfo,
UserInputRequest,
IterationLimitRequest,
SessionUpdateCallback,
IterationLimitCallback,
PieceEngineOptions,
} from '../types.js';
export { COMPLETE_MOVEMENT, ABORT_MOVEMENT } from '../constants.js';
/** Piece engine for orchestrating agent execution */
export class PieceEngine extends EventEmitter {
private state: PieceState;
private config: PieceConfig;
private projectCwd: string;
private cwd: string;
private task: string;
private options: PieceEngineOptions;
private loopDetector: LoopDetector;
private cycleDetector: CycleDetector;
private reportDir: string;
private runPaths: RunPaths;
private abortRequested = false;
private readonly optionsBuilder: OptionsBuilder;
private readonly movementExecutor: MovementExecutor;
private readonly parallelRunner: ParallelRunner;
private readonly arpeggioRunner: ArpeggioRunner;
private readonly teamLeaderRunner: TeamLeaderRunner;
private readonly detectRuleIndex: (content: string, movementName: string) => number;
private readonly callAiJudge: (
agentOutput: string,
conditions: Array<{ index: number; text: string }>,
options: { cwd: string }
) => Promise<number>;
constructor(config: PieceConfig, cwd: string, task: string, options: PieceEngineOptions) {
super();
this.assertTaskPrefixPair(options.taskPrefix, options.taskColorIndex);
this.config = config;
this.projectCwd = options.projectCwd;
this.cwd = cwd;
this.task = task;
this.options = options;
this.loopDetector = new LoopDetector(config.loopDetection);
this.cycleDetector = new CycleDetector(config.loopMonitors ?? []);
if (options.reportDirName !== undefined && !isValidReportDirName(options.reportDirName)) {
throw new Error(`Invalid reportDirName: ${options.reportDirName}`);
}
const reportDirName = options.reportDirName ?? generateReportDir(task);
this.runPaths = buildRunPaths(this.cwd, reportDirName);
this.reportDir = this.runPaths.reportsRel;
this.ensureRunDirsExist();
this.applyRuntimeEnvironment('init');
this.validateConfig();
this.state = createInitialState(config, options);
this.detectRuleIndex = options.detectRuleIndex ?? (() => {
throw new Error('detectRuleIndex is required for rule evaluation');
});
this.callAiJudge = options.callAiJudge ?? (async () => {
throw new Error('callAiJudge is required for rule evaluation');
});
// Initialize composed collaborators
this.optionsBuilder = new OptionsBuilder(
options,
() => this.cwd,
() => this.projectCwd,
(persona) => this.state.personaSessions.get(persona),
() => this.reportDir,
() => this.options.language,
() => this.config.movements.map(s => ({ name: s.name, description: s.description })),
() => this.getPieceName(),
() => this.getPieceDescription(),
);
this.movementExecutor = new MovementExecutor({
optionsBuilder: this.optionsBuilder,
getCwd: () => this.cwd,
getProjectCwd: () => this.projectCwd,
getReportDir: () => this.reportDir,
getRunPaths: () => this.runPaths,
getLanguage: () => this.options.language,
getInteractive: () => this.options.interactive === true,
getPieceMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })),
getPieceName: () => this.getPieceName(),
getPieceDescription: () => this.getPieceDescription(),
getRetryNote: () => this.options.retryNote,
detectRuleIndex: this.detectRuleIndex,
callAiJudge: this.callAiJudge,
onPhaseStart: (step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:start', step, phase, phaseName, instruction, promptParts);
return;
}
this.emit('phase:start', step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration);
},
onPhaseComplete: (step, phase, phaseName, content, phaseStatus, error, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error);
return;
}
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error, phaseExecutionId, iteration);
},
onJudgeStage: (step, phase, phaseName, entry, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:judge_stage', step, phase, phaseName, entry);
return;
}
this.emit('phase:judge_stage', step, phase, phaseName, entry, phaseExecutionId, iteration);
},
});
this.parallelRunner = new ParallelRunner({
optionsBuilder: this.optionsBuilder,
movementExecutor: this.movementExecutor,
engineOptions: this.options,
getCwd: () => this.cwd,
getReportDir: () => this.reportDir,
getInteractive: () => this.options.interactive === true,
detectRuleIndex: this.detectRuleIndex,
callAiJudge: this.callAiJudge,
onPhaseStart: (step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:start', step, phase, phaseName, instruction, promptParts);
return;
}
this.emit('phase:start', step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration);
},
onPhaseComplete: (step, phase, phaseName, content, phaseStatus, error, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error);
return;
}
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error, phaseExecutionId, iteration);
},
onJudgeStage: (step, phase, phaseName, entry, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:judge_stage', step, phase, phaseName, entry);
return;
}
this.emit('phase:judge_stage', step, phase, phaseName, entry, phaseExecutionId, iteration);
},
});
this.arpeggioRunner = new ArpeggioRunner({
optionsBuilder: this.optionsBuilder,
movementExecutor: this.movementExecutor,
getCwd: () => this.cwd,
getInteractive: () => this.options.interactive === true,
detectRuleIndex: this.detectRuleIndex,
callAiJudge: this.callAiJudge,
onPhaseStart: (step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:start', step, phase, phaseName, instruction, promptParts);
return;
}
this.emit('phase:start', step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration);
},
onPhaseComplete: (step, phase, phaseName, content, phaseStatus, error, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error);
return;
}
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error, phaseExecutionId, iteration);
},
});
this.teamLeaderRunner = new TeamLeaderRunner({
optionsBuilder: this.optionsBuilder,
movementExecutor: this.movementExecutor,
engineOptions: this.options,
getCwd: () => this.cwd,
getInteractive: () => this.options.interactive === true,
detectRuleIndex: this.detectRuleIndex,
callAiJudge: this.callAiJudge,
onPhaseStart: (step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:start', step, phase, phaseName, instruction, promptParts);
return;
}
this.emit('phase:start', step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration);
},
onPhaseComplete: (step, phase, phaseName, content, phaseStatus, error, phaseExecutionId, iteration) => {
if (phaseExecutionId == null && iteration == null) {
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error);
return;
}
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error, phaseExecutionId, iteration);
},
});
log.debug('PieceEngine initialized', {
piece: config.name,
movements: config.movements.map(s => s.name),
initialMovement: config.initialMovement,
maxMovements: config.maxMovements,
});
}
private assertTaskPrefixPair(taskPrefix: string | undefined, taskColorIndex: number | undefined): void {
const hasTaskPrefix = taskPrefix != null;
const hasTaskColorIndex = taskColorIndex != null;
if (hasTaskPrefix !== hasTaskColorIndex) {
throw new Error('taskPrefix and taskColorIndex must be provided together');
}
}
/** Ensure run directories exist (in cwd, which is clone dir in worktree mode) */
private ensureRunDirsExist(): void {
const requiredDirs = [
this.runPaths.runRootAbs,
this.runPaths.reportsAbs,
this.runPaths.contextAbs,
this.runPaths.contextKnowledgeAbs,
this.runPaths.contextPolicyAbs,
this.runPaths.contextPreviousResponsesAbs,
this.runPaths.logsAbs,
];
for (const dir of requiredDirs) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
}
private applyRuntimeEnvironment(stage: 'init' | 'movement'): void {
const prepared = prepareRuntimeEnvironment(this.cwd, this.config.runtime);
if (!prepared) return;
log.info('Runtime environment prepared', {
stage,
runtimeRoot: prepared.runtimeRoot,
envFile: prepared.envFile,
prepare: prepared.prepare,
tmpdir: prepared.injectedEnv.TMPDIR,
gradleUserHome: prepared.injectedEnv.GRADLE_USER_HOME,
npmCache: prepared.injectedEnv.npm_config_cache,
});
}
/** Validate piece configuration at construction time */
private validateConfig(): void {
const initialMovement = this.config.movements.find((s) => s.name === this.config.initialMovement);
if (!initialMovement) {
throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(this.config.initialMovement));
}
// Validate startMovement option if specified
if (this.options.startMovement) {
const startMovement = this.config.movements.find((s) => s.name === this.options.startMovement);
if (!startMovement) {
throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(this.options.startMovement));
}
}
const movementNames = new Set(this.config.movements.map((s) => s.name));
movementNames.add(COMPLETE_MOVEMENT);
movementNames.add(ABORT_MOVEMENT);
for (const movement of this.config.movements) {
if (movement.rules) {
for (const rule of movement.rules) {
if (rule.next && !movementNames.has(rule.next)) {
throw new Error(
`Invalid rule in movement "${movement.name}": target movement "${rule.next}" does not exist`
);
}
}
}
}
// 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 */
getState(): PieceState {
return { ...this.state };
}
/** Add user input */
addUserInput(input: string): void {
addUserInputToState(this.state, input);
}
/** Update working directory */
updateCwd(newCwd: string): void {
this.cwd = newCwd;
}
/** Get current working directory */
getCwd(): string {
return this.cwd;
}
/** Get project root directory (where .takt/ lives) */
getProjectCwd(): string {
return this.projectCwd;
}
/** Get piece name */
private getPieceName(): string {
return this.config.name;
}
/** Get piece description */
private getPieceDescription(): string | undefined {
return this.config.description;
}
/** Request graceful abort: interrupt running queries and stop after current movement */
abort(): void {
if (this.abortRequested) return;
this.abortRequested = true;
log.info('Abort requested');
}
/** Check if abort has been requested */
isAbortRequested(): boolean {
return this.abortRequested;
}
/** Get movement by name */
private getMovement(name: string): PieceMovement {
const movement = this.config.movements.find((s) => s.name === name);
if (!movement) {
throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(name));
}
return movement;
}
/** Update persona session and notify via callback if session changed */
private updatePersonaSession(persona: string, sessionId: string | undefined): void {
if (!sessionId) return;
const previousSessionId = this.state.personaSessions.get(persona);
this.state.personaSessions.set(persona, sessionId);
if (this.options.onSessionUpdate && sessionId !== previousSessionId) {
this.options.onSessionUpdate(persona, sessionId);
}
}
/** Emit movement:report events collected by MovementExecutor */
private emitCollectedReports(): void {
for (const { step, filePath, fileName } of this.movementExecutor.drainReportFiles()) {
this.emit('movement:report', step, filePath, fileName);
}
}
/** Run a single movement (delegates to ParallelRunner, ArpeggioRunner, or MovementExecutor) */
private async runMovement(step: PieceMovement, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> {
const updateSession = this.updatePersonaSession.bind(this);
let result: { response: AgentResponse; instruction: string };
if (step.parallel && step.parallel.length > 0) {
result = await this.parallelRunner.runParallelMovement(
step, this.state, this.task, this.config.maxMovements, updateSession,
);
} else if (step.arpeggio) {
result = await this.arpeggioRunner.runArpeggioMovement(
step, this.state,
);
} else if (step.teamLeader) {
result = await this.teamLeaderRunner.runTeamLeaderMovement(
step, this.state, this.task, this.config.maxMovements, updateSession,
);
} else {
result = await this.movementExecutor.runNormalMovement(
step, this.state, this.task, this.config.maxMovements, updateSession, prebuiltInstruction,
);
}
this.emitCollectedReports();
return result;
}
/**
* Determine next movement for a completed movement using rules-based routing.
*/
private resolveNextMovement(step: PieceMovement, response: AgentResponse): string {
if (response.matchedRuleIndex != null && step.rules) {
const nextByRules = determineNextMovementByRules(step, response.matchedRuleIndex);
if (nextByRules) {
return nextByRules;
}
}
throw new Error(`No matching rule found for movement "${step.name}" (status: ${response.status})`);
}
/** Build instruction (public, used by pieceExecution.ts for logging) */
buildInstruction(step: PieceMovement, movementIteration: number): string {
return this.movementExecutor.buildInstruction(
step, movementIteration, this.state, this.task, this.config.maxMovements,
);
}
/**
* 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('_')}`,
persona: monitor.judge.persona,
personaPath: monitor.judge.personaPath,
personaDisplayName: 'loop-judge',
edit: false,
providerOptions: {
claude: {
allowedTools: ['Read', 'Glob', 'Grep'],
},
},
instructionTemplate: processedTemplate,
rules: monitor.judge.rules.map((r) => ({
condition: r.condition,
next: r.next,
})),
passPreviousResponse: true,
};
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.maxMovements,
);
this.emit('movement:start', judgeMovement, this.state.iteration, prebuiltInstruction, this.optionsBuilder.resolveStepProviderModel(judgeMovement));
const { response, instruction } = await this.movementExecutor.runNormalMovement(
judgeMovement,
this.state,
this.task,
this.config.maxMovements,
this.updatePersonaSession.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') {
if (this.abortRequested) {
this.state.status = 'aborted';
this.emit('piece:abort', this.state, 'Piece interrupted by user (SIGINT)');
break;
}
if (this.state.iteration >= this.config.maxMovements) {
this.emit('iteration:limit', this.state.iteration, this.config.maxMovements);
if (this.options.onIterationLimit) {
const additionalIterations = await this.options.onIterationLimit({
currentIteration: this.state.iteration,
maxMovements: this.config.maxMovements,
currentMovement: this.state.currentMovement,
});
if (additionalIterations !== null && additionalIterations > 0) {
this.config = {
...this.config,
maxMovements: this.config.maxMovements + additionalIterations,
};
continue;
}
}
this.state.status = 'aborted';
this.emit('piece:abort', this.state, ERROR_MESSAGES.MAX_MOVEMENTS_REACHED);
break;
}
const movement = this.getMovement(this.state.currentMovement);
this.applyRuntimeEnvironment('movement');
const loopCheck = this.loopDetector.check(movement.name);
if (loopCheck.shouldWarn) {
this.emit('movement:loop_detected', movement, loopCheck.count);
}
if (loopCheck.shouldAbort) {
this.state.status = 'aborted';
this.emit('piece:abort', this.state, ERROR_MESSAGES.LOOP_DETECTED(movement.name, loopCheck.count));
break;
}
this.state.iteration++;
// Build instruction before emitting movement:start so listeners can log it.
// Parallel/arpeggio/team_leader movements handle iteration incrementing internally.
const isDelegated = (movement.parallel && movement.parallel.length > 0) || !!movement.arpeggio || !!movement.teamLeader;
let prebuiltInstruction: string | undefined;
if (!isDelegated) {
const movementIteration = incrementMovementIteration(this.state, movement.name);
prebuiltInstruction = this.movementExecutor.buildInstruction(
movement, movementIteration, this.state, this.task, this.config.maxMovements,
);
}
this.emit('movement:start', movement, this.state.iteration, prebuiltInstruction ?? '', this.optionsBuilder.resolveStepProviderModel(movement));
try {
const { response, instruction } = await this.runMovement(movement, prebuiltInstruction);
this.emit('movement:complete', movement, response, instruction);
if (response.status === 'blocked') {
this.emit('movement:blocked', movement, response);
const result = await handleBlocked(movement, response, this.options);
if (result.shouldContinue && result.userInput) {
this.addUserInput(result.userInput);
this.emit('movement:user_input', movement, result.userInput);
continue;
}
this.state.status = 'aborted';
this.emit('piece:abort', this.state, 'Piece blocked and no user input provided');
break;
}
if (response.status === 'error') {
const detail = response.error ?? response.content;
this.state.status = 'aborted';
this.emit('piece:abort', this.state, `Movement "${movement.name}" failed: ${detail}`);
break;
}
let nextMovement = this.resolveNextMovement(movement, response);
log.debug('Movement transition', {
from: movement.name,
status: response.status,
matchedRuleIndex: response.matchedRuleIndex,
nextMovement,
});
if (response.matchedRuleIndex != null && movement.rules) {
const matchedRule = movement.rules[response.matchedRuleIndex];
if (matchedRule?.requiresUserInput) {
if (!this.options.onUserInput) {
this.state.status = 'aborted';
this.emit('piece:abort', this.state, 'User input required but no handler is configured');
break;
}
const userInput = await this.options.onUserInput({
movement,
response,
prompt: response.content,
});
if (userInput === null) {
this.state.status = 'aborted';
this.emit('piece:abort', this.state, 'User input cancelled');
break;
}
this.addUserInput(userInput);
this.emit('movement:user_input', movement, userInput);
this.state.currentMovement = movement.name;
continue;
}
}
// 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);
break;
}
if (nextMovement === ABORT_MOVEMENT) {
this.state.status = 'aborted';
this.emit('piece:abort', this.state, 'Piece aborted by movement transition');
break;
}
this.state.currentMovement = nextMovement;
} catch (error) {
this.state.status = 'aborted';
if (this.abortRequested) {
this.emit('piece:abort', this.state, 'Piece interrupted by user (SIGINT)');
} else {
const message = getErrorMessage(error);
this.emit('piece:abort', this.state, ERROR_MESSAGES.MOVEMENT_EXECUTION_FAILED(message));
}
break;
}
}
return this.state;
}
/** Run a single iteration (for interactive mode) */
async runSingleIteration(): Promise<{
response: AgentResponse;
nextMovement: string;
isComplete: boolean;
loopDetected?: boolean;
}> {
const movement = this.getMovement(this.state.currentMovement);
this.applyRuntimeEnvironment('movement');
const loopCheck = this.loopDetector.check(movement.name);
if (loopCheck.shouldAbort) {
this.state.status = 'aborted';
return {
response: {
persona: movement.persona ?? movement.name,
status: 'blocked',
content: ERROR_MESSAGES.LOOP_DETECTED(movement.name, loopCheck.count),
timestamp: new Date(),
},
nextMovement: ABORT_MOVEMENT,
isComplete: true,
loopDetected: true,
};
}
this.state.iteration++;
const { response } = await this.runMovement(movement);
const nextMovement = this.resolveNextMovement(movement, response);
const isComplete = nextMovement === COMPLETE_MOVEMENT || nextMovement === ABORT_MOVEMENT;
if (response.matchedRuleIndex != null && movement.rules) {
const matchedRule = movement.rules[response.matchedRuleIndex];
if (matchedRule?.requiresUserInput) {
if (!this.options.onUserInput) {
this.state.status = 'aborted';
return { response, nextMovement: ABORT_MOVEMENT, isComplete: true, loopDetected: loopCheck.isLoop };
}
const userInput = await this.options.onUserInput({
movement,
response,
prompt: response.content,
});
if (userInput === null) {
this.state.status = 'aborted';
return { response, nextMovement: ABORT_MOVEMENT, isComplete: true, loopDetected: loopCheck.isLoop };
}
this.addUserInput(userInput);
this.emit('movement:user_input', movement, userInput);
this.state.currentMovement = movement.name;
return { response, nextMovement: movement.name, isComplete: false, loopDetected: loopCheck.isLoop };
}
}
if (!isComplete) {
this.state.currentMovement = nextMovement;
} else {
this.state.status = nextMovement === COMPLETE_MOVEMENT ? 'completed' : 'aborted';
}
return { response, nextMovement, isComplete, loopDetected: loopCheck.isLoop };
}
}