claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
534 lines (479 loc) • 16.9 kB
text/typescript
/**
* Continue Gate - Long-Running Agent Control
*
* Evaluates whether a long-running agent should continue its next step.
* Prevents runaway loops, budget exhaustion, and coherence degradation.
*
* Problem:
* Current gates are tool-centric (PreToolUse, PreCommand, PreEdit).
* Long-run loops are often internally generated — the agent keeps going
* without a single obviously bad tool call. There is no gate for "should
* this agent continue at all?"
*
* ContinueGate provides step-level evaluation with:
* - Hard limits on consecutive steps without checkpoints
* - Budget acceleration detection via linear regression
* - Coherence threshold enforcement
* - Uncertainty threshold enforcement
* - Rework ratio tracking
* - Automatic checkpoint intervals
* - Cooldown between evaluations
*
* Decision types:
* - continue: Agent may proceed to next step
* - checkpoint: Agent must save state before continuing
* - throttle: Agent should slow down or wait
* - pause: Agent should stop and await human review
* - stop: Agent must halt immediately
*
* @module @claude-flow/guidance/continue-gate
*/
import { randomUUID } from 'node:crypto';
// ============================================================================
// Types
// ============================================================================
/**
* Configuration for the ContinueGate
*/
export interface ContinueGateConfig {
/** Hard limit on consecutive steps without checkpoint (default 100) */
maxConsecutiveSteps: number;
/** Maximum budget slope per step (cost acceleration threshold, default 0.02) */
maxBudgetSlopePerStep: number;
/** Minimum coherence score to continue (default 0.4) */
minCoherenceForContinue: number;
/** Maximum uncertainty score to continue (default 0.8) */
maxUncertaintyForContinue: number;
/** Maximum rework/total steps ratio (default 0.3) */
maxReworkRatio: number;
/** Force checkpoint every N steps (default 25) */
checkpointIntervalSteps: number;
/** Minimum time between evaluations in milliseconds (default 5000) */
cooldownMs: number;
}
/**
* Context for a single step evaluation
*/
export interface StepContext {
/** Current step number in the run */
stepNumber: number;
/** Total tokens consumed so far */
totalTokensUsed: number;
/** Total tool calls made so far */
totalToolCalls: number;
/** Number of steps that redid previous work */
reworkCount: number;
/** Coherence score from CoherenceScheduler (0-1) */
coherenceScore: number;
/** Uncertainty score from UncertaintyAggregator (0-1) */
uncertaintyScore: number;
/** Elapsed time in milliseconds since run start */
elapsedMs: number;
/** Step number of the last checkpoint */
lastCheckpointStep: number;
/** Remaining budget across all dimensions */
budgetRemaining: {
tokens: number;
toolCalls: number;
timeMs: number;
};
/** Recent evaluation decisions (last 10) */
recentDecisions: Array<{
step: number;
decision: 'allow' | 'deny' | 'warn';
}>;
}
/**
* Decision outcome from the continue gate
*/
export interface ContinueDecision {
/** The decision type */
decision: 'continue' | 'checkpoint' | 'throttle' | 'pause' | 'stop';
/** Human-readable reasons for the decision */
reasons: string[];
/** Computed metrics for this evaluation */
metrics: {
/** Budget acceleration rate (tokens per step slope) */
budgetSlope: number;
/** Ratio of rework steps to total steps */
reworkRatio: number;
/** Steps until next required checkpoint */
stepsUntilCheckpoint: number;
/** Coherence health level */
coherenceLevel: 'healthy' | 'degraded' | 'critical';
/** Uncertainty level */
uncertaintyLevel: 'low' | 'moderate' | 'high' | 'extreme';
};
/** Recommended action for the agent */
recommendedAction?: string;
}
/**
* Internal history record for tracking evaluations
*/
interface EvaluationRecord {
step: number;
decision: ContinueDecision;
timestamp: number;
tokensUsed: number;
}
// ============================================================================
// Default Configuration
// ============================================================================
const DEFAULT_CONFIG: ContinueGateConfig = {
maxConsecutiveSteps: 100,
maxBudgetSlopePerStep: 0.02,
minCoherenceForContinue: 0.4,
maxUncertaintyForContinue: 0.8,
maxReworkRatio: 0.3,
checkpointIntervalSteps: 25,
cooldownMs: 5000,
};
const MAX_HISTORY_SIZE = 10000;
const SLOPE_WINDOW_SIZE = 10; // Number of recent steps to use for budget slope calculation
// ============================================================================
// ContinueGate
// ============================================================================
/**
* Gate that evaluates whether a long-running agent should continue.
*
* Prevents runaway execution by checking:
* - Step limits
* - Budget exhaustion and acceleration
* - Coherence degradation
* - Uncertainty thresholds
* - Rework ratios
* - Checkpoint intervals
*
* Maintains history of evaluations and provides aggregate statistics.
*/
export class ContinueGate {
private readonly config: ContinueGateConfig;
private readonly history: EvaluationRecord[] = [];
private lastEvaluationTime = 0;
private readonly tokenHistory: Array<{ step: number; tokens: number }> = [];
constructor(config: Partial<ContinueGateConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Evaluate whether the agent should continue.
*
* Applies decision logic in priority order:
* 1. Coherence below threshold → stop
* 2. Step limit exceeded → stop
* 3. Budget exhausted → stop
* 4. High rework ratio → pause
* 5. High uncertainty → pause
* 6. Budget acceleration → throttle
* 7. Checkpoint interval reached → checkpoint
* 8. Otherwise → continue
*
* @param context - Current step context
* @returns Decision with reasons and metrics
*/
evaluate(context: StepContext): ContinueDecision {
const now = Date.now();
const reasons: string[] = [];
// Track token usage for slope calculation
this.tokenHistory.push({
step: context.stepNumber,
tokens: context.totalTokensUsed,
});
if (this.tokenHistory.length > SLOPE_WINDOW_SIZE) {
this.tokenHistory.shift();
}
// Compute metrics
const budgetSlope = this.computeBudgetSlope();
const reworkRatio = context.stepNumber > 0
? context.reworkCount / context.stepNumber
: 0;
const stepsUntilCheckpoint =
this.config.checkpointIntervalSteps -
(context.stepNumber - context.lastCheckpointStep);
const coherenceLevel = this.getCoherenceLevel(context.coherenceScore);
const uncertaintyLevel = this.getUncertaintyLevel(context.uncertaintyScore);
const metrics = {
budgetSlope,
reworkRatio,
stepsUntilCheckpoint,
coherenceLevel,
uncertaintyLevel,
};
// Decision logic (priority order)
// 1. Coherence below threshold → stop
if (context.coherenceScore < this.config.minCoherenceForContinue) {
reasons.push(
`Coherence below threshold (${context.coherenceScore.toFixed(2)} < ${this.config.minCoherenceForContinue})`,
);
return this.createDecision('stop', reasons, metrics, 'Halt execution and review coherence issues');
}
// 2. Step limit exceeded → stop
if (
context.stepNumber >= this.config.maxConsecutiveSteps &&
context.stepNumber - context.lastCheckpointStep >= this.config.checkpointIntervalSteps
) {
reasons.push(
`Step limit exceeded (${context.stepNumber} >= ${this.config.maxConsecutiveSteps}) without recent checkpoint`,
);
return this.createDecision('stop', reasons, metrics, 'Create checkpoint and review progress');
}
// 3. Budget exhausted → stop
if (
context.budgetRemaining.tokens <= 0 ||
context.budgetRemaining.toolCalls <= 0 ||
context.budgetRemaining.timeMs <= 0
) {
const exhausted: string[] = [];
if (context.budgetRemaining.tokens <= 0) exhausted.push('tokens');
if (context.budgetRemaining.toolCalls <= 0) exhausted.push('tool calls');
if (context.budgetRemaining.timeMs <= 0) exhausted.push('time');
reasons.push(`Budget exhausted: ${exhausted.join(', ')}`);
return this.createDecision('stop', reasons, metrics, 'Increase budget or simplify task scope');
}
// 4. High rework ratio → pause
if (reworkRatio > this.config.maxReworkRatio) {
reasons.push(
`Rework ratio too high (${(reworkRatio * 100).toFixed(1)}% > ${(this.config.maxReworkRatio * 100).toFixed(1)}%)`,
);
return this.createDecision(
'pause',
reasons,
metrics,
'Review recent work for repeated errors or unclear objectives',
);
}
// 5. High uncertainty → pause
if (context.uncertaintyScore > this.config.maxUncertaintyForContinue) {
reasons.push(
`Uncertainty too high (${context.uncertaintyScore.toFixed(2)} > ${this.config.maxUncertaintyForContinue})`,
);
return this.createDecision(
'pause',
reasons,
metrics,
'Resolve uncertain beliefs or gather more evidence before continuing',
);
}
// 6. Budget acceleration → throttle
if (budgetSlope > this.config.maxBudgetSlopePerStep) {
reasons.push(
`Budget acceleration detected (slope: ${budgetSlope.toFixed(4)} > ${this.config.maxBudgetSlopePerStep})`,
);
return this.createDecision(
'throttle',
reasons,
metrics,
'Slow down execution or optimize token usage',
);
}
// 7. Checkpoint interval reached → checkpoint
if (stepsUntilCheckpoint <= 0) {
reasons.push(
`Checkpoint interval reached (${context.stepNumber - context.lastCheckpointStep} >= ${this.config.checkpointIntervalSteps})`,
);
return this.createDecision('checkpoint', reasons, metrics, 'Save current state before continuing');
}
// 8. Otherwise → continue
reasons.push('All checks passed');
return this.createDecision('continue', reasons, metrics);
}
/**
* Evaluate and record the decision in history.
*
* This method also checks the cooldown period — if called too soon
* after the last evaluation, it returns a 'continue' decision without
* full evaluation to prevent excessive overhead.
*
* @param context - Current step context
* @returns Decision with reasons and metrics
*/
evaluateWithHistory(context: StepContext): ContinueDecision {
const now = Date.now();
// Cooldown check — but always evaluate critical stop conditions
// to prevent agents from timing steps to bypass safety checks
if (now - this.lastEvaluationTime < this.config.cooldownMs) {
// Even during cooldown, check hard-stop conditions
if (context.coherenceScore < this.config.minCoherenceForContinue) {
return this.createDecision('stop',
['Coherence below threshold (checked during cooldown)'],
{ budgetSlope: 0, reworkRatio: 0, stepsUntilCheckpoint: 0, coherenceLevel: 'critical', uncertaintyLevel: 'low' },
'Halt execution and review coherence issues');
}
if (context.budgetRemaining.tokens <= 0 || context.budgetRemaining.toolCalls <= 0 || context.budgetRemaining.timeMs <= 0) {
return this.createDecision('stop',
['Budget exhausted (checked during cooldown)'],
{ budgetSlope: 0, reworkRatio: 0, stepsUntilCheckpoint: 0, coherenceLevel: 'healthy', uncertaintyLevel: 'low' },
'Increase budget or simplify task scope');
}
// Non-critical checks can be skipped during cooldown
return {
decision: 'continue',
reasons: ['Cooldown period active; skipping full evaluation'],
metrics: {
budgetSlope: 0,
reworkRatio: 0,
stepsUntilCheckpoint: 0,
coherenceLevel: this.getCoherenceLevel(context.coherenceScore),
uncertaintyLevel: this.getUncertaintyLevel(context.uncertaintyScore),
},
};
}
this.lastEvaluationTime = now;
const decision = this.evaluate(context);
// Record in history
const record: EvaluationRecord = {
step: context.stepNumber,
decision,
timestamp: now,
tokensUsed: context.totalTokensUsed,
};
this.history.push(record);
// Evict oldest if exceeding max size
if (this.history.length > MAX_HISTORY_SIZE) {
this.history.shift();
}
return decision;
}
/**
* Get the full evaluation history.
*
* Returns up to MAX_HISTORY_SIZE most recent evaluations.
*
* @returns Array of evaluation records ordered oldest to newest
*/
getHistory(): Array<{ step: number; decision: ContinueDecision; timestamp: number }> {
return this.history.map(r => ({
step: r.step,
decision: { ...r.decision },
timestamp: r.timestamp,
}));
}
/**
* Get aggregate statistics across all evaluations.
*
* @returns Statistics including total evaluations, decision counts, and average budget slope
*/
getStats(): {
totalEvaluations: number;
decisions: Record<string, number>;
averageBudgetSlope: number;
} {
const decisions: Record<string, number> = {
continue: 0,
checkpoint: 0,
throttle: 0,
pause: 0,
stop: 0,
};
let totalSlope = 0;
let slopeCount = 0;
for (const record of this.history) {
decisions[record.decision.decision]++;
totalSlope += record.decision.metrics.budgetSlope;
slopeCount++;
}
return {
totalEvaluations: this.history.length,
decisions,
averageBudgetSlope: slopeCount > 0 ? totalSlope / slopeCount : 0,
};
}
/**
* Reset all internal state.
*
* Clears history, token tracking, and last evaluation time.
*/
reset(): void {
this.history.length = 0;
this.tokenHistory.length = 0;
this.lastEvaluationTime = 0;
}
/**
* Get the current configuration.
*/
getConfig(): ContinueGateConfig {
return { ...this.config };
}
// =========================================================================
// Private Helpers
// =========================================================================
/**
* Compute the budget slope (rate of token consumption per step)
* using linear regression on the last N steps.
*
* Returns the slope coefficient (tokens per step). A positive slope
* indicates increasing token usage. A slope above the configured
* threshold indicates budget acceleration.
*/
private computeBudgetSlope(): number {
if (this.tokenHistory.length < 2) {
// Need at least 2 points for regression
return 0;
}
const n = this.tokenHistory.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (const point of this.tokenHistory) {
const x = point.step;
const y = point.tokens;
sumX += x;
sumY += y;
sumXY += x * y;
sumXX += x * x;
}
// Linear regression: slope = (n*sumXY - sumX*sumY) / (n*sumXX - sumX*sumX)
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) {
// All x values are the same (shouldn't happen, but guard)
return 0;
}
const slope = (n * sumXY - sumX * sumY) / denominator;
return slope;
}
/**
* Map coherence score to a health level.
*/
private getCoherenceLevel(score: number): 'healthy' | 'degraded' | 'critical' {
if (score >= 0.7) return 'healthy';
if (score >= 0.4) return 'degraded';
return 'critical';
}
/**
* Map uncertainty score to a level.
*/
private getUncertaintyLevel(score: number): 'low' | 'moderate' | 'high' | 'extreme' {
if (score <= 0.3) return 'low';
if (score <= 0.6) return 'moderate';
if (score <= 0.8) return 'high';
return 'extreme';
}
/**
* Create a standardized decision object.
*/
private createDecision(
decision: 'continue' | 'checkpoint' | 'throttle' | 'pause' | 'stop',
reasons: string[],
metrics: ContinueDecision['metrics'],
recommendedAction?: string,
): ContinueDecision {
return {
decision,
reasons,
metrics,
recommendedAction,
};
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Create a ContinueGate instance with optional configuration.
*
* @param config - Partial configuration; unspecified values use defaults
* @returns A fresh ContinueGate instance
*/
export function createContinueGate(config?: Partial<ContinueGateConfig>): ContinueGate {
return new ContinueGate(config);
}