/** * StopHook — 防止无限循环、影子自动化和自我确认的运行时钩子。 * * Stop Hook 是 LoopKernel 的一等对象,在每个循环迭代的关键检查点执行。 * 当检测到停止条件时,产生 StopHook 并写入 ledger,成为 control frame 的最高优先级输入。 * * KCode 的 6 种 Stop Hook 类型(按优先级): * - `done`:验收标准满足,证据齐全,门禁通过。 * - `blocked`:缺用户事实、缺环境、缺权限、缺外部系统证据。 * - `budget-exhausted`:token、时间、工具调用、修复轮次超过预算。 * - `stuck`:重复同一失败、重复同一工具、重复同一问题。 * - `unsafe`:hard-deny、外部路径、凭证、越权、破坏性命令。 * - `scope-drift`:修改范围偏离 Completion Promise 或 PLAN。 * * StuckDetector 检测逻辑: * - 同一 failureReason 出现 3+ 次 → stuck(repeated-failure) * - 同一 toolName+intent 组合出现 5+ 次 → stuck(repeated-tool) * - 同一 questionText 出现 3+ 次 → stuck(repeated-question) * * @see docs/AGENT_GATE_LOOP_REFACTOR_PLAN.md — "Stop Hook", "Loop Budget" * @see reducers.ts — LoopState, LoopStatus * @see action-plan.ts — ActionPlan, GateSeverity, GateCheckpoint */ import type { ActionPlan } from "./action-plan.ts"; import type { LoopObservation, LoopState } from "./reducers.ts"; // --------------------------------------------------------------------------- // Stop Hook types // --------------------------------------------------------------------------- /** Stop Hook 触发类型。 */ export type StopHookType = | "done" | "blocked" | "budget-exhausted" | "stuck" | "unsafe" | "scope-drift"; // --------------------------------------------------------------------------- // Loop Budget // --------------------------------------------------------------------------- /** * LoopBudget — 长流式 loop 的成本治理预算。 * * 超预算必须触发 Stop Hook。 * 用户授权继续时生成新的 budget segment,而不是无限延长当前循环。 */ export interface LoopBudget { /** 最大循环迭代次数。 */ maxTurns: number; /** 最大工具调用次数。 */ maxToolCalls: number; /** 最大验证重试次数。 */ maxVerifyAttempts: number; /** 最大挂钟时间(毫秒)。 */ maxWallClockMs: number; /** 最大上下文字符数。 */ maxContextChars: number; /** 最大委派次数。 */ maxDelegations: number; } /** * BudgetReport — 预算消耗报告。 * * 记录当前各项预算的使用量和上限,用于 StopHook 的 budgetReport 字段。 */ export interface BudgetReport { /** 各项预算消耗详情。 */ consumption: BudgetConsumption[]; /** 是否有任何预算超限。 */ exhausted: boolean; /** 超限的预算项名称列表。 */ exhaustedItems: string[]; } /** 单项预算消耗。 */ export interface BudgetConsumption { /** 预算项名称。 */ item: string; /** 当前消耗量。 */ current: number; /** 预算上限。 */ limit: number; /** 是否超限。 */ exhausted: boolean; } // --------------------------------------------------------------------------- // Stuck detection // --------------------------------------------------------------------------- /** Stuck 签名——用于检测重复模式。 */ export interface StuckSignature { /** 签名类型。 */ kind: "repeated-failure" | "repeated-tool" | "repeated-question"; /** 签名值(hash 或关键文本)。 */ value: string; /** 重复次数。 */ count: number; /** 触发阈值。 */ threshold: number; } /** 内部记录的行动摘要,用于 stuck 检测。 */ interface ActionRecord { /** 工具名称。 */ toolName: string; /** 行动意图摘要。 */ intent: string; /** 是否失败。 */ failed: boolean; /** 失败原因。 */ failureReason?: string; /** 是否是提问行为。 */ isQuestion: boolean; /** 提问内容(若是提问)。 */ questionText?: string; /** ISO 时间戳。 */ timestamp: string; } // --------------------------------------------------------------------------- // StopHook // --------------------------------------------------------------------------- /** * StopHook — 当 loop 应该停止时产生的结构化钩子。 * * 每个 StopHook 必须: * - 明确停止类型(type)和原因(reason)。 * - 记录触发时间(triggeredAt)。 * - 若因预算耗尽触发,携带 budgetReport。 * - 若因 stuck 触发,携带 stuckSignature。 * - 携带上下文信息(context)供 control frame 消费。 */ export interface StopHook { /** 唯一标识,格式:hook-{timestamp}-{random_hex_4}。 */ id: string; /** 停止类型。 */ type: StopHookType; /** 人类可读的停止原因。 */ reason: string; /** ISO 时间戳。 */ triggeredAt: string; /** 预算消耗报告(仅 budget-exhausted 类型)。 */ budgetReport?: BudgetReport; /** Stuck 签名(仅 stuck 类型)。 */ stuckSignature?: StuckSignature; /** 上下文信息——供 control frame 决定下一步。 */ context: StopHookContext; } /** StopHook 上下文——携带 control frame 所需的决策信息。 */ export interface StopHookContext { /** 当前循环迭代数。 */ turn: number; /** 当前阶段。 */ phase?: string; /** 关联的 Completion Promise ID。 */ completionPromiseId?: string; /** 建议的下一步操作(结构化)。 */ nextAction?: string; /** 是否需要用户介入。 */ requiresUserAction: boolean; /** 附加说明。 */ details?: string; } // --------------------------------------------------------------------------- // ID generation // --------------------------------------------------------------------------- /** * 生成 hookId:hook-{timestamp}-{random_hex_4}。 * * @example "hook-1717980000000-a3f1" */ function generateHookId(): string { const timestamp = Date.now(); const randomHex = Math.floor(Math.random() * 0x10000) .toString(16) .padStart(4, "0"); return `hook-${timestamp}-${randomHex}`; } // --------------------------------------------------------------------------- // Default budgets // --------------------------------------------------------------------------- /** 默认 LoopBudget——适用于 normal 模式。 */ export const DEFAULT_LOOP_BUDGET: LoopBudget = { maxTurns: 50, maxToolCalls: 200, maxVerifyAttempts: 10, maxWallClockMs: 30 * 60 * 1000, // 30 分钟 maxContextChars: 500_000, maxDelegations: 10, }; /** Quick 模式 LoopBudget——更严格的限制。 */ export const QUICK_LOOP_BUDGET: LoopBudget = { maxTurns: 20, maxToolCalls: 80, maxVerifyAttempts: 5, maxWallClockMs: 10 * 60 * 1000, // 10 分钟 maxContextChars: 200_000, maxDelegations: 3, }; // --------------------------------------------------------------------------- // StuckDetector // --------------------------------------------------------------------------- /** * StuckDetector — 检测重复失败、重复工具调用和重复提问。 * * 使用三个独立的计数器分别跟踪不同类型的重复模式: * - failureCounts:按失败原因计数(阈值 3) * - toolPatternCounts:按 toolName::intent 组合计数(阈值 5) * - questionCounts:按提问内容计数(阈值 3) * * 为什么 tool 阈值比 failure 高? * 因为重复使用同一工具可能是正常行为(如多次读取不同文件), * 而重复失败几乎总是表明 stuck。 */ export class StuckDetector { /** 已记录的行动摘要,用于调试和审计。 */ private records: ActionRecord[] = []; /** 失败原因计数。 */ private failureCounts = new Map(); /** 工具调用模式计数(toolName::intent)。 */ private toolPatternCounts = new Map(); /** 提问内容计数。 */ private questionCounts = new Map(); /** 重复失败阈值。 */ private static readonly FAILURE_THRESHOLD = 3; /** 重复工具调用模式阈值。 */ private static readonly TOOL_PATTERN_THRESHOLD = 5; /** 重复提问阈值。 */ private static readonly QUESTION_THRESHOLD = 3; /** * 记录一个行动,更新 stuck 检测状态。 * * @param action - 行动计划。 * @param observation - 行动观测结果(可选)。 */ recordAction(action: ActionPlan, observation?: LoopObservation): void { const record: ActionRecord = { toolName: action.kind, intent: action.intent, failed: observation ? !observation.success : false, failureReason: observation && !observation.success ? observation.summary : undefined, isQuestion: action.kind === "ask-user", questionText: action.kind === "ask-user" ? action.intent : undefined, timestamp: new Date().toISOString(), }; this.records.push(record); // 更新失败计数 if (record.failed && record.failureReason) { const key = record.failureReason; this.failureCounts.set(key, (this.failureCounts.get(key) ?? 0) + 1); } // 更新工具调用模式计数 const patternKey = `${record.toolName}::${record.intent}`; this.toolPatternCounts.set(patternKey, (this.toolPatternCounts.get(patternKey) ?? 0) + 1); // 更新提问计数 if (record.isQuestion && record.questionText) { const qKey = record.questionText; this.questionCounts.set(qKey, (this.questionCounts.get(qKey) ?? 0) + 1); } } /** * 检测当前是否 stuck。 * * @returns StuckSignature(若 stuck),否则 null。 */ detect(): StuckSignature | null { // 检查重复失败 for (const [reason, count] of this.failureCounts) { if (count >= StuckDetector.FAILURE_THRESHOLD) { return { kind: "repeated-failure", value: reason, count, threshold: StuckDetector.FAILURE_THRESHOLD, }; } } // 检查重复工具调用模式 for (const [pattern, count] of this.toolPatternCounts) { if (count >= StuckDetector.TOOL_PATTERN_THRESHOLD) { return { kind: "repeated-tool", value: pattern, count, threshold: StuckDetector.TOOL_PATTERN_THRESHOLD, }; } } // 检查重复提问 for (const [question, count] of this.questionCounts) { if (count >= StuckDetector.QUESTION_THRESHOLD) { return { kind: "repeated-question", value: question, count, threshold: StuckDetector.QUESTION_THRESHOLD, }; } } return null; } /** * 获取当前记录的行动数量。 */ get recordCount(): number { return this.records.length; } /** * 重置检测器状态。 */ reset(): void { this.records = []; this.failureCounts.clear(); this.toolPatternCounts.clear(); this.questionCounts.clear(); } } // --------------------------------------------------------------------------- // LoopBudgetTracker // --------------------------------------------------------------------------- /** * LoopBudgetTracker — 从 LoopState 实时计算各项预算消耗。 * * 预算项:turns、toolCalls、verifyAttempts、wallClockMs、contextChars、delegations。 * 任何一项超限即触发 budget-exhausted StopHook。 */ export class LoopBudgetTracker { private readonly budget: LoopBudget; private readonly startedAt: number; constructor(budget: LoopBudget) { this.budget = budget; this.startedAt = Date.now(); } /** * 生成预算消耗报告。 * * @param state - 当前循环状态。 * @returns 预算报告。 */ report(state: LoopState): BudgetReport { const toolCallCount = state.actions.length; const verifyAttempts = state.observations.filter( (o) => o.toolName === "verify" || o.toolName === "npm-run", ).length; const delegationCount = state.actions.filter( (a) => a.kind === "delegate", ).length; const wallClockMs = Date.now() - this.startedAt; const contextChars = JSON.stringify(state).length; const consumption: BudgetConsumption[] = [ { item: "turns", current: state.turn, limit: this.budget.maxTurns, exhausted: state.turn >= this.budget.maxTurns, }, { item: "toolCalls", current: toolCallCount, limit: this.budget.maxToolCalls, exhausted: toolCallCount >= this.budget.maxToolCalls, }, { item: "verifyAttempts", current: verifyAttempts, limit: this.budget.maxVerifyAttempts, exhausted: verifyAttempts >= this.budget.maxVerifyAttempts, }, { item: "wallClockMs", current: wallClockMs, limit: this.budget.maxWallClockMs, exhausted: wallClockMs >= this.budget.maxWallClockMs, }, { item: "contextChars", current: contextChars, limit: this.budget.maxContextChars, exhausted: contextChars >= this.budget.maxContextChars, }, { item: "delegations", current: delegationCount, limit: this.budget.maxDelegations, exhausted: delegationCount >= this.budget.maxDelegations, }, ]; const exhaustedItems = consumption.filter((c) => c.exhausted).map((c) => c.item); return { consumption, exhausted: exhaustedItems.length > 0, exhaustedItems, }; } } // --------------------------------------------------------------------------- // StopHookDetector // --------------------------------------------------------------------------- /** * StopHookDetector — 检测 loop 应该停止的条件。 * * 在每个循环迭代的关键检查点调用 checkStopConditions, * 若返回非空数组,loop 必须停止并将 hooks 写入 ledger。 * * 内部组合了 LoopBudgetTracker(预算跟踪)和 StuckDetector(stuck 检测), * 并从 gate decisions 中提取 unsafe 和 scope-drift 信号。 */ export class StopHookDetector { /** 预算跟踪器。 */ private readonly budgetTracker: LoopBudgetTracker; /** stuck 检测器。 */ private readonly stuckDetector: StuckDetector; constructor(budget: LoopBudget) { this.budgetTracker = new LoopBudgetTracker(budget); this.stuckDetector = new StuckDetector(); } /** * 检查所有停止条件,按优先级顺序: * 1. budget-exhausted — 预算耗尽(最高优先级,防止资源浪费) * 2. stuck — 重复失败/工具/提问(防止死循环) * 3. unsafe — hard-deny 门禁(安全红线) * 4. scope-drift — 偏离承诺范围(范围控制) * * @param state - 当前循环状态 * @returns 触发的 StopHook 列表(可能为空) */ checkStopConditions(state: LoopState): StopHook[] { const hooks: StopHook[] = []; // 1. 检查预算 const budgetHook = this.checkBudget(state); if (budgetHook) { hooks.push(budgetHook); } // 2. 检查 stuck const stuckHook = this.checkStuck(state); if (stuckHook) { hooks.push(stuckHook); } // 3. 检查 unsafe(从 gate decisions 中发现 hard-deny) const unsafeHook = this.checkUnsafe(state); if (unsafeHook) { hooks.push(unsafeHook); } // 4. 检查 scope-drift(从 gate decisions 中发现 scope 偏离) const scopeDriftHook = this.checkScopeDrift(state); if (scopeDriftHook) { hooks.push(scopeDriftHook); } return hooks; } /** * 记录一个行动,更新 stuck 检测状态。 * * @param action - 行动计划。 * @param observation - 行动观测结果(可选)。 */ recordAction(action: ActionPlan, observation?: LoopObservation): void { this.stuckDetector.recordAction(action, observation); } /** * 检查预算是否耗尽。 * * @param state - 当前循环状态。 * @returns StopHook(若预算耗尽),否则 null。 */ checkBudget(state: LoopState): StopHook | null { const report = this.budgetTracker.report(state); if (!report.exhausted) { return null; } const exhaustedSummary = report.exhaustedItems.join(", "); return { id: generateHookId(), type: "budget-exhausted", reason: `预算耗尽:${exhaustedSummary}`, triggeredAt: new Date().toISOString(), budgetReport: report, context: { turn: state.turn, phase: state.phase, nextAction: "review-progress-and-authorize-extension", requiresUserAction: true, details: `已消耗 ${report.exhaustedItems.length} 项预算上限,需要用户授权新的 budget segment 才能继续。`, }, }; } /** * 检查是否 stuck(重复失败/工具/提问)。 * * @param state - 当前循环状态。 * @returns StopHook(若 stuck),否则 null。 */ checkStuck(_state: LoopState): StopHook | null { const signature = this.stuckDetector.detect(); if (!signature) { return null; } const kindLabel: Record = { "repeated-failure": "重复失败", "repeated-tool": "重复工具调用", "repeated-question": "重复提问", }; return { id: generateHookId(), type: "stuck", reason: `${kindLabel[signature.kind]}:「${signature.value}」已出现 ${signature.count} 次(阈值 ${signature.threshold})`, triggeredAt: new Date().toISOString(), stuckSignature: signature, context: { turn: _state.turn, phase: _state.phase, nextAction: "diagnose-stuck-and-change-approach", requiresUserAction: true, details: `检测到 ${kindLabel[signature.kind]}模式,循环可能陷入死循环。建议改变策略或请求用户介入。`, }, }; } /** * 检查是否有 unsafe 条件(来自 gate decisions 的 hard-deny)。 * * @param state - 当前循环状态。 * @returns StopHook(若发现 unsafe),否则 null。 */ private checkUnsafe(state: LoopState): StopHook | null { // 从最近的 gate decisions 中查找 hard-deny const recentHardDeny = state.gateDecisions.find( (d) => !d.allowed && d.findings.some((f) => f.severity === "hard-deny"), ); if (!recentHardDeny) { return null; } const hardDenyFindings = recentHardDeny.findings.filter( (f) => f.severity === "hard-deny", ); const reasons = hardDenyFindings.map((f) => f.message).join("; "); return { id: generateHookId(), type: "unsafe", reason: `安全门禁拒绝:${reasons}`, triggeredAt: new Date().toISOString(), context: { turn: state.turn, phase: state.phase, nextAction: hardDenyFindings[0]?.nextAction ?? "resolve-safety-concern", requiresUserAction: true, details: `检测到 hard-deny 门禁,循环必须停止。原因:${reasons}`, }, }; } /** * 检查是否有 scope-drift(修改范围偏离 Completion Promise)。 * * 当 gate decisions 中出现 scope 相关的 warning 或 evidence-required 时触发。 * * @param state - 当前循环状态。 * @returns StopHook(若发现 scope-drift),否则 null。 */ private checkScopeDrift(state: LoopState): StopHook | null { // 查找 scope 相关的门禁发现 const scopeFinding = state.gateDecisions .flatMap((d) => d.findings) .find( (f) => f.policy.toLowerCase().includes("scope") && (f.severity === "soft-deny" || f.severity === "evidence-required"), ); if (!scopeFinding) { return null; } return { id: generateHookId(), type: "scope-drift", reason: `范围偏离:${scopeFinding.message}`, triggeredAt: new Date().toISOString(), context: { turn: state.turn, phase: state.phase, nextAction: scopeFinding.nextAction ?? "review-scope-and-update-promise", requiresUserAction: true, details: `检测到修改范围偏离 Completion Promise 或 PLAN。${scopeFinding.message}`, }, }; } /** * 创建 done 类型的 StopHook(验收标准满足时由外部调用)。 * * @param state - 当前循环状态。 * @param details - 完成说明。 * @returns done StopHook。 */ static createDoneHook(state: LoopState, details?: string): StopHook { return { id: generateHookId(), type: "done", reason: "验收标准满足,证据齐全,门禁通过", triggeredAt: new Date().toISOString(), context: { turn: state.turn, phase: state.phase, requiresUserAction: false, details: details ?? "所有 doneWhen 条件已满足,可以进入交付阶段。", }, }; } /** * 创建 blocked 类型的 StopHook(缺少必要条件时由外部调用)。 * * @param state - 当前循环状态。 * @param reason - 阻塞原因。 * @param nextAction - 建议的下一步操作。 * @returns blocked StopHook。 */ static createBlockedHook( state: LoopState, reason: string, nextAction?: string, ): StopHook { return { id: generateHookId(), type: "blocked", reason, triggeredAt: new Date().toISOString(), context: { turn: state.turn, phase: state.phase, nextAction: nextAction ?? "resolve-blocking-condition", requiresUserAction: true, details: reason, }, }; } /** * 获取内部的 StuckDetector(用于测试或外部检查)。 */ getStuckDetector(): StuckDetector { return this.stuckDetector; } /** * 获取内部的 LoopBudgetTracker(用于测试或外部检查)。 */ getBudgetTracker(): LoopBudgetTracker { return this.budgetTracker; } } // --------------------------------------------------------------------------- // LoopKernel compatibility adapter // --------------------------------------------------------------------------- import type { LoopBudget as KernelLoopBudget, StopDecision, StopHookDetector as KernelStopHookDetector } from "./loop-kernel.ts"; /** * 将 KCode StopHookDetector 适配为 loop-kernel 的 StopHookDetector 接口。 * * 适配器模式:loop-kernel 定义的接口是 check(state, budget) → StopDecision | null, * 而 KCode 的 StopHookDetector 使用 checkStopConditions(state) → StopHook[]。 * 此适配器桥接两者,同时处理迭代预算检查。 */ export class KernelStopHookAdapter implements KernelStopHookDetector { constructor(private readonly inner: StopHookDetector) {} check(state: LoopState, budget: KernelLoopBudget): StopDecision | null { const hooks = this.inner.checkStopConditions(state); // Also check iteration budget if (state.turn >= budget.maxIterations) { return { reason: `已达最大迭代次数 ${budget.maxIterations}`, hook: "budget_exhausted", }; } if (hooks.length > 0) { const primary = hooks[0]; return { reason: primary.reason, hook: mapStopHookTypeToDecisionHook(primary.type), }; } return null; } } function mapStopHookTypeToDecisionHook(type: StopHookType): StopDecision["hook"] { switch (type) { case "budget-exhausted": return "budget_exhausted"; case "stuck": return "stuck"; case "unsafe": return "error"; case "blocked": return "user_abort"; case "scope-drift": return "user_abort"; case "done": return "error"; // done should be handled by CompletionPromise } } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /** * 创建 StopHookDetector 实例。 * * @param budget - 预算配置,默认使用 DEFAULT_LOOP_BUDGET。 * @returns 新建的 StopHookDetector。 */ export function createStopHookDetector(budget?: LoopBudget): StopHookDetector { return new StopHookDetector(budget ?? DEFAULT_LOOP_BUDGET); } /** * Create a stop hook detector compatible with the loop-kernel interface. */ export function createKernelStopHookDetector(budget?: LoopBudget): KernelStopHookDetector { return new KernelStopHookAdapter(new StopHookDetector(budget ?? DEFAULT_LOOP_BUDGET)); }