Repository Public
1/**2 * Deterministic stall detection (CONTRACT §5.5). Fed only with journaled data, so a replay reaches the3 * same triggers at the same points.4 *5 * - the same tool call (name + canonical input) N times in a row with the same result;6 * - M model turns without progress (a successful file write, a commit, a criterion change, or an7 * interaction with the owner).8 * First trigger ⇒ an operator note; second ⇒ a question to the owner; after the owner answered the9 * cycle starts again.10 */11import { canonicalJson } from '@aos/kernel';12import { sha256Hex } from '@aos/tools';1314export type StallAction = 'none' | 'note' | 'ask';1516export class StallDetector {17 private lastSig: string | null = null;18 private repeats = 0;19 private idleTurns = 0;20 private stage: 0 | 1 = 0;21 private reasonText = '';2223 constructor(24 private readonly repeatLimit: number,25 private readonly turnLimit: number,26 ) {}2728 /** One executed (or refused) tool call and what the model saw as its result. */29 recordCall(name: string, input: unknown, ok: boolean, content: string): void {30 const sig = sha256Hex(`${name}\n${canonicalJson(input)}\n${ok ? 1 : 0}\n${content}`);31 if (sig === this.lastSig) this.repeats++;32 else {33 this.lastSig = sig;34 this.repeats = 1;35 }36 }37 /** A model turn ended (with or without tool calls). */38 recordTurn(): void {39 this.idleTurns++;40 }41 /** A successful write / commit / criterion change, or an owner interaction. */42 progress(): void {43 this.idleTurns = 0;44 }4546 /** Call once per turn, after its tool batch. Resets the counter that fired. */47 check(): StallAction {48 let reason: string | null = null;49 if (this.repeats >= this.repeatLimit) reason = `the same tool call returned the same result ${this.repeats} times in a row`;50 else if (this.idleTurns >= this.turnLimit) reason = `${this.idleTurns} turns have passed without a successful write, commit or criterion change`;51 if (!reason) return 'none';52 this.reasonText = reason;53 this.repeats = 0;54 this.lastSig = null;55 this.idleTurns = 0;56 if (this.stage === 0) {57 this.stage = 1;58 return 'note';59 }60 this.stage = 0;61 return 'ask';62 }63 get reason(): string {64 return this.reasonText;65 }66}6768export const STALL_NOTE = (reason: string): string =>69 `You appear stuck: ${reason}. Stop and re-plan: state what you know, what you tried and why it failed, then take a different approach — ` +70 'or, if you cannot make progress without the owner, ask them one precise question with ask_owner, or finish with "blocked".';7172export const STALL_CONTINUE = 'Let it continue';73export const STALL_STOP = 'Stop the mission';74export function stallQuestion(reason: string, lastText: string): Record<string, unknown> {75 return {76 text: 'The agent appears stuck. How should it proceed?',77 detail: `Reason: ${reason} (for the second time).${lastText ? `\n\nLast message from the agent:\n${lastText.slice(0, 1500)}` : ''}\n\nReply with guidance, or choose an option.`,78 options: [STALL_CONTINUE, STALL_STOP],79 source: 'stall',80 };81}82