/**
 * minimal-loop — AgentLoop for multi-provider routing (Anthropic + Groq cascade).
 *
 * PUBLIC SURFACE — exactly 5 exports (1 class, 4 type re-exports). Any
 * additional helpers MUST remain unexported.
 */

import { SpanStatusCode, type Tracer } from "@opentelemetry/api";
import {
  type AgentBudgetState,
  type LogMessage,
  compactToolLogDecay,
  createCoherenceDetector,
  emergencyContextSummary,
} from "../budget/budget-state.js";
import { PromiseProgressTracker } from "../budget/promise-progress.js";
import type { ApprovalChannel } from "../hitl/approval-channel.js";
import type { CapabilityGate, CapabilityGateVerdict } from "../permissions/capability-gate.js";
import type { RenewalManager } from "../permissions/renewal-manager.js";
import { makeEvent } from "../remote/events.js";
import type { InstructionInbox } from "../remote/inbox.js";
import type { SessionEventSink } from "../remote/sink.js";
import type { VetoChannel } from "../remote/veto.js";
import type { ProviderRouter, ProviderRouterResponse } from "../router/provider-router.js";
import {
  agentSpan,
  getTracer,
  llmSpan,
  recordLlmUsage,
  recordToolResult,
  toolSpan,
} from "../tracking/gen-ai.js";
import { TrajectoryConfidenceTracker } from "./trajectory-confidence.js";
import { WorldStateHashTracker, canonicalStateHash, isReadOnlyTool } from "./world-state-hash.js";

// ─── ToolRegistry contract ─────────────────────────────────────────────────

// Re-export the unified contract so legacy callers of
// `import { ToolRegistry } from "@vauban-org/agent-sdk"` keep working unchanged.
export type { ToolRegistry } from "../tools/types.js";
import type { ToolRegistry, ToolResult } from "../tools/types.js";

// ─── Types ────────────────────────────────────────────────────────────────

interface AgentLoopConfig {
  agentId: string;
  agentVersion: string;
  systemPrompt: string;
  provider: ProviderRouter;
  tools: ToolRegistry;
  budget: AgentBudgetState;
  approvalChannel?: ApprovalChannel;
  tracker?: {
    recordStep: (d: {
      inputTokens: number;
      outputTokens: number;
      toolCalls?: number;
      costUsd: number;
    }) => Promise<void>;
  };
  /** Inject a tracer for tests. Defaults to the singleton OTel tracer. */
  tracer?: Tracer;
  /** HITL poll interval (ms). Default 500. */
  approvalPollIntervalMs?: number;
  /** HITL default timeout (ms). Default 60_000. */
  approvalTimeoutMs?: number;
  /**
   * Optional Biscuit capability gate. When provided, every tool call is
   * checked against the gate BEFORE dispatch. Denied calls produce a
   * `tool_denied` event and a structured tool-result error; the loop
   * continues with the next call (no panic).
   */
  capabilityGate?: CapabilityGate;
  /**
   * Hook fired when the capability gate denies a tool call. Receives the
   * call name and the deny reason. Best-effort — exceptions are swallowed.
   */
  onToolDenied?: (event: {
    toolName: string;
    reason: string;
    budgetUsed: number;
  }) => void;
  /**
   * Coarse cost-per-call (USD). Default 0 (loop doesn't know LLM unit
   * cost). Hosts that want budget enforcement set this to a per-call
   * estimate; it accumulates across the loop and is passed to the gate.
   */
  costPerToolCallUsd?: number;
  /**
   * Optional auto-renewal. When provided, the loop calls `maybeRenew()`
   * once per LLM iteration (before tool dispatch). The manager debounces
   * and only re-issues when ≥80% of the token lifetime has elapsed.
   */
  renewalManager?: RenewalManager;
  /**
   * Number of consecutive identical tool calls that triggers a loop abort.
   * Default 6. Set higher (e.g. 12) for autonomous research agents that
   * legitimately re-visit the same tools with the same args.
   */
  loopDetectionWindow?: number;
  /**
   * Steps without any tool call before a stall is declared.
   * Default 10. Set higher for agents that do long reasoning stretches.
   */
  stallThreshold?: number;
  /**
   * Optional embedding function for progress-based stall detection.
   * When provided, PromiseProgressTracker replaces the heuristic coherence check.
   * Signature: (text: string) => Promise<number[]>
   */
  embedFn?: (text: string) => Promise<number[]>;
  /**
   * Prior conversation history as proper messages (sprint-736+).
   *
   * When provided, the log is built as:
   *   [system] [initialMessages...] [user: userMessage]
   *
   * This replaces the legacy pattern of injecting history as a text suffix
   * in the system prompt. The model sees its own prior tool calls and results
   * as structured messages instead of having to parse a text summary.
   *
   * Messages with role "system" in this array are ignored — the system prompt
   * from `config.systemPrompt` is always used as the first message.
   */
  initialMessages?: Array<{ role: string; content: string; toolName?: string }>;
  /**
   * When true, disable ALL stop heuristics except budget_exhausted, complete,
   * user_cancelled, tool_denied, and error. Specifically:
   *   - WorldStateHashTracker collision → loop_definitive ; DISABLED
   *   - PromiseProgressTracker plateau → loop_definitive ; DISABLED
   *   - createCoherenceDetector loop/stall → incoherent ; DISABLED
   *
   * The read-result cache still applies (returns cached results to save tokens
   * and prevent the model from doing redundant work) but never aborts the loop.
   *
   * Use this in auto mode where the model is trusted to decide when to stop.
   * Matches Claude Code / Codex behavior: no heuristic safeguards.
   */
  disableLoopDetection?: boolean;
  /**
   * Wall-clock ceiling in milliseconds. 0 or undefined = unlimited.
   *
   * This is the SOTA antifragile backstop for autonomous runs: instead of an
   * arbitrary step cap (a "step" can be 100 or 50 000 tokens — unpredictable),
   * wall-clock time is semantic and predictable. When the elapsed time since
   * loop start exceeds this value, the loop stops with stopReason
   * "budget_exhausted" and loopDetail.reason "max_time".
   *
   * Recommended: interactive = unlimited (user watches), autonomous/daemon =
   * a real ceiling (e.g. 600_000 = 10 min).
   */
  maxWallClockMs?: number;
  /**
   * Cost ceiling in USD. 0 or undefined = unlimited.
   *
   * The twin of `maxWallClockMs`: a semantic, predictable budget bound for
   * autonomous runs. After each LLM round-trip the loop adds the step cost
   * (computed via `costFn`) to a running total; when it reaches `maxCostUsd`
   * the loop stops with stopReason "budget_exhausted" and
   * loopDetail.reason "max_cost".
   *
   * Requires `costFn` to be set — without a pricing function the loop cannot
   * compute cost and the ceiling is inert.
   */
  maxCostUsd?: number;
  /**
   * Pricing function: maps a step's token usage to a USD cost. Injected by
   * the caller (the SDK is pricing-table-agnostic on purpose). When set, the
   * loop accumulates cost per step and exposes the total in
   * `budgetFinal.tokensBudget` consumers / enables the `maxCostUsd` ceiling.
   */
  costFn?: (usage: { inputTokens: number; outputTokens: number }) => number;
  /**
   * Remote-control event sink. When set, the loop emits `SessionEvent`s
   * (run.start, run.step, assistant.message, tool.call.*, run.finished) so a
   * remote client can observe the run live. Default: no events emitted.
   */
  eventSink?: SessionEventSink;
  /**
   * Remote-control instruction inbox. When set, the loop drains it at every
   * step boundary and injects queued instructions into the conversation as
   * `user` messages — mid-run steering (interrupt-and-redirect).
   */
  instructionInbox?: InstructionInbox;
  /**
   * Pre-tool-call veto bus (T6a). When set together with `vetoWindowMs > 0`
   * and an `eventSink`, the loop emits `tool.intent` before every tool call
   * and waits up to `vetoWindowMs` for a matching signal. A vetoed call is
   * SKIPPED and recorded as `[vetoed by <by>]` in the conversation log so
   * the LLM can adjust on its next step.
   */
  vetoChannel?: VetoChannel;
  /**
   * Milliseconds the loop waits on `vetoChannel` per tool call before
   * proceeding. Default: 0 (veto disabled). Keep low (e.g. 1500ms) for
   * interactive supervision, higher (e.g. 10s) for fully gated runs.
   */
  vetoWindowMs?: number;
}

interface AgentLoopRunResult {
  finalMessage: string;
  stopReason:
    | "complete"
    | "budget_exhausted"
    | "incoherent"
    | "loop_definitive"
    | "tool_denied"
    | "user_cancelled"
    | "error";
  budgetFinal: AgentBudgetState;
  traceId: string;
  /** Populated when stopReason === "error" — the underlying exception message. */
  errorMessage?: string;
  /**
   * Populated when stopReason === "loop_definitive". Identifies which
   * tool+args+output state was observed twice and the earlier step index.
   */
  loopDetail?: {
    toolName: string;
    hash: string;
    firstStep: number;
    repeatStep: number;
    /**
     * Sub-reason for the stop. Distinguishes WHY a budget_exhausted /
     * loop_definitive fired so callers and the CLI banner can be precise.
     */
    reason?: "max_steps" | "max_time" | "max_cost" | "hash_collision" | "progress_plateau";
  };
  /** Cumulative LLM cost in USD across the run (0 when no costFn is set). */
  costUsd?: number;
}

class AgentLoopImpl {
  private readonly config: AgentLoopConfig;
  private readonly tracer: Tracer;

  constructor(config: AgentLoopConfig) {
    this.config = config;
    this.tracer = config.tracer ?? getTracer("vauban-agent-sdk");
  }

  async run(userMessage: string): Promise<AgentLoopRunResult> {
    const runId = cryptoRandomId();
    const rootSpan = agentSpan(this.tracer, {
      agentId: this.config.agentId,
      agentVersion: this.config.agentVersion,
      runId,
    });
    const traceId = rootSpan.spanContext().traceId;

    // Build conversation log.
    // sprint-736+: if initialMessages provided, use them as prior conversation
    // history (proper messages) instead of injecting history as text in the
    // system prompt. This is how Claude Code / Codex work — the model sees its
    // own prior tool calls as structured messages, not as a text blob.
    const log: LogMessage[] = [
      { role: "system", content: this.config.systemPrompt },
      ...(this.config.initialMessages
        ?.filter((m) => m.role !== "system") // system is always from config
        .map((m) => ({
          role: m.role as LogMessage["role"],
          content: m.content,
          ...(m.toolName ? { toolName: m.toolName } : {}),
        })) ?? []),
      { role: "user", content: userMessage },
    ];

    // Remote-control event sink — no-op safe when remote-control is off.
    const sink = this.config.eventSink;
    sink?.emit(
      makeEvent("run.start", {
        runId,
        agentId: this.config.agentId,
        agentVersion: this.config.agentVersion,
        task: userMessage.slice(0, 2000),
        startedAt: new Date().toISOString(),
      }),
    );

    const coherence = createCoherenceDetector({
      loopDetectionWindow: this.config.loopDetectionWindow,
      stallThreshold: this.config.stallThreshold,
    });
    const recentCalls: Array<{ name: string; args: unknown }> = [];
    const worldStateTracker = new WorldStateHashTracker();
    // Progress-based stall detection — replaces heuristic coherence check when
    // embedFn is provided. Tracks cosine(output, goal) delta across steps.
    // Plateau = 3+ consecutive steps with delta < 0.02 → loop correction.
    const progressTracker = this.config.embedFn
      ? new PromiseProgressTracker(userMessage, this.config.embedFn, {
          plateauEpsilon: 0.02,
          plateauWindow: 3,
        })
      : null;
    const trajectoryTracker = new TrajectoryConfidenceTracker();
    /** How many self-correction injections have been made this turn. */
    let loopCorrectionCount = 0;
    /** 1 correction injection, then stop. More corrections = model ignores them = infinite loop. */
    const MAX_LOOP_CORRECTIONS = 2;
    /**
     * Cache for read-only tool results within this turn.
     * When the model calls read_file / list_directory / run_bash (cat/ls/grep...)
     * with identical args a second time, return the cached result instead of
     * re-executing. This is transparent to the model (it gets the info it wants)
     * and avoids the WorldStateHashTracker collision → loop_definitive path.
     *
     * Cache key = "<toolName>:<canonicalJSON(args)>"
     */
    const readResultCache = new Map<string, string>();
    let stepsWithoutTool = 0;
    let stopReason: AgentLoopRunResult["stopReason"] = "complete";
    let finalMessage = "";
    let loopDetail: AgentLoopRunResult["loopDetail"];
    /** Cumulative tool-call cost across the loop (USD), for the gate. */
    let budgetUsedUsd = 0;
    /** Cumulative LLM token cost (USD) — for the maxCostUsd ceiling. */
    let cumulativeCostUsd = 0;
    /** Wall-clock loop start — for the maxWallClockMs antifragile backstop. */
    const loopStartMs = Date.now();

    try {
      while (true) {
        // Remote-control: drain injected instructions at the step boundary —
        // interrupt-and-redirect. Each becomes a `user` steering message in
        // the log; the model sees it on the next round-trip.
        if (this.config.instructionInbox) {
          for (const instr of this.config.instructionInbox.drain()) {
            log.push({
              role: "user",
              content: instr.whisper
                ? `[steering hint — not a task change] ${instr.text}`
                : instr.text,
            });
            sink?.emit(
              makeEvent("instruction.injected", {
                text: instr.text,
                source: instr.source,
                whisper: instr.whisper,
              }),
            );
          }
        }

        // maxSteps === 0 means unlimited (used with permissionLevel "auto").
        if (
          this.config.budget.maxSteps > 0 &&
          this.config.budget.stepCount >= this.config.budget.maxSteps
        ) {
          stopReason = "budget_exhausted";
          loopDetail = {
            toolName: "",
            hash: "max_steps",
            firstStep: 0,
            repeatStep: this.config.budget.stepCount,
            reason: "max_steps",
          };
          break;
        }

        // Wall-clock backstop — the SOTA antifragile bound for autonomous
        // runs. Unlike maxSteps (arbitrary), elapsed time is semantic and
        // predictable. 0/undefined = unlimited (interactive mode).
        if (
          this.config.maxWallClockMs !== undefined &&
          this.config.maxWallClockMs > 0 &&
          Date.now() - loopStartMs >= this.config.maxWallClockMs
        ) {
          stopReason = "budget_exhausted";
          loopDetail = {
            toolName: "",
            hash: "max_time",
            firstStep: 0,
            repeatStep: this.config.budget.stepCount,
            reason: "max_time",
          };
          rootSpan.addEvent("gen_ai.agent.max_time", {
            elapsed_ms: Date.now() - loopStartMs,
            limit_ms: this.config.maxWallClockMs,
          });
          break;
        }

        // Cost backstop — twin of the wall-clock bound. Stops the run once
        // accumulated LLM cost reaches the ceiling. Inert without costFn.
        if (
          this.config.maxCostUsd !== undefined &&
          this.config.maxCostUsd > 0 &&
          cumulativeCostUsd >= this.config.maxCostUsd
        ) {
          stopReason = "budget_exhausted";
          loopDetail = {
            toolName: "",
            hash: "max_cost",
            firstStep: 0,
            repeatStep: this.config.budget.stepCount,
            reason: "max_cost",
          };
          rootSpan.addEvent("gen_ai.agent.max_cost", {
            cost_usd: cumulativeCostUsd,
            limit_usd: this.config.maxCostUsd,
          });
          break;
        }

        // Auto-renewal: best-effort, debounced inside the manager. A
        // failed renewal is non-fatal — the existing token may still be
        // valid; the gate will deny on the next call if it expired.
        if (this.config.renewalManager) {
          try {
            await this.config.renewalManager.maybeRenew();
          } catch {
            /* swallow — verifier will surface expiry on next call */
          }
        }

        // Compaction trigger.
        if (
          this.config.budget.stepCount > 0 &&
          this.config.budget.stepCount === this.config.budget.compactionTrigger
        ) {
          const before = this.config.budget.contextWindow.currentTokens;
          // Ebbinghaus decay-based compaction — keeps high-value steps, evicts stale ones.
          const compacted = compactToolLogDecay(log, {
            halfLifeTurns: 8,
            errorBonus: 0.3,
            keepTopK: 30,
          });
          log.splice(0, log.length, ...compacted);
          rootSpan.addEvent("gen_ai.agent.compacted", {
            before_tokens: before,
            after_tokens: this.config.budget.contextWindow.currentTokens,
          });
        }

        // Emergency summary if context > 90% of max.
        const { contextWindow } = this.config.budget;
        if (
          contextWindow.maxTokens > 0 &&
          contextWindow.currentTokens > Math.floor(contextWindow.maxTokens * 0.9)
        ) {
          try {
            const summary = await emergencyContextSummary(
              log,
              async (prompt) => {
                const res = await this.config.provider.complete({
                  messages: [{ role: "user", content: prompt }],
                  maxTokens: 1024,
                });
                return res.content;
              },
              { recursion: false },
            );
            log.splice(0, log.length, {
              role: "system",
              content: `[context recap] ${summary}`,
            });
            contextWindow.currentTokens = estimateTokens(summary);
            rootSpan.addEvent("gen_ai.agent.emergency_summary", {
              tokens_after: contextWindow.currentTokens,
            });
          } catch (err) {
            rootSpan.addEvent("gen_ai.agent.emergency_summary_failed", {
              error: (err as Error)?.message ?? String(err),
            });
          }
        }

        // ─── LLM round-trip ─────────────────────────────────────────────
        const lSpan = llmSpan(this.tracer, {
          provider: "router",
          model: "auto",
          maxTokens: this.config.budget.tokensBudget.output,
          messageCount: log.length,
        });
        let response: ProviderRouterResponse;
        try {
          response = await this.config.provider.complete({
            messages: log.map((m) => ({
              role: m.role,
              content: m.content,
              ...(m.toolName ? { toolName: m.toolName } : {}),
            })),
            maxTokens: this.config.budget.tokensBudget.output,
          });
          recordLlmUsage(lSpan, {
            inputTokens: response.usage.inputTokens,
            outputTokens: response.usage.outputTokens,
            latencyMs: response.latencyMs,
            finishReason: response.toolCalls.length > 0 ? "tool_use" : "stop",
          });
          lSpan.setAttribute("gen_ai.system", response.provider);
          lSpan.setStatus({ code: SpanStatusCode.OK });
        } catch (err) {
          lSpan.setStatus({
            code: SpanStatusCode.ERROR,
            message: (err as Error)?.message,
          });
          lSpan.recordException(err as Error);
          lSpan.end();
          throw err;
        } finally {
          if (lSpan.isRecording()) lSpan.end();
        }

        // Counters.
        this.config.budget.stepCount += 1;
        this.config.budget.tokensBudget.usedInput += response.usage.inputTokens;
        this.config.budget.tokensBudget.usedOutput += response.usage.outputTokens;
        this.config.budget.contextWindow.currentTokens +=
          response.usage.inputTokens + response.usage.outputTokens;

        // Cost accounting — feeds the maxCostUsd ceiling. The SDK is
        // pricing-table-agnostic; costFn is injected by the caller.
        let stepCostUsd = 0;
        if (this.config.costFn) {
          try {
            stepCostUsd = this.config.costFn({
              inputTokens: response.usage.inputTokens,
              outputTokens: response.usage.outputTokens,
            });
          } catch {
            stepCostUsd = 0; // pricing failure is non-fatal
          }
          cumulativeCostUsd += stepCostUsd;
        }

        await this.config.tracker?.recordStep({
          inputTokens: response.usage.inputTokens,
          outputTokens: response.usage.outputTokens,
          toolCalls: response.toolCalls.length,
          costUsd: stepCostUsd,
        });

        // Remote-control: emit the step delta to observers.
        sink?.emit(
          makeEvent("run.step", {
            stepIndex: this.config.budget.stepCount - 1,
            inputTokens: response.usage.inputTokens,
            outputTokens: response.usage.outputTokens,
            costUsd: stepCostUsd,
          }),
        );

        // ─── No tool call → assistant finalises. ────────────────────────
        if (response.toolCalls.length === 0) {
          log.push({ role: "assistant", content: response.content });
          finalMessage = response.content;
          stopReason = "complete";
          sink?.emit(makeEvent("assistant.message", { content: response.content }));
          break;
        }

        if (response.content.trim() !== "") {
          sink?.emit(makeEvent("assistant.message", { content: response.content }));
        }

        log.push({ role: "assistant", content: response.content });
        stepsWithoutTool = 0;

        // ─── Execute each tool call sequentially. ───────────────────────
        let userCancelled = false;
        for (const tc of response.toolCalls) {
          recentCalls.push({ name: tc.name, args: tc.args });

          const tool = this.config.tools.get(tc.name);
          const isDangerous = tool?.dangerous === true;

          // ─── Capability gate (Biscuit) — pre-dispatch. ────────────────
          if (this.config.capabilityGate) {
            let verdict: CapabilityGateVerdict;
            try {
              verdict = await this.config.capabilityGate.verify({
                toolName: tc.name,
                budgetUsed: budgetUsedUsd,
                mcpScopes: tool?.mcpScopes,
              });
            } catch {
              verdict = { allowed: false, reason: "gate_error" };
            }
            if (!verdict.allowed) {
              try {
                this.config.onToolDenied?.({
                  toolName: tc.name,
                  reason: verdict.reason,
                  budgetUsed: budgetUsedUsd,
                });
              } catch {
                /* best-effort */
              }
              rootSpan.addEvent("gen_ai.tool.denied", {
                tool_name: tc.name,
                reason: verdict.reason,
              });
              log.push({
                role: "tool",
                content: `ERROR: capability_denied:${verdict.reason}`,
                toolName: tc.name,
              });
              continue; // Token expiry / scope deny is graceful — try next call.
            }
            // Account this call against the per-loop budget for the
            // verifier on subsequent calls.
            budgetUsedUsd += this.config.costPerToolCallUsd ?? 0;
          }

          if (isDangerous) {
            if (!this.config.approvalChannel) {
              stopReason = "user_cancelled";
              userCancelled = true;
              break;
            }
            const approved = await this.awaitApproval(tc);
            if (!approved) {
              stopReason = "user_cancelled";
              userCancelled = true;
              break;
            }
          }

          // ── Read-result cache ────────────────────────────────────────────
          // For idempotent read-only tools (read_file, list_directory, cat,
          // ls, grep…), return cached result on repeat calls instead of
          // re-executing. This is transparent to the model — it gets the
          // same info without triggering the WorldStateHashTracker collision
          // path and without burning a tool-execution round-trip.
          const cacheKey = isReadOnlyTool(tc.name, tc.args)
            ? canonicalStateHash(tc.name, tc.args, "")
            : null;
          const cached = cacheKey ? readResultCache.get(cacheKey) : null;

          let toolContent: string;
          if (cached !== undefined && cached !== null) {
            // Return cached content — skip actual execution.
            toolContent = cached;
            log.push({ role: "tool", content: toolContent, toolName: tc.name });
            // Don't run WorldStateHashTracker for cache hits — it's intentional.
            recentCalls.push({ name: tc.name, args: tc.args });
            this.config.budget.stepCount++;
            continue;
          }

          const tSpan = toolSpan(this.tracer, tc.name, tc.args);
          const argsPreview = JSON.stringify(tc.args ?? {}).slice(0, 500);
          const callId = cryptoRandomId();

          // ── T6a veto window ───────────────────────────────────────────────
          // Emit tool.intent before doing anything irreversible, then wait
          // up to vetoWindowMs for a matching signal on the veto channel.
          // A vetoed call is replaced with a structured "[vetoed by X]"
          // result the LLM sees on its next step.
          const vetoChan = this.config.vetoChannel;
          const vetoWindow = this.config.vetoWindowMs ?? 0;
          if (vetoChan && vetoWindow > 0 && sink) {
            sink.emit(
              makeEvent("tool.intent", {
                callId,
                toolName: tc.name,
                argsPreview,
                vetoWindowMs: vetoWindow,
              }),
            );
            const veto = await vetoChan.await(callId, vetoWindow);
            if (veto) {
              const reasonSuffix = veto.reason ? `: ${veto.reason}` : "";
              const vetoedContent = `[vetoed by ${veto.by}${reasonSuffix}]`;
              sink.emit(
                makeEvent("tool.call.end", {
                  callId,
                  toolName: tc.name,
                  ok: false,
                  resultPreview: vetoedContent,
                }),
              );
              log.push({
                role: "tool",
                content: vetoedContent,
                toolName: tc.name,
              });
              this.config.budget.stepCount++;
              tSpan.end();
              continue;
            }
          }
          // ── /T6a ──────────────────────────────────────────────────────────

          sink?.emit(
            makeEvent("tool.call.start", {
              callId,
              toolName: tc.name,
              argsPreview,
            }),
          );
          const toolStartMs = Date.now();
          const result = await this.config.tools.execute(tc.name, tc.args);
          recordToolResult(tSpan, {
            success: result.ok,
            errorMessage: result.ok ? undefined : result.error.message,
          });
          tSpan.end();

          toolContent = summariseToolResult(result);
          sink?.emit(
            makeEvent("tool.call.end", {
              callId,
              toolName: tc.name,
              ok: result.ok,
              resultPreview: toolContent.slice(0, 500),
              durationMs: Date.now() - toolStartMs,
            }),
          );
          // Store in read-result cache if this is a read-only tool.
          if (cacheKey) {
            readResultCache.set(cacheKey, toolContent);
          }
          log.push({ role: "tool", content: toolContent, toolName: tc.name });

          // World-state hash — loop detection with self-correction (O(1)).
          // On first collision: inject a correction message and continue.
          // Only stop after MAX_LOOP_CORRECTIONS ignored corrections.
          const obs = worldStateTracker.observe(
            tc.name,
            tc.args,
            toolContent,
            this.config.budget.stepCount,
          );
          if (obs.isCollision && !this.config.disableLoopDetection) {
            loopCorrectionCount += 1;
            rootSpan.addEvent("gen_ai.agent.loop_correction", {
              tool_name: tc.name,
              correction: loopCorrectionCount,
              first_step: obs.previousStep ?? -1,
              repeat_step: this.config.budget.stepCount,
            });

            if (loopCorrectionCount >= MAX_LOOP_CORRECTIONS) {
              stopReason = "loop_definitive";
              loopDetail = {
                toolName: tc.name,
                hash: obs.hash,
                firstStep: obs.previousStep ?? -1,
                repeatStep: this.config.budget.stepCount,
              };
              rootSpan.addEvent("gen_ai.agent.loop_definitive", {
                tool_name: tc.name,
                first_step: obs.previousStep ?? -1,
                repeat_step: this.config.budget.stepCount,
              });
              break;
            }

            // Inject corrective context — agent must try a different approach.
            const pathHint =
              typeof tc.args === "object" &&
              tc.args !== null &&
              "path" in (tc.args as Record<string, unknown>)
                ? ` (path: ${String((tc.args as Record<string, string>).path)})`
                : typeof tc.args === "object" &&
                    tc.args !== null &&
                    "command" in (tc.args as Record<string, unknown>)
                  ? ` (cmd: ${String((tc.args as Record<string, string>).command).slice(0, 60)})`
                  : "";
            const correctionMsg =
              `[LOOP CORRECTION ${loopCorrectionCount}/${MAX_LOOP_CORRECTIONS}] ` +
              `STOP. You already ran \`${tc.name}\`${pathHint} at step ${
                obs.previousStep ?? "?"
              } ` +
              `with an IDENTICAL result. This is a loop. ` +
              `You MUST take a completely different action now. ` +
              `If you are looking for a project or file, check the conversation history — ` +
              `the location was likely already established. ` +
              `Do NOT repeat filesystem exploration commands.`;
            log.push({ role: "user", content: correctionMsg });
          }

          // Progress-based stall detection (replaces heuristic when embedFn provided)
          if (progressTracker && !this.config.disableLoopDetection) {
            const snapshot = await progressTracker.observe(toolContent);
            if (snapshot.isPlateau) {
              loopCorrectionCount += 1;
              if (loopCorrectionCount >= MAX_LOOP_CORRECTIONS) {
                stopReason = "loop_definitive";
                loopDetail = {
                  toolName: tc.name,
                  hash: "progress_plateau",
                  firstStep: this.config.budget.stepCount - snapshot.observations,
                  repeatStep: this.config.budget.stepCount,
                };
                break;
              }
              const plateauMsg =
                `[PROGRESS PLATEAU ${loopCorrectionCount}/${MAX_LOOP_CORRECTIONS}] ` +
                `No measurable progress toward the goal for the last ${snapshot.observations} steps ` +
                `(similarity delta: ${snapshot.progress.toFixed(3)}). ` +
                `Think differently. What concrete, new action would directly advance the task?`;
              log.push({ role: "user", content: plateauMsg });
            }
          }

          // Wire TrajectoryConfidenceTracker
          const stepConf = 1.0 - loopCorrectionCount / MAX_LOOP_CORRECTIONS;
          trajectoryTracker.observe(stepConf);
          // Low trajectory confidence = interrupt recommended (surfaced via loopDetail in future)
        }

        if (userCancelled) break;
        if (stopReason === "loop_definitive") break;

        // ─── Coherence check on tool loop / stall. ──────────────────────
        const verdict = coherence.check(recentCalls, stepsWithoutTool);
        this.config.budget.coherenceScore = verdict.score;
        if ((verdict.isLoop || verdict.isStall) && !this.config.disableLoopDetection) {
          stopReason = "incoherent";
          rootSpan.addEvent("gen_ai.agent.incoherent", {
            is_loop: verdict.isLoop,
            is_stall: verdict.isStall,
          });
          break;
        }
      }

      rootSpan.setAttribute("gen_ai.agent.stop_reason", stopReason);
      rootSpan.setStatus({ code: SpanStatusCode.OK });
      sink?.emit(
        makeEvent("run.finished", {
          runId,
          stopReason,
          stepCount: this.config.budget.stepCount,
          costUsd: cumulativeCostUsd,
          finishedAt: new Date().toISOString(),
        }),
      );
      return {
        finalMessage,
        stopReason,
        budgetFinal: this.config.budget,
        traceId,
        costUsd: cumulativeCostUsd,
        ...(loopDetail ? { loopDetail } : {}),
      };
    } catch (err) {
      const errorMessage = (err as Error)?.message ?? String(err);
      rootSpan.recordException(err as Error);
      sink?.emit(
        makeEvent("run.finished", {
          runId,
          stopReason: "error",
          stepCount: this.config.budget.stepCount,
          costUsd: cumulativeCostUsd,
          finishedAt: new Date().toISOString(),
        }),
      );
      rootSpan.setStatus({
        code: SpanStatusCode.ERROR,
        message: errorMessage,
      });
      return {
        finalMessage: "",
        stopReason: "error",
        errorMessage,
        budgetFinal: this.config.budget,
        traceId,
      };
    } finally {
      rootSpan.end();
    }
  }

  private async awaitApproval(tc: {
    name: string;
    args: unknown;
  }): Promise<boolean> {
    const channel = this.config.approvalChannel;
    if (!channel) return false;
    const timeoutMs = this.config.approvalTimeoutMs ?? 60_000;
    const pollMs = this.config.approvalPollIntervalMs ?? 500;

    const requestId = await channel.send({
      agentId: this.config.agentId,
      action: tc.name,
      context: safeStringify(tc.args),
      timeoutMs,
    });

    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      const verdict = await channel.poll(requestId);
      if (verdict !== null) {
        return verdict.approved === true;
      }
      await sleep(pollMs);
    }
    await channel.cancel(requestId).catch(() => {
      /* best-effort */
    });
    return false;
  }
}

// ─── Helpers (unexported) ─────────────────────────────────────────────────

function cryptoRandomId(): string {
  const bytes = new Uint8Array(8);
  if (typeof globalThis.crypto?.getRandomValues === "function") {
    globalThis.crypto.getRandomValues(bytes);
  } else {
    for (let i = 0; i < bytes.length; i++) {
      bytes[i] = Math.floor(Math.random() * 256);
    }
  }
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

function safeStringify(v: unknown): string {
  try {
    return typeof v === "string" ? v : (JSON.stringify(v) ?? "null");
  } catch {
    return "[unserialisable]";
  }
}

function summariseToolResult(r: ToolResult): string {
  if (r.ok) {
    return safeStringify(r.data).slice(0, 2000);
  }
  return `ERROR: ${r.error.message}`;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function estimateTokens(s: string): number {
  return Math.ceil(s.length / 4);
}

// ─── PUBLIC SURFACE ───────────────────────────────────────────────────────

export { AgentLoopImpl as AgentLoop };
export type { AgentLoopRunResult, AgentLoopConfig };
