---
url: 'https://adk.nht.io/showcase/punching-above-its-weights-source.md'
description: >-
  The complete source of the flagship docs agent from the Punching Above Its
  Weights showcase — the scaffolding you write, not the loop, batteries, or
  shims ADK provides. Read it, port it, argue with it.
---

# The Agent, In Full

This is not a walkthrough. [Punching Above Its Weights](/showcase/punching-above-its-weights) already
did the walkthrough — the planner, the gate cascade, the subtractive pass, the recovery ladder, all named
and quoted in place. This page is the 31 source files those excerpts were pulled from: the complete,
unredacted source of the agent running live on this site (the floating Ask ADK launcher), viewable end to end and fetchable whole by
whatever coding agent you point at it. No excerpts, no "left as an exercise," no synthetic cleanup pass
before publishing. What ran on the wire is what's below.

The 31 files split into two groups, and the split tracks one question: did we write this, or did we import
it from ADK? The split is deliberate and it is not decoration. **The agent** is the scaffolding this
showcase is actually about — code this project wrote on top of ADK, not shipped by it: the planner that
commits to a turn's shape before generation, the gate cascade that owns turn completion, the subtractive
context pass, the tool registry, the RAG and keyword-search retrieval, the classifiers and prose
heuristics that back the gates, the citation and artifact-handle machinery, and the system prompt that
ties it together. Every file in this group is portable — read it as reference for building the equivalent
scaffolding around your own agent, on your own model, in your own runtime. None of it depends on this repo
being a VitePress docs site.

**The deployment glue** is the other half — the wiring that made this specific deployment run, not
scaffolding meant to be ported: the LiteRT-LM Web Worker and its proxy, the GPU-loss recovery policy, the
LiteRT battery adapter wiring, the SQLite/Kysely persistence stack (the worker, the dialect, the swarm
store, the store facade), the embedder, and the small state/route plumbing that hosts all of it inside a
docs page. This is the part that is legitimately specific to "small model, in a browser tab, on WebGPU" —
read it if you're deploying the same way, skim it if you're not.

Four files are excluded on purpose, and it's worth saying why rather than just quietly dropping them.
`agent_adk.ts` is a thin app-side binding over `@nhtio/adk/shims`'s already-public API — there's nothing to
learn from it that the [shims documentation](/assembly/runtime-loading) and [`createAdkShim`](https://adk.nht.io/api/@nhtio/adk/shims/functions/createAdkShim) TSDoc
don't already teach better, and consumers should build against the shipped module, not against this
showcase's private call site. `agent_wiretap.ts` is a dev-only observability tap that doesn't exist in the
released build. `agent_dialog.vue` and `agent_dialog_parts.ts` are UI chrome — the chat window rendering
this agent's output — which is not what this page is for.

A porting note: the source below is MIT-licensed with the rest of this repository, so copying it wholesale
is fine. What it depends on is not this showcase — it's the shipped API surface: the
[Context Batteries](/batteries/context/) (`thrift`, the subtractive pass every dispatch here runs) and
`@nhtio/adk/shims` (the async-resolver seam, documented in full on
[Runtime Loading](/assembly/runtime-loading)) are both public, versioned, and exist independently of this
agent. Build against those, not against this page's private wiring.

## Full source — machine-readable mirror

Everything below is the complete, unabridged source of the 31 curated files behind the viewer above, each
under its own heading. This section exists for LLM consumption: a coding agent fetching this page's
generated markdown mirror gets the entire agent, not a summary of it. Two groups, in the same order as the
viewer's file tree — "the agent" (scaffolding, portable to any agent/runtime), then "the deployment glue"
(this build's specific WebGPU/Worker/SQLite wiring).

### The agent

#### agent\_runtime.ts

```ts
// #region four_pipelines
// The flagship agent harness — the orchestration core, built on the ADK TurnRunner.
//
// WHY TurnRunner (not DispatchRunner directly): the ADK splits work across FOUR pipelines for
// a cost reason (see the LLM-Dispatch / "what each pipeline owns" docs). TurnRunner is the seam
// that owns all four; DispatchRunner only exposes the per-iteration two. The flagship needs the
// turn-scoped seam, so it uses TurnRunner:
//
//   - turnInputPipeline      (once / turn) → hydrate the TURN CONSTANTS: standing instructions.
//                                            (Tools are turn-constant too, supplied via config.)
//   - dispatchInputPipeline  (per iteration, before the model) → hydrate the CONVERSATIONAL
//                                            state the loop reasons over (messages — re-drained
//                                            each iteration so a MID-TURN interrupt folds in —
//                                            plus memories, thoughts, tool-calls, and the
//                                            pre-fetched retrievables), then the subtractive pass
//                                            and the force-answer guard.
//   - dispatchOutputPipeline (per iteration, after the model) → loop telemetry (which tool ran).
//   - persistence callbacks → the SQLite facade. The transformers.js adapter is a well-behaved
//                                            executor: it calls ctx.storeMessage / storeThought /
//                                            storeToolCall and ctx.ack() itself, so those
//                                            callbacks ARE the persistence wiring — no bespoke
//                                            store loop.
//
// LOOP CONTROL is by SIGNALS, using the ADK primitives (ctx.iteration, ctx.toolCallCount) — the
// policy thresholds are ours. We force an answer only on a GENUINE repeat (same tool+args ⇒ same
// checksum, REPEAT_LIMIT+ times) or the hard iteration cap — never on "any tool was used", so a
// healthy multi-step turn (search → catalog → call_a_tool → answer) runs freely.
//
// CHAT HISTORY vs CONTEXT WINDOW: the user turn is persisted in run(); the adapter persists the
// final assistant turn via the storeMessage callback. The model-facing context (turnMessages and
// the rest) is rebuilt each turn and never rendered. See memory chat_history_vs_context_window.
// #endregion four_pipelines

import { pendingGate } from './agent_state'
import { GpuLossPolicy } from './gpu_loss_policy'
import { requestIsOverScope } from './agent_citations'
import { searchSemantic, type DocHit } from './agent_rag'
import { buildSystemPrompt } from './agent_system_prompt'
import { MODELS, type ModelChoice } from './agent_models'
import { makeDispatchOutput } from './agent_gate_cascade'
import { makeDispatchInput } from './agent_context_assembly'
import { currentPath, navigateInternal } from './agent_route'
import { makePlan, renderPlanThought } from './agent_planner'
import { makeTurnCallbacks } from './agent_persistence_callbacks'
import { type ThriftTrace, type WorkingImage } from './agent_subtractive_pass'
import { createWorkerEngine, asWorkerEngineProxy } from './litert_lm_worker_proxy'
import { readModelFacingPreview, readToolResultPreview } from './agent_artifact_handles'
import { makeEmitCheckerThought, makeEmitThought, type TurnState } from './agent_turn_state'
import {
  makeDefaultSpoolStore,
  makeLiteRtAdapter,
  ensureEncoderWiring,
} from './agent_litert_adapter'
import {
  isContextOverflowError,
  isGpuOomError,
  isWindowTooSmallError,
  unwrapToRootCause,
} from './agent_errors'
import {
  buildToolRegistry,
  type CapturedAnswer,
  type PlanAnswerKind,
  type PlanAnswerScope,
} from './agent_tools'
import {
  looksLikeDirectiveAcknowledgment,
  looksLikeRawDocSource,
  stripFalseAbstentionPreamble,
} from './agent_prose_heuristics'
import {
  contentTokens,
  assembleCompactedTurns,
  COMPACTION_SYSTEM_PROMPT,
  type ThriftHistoryTurnType,
  type CompactSummarizeFnType,
} from './agent_adk'
import {
  classifyIsAbstention,
  classifyIsDocAnswer,
  classifyNeedsToolVerification,
  classifyPlanViolation,
  cleanInlinePathsIfAny,
} from './agent_classifiers'
import {
  type AdapterFactory,
  type AgentEvents,
  type AgentGateKind,
  type AgentThoughtKind,
  type HarnessAdapter,
  type RunInput,
  type RunResult,
} from './agent_harness_types'
import {
  storeMessage,
  fetchMessages,
  ensureDefaultConversation,
  DEFAULT_CONVERSATION_ID,
  storeMedia,
  storeThought,
  type ConversationMessage,
  type ToolCallRecord,
} from './agent_store_facade'
import {
  TurnRunner,
  DispatchRunner,
  Message,
  Tokenizable,
  Media,
  inMemoryMediaReader,
  isError,
  probeGpuBudget,
  type TurnContext,
  type TurnRunnerConfig,
  type TurnPipelineMiddlewareFn,
  type DispatchExecutorFn,
} from './agent_adk'
import {
  CAPACITY_MIN_CONTENT_WORDS,
  CAPACITY_RAG_SCORE_FLOOR,
  CHECKER_THOUGHT_ID,
  CONTEXT_OVERFLOW_RETRY_FLOOR,
  DYNAMIC_OUTPUT_BUDGET_ENABLED,
  IMAGE_TOKEN_COST,
  LITERT_ENGINE_MAX_TOKENS,
  MIN_ANSWER_TOKENS,
  RAG_TOPK,
  SCOPE_OUTPUT_FRACTION,
} from './agent_turn_policy'
export { DEFAULT_MODEL, listModels, modelSupportsThinking } from './agent_models'
export type { ModelChoice, ModelConfig } from './agent_models'
export type {
  AdapterFactory,
  AgentEvents,
  AgentGateKind,
  AgentPhase,
  AgentThoughtKind,
  HarnessAdapter,
  RunInput,
  RunResult,
} from './agent_harness_types'

export class AgentHarness {
  #adapter: HarnessAdapter | null = null
  #model: ModelChoice
  readonly #events: AgentEvents
  // Injected LLM-adapter factory (Node harness supplies an Ollama-backed one); undefined → built-in LiteRt.
  readonly #adapterFactory: AdapterFactory | null
  // Injected spool store (Node harness supplies an in-memory one); undefined → OpfsSpoolStore (browser).
  readonly #spoolStoreFactory: (() => unknown) | null
  // Sliding-window device-loss escalation decider for the worker-hosted engine (1st loss → respawn the
  // worker; 2nd inside the ~2-min window → page reload). Wired ONCE into the injected createEngine factory
  // and consulted on every ev:deviceLost from the worker. See gpu_loss_policy.ts.
  readonly #gpuLossPolicy = new GpuLossPolicy()
  // Re-entrancy guard: a device loss can fire while another recovery is already respawning the worker.
  // Prevents stacking dispose+preload cycles on the same loss burst.
  #respawningAfterLoss = false
  // The engine ceiling is large; the slider drives the thrift budget below it.
  #contextWindow = 8192
  // Output ceiling (the max-output slider). Default within the window's output reserve. The dialog
  // clamps it to [enough-to-call-a-tool, contextWindow/4] before setting it.
  #maxTokens = 2048
  // Whether to GENERATE the model's own reasoning/thinking channel. Default OFF (thinking burns the tool
  // loop). Controls GENERATION only, NOT display — the synthetic plan + nudge thoughts emit via onThought
  // regardless and are always shown; this only adds the model's own CoT to that stream. Applies on the
  // next adapter build (the dialog requires unload→load to change models/settings anyway).
  #enableThinking = false
  // OPT-IN: skip injecting the harness's synthetic THIS-turn thoughts (plan / nudge / cite / checker) into
  // the MODEL prompt (ctx.turnThoughts). Default false = inject them (Gemma relies on them as its own
  // reasoning-continuation, the book-end plan discipline). Set true for models whose chat template treats a
  // TRAILING ASSISTANT message as a completed turn and emits an immediate stop — synthetic thoughts render as
  // assistant messages and, being generated in response to the user turn, land LAST, so the prompt ends
  // assistant. Gemma/deepseek continue past that; qwen3-coder-next stops (observed: eval_count:1, empty
  // content, 300+ dispatch spin). This flag keeps them OUT of the prompt (they STILL emit to the UI via
  // onThought — display is unaffected) so the prompt ends on the user/tool turn the model expects. The plan
  // still governs tool routing + gates; only its in-prompt THOUGHT surface is suppressed.
  #injectSyntheticThoughts = true
  // CONTEXT-MANAGEMENT STRATEGY — the head-to-head experiment knob (confirmation-bias check). Which
  // cross-turn history-assembly + per-dispatch fit strategy this run uses. Default 'thrift' = the real
  // system (relevance-select ALL history via selectRelevantTurns + subtractive pass). The two BASELINES are
  // swapped in ONLY for the comparison run, same model/corpus/window — the thrift claim is model-agnostic
  // and tested head-to-head:
  //   'compact' — faithful Claude Code auto-compaction: keep the most-recent turns verbatim + a running
  //               structured summary of everything older, re-summarised by a SAME-MODEL dispatch when the
  //               kept history approaches the window. Lossy-but-bounded; the realistic competitor.
  //   'naive'   — the floor: keep the most-recent turns that fit, FIFO-drop the oldest, no relevance/summary.
  // Only the history-selection funnel (getSelectedTurns) branches on this; the per-dispatch subtractive pass
  // still runs for ALL strategies (so a baseline can't "silently overflow" — it just loses DETAIL, which is
  // exactly what the quality judge measures). Set via setContextStrategy (env ADK_CONTEXT_STRATEGY).
  #contextStrategy: 'thrift' | 'compact' | 'naive' = 'thrift'
  // COMPACT-arm running summary of older turns + the count of older turns it already covers (so we only
  // re-summarise when NEW turns have aged out past the keep-verbatim window). Reset on dispose/clear.
  #compactSummary: string | null = null
  #compactCoveredOlder = 0
  // Auto-resolution for the navigate-permission TurnGate (ctx.waitFor in navigate_to_page). In the browser
  // a human clicks allow/deny; a HEADLESS harness (the Node stress corpus) has no dialog, so an opened gate
  // never resolves and the whole run DEADLOCKS on that one dispatch (observed: kimi-k2.5 reached for
  // navigate_to_page as if it were a research step and wedged turn 1 forever — no socket, cputime frozen).
  // When set, the turnGateOpen observer auto-answers with this verdict instead of waiting on pendingGate.
  // null = browser behavior (surface to the dialog, wait for the human). false = auto-DENY (the faithful
  // headless default: the tool returns "user declined navigation", the turn continues, no real navigation
  // happens in a non-DOM environment anyway).
  #autoGateVerdict: boolean | null = null

  constructor(
    model: ModelChoice,
    events: AgentEvents = {},
    // Optional injection seams for the portable Node harness (see tests/agent/_harness/*). Both default to the
    // browser implementations, so the live app constructs `new AgentHarness(model, events)` exactly as before.
    opts?: {
      adapterFactory?: AdapterFactory
      spoolStoreFactory?: () => unknown
      // Headless auto-ack for the navigate TurnGate. Pass false (or true) in the Node harness so a model that
      // calls navigate_to_page doesn't deadlock waiting on a dialog that isn't there. See #autoGateVerdict.
      autoGateVerdict?: boolean
    }
  ) {
    this.#model = model
    this.#events = events
    this.#adapterFactory = opts?.adapterFactory ?? null
    this.#spoolStoreFactory = opts?.spoolStoreFactory ?? null
    this.#autoGateVerdict = opts?.autoGateVerdict ?? null
    // Kick off @nhtio/encoder wiring (registers ADK encodables + resolves the OPFS root for the
    // spool-reader resolver). Awaited before the first decode in fetchToolCallsCallback.
    void ensureEncoderWiring()
  }

  get model(): ModelChoice {
    return this.#model
  }

  setContextWindow(n: number): void {
    this.#contextWindow = n
  }

  /** Set the output-token ceiling. Applies to the NEXT adapter build (call before preload, or it
   *  takes effect after the next unload→load). The dialog clamps the value before calling. */
  setMaxTokens(n: number): void {
    this.#maxTokens = n
  }

  /** The ACTUAL output-token cap to apply for a turn, given the planner's committed answer_scope. The
   *  user's slider (`#maxTokens`) is the hard ceiling; `scope` tightens BELOW it (never above) so a
   *  `brief` answer is asked to finish inside a small budget. Undefined scope (no plan, or a non-answer
   *  dispatch) returns the full ceiling — today's behavior, the safe default. Floored at
   *  MIN_ANSWER_TOKENS so a too-low slider can't starve the answer below "enough to finish". */
  #resolveOutputTokens(scope?: PlanAnswerScope): number {
    // Flag off → the lever is inert (full ceiling), so both the stash write and the scoped plan-thought
    // words collapse to pre-change behavior. Lets the incomplete-answer gate be evaluated on its own.
    if (!DYNAMIC_OUTPUT_BUDGET_ENABLED || !scope) return this.#maxTokens
    const scaled = Math.round(this.#maxTokens * SCOPE_OUTPUT_FRACTION[scope])
    return Math.max(Math.min(scaled, this.#maxTokens), Math.min(MIN_ANSWER_TOKENS, this.#maxTokens))
  }

  /** Output budget expressed as an approximate word count, for telling the planner/worker how long the
   *  answer may be before the max-output cap truncates it. ~0.75 English words per token (conservative),
   *  floored and rounded to a friendly figure so it reads as a soft target. Single source of truth so
   *  the planner prompt and the plan-thought agree. Pass the turn's `scope` so the words target reflects
   *  the SCOPED cap actually applied (see {@link #resolveOutputTokens}); omit it for the ceiling (the
   *  planner prompt, which describes the max BEFORE a scope is chosen). */
  #budgetWords(scope?: PlanAnswerScope): number {
    return Math.max(40, Math.round((this.#resolveOutputTokens(scope) * 0.75) / 10) * 10)
  }

  /** Turn the model's reasoning/thinking channel on/off. Applies on the next adapter build. */
  setEnableThinking(on: boolean): void {
    this.#enableThinking = on
  }

  /**
   * Opt out of injecting the harness's synthetic this-turn thoughts (plan/nudge/cite/checker) into the model
   * prompt. Pass `false` for a model whose chat template stops on a trailing-assistant turn (e.g.
   * qwen3-coder-next). The thoughts still emit to the UI; only their in-prompt surface is suppressed. Default
   * on. Takes effect on the next turn.
   */
  setInjectSyntheticThoughts(on: boolean): void {
    this.#injectSyntheticThoughts = on
  }

  /**
   * Set the context-management strategy for the head-to-head comparison (see #contextStrategy).
   * 'thrift' (default, the real system) | 'compact' (faithful Claude Code auto-compaction baseline) |
   * 'naive' (FIFO-drop floor). Only affects cross-turn history assembly; the per-dispatch subtractive pass
   * always runs. Takes effect on the next turn.
   */
  setContextStrategy(strategy: 'thrift' | 'compact' | 'naive'): void {
    this.#contextStrategy = strategy
  }

  /** Swap the model — disposes the old adapter (frees ONNX/WebGPU) and forces a reload. */
  async setModel(model: ModelChoice): Promise<void> {
    if (model === this.#model && this.#adapter) return
    await this.dispose()
    this.#model = model
  }

  #ensureAdapter(): HarnessAdapter {
    if (!this.#adapter) {
      // Injected factory path (Node harness → Ollama battery). Bypasses the LiteRt-specific config below.
      if (this.#adapterFactory) {
        this.#adapter = this.#adapterFactory(this.#model, {
          maxTokens: this.#maxTokens,
          enableThinking: this.#enableThinking,
          contextWindowMax: LITERT_ENGINE_MAX_TOKENS,
          contextWindow: this.#contextWindow,
        })
        return this.#adapter
      }
      const cfg = MODELS[this.#model]
      this.#adapter = makeLiteRtAdapter(
        cfg,
        {
          model: cfg.model,
          createEngine: createWorkerEngine({
            onDeviceLost: ({ reason }) => {
              void this.#onEngineDeviceLost(reason)
            },
            onProgress: () => {
              this.#events.onLoadProgress?.({ file: '', pct: null })
            },
          }),
          enableThinking: this.#enableThinking,
          maxTokens: this.#maxTokens,
          maxNumTokens: LITERT_ENGINE_MAX_TOKENS,
          contextWindow: Math.max(2048, this.#contextWindow - this.#maxTokens),
        } as never,
        this.#events,
        () => (this.#spoolStoreFactory?.() ?? makeDefaultSpoolStore()) as never
      )
    }
    return this.#adapter
  }

  /** Preload the model (the ~2GB cold load) without running a turn. RAG is NOT seeded here —
   *  it is lazy (only when search runs / pre-fetch fires), so model-load progress is the only
   *  thing the user waits on. */
  async preload(): Promise<void> {
    this.#events.onPhase?.('loading')
    const adapter = this.#ensureAdapter()
    // The WebGPU budget is DEVICE-level (not model-level) — known the moment WebGPU is available, so
    // probe it now and surface it immediately, rather than waiting for the first generate's `ready`
    // lifecycle. Lets the user size the context window against real hardware before sending a turn.
    try {
      const b = await probeGpuBudget()
      if (b.available) {
        this.#events.onGpuBudget?.({
          maxBufferMB: Math.round(b.maxBufferBytes / (1024 * 1024)),
          available: true,
        })
      }
    } catch {
      /* budget is observability-only; never block load on it */
    }
    // ACTUALLY load the engine here (the ~2GB fetch + WebGPU compile). #ensureAdapter only CONSTRUCTS the
    // adapter; the heavy load is otherwise deferred to the first generate — which made "Load" return
    // instantly and the loading banner flash away, then the real wait happened (mislabelled "Thinking…")
    // on the first turn. Awaiting adapter.preload() keeps phase='loading' for the whole download/compile,
    // so the banner reflects reality. The onInitProgress hook (wired in #ensureAdapter) drives the bar.
    try {
      // Ollama (Node harness) has no cold-load, so its adapter omits preload() — skip when absent.
      await adapter.preload?.()
    } finally {
      this.#events.onPhase?.('idle')
    }
  }

  async dispose(): Promise<void> {
    const a = this.#adapter as unknown as {
      dispose?: () => Promise<void> | void
    } | null
    if (a?.dispose) {
      try {
        await a.dispose()
      } catch {
        /* best-effort */
      }
    }
    this.#adapter = null
  }

  /**
   * Free the WebGPU device memory the loaded engine retains, WITHOUT switching models. Delegates to the
   * adapter's `recycle()` (`dispose()` then `preload()` the same model).
   *
   * @remarks
   * With the disposable-Worker engine injected (browser path), this is now a TOTAL teardown: the adapter's
   * `dispose()` → the worker-proxy engine's `delete()` → `worker.terminate()` (destroys the whole WebGPU
   * context + WASM heap + every dead ref in one OS-level op), and `preload()` spawns a FRESH worker that
   * calls requestAdapter() from a clean context. This is what lets recycling actually recover from a
   * WebGPU device loss — a main-thread `dispose()` only nulls JS refs and reuses the dead GPUAdapter. It is
   * also the consumer-facing lever surfaced after an OOM (reclaim the retained working-set, then Retry on a
   * smaller window). Re-incurs the cold load (download cached; WebGPU graph/shader compile is not), so we
   * drive the load phase.
   */
  async recycle(): Promise<void> {
    const a = this.#adapter as unknown as {
      recycle?: () => Promise<void>
    } | null
    if (!a?.recycle) {
      // No adapter loaded yet — nothing retained to free.
      return
    }
    this.#events.onPhase?.('loading')
    this.#events.onActivity?.('Freeing GPU memory…')
    try {
      await a.recycle()
    } finally {
      this.#events.onActivity?.('')
      this.#events.onPhase?.('idle')
    }
  }

  /**
   * Handle a WebGPU device loss reported by the worker-hosted engine (driver TDR / GPU-process crash). The
   * engine is dead — no in-flight or future turn can complete on it. Runs the escalation ladder:
   *
   *   1st loss in the ~2-min window → RESPAWN: terminate the dead worker + spawn a fresh one (adapter
   *     `recycle()`), so a transient TDR recovers with a clean GPU context. History is untouched (SQLite).
   *   2nd loss inside the window → RELOAD: emit `onDeviceLost({action:'reload'})` and let the dialog do a
   *     `window.location.reload()`. Chrome blocks new GPU adapters after 2 GPU-process crashes in ~2 min;
   *     only a reload clears it. The reload is lossless — the conversation lives in SQLite.
   *
   * Re-entrancy-guarded so a burst of loss events on one crash doesn't stack recovery cycles.
   */
  async #onEngineDeviceLost(reason: string): Promise<void> {
    const action = this.#gpuLossPolicy.record()
    // Always tell the app first (transient "recovering" banner for respawn; reload trigger for reload).
    this.#events.onDeviceLost?.({ action, reason })
    if (action === 'reload') {
      // The dialog owns the actual reload (it can show a banner first). Nothing more to do here — do NOT
      // respawn, the reload supersedes it.
      return
    }
    if (this.#respawningAfterLoss) return
    this.#respawningAfterLoss = true
    this.#events.onPhase?.('loading')
    this.#events.onActivity?.('Recovering the GPU engine…')
    try {
      await this.recycle()
    } catch {
      // A respawn that itself fails escalates the same way a repeat loss would — force a reload.
      this.#events.onDeviceLost?.({ action: 'reload', reason })
    } finally {
      this.#respawningAfterLoss = false
      this.#events.onActivity?.('')
      this.#events.onPhase?.('idle')
    }
  }

  /**
   * DEV/TEST-ONLY: simulate a WebGPU device loss on the worker-hosted engine to exercise the
   * respawn→reload escalation ladder end-to-end (the worker destroys its sentinel device, which fires the
   * same `ev:deviceLost` path a real driver TDR takes). Reaches the worker proxy via the adapter's already-
   * resolved engine. No-op when no worker engine is loaded (Node/Ollama path, or before preload). Returns
   * true when the force was dispatched. Gated by the dialog behind `__ADK_WIRETAP__` so it never ships.
   */
  async forceDeviceLostForTest(): Promise<boolean> {
    const a = this.#adapter as unknown as {
      preload?: () => Promise<unknown>
    } | null
    if (!a?.preload) return false
    let engine: unknown
    try {
      engine = await a.preload()
    } catch {
      return false
    }
    const proxy = asWorkerEngineProxy(engine)
    if (!proxy) return false
    await proxy.forceDeviceLost()
    return true
  }

  /**
   * COMPACT-arm summariser (head-to-head baseline only; see #contextStrategy === 'compact'). The shipped
   * `@nhtio/adk/batteries/context/compact` battery owns the summarise/assemble ALGORITHM (keep-verbatim
   * window, summarise-at-tokens threshold, request assembly, the faithful Claude Code compaction prompt) —
   * this is now just the injected {@link CompactSummarizeFnType} model-call seam the battery cannot bundle
   * itself. Same dispatch shape as #classifyBinary (all-noop callbacks, output captured via storeMessage),
   * maxTokens ~1200, toolCallParser 'none'. A failed summariser degrades to the prior summary (or empty) —
   * the baseline just keeps less, matching the original inline behavior.
   *
   * CRITICAL BEHAVIORAL NOTE (reported per the "consume the shipped battery" mission, not silently
   * adapted): the battery's own `summariseTurns` measures `onCost`'s `inTok` over `` `${system}\n\n${text}` ``
   * (system prompt INCLUDED), whereas this showcase's prior local copy measured `inTok` over the user-content
   * text ALONE (no system prompt). Delegating to the battery's `onCost` as-is would inflate the recorded
   * compact-arm token cost by the (fixed, ~400-token) system-prompt size on every summarisation call,
   * changing the honest head-to-head token-cost comparison the demo reports. Preserved the ORIGINAL
   * (narrower) measurement here by NOT using the battery's built-in `onCost` plumbing for the ring counter —
   * instead this method now measures `inTok`/`outTok` itself, the same way the pre-refactor code did, and
   * pushes to `__agentCompactionCost` directly. The battery's `summariseTurns` char-cap
   * (`HISTORY_TEXT_CHAR_CAP`) also differs subtly in SCOPE from the pre-refactor local slice (see this
   * method's caller, {@link CompactAssembleCompactedTurnsOptionsType}'s wiring below, for the corresponding
   * note) — both are flagged in the final report as CRITICAL drift, not silently reconciled beyond what's
   * done here.
   */
  #buildCompactSummarizer(): CompactSummarizeFnType {
    return async ({ system, text }: { system: string; text: string }): Promise<string> => {
      const adapter = this.#ensureAdapter()
      let out = ''
      const noop = async (): Promise<void> => undefined
      const noopArr = async (): Promise<never[]> => []
      try {
        await DispatchRunner.dispatch({
          raw: {
            systemPrompt: new Tokenizable(system),
            standingInstructions: [],
            messages: [
              new Message({
                id: `__compact-${Date.now()}`,
                role: 'user',
                content: text,
                createdAt: new Date().toISOString(),
                updatedAt: new Date().toISOString(),
              }),
            ],
            fetchMemories: noopArr,
            fetchRetrievables: noopArr,
            fetchMessages: noopArr,
            fetchThoughts: noopArr,
            fetchToolCalls: noopArr,
            fetchTools: noopArr,
            refreshStandingInstructions: noopArr,
            storeStandingInstruction: noop,
            mutateStandingInstruction: noop,
            deleteStandingInstruction: noop,
            storeMemory: noop,
            mutateMemory: noop,
            deleteMemory: noop,
            storeRetrievable: noop,
            mutateRetrievable: noop,
            deleteRetrievable: noop,
            storeMessage: async (_c: unknown, v: { content?: { toString(): string } }) => {
              out += v.content?.toString?.() ?? ''
            },
            mutateMessage: noop,
            deleteMessage: noop,
            storeThought: noop,
            mutateThought: noop,
            deleteThought: noop,
            storeToolCall: noop,
            mutateToolCall: noop,
            deleteToolCall: noop,
            storeMediaBytes: noop,
            storeRetrievableBytes: noop,
          } as never,
          executor: await adapter.executor({
            maxTokens: 1200,
            toolCallParser: 'none',
            stream: false,
            autoAck: true,
            enableThinking: false,
          }),
        })
      } catch (err) {
        if (isGpuOomError(err)) throw err
        // A failed summariser degrades to empty text — assembleCompactedTurns's own `|| priorSummary`
        // fallback (mirroring the pre-refactor inline degrade-to-prior-summary behavior) takes it from here.
        return ''
      }
      // Record the summariser token cost (input estimate + output) for the token-cost comparison — this is
      // the overhead the compact baseline pays and thrift does not. Measured over `text` ALONE (no system
      // prompt), matching the pre-refactor local behavior exactly (see this method's TSDoc, CRITICAL note).
      const ring = ((
        globalThis as unknown as {
          __agentCompactionCost?: Array<{ inTok: number; outTok: number }>
        }
      ).__agentCompactionCost ??= [])
      const summary = out.trim()
      ring.push({
        inTok: Tokenizable.estimateTokens(text, 'gemma'),
        outTok: Tokenizable.estimateTokens(summary, 'gemma'),
      })
      return summary
    }
  }

  /**
   * COMPACT-arm history assembler (baseline; #contextStrategy === 'compact'). Delegates the keep-verbatim /
   * summarise-at-tokens / rolling-summary algorithm to the shipped
   * `@nhtio/adk/batteries/context/compact` battery's `assembleCompactedTurns` (via the agent_adk.ts shim),
   * threading `#compactSummary`/`#compactCoveredOlder` through as `priorState` in and writing the returned
   * `{ summary, coveredOlder }` back — the battery is stateless between calls by design (see its own TSDoc),
   * so this class's private fields are now the caller-side persistence the battery expects.
   *
   * CRITICAL BEHAVIORAL NOTE (char-cap scope — reported, not silently adapted): the battery's
   * `summariseTurns` slices `` (priorSummaryPrefix + historyText) `` TOGETHER at a combined 12,000-char cap,
   * whereas the pre-refactor local `#summariseTurns` — due to operator precedence in
   * `` `CONVERSATION TO SUMMARISE:\n${historyText}`.slice(0, 12000) `` — capped ONLY the historyText
   * template literal, leaving a prepended `priorSummary` UNBOUNDED. Delegating to the battery therefore
   * changes the cap's scope: after several rounds of growth, `priorSummary` could now consume some/most of
   * the 12k budget that previously went entirely to fresh historyText. Not reconciled here (doing so would
   * require bypassing the battery's own internal request assembly, defeating the "consume the shipped API"
   * goal) — flagged as CRITICAL drift in the final report per the mission's explicit instruction.
   */
  async #assembleCompactedTurns(
    turns: ReadonlyArray<ThriftHistoryTurnType<ConversationMessage, ToolCallRecord>>
  ): Promise<Array<ThriftHistoryTurnType<ConversationMessage, ToolCallRecord>>> {
    // keepVerbatim/summariseAtTokens match the battery's own defaults (DEFAULT_KEEP_VERBATIM=2,
    // DEFAULT_SUMMARISE_AT_TOKENS=2500) — confirmed byte-identical to the pre-refactor local constants
    // they replace; systemPrompt is passed explicitly (even though it also matches the battery default)
    // for self-documentation, mirroring agent_subtractive_pass.ts's convention.
    const result = await assembleCompactedTurns<ConversationMessage, ToolCallRecord>(turns, {
      summarize: this.#buildCompactSummarizer(),
      estimateTokens: (v, enc, ctx) => Tokenizable.estimateTokens(v, enc, ctx as never),
      encoding: 'gemma',
      systemPrompt: COMPACTION_SYSTEM_PROMPT,
      priorState: { summary: this.#compactSummary, coveredOlder: this.#compactCoveredOlder },
    })
    this.#compactSummary = result.summary
    this.#compactCoveredOlder = result.coveredOlder
    return result.turns
  }

  /**
   * Surface a context overflow through the SAME observability channels the other rails use: an onGate
   * `context-overflow` event (so it lands in the instrumentation / "what happened" stream alongside the
   * behavioral gates), plus onPhase('error') + onActivity(''). This is what "emit through the appropriate
   * observability channels" means — the overflow is a first-class, observed event, not a swallowed
   * string. Idempotent-safe to call from both the nack and error seams (the dialog dedupes by content).
   */
  #emitContextOverflow(err: unknown): void {
    const msg = isError(err) ? err.message : String(err)
    this.#events.onGate?.({ gate: 'context-overflow', detail: msg })
    this.#events.onPhase?.('error')
    this.#events.onActivity?.('')
  }

  /**
   * Run one turn, with a bounded ONE-SHOT context-overflow recovery. Defense-in-depth layer 3 (after
   * thrift + the typed guard): if a dispatch still overflows the engine cap, tighten this turn's effective
   * context window a step and re-run the SAME turn ONCE so the subtractive pass sheds harder. If it
   * overflows again, surface the typed banner (layer 4). Never loops — a single retry, then give up
   * cleanly. Any non-overflow outcome (success, OOM, refusal) returns immediately without a retry.
   */
  async run(input: RunInput): Promise<RunResult> {
    const first = await this.#runTurnOnce(input)
    // Two recovery signals share ONE machinery — shrink the effective window a step so the subtractive pass
    // sheds harder, then re-run the SAME turn:
    //   • contextOverflow — the prompt exceeded the engine cap (one-shot: shed once, then surface the banner).
    //   • contextPoisoned  — the model surfaced an internal trust-tier fence twice this turn; its context is
    //     poisoned by accumulated thrash. Per the user's spec, ESCALATE: shed and retry PROGRESSIVELY until
    //     the model answers clean (result no longer poisoned) or we hit the window floor — at which point
    //     #runTurnOnce's own LEAK_GATE_LIMIT/backstop ships a clean gracefulFallback, never the raw fence.
    if (!first.contextOverflow && !first.contextPoisoned) return first
    const savedWindow = this.#contextWindow
    try {
      let result = first
      // Bounded by the floor: 8192 → 6144 → 4608 → 3456 → 2592 → 2048 is a handful of steps, so this cannot
      // spin. Overflow only ever takes the first step (its own recovery is one-shot by contract); poison
      // keeps stepping while it remains poisoned.
      while (result.contextOverflow || result.contextPoisoned) {
        const tightened = Math.max(
          CONTEXT_OVERFLOW_RETRY_FLOOR,
          Math.floor(this.#contextWindow * 0.75)
        )
        const atFloor = tightened >= this.#contextWindow
        if (atFloor) {
          // Can't shed any further. Overflow → surface the too-long banner (return as-is). Poison → do ONE
          // final attempt that FORCES a clean fallback on any further leak, so the turn resolves with a real
          // reply instead of returning an empty poisoned result.
          if (result.contextOverflow) return result
          this.#events.onActivity?.('Refocusing — shedding and retrying…')
          return await this.#runTurnOnce(input, {
            isOverflowRetry: true,
            forceFallbackOnLeak: true,
          })
        }
        this.#events.onActivity?.(
          result.contextOverflow
            ? 'Context too long — shedding and retrying…'
            : 'Refocusing — shedding and retrying…'
        )
        this.setContextWindow(tightened)
        const wasOverflow = result.contextOverflow === true
        result = await this.#runTurnOnce(input, { isOverflowRetry: true })
        // Overflow recovery is one-shot (existing contract): after one shed, return whatever we got so the
        // too-long banner can surface if it overflowed again. Poison recovery keeps escalating.
        if (wasOverflow && !result.contextPoisoned) return result
      }
      return result
    } finally {
      this.setContextWindow(savedWindow)
    }
  }

  /**
   * Run one turn end-to-end through a TurnRunner. Persists the user message first (so the
   * turn's history fetch includes it), wires hydration + loop control into the right pipelines,
   * streams the answer, and lets the adapter persist the final assistant turn via the callback.
   */
  async #runTurnOnce(
    input: RunInput,
    opts?: { isOverflowRetry?: boolean; forceFallbackOnLeak?: boolean }
  ): Promise<RunResult> {
    // Immediate sign of life: a cold turn includes the ~2GB load — surface it BEFORE any await.
    this.#events.onPhase?.(this.#adapter ? 'generating' : 'loading')
    this.#events.onActivity?.(this.#adapter ? 'Thinking…' : 'Loading the model…')

    const convId = await ensureDefaultConversation()
    const stamp = Date.now()
    // The dialog persists the user message before the (slow) turn so the human sees it instantly,
    // then passes its id here. When it does, reuse that id and DON'T re-store (no duplicate row).
    // When it doesn't (programmatic caller), persist the user message ourselves.
    const userMessageId = input.userMessageId ?? `u-${stamp}`
    const mediaId = `mm-${stamp}`
    // On an OVERFLOW RETRY the user message + media are ALREADY persisted from the first attempt — the
    // retry only re-runs the model with a tighter window, so skip re-persisting (a fresh Date.now()
    // mediaId would otherwise duplicate the media row, and the message row is upserted by id but there's
    // no reason to touch it). The retry reuses the same conversation so fetchMessages still sees them.
    const isOverflowRetry = opts?.isOverflowRetry === true
    // Set by run() ONLY on the final poison retry at the window floor: if the model STILL leaks a fence,
    // commit a clean gracefulFallback instead of raising contextPoisoned again (which would return an empty
    // answer). Guarantees the escalation always terminates with a real, clean reply — never the plumbing,
    // never blank.
    const forceFallbackOnLeak = opts?.forceFallbackOnLeak === true
    if (!input.userMessageId && !isOverflowRetry) {
      // Persist the user message NOW (HUMAN plane) so the turn's fetchMessages includes it. Use a
      // stable id so an uploaded image can be linked to this exact message row.
      await storeMessage(convId, {
        id: userMessageId,
        role: 'user',
        content: input.text,
      })
    }
    // Persist uploaded media bytes durably (survives reload; rendered in history). This is the
    // user-upload path — the adapter's storeMediaBytes callback covers TOOL-generated media.
    if (input.image && !isOverflowRetry) {
      await storeMedia({
        id: mediaId,
        conversationId: convId,
        messageId: userMessageId,
        kind: 'image',
        mimeType: input.image.mimeType,
        filename: 'attachment.png',
        origin: 'user-upload',
        bytes: input.image.bytes,
      })
    }

    const adapter = this.#ensureAdapter()
    const path = currentPath()

    // BASELINE PRE-RETRIEVAL — run ONCE, memoized, and reused by BOTH the planner (which counts the
    // high-confidence hits to decide capacity_scoped) and fetchRetrievablesCallback (which seeds the turn's
    // retrievables). It depends only on the user text + current path, so it's safe to run before the
    // planner. Memoized so the turn never double-searches. Fail-open to [] (retrieval must never block a
    // turn). See RAG_TOPK / CAPACITY_RAG_* for the breadth signal.
    let prefetchedHitsPromise: Promise<DocHit[]> | null = null
    const getPrefetchedHits = (): Promise<DocHit[]> => {
      if (!prefetchedHitsPromise) {
        prefetchedHitsPromise = searchSemantic(input.text, {
          topK: RAG_TOPK,
          currentPath: path,
        }).catch(() => [] as DocHit[])
      }
      return prefetchedHitsPromise
    }

    // The image working-set entry (token weight, for the subtractive pass) + the image-bearing
    // user Message (the multimodal carrier the adapter decodes). On-thesis: the image is decoded
    // for the MODEL only on its upload turn; the subtractive pass may still shed it (media is the
    // biggest hog). Past images stay persisted + rendered for the human, not re-fed to the model.
    const image: WorkingImage | undefined = input.image
      ? { label: 'attached image', tokenCost: IMAGE_TOKEN_COST }
      : undefined
    const imageMessage: Message | null = input.image
      ? new Message({
          id: userMessageId,
          role: 'user',
          content: input.text,
          attachments: [
            Media.userAttachment({
              id: mediaId,
              kind: 'image',
              mimeType: input.image.mimeType,
              filename: 'attachment.png',
              reader: inMemoryMediaReader(input.image.bytes),
            }),
          ],
          createdAt: new Date().toISOString(),
          updatedAt: new Date().toISOString(),
        })
      : null

    // Turn-local mutable state shared across pipelines.
    const state: TurnState = {
      lastTrace: null,
      refused: false,
      answer: '',
      lastReportedTool: null,
      dispatchError: null,
      dispatchErrorIsOom: false,
      dispatchErrorIsOverflow: false,
      dispatchErrorIsWindowTooSmall: false,
      turnPoisoned: false,
      sawSourcelessAnswer: false,
      captured: null,
      proseAnswer: '',
      lastRejectedProse: '',
      repeatedProseCount: 0,
      noProgressProseCount: 0,
      bestOwnWordsProse: '',
      incompleteAnswerCount: 0,
      fakeCitationCount: 0,
      leakedEnvelopeCount: 0,
      emptyGenCount: 0,
      retrievalAbstentionCount: 0,
      tooLargeHandles: new Set<string>(),
      turnPlan: null,
      turnThoughtRecords: new Map<string, { kind: AgentThoughtKind; text: string }>(),
      activeNudges: new Map<AgentGateKind, string>(),
      activeNudgeThoughts: new Map<AgentGateKind, string>(),
      groundingSources: [],
      selectedTurnsPromise: null,
      seededIds: new Set<string>(),
      priorTurnToolCallIds: new Set<string>(),
      priorThoughtIds: new Set<string>(),
      prevToolCallCount: 0,
    }
    const emitThought = makeEmitThought({ state, events: this.#events })
    // A CHECKER announces — in the worker's own first-person voice — the criterion it is about to weigh
    // the latest generation against, BEFORE it accepts/rejects. Per the user: "give the checker a
    // synthetic thought of 'I must evaluate if this XXXX' where XXXX is the criteria". Surfaces the rails
    // *deciding* in the Thinking panel (showcase), and doubles as channel-2 self-talk the 2B is likelier
    // to align with than to argue against. `criteria` is the bar in plain words (e.g. "answer actually
    // came from the provide_answer tool, not just inline prose"). One stable id → the panel always shows
    // the most recent criterion under evaluation. Turn-scoped; never replayed into the model next turn.
    const emitCheckerThought = makeEmitCheckerThought({
      emitThought,
      checkerThoughtId: CHECKER_THOUGHT_ID,
    })

    const { registry, defaultVisible } = buildToolRegistry({
      currentPath: () => path,
      navigateInternal,
      onAnswer: (a) => {
        state.captured = a
      },
      groundSources: () => state.groundingSources,
    })

    // Build the catalog blurb (name — description) the planner sees, from the full registry. Hidden
    // tools are included — the planner's job is to pick the RIGHT tool, including ones the worker must
    // reach via call_a_tool. This is the list that stops the planner inventing tool names.
    const toolBlurbs = registry
      .all()
      .map((t) => `- ${t.name} — ${t.description ?? ''}`)
      .join('\n')

    // PLANNER BOOK-END (open). Decide how to answer up front, against the real catalog. Fail-open:
    // a null plan leaves the loop unplanned (prior behaviour). The plan seeds a synthetic this-turn
    // thought (iteration 0) and is validated at close. An OOM here rethrows like any generate OOM.
    this.#events.onActivity?.('Planning…')
    // Give the planner the IMMEDIATELY-PRIOR turn (last assistant answer + the user question that
    // prompted it) so it can tell a fresh library question from a REACTION to what was just said. Without
    // this, a coreferent follow-up like "so it's not much of a kit, is it?" has no signal it's a follow-up
    // and misclassifies as doc_cited. The current user message is already persisted (above), so it is the
    // LAST row — the prior turn is the assistant/user pair before it. Best-effort: a load failure just
    // yields no prior context (the planner falls back to its default classification).
    let priorContext: string | undefined
    let priorAnswer: string | undefined
    try {
      const rows = await fetchMessages(convId)
      const priorAssistantIdx = (() => {
        // Walk back from the end, skipping the just-persisted current user message, to the most recent
        // assistant answer.
        for (let i = rows.length - 1; i >= 0; i--) {
          if (rows[i].role === 'assistant') return i
        }
        return -1
      })()
      if (priorAssistantIdx >= 0) {
        const prevAssistant = rows[priorAssistantIdx]
        priorAnswer = prevAssistant.content
        // The user question that prompted that answer = the nearest user row before it.
        let prevUser: (typeof rows)[number] | undefined
        for (let i = priorAssistantIdx - 1; i >= 0; i--) {
          if (rows[i].role === 'user') {
            prevUser = rows[i]
            break
          }
        }
        const clip = (s: string, n = 500): string => (s.length > n ? s.slice(0, n) + '…' : s)
        priorContext =
          'The immediately-PRIOR turn of this conversation (for context — the user may be reacting ' +
          'to it):\n' +
          (prevUser ? `  · The user previously asked: "${clip(prevUser.content)}"\n` : '') +
          `  · You previously answered: "${clip(prevAssistant.content)}"`
      }
    } catch {
      /* best-effort — no prior context on load failure */
    }
    // DETERMINISTIC capacity signal: how many pre-retrieved passages are strongly on-topic. Many ⇒ the
    // request spans a lot of material ⇒ capacity_scoped (won't fit). Computed from the SAME memoized
    // pre-retrieval the turn reuses. Fed to the planner (drives its capacity synthetic thought) AND used
    // for the post-plan override below.
    const prefetchedHits = await getPrefetchedHits()
    const highConfidenceHits = prefetchedHits.filter(
      (h) => h.score >= CAPACITY_RAG_SCORE_FLOOR
    ).length
    // NEVER-SHED citation set. The subtractive pass can evict ctx.turnRetrievables at high util (observed:
    // an extreme capacity turn hit 100% and the RAG was shed, so the iteration backstop had nothing to
    // cite → uncited). These pre-fetched hits are captured HERE, before the loop, and never shed — so the
    // backstop / capacity accept / dup-stop rescue can always cite real pages even when the working-set
    // retrievables are gone. Deduped by page path, highest-score first, capped for chip sanity.
    const prefetchedSources: Array<{ path: string; title: string }> = (() => {
      const seen = new Set<string>()
      const out: Array<{ path: string; title: string; score: number }> = []
      for (const h of prefetchedHits) {
        const srcPath = (h.pageUrl || '').replace(/#.*$/, '')
        if (!srcPath.startsWith('/') || seen.has(srcPath)) continue
        seen.add(srcPath)
        out.push({ path: srcPath, title: h.title || srcPath, score: h.score })
      }
      return out
        .sort((a, b) => b.score - a.score)
        .slice(0, 6)
        .map((s) => ({ path: s.path, title: s.title }))
    })()
    // Publish the never-shed real pages to the tool-deps grounding holder, so a good-but-sourceless
    // provide_answer is delivered grounded from these instead of hard-erroring (Thread A+B fix).
    state.groundingSources = prefetchedSources
    state.turnPlan = await makePlan(adapter, {
      userText: input.text,
      toolBlurbs,
      priorContext,
      priorAnswer,
      highConfidenceHits,
      budgetWords: this.#budgetWords(),
      model: this.#model,
      maxTokens: this.#maxTokens,
      contextWindow: this.#contextWindow,
    })
    // POST-PLAN DETERMINISTIC OVERRIDE — the reliability guarantee. The 2B classifies a genuinely-broad
    // "give me everything" request as doc_cited most of the time (docs DO answer it; it just can't be
    // rendered in FULL), and neither the prompt bullet nor the synthetic thought flips it reliably. So when
    // the breadth signal is unmistakable — explicit-exhaustive phrasing OR many high-confidence hits — and
    // the planner still committed doc_cited, we rewrite the kind to capacity_scoped here. Safe because
    // capacity_scoped is delivery-compatible with doc_cited (both search + cite via provide_answer, same
    // close gates); we only change the VOICE (honest decline + pointer) and ensure a search tool is present.
    if (
      state.turnPlan &&
      state.turnPlan.answerKind === 'doc_cited' &&
      requestIsOverScope(input.text, highConfidenceHits)
    ) {
      const tools = state.turnPlan.toolsToUse.includes('search_docs_semantic')
        ? state.turnPlan.toolsToUse
        : [...state.turnPlan.toolsToUse, 'search_docs_semantic']
      state.turnPlan = {
        ...state.turnPlan,
        answerKind: 'capacity_scoped',
        answerScope: 'brief',
        toolsToUse: tools,
      }
      this.#events.onActivity?.('Planning…')
    }

    // PLANNER AS TOOL ROUTER. The planner saw the WHOLE catalog and picked the tools for this turn;
    // surface those directly to the worker (on top of the always-visible defaults) so it can call them
    // NATIVELY — no tool_catalog → call_a_tool browse step, which a small model cannot reliably perform
    // (observed: it narrates "I reviewed the catalog" without ever issuing an artifact_* read). The
    // thrift lever survives: only this shortlist is visible, not all ~60 tools. Plan names are
    // validated against the registry so a hallucinated name can't widen the set. This is ADDITIVE to
    // the gates — the plan-conformance / unread-handle / unverified-claim rails all still apply.
    const known = new Set(registry.all().map((t) => t.name))
    const routedTools = (state.turnPlan?.toolsToUse ?? []).filter((t) => known.has(t))
    // When the plan committed to a CONVERSATIONAL reply, expose NO tools at all — a greeting / small-talk
    // turn must be answered in plain prose, and leaving tool_catalog / provide_answer visible is exactly
    // what lured the 2B into browsing the catalog (and dumping it) or firing provide_answer with an
    // invented source. With nothing to reach for, the only move left is to answer. (The close gate +
    // 'answer in prose' prompt back this up; this removes the temptation at the source.) Any other plan
    // gets the always-visible defaults plus the planner's routed shortlist.
    const visibleToolNames =
      state.turnPlan?.answerKind === 'conversational' || state.turnPlan?.answerKind === 'rhetorical'
        ? []
        : [...new Set([...defaultVisible, ...routedTools])]

    // The navigate-permission gate (ctx.waitFor) is opened by the navigate tool and wired by
    // TurnRunner to its TurnGate; we surface it to the dialog below via the `turnGateOpen`
    // observability event (no bespoke opener needed — TurnRunner owns the gate lifecycle).

    // ── Persistence + retrieval callbacks (the real integration surface) ───────
    const turnCallbacks = makeTurnCallbacks({
      convId,
      state,
      contextWindow: this.#contextWindow,
      maxTokens: this.#maxTokens,
      contextStrategy: this.#contextStrategy,
      inputText: input.text,
      ensureEncoderWiring,
      assembleCompactedTurns: (turns) => this.#assembleCompactedTurns(turns),
      getPrefetchedHits,
      readToolResultPreview,
      readModelFacingPreview,
    })
    const {
      noop2,
      storeMediaBytesCallback,
      fetchMessagesCallback,
      fetchMemoriesCallback,
      fetchToolCallsCallback,
      fetchThoughtsCallback,
      fetchRetrievablesCallback,
      refreshStandingInstructionsCallback,
      storeMemoryCallback,
      mutateMemoryCallback,
      deleteMemoryCallback,
      storeStandingInstructionCallback,
      deleteStandingInstructionCallback,
      storeMessageCallback,
      storeToolCallCallback,
    } = turnCallbacks

    // ── Pipelines ──────────────────────────────────────────────────────────────
    // turnInputPipeline (ONCE / turn): hydrate the turn constants — standing instructions.
    const hydrateStanding: TurnPipelineMiddlewareFn = (async (
      ctx: TurnContext,
      next: () => Promise<void>
    ) => {
      for (const si of await ctx.refreshStandingInstructions()) {
        ctx.standingInstructions.add(typeof si === 'string' ? new Tokenizable(si) : si)
      }
      await next()
    }) as TurnPipelineMiddlewareFn

    // turnInputPipeline (ONCE / turn): hydrate the baseline RAG as a TURN CONSTANT. Retrievables belong to the
    // turn, not a single dispatch — like the tool registry (kept whole; the subtractive pass only HIDES tools
    // for a dispatch, never unregisters them). Seeding here (not in dispatchInput's iteration-0 block) makes
    // `source.turnRetrievables` a stable set: every dispatch gets a FRESH COPY of it (dispatch_runner builds
    // `[...source.turnRetrievables]` per iteration), so the per-dispatch subtractive shed is non-destructive —
    // eject passages to fit THIS window, and the NEXT dispatch rebuilds the full set. The old iteration-0 seed
    // (in dispatchInput) let step-4 shed passages to zero and never re-hydrated them, so on a multi-dispatch
    // doc turn RAG evaporated after the first iteration and the RAG-tools eject (subtractive Step 3c) could no
    // longer fire. Gate on the SAME `wantsDocContext` predicate as before (doc_cited/capacity_scoped plan, or a
    // substantive query) so a greeting/small-talk turn still seeds NO passages. `fetchRetrievables` reuses the
    // memoized pre-retrieval (getPrefetchedHits), so this is the same single search the planner already ran.
    const hydrateRetrievables: TurnPipelineMiddlewareFn = (async (
      ctx: TurnContext,
      next: () => Promise<void>
    ) => {
      const wantsDocContext =
        state.turnPlan?.answerKind === 'doc_cited' ||
        state.turnPlan?.answerKind === 'capacity_scoped' ||
        (contentTokens(input.text).size >= CAPACITY_MIN_CONTENT_WORDS &&
          state.turnPlan?.answerKind !== 'conversational' &&
          state.turnPlan?.answerKind !== 'rhetorical' &&
          state.turnPlan?.answerKind !== 'tool_computed')
      if (wantsDocContext) {
        for (const r of await ctx.fetchRetrievables()) ctx.turnRetrievables.add(r)
      }
      await next()
    }) as TurnPipelineMiddlewareFn

    const dispatchInput = makeDispatchInput({
      state,
      events: this.#events,
      input,
      image,
      imageMessage,
      visibleToolNames,
      adapter: this.#adapter,
      contextWindow: this.#contextWindow,
      maxTokens: this.#maxTokens,
      model: this.#model,
      injectSyntheticThoughts: this.#injectSyntheticThoughts,
      resolveOutputTokens: (scope) => this.#resolveOutputTokens(scope),
      budgetWords: (scope) => this.#budgetWords(scope),
      emitThought,
      makeWindowTooSmallError,
      renderPlanThought,
    })

    const dispatchOutput = makeDispatchOutput({
      state,
      events: this.#events,
      input,
      contextWindow: this.#contextWindow,
      maxTokens: this.#maxTokens,
      forceFallbackOnLeak,
      prefetchedSources,
      emitCheckerThought,
      emitContextOverflow: (err) => this.#emitContextOverflow(err),
      classifyIsAbstention: (userQuestion, reply) =>
        classifyIsAbstention(adapter, userQuestion, reply),
      classifyPlanViolation: (plannedKind, uncalledTools, reply) =>
        classifyPlanViolation(adapter, plannedKind, uncalledTools, reply),
      classifyIsDocAnswer: (text) => classifyIsDocAnswer(adapter, text),
      classifyNeedsToolVerification: (userQuestion, reply) =>
        classifyNeedsToolVerification(adapter, userQuestion, reply),
      cleanInlinePathsIfAny: (cap, kind) => cleanInlinePathsIfAny(adapter, this.#events, cap, kind),
      gracefulFallback,
    })

    // #region thrift_window_guard
    // GUARD = THRIFT WINDOW (Option A). The battery's pre-dispatch overflow guard must enforce the turn's
    // ACTUAL input budget — the thrift window minus the output reserve — NOT the fixed engine cap the
    // adapter was constructed with. Passing it per-dispatch is load-bearing twice over: (1) it collapses
    // the silent overshoot zone (constructor guard = engineMax−maxTokens = 11776 while thrift targets
    // #contextWindow = 8192, so a heavy turn thrift COULDN'T fit its 8192 target rode un-caught up to 11776
    // then tipped over the engine limit — the multi-model overflow bug); (2) the overflow-retry ladder
    // mutates #contextWindow (8192→6144→…), and only a per-dispatch override reflects that, so the guard
    // tightens WITH the shed target instead of staying frozen at construction. maxNumTokens (the real
    // engine hard-limit) is a separate concern and stays at the constructor value.
    const executor: DispatchExecutorFn = await adapter.executor({
      contextWindow: Math.max(2048, this.#contextWindow - this.#maxTokens),
    } as never)
    // #endregion thrift_window_guard

    const config: TurnRunnerConfig = {
      executorCallback: executor,
      tools: registry.all(),
      fetchToolsCallback: async (_ctx: unknown) => registry.all(),
      fetchMessagesCallback,
      fetchMemoriesCallback,
      fetchThoughtsCallback,
      fetchToolCallsCallback,
      fetchRetrievablesCallback,
      refreshStandingInstructionsCallback,
      storeStandingInstructionCallback,
      mutateStandingInstructionCallback: noop2,
      deleteStandingInstructionCallback,
      storeMemoryCallback,
      mutateMemoryCallback,
      deleteMemoryCallback,
      storeRetrievableCallback: noop2,
      mutateRetrievableCallback: noop2,
      deleteRetrievableCallback: noop2,
      storeMessageCallback,
      mutateMessageCallback: noop2,
      deleteMessageCallback: noop2,
      storeThoughtCallback: noop2,
      mutateThoughtCallback: noop2,
      deleteThoughtCallback: noop2,
      storeToolCallCallback,
      mutateToolCallCallback: noop2,
      deleteToolCallCallback: noop2,
      storeMediaBytesCallback,
      // Retrievable bytes: the flagship's retrievables are pre-fetched in-memory each turn (not
      // byte-spooled), so this conduit is unused. A throwing arity-3 stub satisfies the
      // reader-returning signature; it is never invoked.
      storeRetrievableBytesCallback: (async (
        _a: unknown,
        _b: unknown,
        _c: unknown
      ): Promise<never> => {
        throw new Error('retrievable byte spooling is not used by the flagship agent')
      }) as unknown as TurnRunnerConfig['storeRetrievableBytesCallback'],
      // #region pipeline_wiring
      turnInputPipeline: [hydrateStanding, hydrateRetrievables],
      dispatchInputPipeline: [dispatchInput as never],
      dispatchOutputPipeline: [dispatchOutput as never],
      // #endregion pipeline_wiring
    }

    const runner = new TurnRunner(config)
    // Stream the assistant message to the dialog as it generates.
    runner.on('message', (content: unknown) => {
      const c = content as { full?: string; aDelta?: string }
      if (typeof c?.full === 'string') state.answer = c.full
      else if (typeof c?.aDelta === 'string') state.answer += c.aDelta
      this.#events.onDelta?.(state.answer)
    })
    // Stream the model's REAL chain-of-thought to the dialog (only generated when the user enabled the
    // model's thinking channel). The synthetic plan/nudge thoughts are emitted separately, from
    // dispatchInput where they're injected. `full` is the accumulated text, so the UI replaces by id.
    runner.on('thought', (content: unknown) => {
      const c = content as {
        id?: string
        full?: string
        aDelta?: string
        isComplete?: boolean
      }
      const text = typeof c?.full === 'string' ? c.full : (c?.aDelta ?? '')
      emitThought({
        id: c?.id ?? 'reasoning',
        kind: 'reasoning',
        text,
        isComplete: c?.isComplete === true,
      })
    })
    // The navigate gate: when a tool opens it via ctx.waitFor, surface it to the dialog.
    runner.observe('turnGateOpen', (gate: unknown) => {
      const g = gate as {
        id: string
        reason: string
        payload: unknown
        resolve: (v: unknown) => void
        reject: (e: Error) => void
      }
      // Headless auto-ack: no dialog to answer this gate, so resolve it immediately with the configured
      // verdict instead of parking it on pendingGate (which nothing in a Node run ever reads → deadlock).
      // Still surface it through onGate for observability so the run row records that the gate fired.
      if (this.#autoGateVerdict !== null) {
        this.#events.onGate?.({ gate: 'navigate', detail: g.reason })
        g.resolve({ allow: this.#autoGateVerdict })
        return
      }
      // expiresAt (the gate's auto-decline deadline) rides the payload — surface it so the dialog renders a
      // live countdown next to Allow/Deny. Undefined for a gate without a timeout (waits indefinitely).
      const expiresAt = (g.payload as { expiresAt?: unknown } | null | undefined)?.expiresAt
      pendingGate.value = {
        id: g.id,
        reason: g.reason,
        payload: g.payload,
        resolve: (v) => g.resolve(v),
        reject: (e) => g.reject(e),
        ...(typeof expiresAt === 'number' ? { expiresAt } : {}),
      }
    })
    runner.observe('turnGateClosed', () => {
      pendingGate.value = null
    })
    // Non-fatal warnings the runner recovered from (today: token-estimation degraded to a char guesstimate
    // when a tokenizer threw — e.g. special-token text). Surface via the SAME onGate instrumentation stream
    // the rails use, so the degrade is visible in the "what happened" panel instead of silent. Does NOT set
    // dispatchError — the turn continues normally.
    runner.observe('warning', (w: unknown) => {
      const evt = w as { kind?: string; message?: string } | null | undefined
      this.#events.onGate?.({
        gate: (evt?.kind as AgentGateKind) ?? 'token-estimation-degraded',
        detail: evt?.message ?? 'a non-fatal degradation occurred',
      })
    })
    runner.observe('error', (err: unknown) => {
      // A nacked dispatch (e.g. a WebGPU/WASM OOM during generate) REJECTS DispatchRunner.dispatch,
      // which TurnRunner catches and re-emits here as 'error' — and CRUCIALLY the dispatch-output
      // pipeline is skipped on a nack (the runner breaks the iteration loop before it runs), so the
      // dispatchOutput nackError capture never fires. This observer is therefore the real seam for a
      // generation OOM. Capture the first one; run() surfaces it as the turn error + oom flag. We only
      // OVERWRITE a prior non-OOM error with an OOM (the capacity signal is the one worth showing).
      //
      // The TurnRunner emits errors already WRAPPED in a generic container (E_LLM_EXECUTION_EXECUTOR_ERROR
      // from a thrown executor, E_OUTPUT_PIPELINE_ERROR from an output-pipeline throw, etc.) whose
      // `.message` is a useless "…callback threw an error." and whose OOM/overflow signature lives on the
      // CAUSE. Unwrap to the root cause FIRST so both classification and the surfaced message are the real
      // failure — otherwise a genuine OOM/overflow (or any diagnosable error) reads as the generic wrapper.
      const root = unwrapToRootCause(err)
      const isOom = isGpuOomError(root)
      const isOverflow = isContextOverflowError(root)
      const isWindowSmall = isWindowTooSmallError(root)
      // The window-too-small WALL nacks from dispatchInput, so the dispatch REJECTS and TurnRunner
      // re-emits it HERE (the dispatchOutput nackError capture is skipped on a nack). Classify it so
      // run() surfaces the windowTooSmall flag (the budget-math banner) — WITHOUT this the wall's nack
      // was captured as a generic error and the flag never set (the 4K stall). Treat it like overflow:
      // it always wins the capture (it's the actionable signal), and rides the same observability channel.
      if (!state.dispatchError || isOom || isOverflow || isWindowSmall) {
        state.dispatchError = isError(root) ? root.message : String(root)
        state.dispatchErrorIsOom = isOom
        state.dispatchErrorIsOverflow = isOverflow
        state.dispatchErrorIsWindowTooSmall = isWindowSmall
        if (isOverflow || isWindowSmall) this.#emitContextOverflow(root)
      }
    })

    try {
      await runner.run({
        turnAbortController: new AbortController(),
        // Grade the system prompt to the turn's INPUT budget (declared window − output reserve). A tight
        // edge window (e.g. 4096/1024 → 3072 input) renders the dense floor; a generous window renders
        // the full verbose prompt. Uses #contextWindow (the current thrift window, which the overflow
        // ladder may have tightened) so the prompt shrinks in lockstep with the wall.
        systemPrompt: buildSystemPrompt(
          {
            currentPath: path,
            hasImage: !!input.image,
          },
          Math.max(2048, this.#contextWindow - this.#maxTokens)
        ),
        standingInstructions: [],
      })
    } catch (err) {
      this.#events.onPhase?.('error')
      this.#events.onActivity?.('')
      // A thrown OOM (e.g. rethrown from the answer classifier) is the same capacity signal as a nacked
      // one — flag it so the dialog shows the capacity banner + Retry, not a generic error. A thrown
      // context overflow (e.g. rethrown from a classifier's own dispatch) likewise gets the too-long
      // banner via contextOverflow. Unwrap the generic ADK container to the root cause first, so the
      // classification and the message the banner shows reflect the REAL failure (not "…callback threw").
      const root = unwrapToRootCause(err)
      const overflow = isContextOverflowError(root)
      const windowTooSmall = isWindowTooSmallError(root)
      // The window-too-small WALL rides the same observability channel as the engine overflow (same
      // user-facing class: "your window can't hold this turn"), but stays a DISTINCT RunResult flag so
      // run() won't retry it and the dialog shows the budget-math banner, not the too-long one.
      if (overflow || windowTooSmall) this.#emitContextOverflow(root)
      return {
        answer: state.answer,
        trace: state.lastTrace,
        refused: state.refused,
        error: isError(root) ? root.message : String(root),
        oom: isGpuOomError(root),
        contextOverflow: overflow,
        windowTooSmall,
      }
    }

    // A NACK (e.g. WebGPU OOM mid-generate) ends the turn without throwing. Surface it as the turn's
    // error so the dialog can show the friendly "reduce the context window + Retry" banner — and do
    // NOT persist a fake assistant message for a turn the model never actually answered.
    if (state.dispatchError && !state.captured) {
      this.#events.onPhase?.('error')
      this.#events.onActivity?.('')
      return {
        answer: state.answer,
        trace: state.lastTrace,
        refused: state.refused,
        error: state.dispatchError,
        oom: state.dispatchErrorIsOom,
        contextOverflow: state.dispatchErrorIsOverflow,
        windowTooSmall: state.dispatchErrorIsWindowTooSmall,
      }
    }

    // POISONED-CONTEXT early return. The model surfaced an XML-shaped trust-tier fence a second time this
    // turn — its context is poisoned (accumulated thrash). Do NOT persist anything; signal `run()` to shed
    // the window and re-run. No user-facing error (unlike overflow/OOM) — this is a silent internal retry.
    if (state.turnPoisoned && !state.captured) {
      this.#events.onActivity?.('')
      return { answer: '', trace: state.lastTrace, refused: state.refused, contextPoisoned: true }
    }

    // Persist the FINAL answer. Either a CITED answer captured from provide_answer (sources become
    // message references / chips), or a plain-prose reply (conversational, general-knowledge, or an
    // honest "I don't know" — abstention is prose now, not a tool). Rejected attempts are not persisted.
    const cap = state.captured as CapturedAnswer | null
    let assistantMsgId: string
    if (cap) {
      // Cited answer via provide_answer — persist with its sources as references. (Inline-path cleanup runs
      // earlier, in the dispatchOutput pipeline where the answer is captured — see #rewriteRemoveInlinePaths.)
      state.answer = cap.text
      const stored = await storeMessage(convId, {
        role: 'assistant',
        content: cap.text,
        references: cap.sources.map((s) => ({
          id: s.path,
          pageUrl: s.path,
          anchor: '',
          title: s.title ?? s.path,
          headingPath: [],
          excerpt: '',
        })),
      })
      assistantMsgId = stored.id
    } else {
      // A plain-prose reply (no answer tool) — conversational, general-knowledge, an honest IDK, or a
      // dup-stop/hard-stop rescue. Persist as-is, no citations. Strip any FALSE-ABSTENTION preamble at
      // this single commit point so a good synthesis the 2B prefixed with "I couldn't find … however,"
      // is delivered as the real answer (covers every accept path, not just the dup-stop rescue).
      const cleaned = stripFalseAbstentionPreamble(state.proseAnswer || '')
      // FINAL SAFETY NET: never commit a verbatim doc-source QUOTE (raw {@link …}, ::: containers, //
      // comments) NOR a directive-ACKNOWLEDGMENT ("I understand the directive, I will ensure…") as the
      // answer. If either slipped through every gate, surface the graceful fallback instead — defense in
      // depth so neither documentation source nor gate-meta-chatter ever reaches the human plane.
      state.answer =
        looksLikeRawDocSource(cleaned) || looksLikeDirectiveAcknowledgment(cleaned)
          ? gracefulFallback(state.turnPlan?.answerKind)
          : cleaned || "I wasn't able to produce an answer for that."
      const stored = await storeMessage(convId, {
        role: 'assistant',
        content: state.answer,
      })
      assistantMsgId = stored.id
    }
    // Persist this turn's reasoning, tied to the assistant message just stored, so the Thinking panel
    // rehydrates on reload (durable turn history). Insertion order is the reasoning order. Best-effort:
    // a thought-store failure must never sink an answer that already landed.
    // CRITICAL: do NOT persist with the in-turn key (`__plan-thought` / `__nudge:*`). Those stable ids are
    // ONLY for de-duping within a turn (live UI + record map). If they were written to the store, next
    // turn's fetchThoughts would return id `__plan-thought`, dispatchInput would mark it seeded, and the
    // injection guard (`!seededIds.has(PLAN_THOUGHT_ID)`) would SUPPRESS the new turn's plan — so the
    // planner silently stopped running from turn 2 on. Let storeThought mint a fresh unique id instead.
    try {
      for (const [, t] of state.turnThoughtRecords) {
        // Never persist a blank-text thought. A streaming reasoning partial (or any emitThought fired
        // before its text landed) can record an empty string; persisting it plants a row that the NEXT
        // turn's fetchThoughts would feed to `new Thought`, which REJECTS empty content
        // (E_INVALID_INITIAL_THOUGHT_VALUE) inside dispatchInput — ending that turn in an error banner.
        // Skip at the source; the fetch side also filters (belt-and-suspenders). [[no_pathological_cases_dont_cheat]]
        if (typeof t.text !== 'string' || t.text.trim().length === 0) continue
        await storeThought(convId, {
          messageId: assistantMsgId,
          kind: t.kind,
          text: t.text,
        })
      }
    } catch {
      /* reasoning is non-critical history — never fail the turn over it */
    }

    this.#events.onPhase?.('complete')
    this.#events.onActivity?.('')
    this.#events.onTurnComplete?.()
    return { answer: state.answer, trace: state.lastTrace, refused: state.refused }
  }
}

/**
 * The WALL. Build the typed error the loop nacks with when the subtractive pass could not fit the user's
 * context-window budget even after shedding everything sheddable (`ThriftTrace.refused`). This is a
 * docs-app / flagship-loop POLICY error — NOT a battery concern — so it lives here, not in `src/` (the
 * battery keeps its own engine-cap `E_LITERT_LM_CONTEXT_OVERFLOW`). The `code` is cross-realm-safe like
 * the others so {@link isWindowTooSmallError} can classify it from the catch/nack seams. The message names
 * the real math so the user knows the exact lever: raise the window, or reduce the tool shortlist. The
 * distinction from a context overflow matters: overflow = the ENGINE's fixed cap was exceeded (recovery
 * shrinks the window and retries); window-too-small = the USER's chosen budget can't hold this turn's
 * unsheddable floor (retrying at the same window would refuse identically — so `run()` does NOT retry).
 */
function makeWindowTooSmallError(trace: ThriftTrace): Error & { code: string } {
  const over = trace.totalAfter - trace.budget
  // AGENT VOICE. This message is shown to the user VERBATIM (agent_dialog reportTurnError → errorMsg). The
  // agent has just discovered, mid-task, that it has gathered more than its working memory can hold and
  // still leave room to reply — so it should SAY that, in first person, the way an assistant would when it
  // hits its own limit: own it, say what it CAN still do, and name the levers the user actually controls
  // (the context-window slider raises the budget; a smaller max-output reserve frees input room; a more
  // focused question needs fewer sources). NOT a system diagnostic. The exact token math is kept as a short
  // trailing parenthetical for the curious/devs, not the lead. No unreachable levers ("reduce tools" — the
  // planner picks those internally). ([[adk_ethos_surface_not_impose]])
  const err = new Error(
    `I've pulled together more material on this than I can hold in memory at once and still have room to ` +
      `write you a proper answer — I ran out of working space partway through. Try narrowing the question ` +
      `to the part you care about most, or give me more room to think by raising the context-window slider ` +
      `(or lowering the reply-length reserve). ` +
      `(Working memory: needed ${trace.totalAfter.toLocaleString()} tokens, ${over.toLocaleString()} over ` +
      `the ${trace.budget.toLocaleString()}-token budget for this ${trace.contextWindow.toLocaleString()}-token window.)`
  ) as Error & { code: string }
  err.code = 'E_ADK_WINDOW_TOO_SMALL'
  err.name = 'E_ADK_WINDOW_TOO_SMALL'
  return err
}

/**
 * The graceful fallback string when every gate rejected the model's output and there's no usable prose to
 * surface — routed by the plan's answer_kind so the message actually fits the request. Before this was
 * kind-aware, a conversational/rhetorical turn (e.g. "so it's not much of a kit, is it?") that fell back
 * got the doc-citation string ("I found relevant documentation… open the cited pages"), which is nonsense
 * for a turn that neither searched nor cited. Each kind now gets a fallback that matches what it was
 * actually doing.
 */
function gracefulFallback(kind: PlanAnswerKind | undefined): string {
  switch (kind) {
    case 'conversational':
    case 'rhetorical':
      // No docs, no tool — a re-engage prompt, never doc framing.
      return "I'm not quite sure I follow — could you say a bit more about what you mean?"
    case 'tool_computed':
      return "I couldn't get that value just now — mind trying again?"
    case 'capacity_scoped':
      // The honest capacity-decline turn wedged before it could compose — keep the on-brand message
      // (small on-device model, narrow it or read the docs), never the doc-abstention.
      return "That's a broad, exhaustive ask — more than I can lay out in full as a small on-device model. Try narrowing it to one area, or browse the documentation directly for the complete picture."
    case 'doc_cited':
    default:
      // The only place the doc-flavored abstention is apt: a doc question that searched but couldn't
      // compose. Kept as the default too (a null plan defaulting to a doc lookup is the safe assumption).
      return "I found relevant documentation but couldn't compose a clear answer from it. Try asking a more specific question, or open the cited pages in the tool details."
  }
}

void DEFAULT_CONVERSATION_ID

```

#### agent\_gate\_cascade.ts

```ts
import { makeGateControls, type TurnState } from './agent_turn_state'
import { ENCODING as THRIFT_ENCODING } from './agent_subtractive_pass'
import { isError, Tokenizable, type DispatchContext } from './agent_adk'
import {
  activityForTool,
  readModelFacingPreview,
  readToolResultPreview,
} from './agent_artifact_handles'
import {
  isContextOverflowError,
  isGpuOomError,
  isWindowTooSmallError,
  unwrapToRootCause,
} from './agent_errors'
import {
  EMPTY_GEN_LIMIT,
  DUPLICATE_CALL_HARD_STOP,
  INCOMPLETE_ANSWER_LIMIT,
  LEAK_GATE_LIMIT,
  NO_PROGRESS_PROSE_LIMIT,
  RESULT_HEADROOM_FACTOR,
  RESULT_TOO_LARGE_MIN_TOKENS,
  RETRIEVAL_ABSTENTION_LIMIT,
  STUCK_PROSE_LIMIT,
} from './agent_turn_policy'
import {
  allRetrievableSources,
  capturedIsGroundedInContext,
  collectUnreadResultHandles,
  findGroundingRetrievable,
  hasTrailingSourcesList,
  looksLikeJsonNotProse,
  selectAnswerBearingCall,
  stripTrailingSourcesList,
  validatedNamedSources,
  validatedTrailingNakedSources,
} from './agent_citations'
import {
  answerHasXmlTag,
  levenshteinSimilarity,
  looksLikeBareAnswerArgs,
  looksLikeDegenerateReply,
  looksLikeDirectiveAcknowledgment,
  looksLikeEmptyOrControlResult,
  looksLikeFakeCitation,
  looksLikeParroting,
  looksLikeRawDocSource,
  looksTruncated,
  normaliseForCompare,
  similarityThresholdFor,
  stripFalseAbstentionPreamble,
  stripLeadingDirectiveAck,
  stripLeadingFieldLabel,
  stripLeakedToolTag,
  stripTrailingQuote,
  stripTrailingToolCallLeak,
} from './agent_prose_heuristics'
import type { AgentEvents } from './agent_harness_types'
import type { CapturedAnswer, PlanAnswerKind } from './agent_tools'

export function makeDispatchOutput(deps: {
  state: TurnState
  events: AgentEvents
  input: { text: string }
  contextWindow: number
  maxTokens: number
  forceFallbackOnLeak: boolean
  prefetchedSources: Array<{ path: string; title: string }>
  emitCheckerThought: (criteria: string) => void
  emitContextOverflow: (err: unknown) => void
  classifyIsAbstention: (userQuestion: string, reply: string) => Promise<boolean>
  classifyPlanViolation: (
    plannedKind: string,
    uncalledTools: string[],
    reply: string
  ) => Promise<boolean>
  classifyIsDocAnswer: (text: string) => Promise<boolean>
  classifyNeedsToolVerification: (userQuestion: string, reply: string) => Promise<boolean>
  cleanInlinePathsIfAny: (
    cap: CapturedAnswer | null,
    kind?: PlanAnswerKind | undefined
  ) => Promise<CapturedAnswer | null>
  gracefulFallback: (kind: PlanAnswerKind | undefined) => string
}) {
  const {
    state,
    events,
    input,
    contextWindow,
    maxTokens,
    forceFallbackOnLeak,
    prefetchedSources,
    emitCheckerThought,
    emitContextOverflow,
    classifyIsAbstention,
    classifyPlanViolation,
    classifyIsDocAnswer,
    classifyNeedsToolVerification,
    cleanInlinePathsIfAny,
    gracefulFallback,
  } = deps

  // #region terminator_doc
  // dispatchOutputPipeline (PER iteration, after the model): the TURN TERMINATOR (autoAck is
  // off — the harness owns when the turn ends). Four cases per iteration:
  //   1. An answer tool fired (captured) → ack: cited answer / honest IDK ends the turn.
  //   2. A non-answer tool ran this iteration (search, etc.) → keep looping to act on it.
  //   3. Prose, classified as a doc ANSWER (1) but UNCITED → set needsCitation; do NOT ack —
  //      the next iteration nudges the model to re-issue via provide_answer.
  //   4. Prose, classified conversational (0) → ack: a chat reply is a valid response.
  // Backstop: at the hard cap, ack regardless so a stubborn model can't loop forever.
  // #endregion terminator_doc
  const dispatchOutput = async (ctx: DispatchContext, next: () => Promise<void>): Promise<void> => {
    let prevToolCallCount = state.prevToolCallCount
    let lastReportedTool = state.lastReportedTool
    let dispatchError = state.dispatchError
    let dispatchErrorIsOom = state.dispatchErrorIsOom
    let dispatchErrorIsOverflow = state.dispatchErrorIsOverflow
    let dispatchErrorIsWindowTooSmall = state.dispatchErrorIsWindowTooSmall
    let turnPoisoned = state.turnPoisoned
    let sawSourcelessAnswer = state.sawSourcelessAnswer
    let captured = state.captured
    let proseAnswer = state.proseAnswer
    let lastRejectedProse = state.lastRejectedProse
    let repeatedProseCount = state.repeatedProseCount
    let noProgressProseCount = state.noProgressProseCount
    let bestOwnWordsProse = state.bestOwnWordsProse
    let incompleteAnswerCount = state.incompleteAnswerCount
    let fakeCitationCount = state.fakeCitationCount
    let leakedEnvelopeCount = state.leakedEnvelopeCount
    let emptyGenCount = state.emptyGenCount
    let retrievalAbstentionCount = state.retrievalAbstentionCount
    let turnPlan = state.turnPlan
    let answer = state.answer
    const tooLargeHandles = state.tooLargeHandles
    const seededIds = state.seededIds
    try {
      // If the adapter NACKed this dispatch (e.g. a WebGPU OOM during generate), capture the error
      // so run() can surface it (the dialog maps WebGPU-OOM signatures to the friendly "reduce the
      // context window + Retry" banner). A nack also ends the turn, so there's nothing more to do.
      if (ctx.nackError) {
        // Unwrap the generic ADK container (E_LLM_EXECUTION_EXECUTOR_ERROR etc.) to the root cause so the
        // surfaced message + OOM/overflow classification reflect the REAL failure, not the wrapper's
        // "…callback threw an error." string (whose signature never matches OOM/overflow).
        const root = unwrapToRootCause(ctx.nackError)
        dispatchError = isError(root) ? root.message : String(root)
        // Detect a WebGPU OOM structurally from the typed error the battery now nacks — by its `code`
        // (cross-realm-safe, survives the bundle boundary) with a message-signature fallback. No regex
        // on the raw provider string: the battery surfaced a catchable type, we read it.
        dispatchErrorIsOom = isGpuOomError(root)
        // Same for a CONTEXT OVERFLOW — the typed E_LITERT_LM_CONTEXT_OVERFLOW from either the
        // pre-dispatch guard or the engine's own "too long" throw (translated by the battery). Emit it
        // through the SAME observability channels the other rails use so it's a first-class, observed
        // event — not a swallowed string.
        dispatchErrorIsOverflow = isContextOverflowError(root)
        dispatchErrorIsWindowTooSmall = isWindowTooSmallError(root)
        if (dispatchErrorIsOverflow || dispatchErrorIsWindowTooSmall) emitContextOverflow(root)
        await next()
        return
      }
      const calls = [...ctx.turnToolCalls]
      const latest = calls[calls.length - 1] as { tool?: string; checksum?: string } | undefined
      if (latest?.tool && latest.tool !== lastReportedTool) {
        lastReportedTool = latest.tool
        events.onActivity?.(activityForTool(latest.tool))
      }
      // NORMALISE the prose reply BEFORE the gates judge it: if it's a good own-words answer with a
      // verbatim QUOTE block appended ("…own words… This concept is described: \"<doc text>\" (source: …)"),
      // strip the trailing quote and keep the synthesis. Otherwise the raw-doc/parroting guard would
      // reject the WHOLE reply over its tail and discard a perfectly good answer (the context-tap finding).
      // No-op when there's no quote tail or no substantial own-words head.
      if (proseAnswer) proseAnswer = stripTrailingQuote(proseAnswer)
      // NORMALISE a leaked TOOL-CALL TAG: the 2B sometimes writes a tool name as a pseudo-XML tag around an
      // otherwise-good prose answer ("<provide_answer> The core thesis is…") instead of actually calling the
      // tool. The prose is fine; only the wrapper tag is junk. Strip the tag (and any matching close) so the
      // clean answer commits, rather than shipping the tag or bouncing the whole reply. (Distinct from the
      // leaked-ENVELOPE gate, which catches internal <thought_…>/<peer_…> runtime plumbing.)
      if (proseAnswer) proseAnswer = stripLeakedToolTag(proseAnswer)
      // NORMALISE a TRAILING dangling tool-call fragment: the 2B finishes a good prose answer, then starts
      // narrating a tool call as literal text ("…your application. provide_answer: {") and runs off the end.
      // The angle-bracket tag stripper above misses this bare "toolname: {" tail; cut it, keeping the prose.
      if (proseAnswer) proseAnswer = stripTrailingToolCallLeak(proseAnswer)
      // NORMALISE a leading directive-ACK PREAMBLE: the 2B, having seen a correction, sometimes opens a
      // real answer with an apology/compliance sentence ("I sincerely apologize… I will make sure my
      // responses are clean." then the actual answer). Drop that preamble so the user sees the answer, not
      // the meta-chatter. No-op when the reply doesn't start with an ack or nothing substantial follows.
      if (proseAnswer) proseAnswer = stripLeadingDirectiveAck(proseAnswer)
      // NORMALISE a leading field-name label ("answer: <real answer>") the 2B sometimes prints in front of
      // an otherwise-good reply (stress T1#3). Benign stray label (not plumbing), safe to drop.
      if (proseAnswer) proseAnswer = stripLeadingFieldLabel(proseAnswer)
      const newToolCalls = calls.length - prevToolCallCount
      prevToolCallCount = calls.length
      // STICKY nudges: a fresh tool call means the model is acting again, so retire the
      // "you must call/retry a tool" corrections — they re-arm below if the new output still violates.
      // We do NOT blanket-clear every iteration; an unsatisfied correction persists (per the user:
      // don't drop a nudge until the model is compliant).
      // `thought` is the optional first-person "next action" line injected as a synthetic thought
      // alongside the directive (dual-channel). `clearGate` retires both maps for a satisfied gate.
      const { clearGate, emitGate, clearAllGates } = makeGateControls({ state, events })
      // Has the model browsed the tool catalog at any point this turn? Used by the "don't give up
      // easily" gate: an abstention before ever looking at what tools exist is premature.
      const browsedCatalog = calls.some((c) => (c as { tool?: string }).tool === 'tool_catalog')
      // Did ANY non-meta tool return a successful (non-error) result this turn? When true, a hard
      // claim is plausibly grounded and the unverified-claim gate must NOT fire (the false-abstention
      // / answer path handles it). When false, an asserted computation/fact came from the model's head
      // or after a failed tool — exactly what the unverified-claim gate catches.
      const META_TOOLS = new Set([
        'tool_catalog',
        'navigate_to_page',
        'store_memory',
        'update_memory',
      ])
      const hasSuccessfulResult = calls.some(
        (c) => c.tool && !META_TOOLS.has(c.tool) && !(c as { isError?: boolean }).isError
      )
      // Did the model run a doc SEARCH (successfully) this turn? A cited @nhtio/adk answer must be backed
      // by a real search — the observed failure is the 2B firing provide_answer with a HALLUCINATED path
      // it never looked up. The doc-cited close gate uses this to force a search before accepting a cite.
      const SEARCH_TOOLS = new Set(['search_docs_semantic', 'search_docs_keyword'])
      const searchedThisTurn = calls.some(
        (c) => c.tool && SEARCH_TOOLS.has(c.tool) && !(c as { isError?: boolean }).isError
      )
      // Did a DEFINITIVE (non-retrieval) tool return a successful result this turn? This is the subset of
      // hasSuccessfulResult that ACTUALLY GROUNDS an answer: a tool whose result IS the answer (calculate →
      // the number, get_current_time → the date), NOT retrieval (search_docs_* → passages, artifact_* →
      // reading those passages) whose "success" only means "documents came back", never "the answer is in
      // them". The false-abstention gate keys on this — a definitive result present means an abstention is
      // provably false (the answer is literally the tool output); a retrieval-only result means the
      // abstention MIGHT be honest (the passages may simply not cover the question), so that gate is
      // bounded (RETRIEVAL_ABSTENTION_LIMIT) rather than unbounded. artifact_* readers are retrieval too:
      // they surface what a search returned, so a successful artifact_json_get is not definitive grounding.
      // Tools whose successful result is NOT a definitive information source: the answer-DELIVERY tools
      // (provide_answer / say_i_dont_know — they carry the model's OWN words, not a looked-up fact) and the
      // artifact-lifecycle no-op release_artifact. These, like META_TOOLS/SEARCH_TOOLS/artifact_* readers,
      // must NOT make hasDefinitiveResult true — otherwise a turn that merely called provide_answer (which
      // this abstention-loop DID, 5×) would wrongly count as "a definitive tool grounded the answer" and
      // route into the strong UNBOUNDED gate, re-creating the wedge. (Root-caused: the first fix cut only
      // META/SEARCH/artifact_*, so provide_answer slipped through and the unbounded branch fired anyway.)
      const NON_DEFINITIVE_TOOLS = new Set([
        'provide_answer',
        'say_i_dont_know',
        'release_artifact',
        'call_a_tool',
      ])
      const hasDefinitiveResult = calls.some(
        (c) =>
          c.tool &&
          !META_TOOLS.has(c.tool) &&
          !SEARCH_TOOLS.has(c.tool) &&
          !NON_DEFINITIVE_TOOLS.has(c.tool) &&
          !c.tool.startsWith('artifact_') &&
          !(c as { isError?: boolean }).isError
      )

      // A call_a_tool that named a non-existent tool returns a "No tool named …" result carrying the
      // real name (or a catalog pointer). If the LATEST call was a clean (non-error) call, the model is
      // acting correctly again — retire the bad-name correction; otherwise arm/keep it.
      if (latest?.tool === 'call_a_tool') {
        const lastResult = await readToolResultPreview(
          (calls[calls.length - 1] as { results?: unknown }).results
        )
        if (lastResult && lastResult.startsWith('No tool named')) {
          emitGate(
            'bad-tool-name',
            'Unknown tool name; suggested the closest real name and ordered a retry',
            `${lastResult} Do this now: call call_a_tool again with the corrected exact tool name. ` +
              'Do NOT tell the user you lack the capability — the tool exists, you just used the wrong name.',
            'I used a wrong tool name. I need to call call_a_tool again with the exact corrected name now.'
          )
        } else if (lastResult && lastResult.startsWith('The arguments you passed to ')) {
          // BAD-ARGS: the schema rejected the args and the tool returned the REAL required schema (a
          // directive the 2B ignores). Pair it with a first-person thought so the model re-issues with the
          // right args. Reuses the bad-tool-name kind (TOOL_REQUIRING → C1 clears any live prose gate).
          // NB: matches a STABLE prefix of agent_tools.ts's bad-args return — keep both in sync.
          emitGate(
            'bad-tool-name',
            'Tool arguments rejected by the schema; returned the real required args and ordered a retry',
            `${lastResult}`,
            'My arguments were wrong. My next output is call_a_tool again with the exact argument names from ' +
              'the schema the tool just gave me — I will not guess.'
          )
        } else {
          clearGate('bad-tool-name')
        }
      }
      // SOURCELESS/INVALID provide_answer: the tool returned a correction STRING (not onAnswer) because the
      // model wrote a real answer but omitted or mis-cited `sources`. That string never arms a gate, so flag
      // it here — dispatchInput then injects the cite-thought ("I write the answer; the harness attaches the
      // pages") next iteration. (After the C2/C3 changes the handler grounds silently in the common case, so
      // this mainly covers the residual.) NB: matches STABLE prefixes of agent_tools.ts's returns.
      if (latest?.tool === 'provide_answer') {
        const paResult = await readToolResultPreview(
          (calls[calls.length - 1] as { results?: unknown }).results
        )
        if (
          paResult &&
          (paResult.startsWith('provide_answer needs exactly two arguments') ||
            paResult.startsWith('None of the cited sources are real'))
        ) {
          sawSourcelessAnswer = true
        }
      }

      // #region plan_conformance
      // The plan names tools the worker committed to. DETERMINISTIC signal: which planned tools were
      // never SUCCESSFULLY called this turn. `planToolsSatisfied` (none uncalled) retires the
      // plan-violation correction mid-loop; `uncalledPlanTools` is fed to the semi-deterministic
      // conformance classifier at close to make a real violation far more likely to be caught.
      const calledToolNames = new Set(
        calls.filter((c) => !(c as { isError?: boolean }).isError).map((c) => c.tool)
      )
      const uncalledPlanTools = turnPlan
        ? turnPlan.toolsToUse.filter((t) => !calledToolNames.has(t))
        : []
      const planToolsSatisfied = uncalledPlanTools.length === 0
      // #endregion plan_conformance

      // DETERMINISTIC unread-handle check (no classifier). Whenever the model produced PROSE instead of
      // a tool call, did it leave an artifact handle (e.g. a tool_catalog result it requested) UNREAD —
      // i.e. never opened via an artifact_* call? That is the observed failure: the model requests the
      // catalog, then fabricates its contents in prose rather than browsing it. A purely structural
      // signal, so we check it directly (not via the abstention classifier).
      const proseNoTool = !captured && newToolCalls === 0 && !looksLikeBareAnswerArgs(proseAnswer)
      // Track CONSECUTIVE empty generations (no call, no captured answer, no prose, not even bare-args).
      // Just a counter here — the STOP happens at the no-progress backstop block below (which owns the
      // loop-terminating ack idiom). Reset on ANY real output. See EMPTY_GEN_LIMIT for the full rationale.
      if (
        newToolCalls === 0 &&
        !captured &&
        !proseAnswer.trim() &&
        !looksLikeBareAnswerArgs(proseAnswer)
      ) {
        emptyGenCount++
      } else {
        emptyGenCount = 0
      }
      // A new tool call is real progress toward grounding a retrieval-abstention — the legit recoveries
      // re-searched repeatedly before settling. Reset the retrieval-abstention counter here so only a
      // model that has STOPPED issuing new tool calls and is just re-abstaining can trip the bound.
      // (Increment is at the false-abstention gate itself, and only for a retrieval-only result.)
      if (newToolCalls > 0) {
        retrievalAbstentionCount = 0
      }
      // Compute unread handles whenever the model is trying to ANSWER — bare prose (proseNoTool) OR an
      // answer tool fired (captured). The observed over-abstention: the model SEARCHED (got a spooled-artifact
      // handle) then answered from parametric knowledge without ever reading the excerpts, so the harness
      // refused the ungrounded guess and it abstained. collectUnreadResultHandles excludes handles already
      // opened via artifact_* and returns null when nothing is unread, so this never false-fires.
      const answering = proseNoTool || captured !== null
      const visibleToolNamesNow = new Set(
        ctx.tools.visible().map((t) => (t as { name?: string }).name)
      )
      // Is the answer tool still visible? The subtractive pass sheds `provide_answer` at a tight window
      // (tools are a last-resort sheddable bucket — see agent_subtractive_pass.ts): shedding it frees its
      // schema tokens for MORE prefetched RAG (higher-quality grounded prose) and removes the citation
      // pressure the model can't satisfy at that budget. When it's gone, the model answers in PROSE and the
      // auto-capture path below commits it — and every citation instruction/nudge dissolves (we must not
      // prompt a citation the window can't hold or that a 2B renders as malformed inline paths).
      const answerToolAvailable = visibleToolNamesNow.has('provide_answer')
      // Is a SEARCH tool visible this dispatch? The doc_cited "you must search before citing" demand is only
      // satisfiable when search is actually callable; if the pass shed it (tight window), demanding a search
      // is the same "demand a tool the budget can't provide" contradiction the read-consume/citation gates
      // already dissolve on. When search is shed, we commit grounded from the prefetched RAG instead. (Search
      // VISIBLE → the search-to-cite contract is unchanged, per the search-preferred decision.)
      const searchToolVisible =
        visibleToolNamesNow.has('search_docs_semantic') ||
        visibleToolNamesNow.has('search_docs_keyword')
      // #region unread_handle_gate
      // READ-CONSUME INVARIANT (deterministic, unbounded). Whenever the model is trying to ANSWER (bare
      // prose OR an answer tool fired), does it leave an unread artifact handle it produced this turn? If so,
      // it must open it via an artifact_* reader before answering. We do NOT guard on artifact_* tool
      // VISIBILITY: the forged readers are ephemeral + adapter-local (rendered into the prompt from the
      // adapter's merged registry, never onto ctx.tools.visible()), so checking visible() was structurally
      // always-false and permanently DISABLED this gate — the model was handed a readable handle it was never
      // forced to read, and fabricated the value instead (the get_current_time "It is Wednesday" hallucination,
      // caught only by the parroting canary → apology loop). The presence of an unread SpooledArtifact handle
      // IMPLIES its readers were forged into the prompt this iteration; that is the availability signal. The
      // tight-window concern (readers shed) is already covered: if the HANDLE itself was shed,
      // collectUnreadResultHandles finds nothing → null → no demand → no deadlock (this-turn results are never
      // budget-shed and artifact_* readers shed LAST; see agent_subtractive_pass.ts).
      const unreadHandles = answering ? await collectUnreadResultHandles(calls) : null
      // Shared unread-handle gate emission (used by BOTH the answer-tool path below and the prose path) — one
      // first-person "read it now" thought, so a 2B commits to reading the handle as its next action.
      const emitUnreadHandleGate = (handles: NonNullable<typeof unreadHandles>): void => {
        const refs = handles.map((h) => `- ${h.tool} (callId=${h.callId})`).join('\n')
        emitGate(
          'unread-handle',
          'Answered without reading a tool result it requested; sent the model back to read the handle',
          'You answered without reading a tool result you already requested THIS TURN. It was not ' +
            'inlined — it is waiting as an artifact handle, and you have not opened it. Do NOT describe ' +
            'or guess its contents, and do NOT reply "I understand"/"I apologize" — that is not an answer. ' +
            'READ it now with an artifact_* tool (artifact_head, or artifact_json_get with a path like ' +
            '"$[*].excerpt" for a JSON result) using the callId below, THEN deliver the answer as a ' +
            'provide_answer TOOL CALL citing it (not ' +
            `chat prose, not "<call:…" text):\n${refs}`,
          `I have not actually opened the tool result yet (${handles[0]?.tool}, callId=${handles[0]?.callId}). My NEXT output is an artifact_* call to read it — not prose, not an apology. Then I emit a provide_answer TOOL CALL citing it.`
        )
      }
      // #endregion unread_handle_gate
      // #region parroting_check
      // PARROTING CHECK (deterministic). When the model replied in PROSE, compare that reply against how
      // every piece of context was rendered TO THE MODEL — each prior message + each tool result (the
      // model-facing preview, the exact text it saw) — via Levenshtein similarity. A reply that closely
      // mirrors any single context entry is a parrot, not an answer (the observed leak: echoing
      // provide_answer's "Answer delivered…" confirmation, a rejection notice, or a search blob). Gathered
      // only on the prose path; skipped otherwise (a tool call is never a parrot).
      // TOOL_COMPUTED EXEMPTION. For a tool_computed turn that already has a successful tool result, the
      // model's job is to STATE that computed VALUE — which necessarily resembles the tool-result text it
      // just read (e.g. get_current_time → artifact_head → "It is Sunday, July 5, 2026" mirrors the
      // "UTC: Sunday, July 5, 2026 …" the reader returned). Comparing the answer against the TOOL RESULT
      // previews therefore mislabels the correct readback as parroting, and the loop the whole flagship was
      // built to fix reappears: the model states the right value, parroting rejects it, it re-emits, ~50×.
      // (PROVEN in the Node/Ollama real-loop dump: turn "what day is it?" = 104 dispatches, get_current_time
      // → artifact_head → "It is Sunday" at dispatch 7, then ~50 rejected restatements.) So on a
      // tool_computed turn WITH a successful result, do NOT compare against the tool-result previews. We STILL
      // compare against prior MESSAGES — echoing a nudge / system directive is parroting on any turn kind.
      const parrotExemptToolResults =
        turnPlan?.answerKind === 'tool_computed' && hasSuccessfulResult
      // SELF-ECHO EXEMPTION. The parroting check must compare the answer against EXTERNAL context the model
      // might be echoing (a nudge/directive, a tool result, seeded prior-turn history) — NOT against the
      // model's OWN this-turn prior generations. PROVEN on the wire (Node/Ollama T7 parroting-match tap):
      // 26/28 parroting matches were the model's own earlier prose answer THIS turn, 0 were RAG. Mechanism:
      // an answer gets rejected by some OTHER gate (incomplete/needs-citation) → flushed into turnMessages →
      // the next attempt at the SAME question is naturally similar → parroting matches the prior attempt →
      // rejected → compounds into the doc-turn loop (23-58 dispatches). Answering a question similarly to your
      // own prior attempt is not parroting. Exclude assistant-role messages that are THIS-turn generations
      // (assistant role AND not in seededIds — the same discriminator the self-echo cap uses above).
      // [[fence_nonce_id_miscitation]] residual; [[tool_computed_loop_is_self_apology_accumulation]] class.
      const parrotedContext =
        proseNoTool && unreadHandles === null
          ? await (async (): Promise<boolean> => {
              const ctxStrings: string[] = []
              for (const m of ctx.turnMessages) {
                if (
                  (m as { role?: string }).role === 'assistant' &&
                  !seededIds.has((m as { id?: string }).id ?? '')
                ) {
                  continue // the model's own this-turn re-attempt — not external context to parrot
                }
                const c = (m as { content?: { toString(): string } }).content
                if (c) ctxStrings.push(c.toString())
              }
              if (!parrotExemptToolResults) {
                for (const c of calls) {
                  const preview = await readModelFacingPreview(
                    c as Parameters<typeof readModelFacingPreview>[0]
                  )
                  if (preview) ctxStrings.push(preview)
                }
              }
              return looksLikeParroting(proseAnswer, ctxStrings)
            })()
          : false
      // #endregion parroting_check
      // Abstention is still classifier-judged, but only when there's nothing unread to deterministically
      // act on first (the unread-handle gate takes precedence and is cheaper/surer).
      const isAbstention =
        proseNoTool && unreadHandles === null
          ? await classifyIsAbstention(input.text, proseAnswer)
          : false

      if (captured && unreadHandles !== null) {
        // READ-FIRST GATE (answer path) — an UNBOUNDED INVARIANT, not a nudge-then-accept. The model fired
        // an answer tool while a search result it requested THIS TURN is still an UNREAD handle — i.e. it
        // answered from parametric knowledge without opening the excerpts (the over-abstention root: guess →
        // harness refuses the ungrounded guess → abstain). An answer that never consumed its retrieved
        // source is not a valid answer; reject it EVERY time until the handle is read. There is NO bound and
        // NO give-up branch: we never accept an ungrounded guess as the answer. (A model that refuses to
        // read after repeated steers is a scaffolding signal to fix upstream — the read-first thought, the
        // plan, the tool ergonomics — NOT a case to cap. See the no-pathological-cases doctrine.)
        // collectUnreadResultHandles is null for a math / conversational / already-read turn, so this only
        // fires on a searched-but-unread doc answer.
        emitCheckerThought(
          'model actually READ the search results it requested before answering from them — a cited answer must be grounded in the excerpts it opened, not guessed from a handle it never read'
        )
        captured = null
        emitUnreadHandleGate(unreadHandles)
      } else if (captured) {
        emitCheckerThought(
          'answer the agent just delivered through provide_answer actually follows the plan it committed to — the right answer kind, every planned tool really called, and a real cited source rather than a guess'
        )
        // An answer tool fired. Honour it ONLY if it CONFORMS to the plan.
        //
        // DETERMINISTIC conversational check (no classifier): if the plan committed to a `conversational`
        // answer, the turn must end in PLAIN PROSE — provide_answer is for CITED @nhtio/adk answers only.
        // The observed failure: on a conversational plan the 2B still fired provide_answer and HALLUCINATED
        // a documentation source to satisfy the tool's required `sources`. Reject it and re-loop demanding
        // a plain-prose reply. (provide_answer carries sources; a conversational turn has none to cite.)
        const planIsConversational = turnPlan?.answerKind === 'conversational'
        // SEMI-DETERMINISTIC tool check: when the plan committed to tools and some went uncalled
        // (deterministic signal), ask the conformance classifier — fed that fact — whether the answer
        // violates the plan. (We only spend the classifier when there's a real reason to doubt: a
        // tool-bearing plan with uncalled tools, or a tool_computed plan with no successful result. A
        // fully-satisfied plan is honoured free.)
        const planNeedsTools = !!turnPlan && turnPlan.toolsToUse.length > 0
        const planSaysToolFirst = turnPlan?.answerKind === 'tool_computed'
        // A planned tool that was SHED (not visible this dispatch) is not a violation to demand — the model
        // literally cannot call it. Only uncalled tools that are STILL VISIBLE count toward a plan violation,
        // so we never insist "call the tool you planned" for a tool the tight-window pass removed.
        const uncalledVisiblePlanTools = uncalledPlanTools.filter((t) => visibleToolNamesNow.has(t))
        const suspectViolation =
          (planNeedsTools && uncalledVisiblePlanTools.length > 0) ||
          (planSaysToolFirst && !hasSuccessfulResult)
        const violatesPlan =
          !planIsConversational &&
          suspectViolation &&
          (await classifyPlanViolation(
            turnPlan?.answerKind ?? 'tool_computed',
            uncalledVisiblePlanTools,
            proseAnswer || (captured as CapturedAnswer).text
          ))
        if (planIsConversational) {
          // The plan said conversational, but provide_answer fired — reject it, keep the answer TEXT, and
          // re-loop demanding plain prose. The model already wrote the answer; it just used the wrong
          // delivery (a cited tool with an invented source) for a non-ADK reply.
          const answerText = (captured as CapturedAnswer).text
          emitGate(
            'plan-violation',
            'Conversational plan, but the model answered via provide_answer with a (likely invented) source; required plain prose instead',
            'Your plan for this turn is a CONVERSATIONAL reply — that must be PLAIN PROSE, not a ' +
              'provide_answer call. provide_answer is ONLY for factual @nhtio/adk answers that cite a real ' +
              'documentation page; do not invent a source to satisfy it. Reply again as ordinary text, ' +
              'with no tool call and no citation' +
              (answerText ? `. Use the same wording you just wrote: ${answerText}` : '.'),
            'My plan is a conversational reply, so I must answer in plain prose — not provide_answer, and ' +
              'with no made-up source. My next output is just the text.'
          )
          captured = null // reject the cited answer; re-loop toward plain prose
        } else if (
          (turnPlan?.answerKind === 'doc_cited' || turnPlan?.answerKind === 'capacity_scoped') &&
          !searchedThisTurn &&
          searchToolVisible &&
          !capturedIsGroundedInContext(captured as CapturedAnswer, [
            ...allRetrievableSources([...ctx.turnRetrievables] as never),
            ...prefetchedSources,
          ])
        ) {
          // DOC-CITED (or capacity_scoped) but the model NEVER SEARCHED this turn and a search tool IS
          // available. A well-formed provide_answer is ALREADY citation-checked: its sources passed
          // isKnownDocPath at capture (agent_tools.ts), so the paths are REAL, not invented. The ONLY residual
          // risk is a REAL path reached by a lucky GUESS rather than grounding. So we do NOT re-citation-check
          // a well-formed call — we check GROUNDING: if a cited path is present in THIS turn's retrievables
          // (the prefetched RAG that is already in context — real grounding, just not via an in-turn search
          // CALL), accept it (handled by the branch below). We only reach HERE — and demand a search — when
          // NONE of the cited paths are in context, i.e. the citation is an ungrounded guess.
          // (PROVEN in the Node/Ollama corpus dump T7#0: the model fired provide_answer NINE times with real,
          // in-context sources like /assembly/byo-tools, each bounced by this gate for lack of an in-turn
          // search — a ~20-dispatch loop on a correctly-grounded, correctly-cited answer. Per the user: a
          // well-formed provide_answer must not be re-citation-checked.)
          emitGate(
            'needs-citation',
            'Cited an @nhtio/adk answer without searching, and the cited page is not in this turn’s grounded context; required a real search',
            'You answered an @nhtio/adk question with provide_answer but never searched the documentation ' +
              'this turn and the page you cited is not among the passages you were given — so the citation is ' +
              'a guess, not grounded. Do this now: call search_docs_semantic (or search_docs_keyword) with a ' +
              'query for this question, READ the results, then call provide_answer again citing a page the ' +
              'search actually returned. Do not cite a path you did not get from a search.',
            'I cited a page I was not actually given. My next output must be a search_docs_semantic call for ' +
              'this question; then I cite a page it returns.'
          )
          captured = null // reject the ungrounded cite; re-loop toward a real search
        } else if (
          (turnPlan?.answerKind === 'doc_cited' || turnPlan?.answerKind === 'capacity_scoped') &&
          !searchedThisTurn &&
          searchToolVisible &&
          capturedIsGroundedInContext(captured as CapturedAnswer, [
            ...allRetrievableSources([...ctx.turnRetrievables] as never),
            ...prefetchedSources,
          ])
        ) {
          // GROUNDED without an in-turn search CALL. The model answered via provide_answer and the page it
          // cited IS in this turn's context (prefetched RAG / retrievables) — real grounding. A well-formed,
          // already-citation-checked, in-context-grounded answer is complete; do NOT bounce it into a search
          // it doesn't need (the T7#0 loop). Commit it, cited from what it grounded in. (The preceding branch
          // already handled the NOT-in-context case by demanding a search.)
          captured = await cleanInlinePathsIfAny(captured, 'doc_cited')
          events.onGate?.({
            gate: 'needs-citation',
            detail:
              'Answer was grounded in the pre-fetched pages already in context (no in-turn search needed); committed it as cited',
          })
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        } else if (
          (turnPlan?.answerKind === 'doc_cited' || turnPlan?.answerKind === 'capacity_scoped') &&
          !searchedThisTurn &&
          !searchToolVisible
        ) {
          // Same situation, but the search tool was SHED (tight window): demanding "search first" is
          // impossible, the contradiction that deadlocked a 2B. Instead ACCEPT the answer, grounded from the
          // prefetched RAG that is already in context (never invented) — the honest FAFO behavior at a window
          // the user made too small to also carry a search round-trip. [[flagship_window_wall_and_toolcalls_phantom]]
          const groundSrc = allRetrievableSources([...ctx.turnRetrievables] as never)
          const src = groundSrc.length > 0 ? groundSrc : prefetchedSources
          captured = {
            kind: 'answer',
            text: (captured as CapturedAnswer).text,
            sources: src,
          }
          events.onGate?.({
            gate: 'needs-citation',
            detail:
              src.length > 0
                ? `Search tool shed (window too small to re-search); committed the answer grounded from the ${src.length} pre-fetched page${src.length === 1 ? '' : 's'}`
                : 'Search tool shed (window too small to re-search); committed the answer as-is rather than deadlock',
          })
          captured = await cleanInlinePathsIfAny(captured, 'doc_cited')
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        } else if (violatesPlan) {
          // Name only VISIBLE uncalled tools — a shed tool is not "directly available by name", so demanding
          // it would be the very frozen-contradiction this reality-matching removes.
          const toolList = uncalledVisiblePlanTools.join(', ') || 'the right tool'
          emitGate(
            'plan-violation',
            'Answer did not follow the plan (planned tools were skipped); made the model comply',
            `STOP. You have NOT actually called ${toolList} yet — there is ` +
              'no tool result in this turn. Do not say "I have already called" it; you have not. Your ' +
              'ENTIRE next reply must be the tool call itself and nothing else — emit the call to ' +
              `${uncalledVisiblePlanTools[0] ?? toolList} now (it is directly available by name). After it ` +
              'returns, read the result and answer from it.',
            `I have not actually called ${uncalledVisiblePlanTools[0] ?? toolList} yet. My very next output must be that tool call, nothing else.`
          )
          captured = null // reject the non-conforming answer; keep looping (UNBOUNDED per design)
        } else if (
          looksTruncated((captured as CapturedAnswer).text) &&
          incompleteAnswerCount < INCOMPLETE_ANSWER_LIMIT
        ) {
          // provide_answer conforms but its answer TEXT was CUT OFF mid-sentence by the output budget
          // (observed A#0: "…Silent Failures: ADK enforces a"). A provide_answer answer skips the prose
          // truncation gate, so it needs its own check here. Reject + re-loop for a shorter, complete answer
          // via provide_answer, bounded by INCOMPLETE_ANSWER_LIMIT so it can't livelock.
          incompleteAnswerCount++
          captured = null
          emitGate(
            'incomplete-answer',
            'provide_answer text was cut off mid-sentence by the output budget; asked for a shorter, complete answer',
            'Your answer was CUT OFF before it finished — it ran past the output budget and stopped mid-' +
              'sentence. Call provide_answer AGAIN with the SAME sources but a much SHORTER answer: lead with ' +
              'only the few most important points, keep each to a sentence or two, and make sure the final ' +
              'sentence actually completes.',
            'My last answer overran the budget and got cut off, so it was too long. I will be RUTHLESSLY ' +
              'succinct this time: at most 3 short sentences total, leading with only the single most ' +
              'important point and dropping every secondary detail. A brief COMPLETE answer beats a long ' +
              'truncated one. My next provide_answer call is that short answer, with the same sources and a ' +
              'final sentence that actually ends.'
          )
        } else if (answerHasXmlTag((captured as CapturedAnswer).text)) {
          // provide_answer conforms but its answer TEXT surfaced an XML-shaped trust-tier fence outside a code
          // fence. A cited answer skips the prose leaked-XML gate, so it mirrors the poison-signal escalation
          // here: 1st leak this turn → PLAIN REGENERATE (captured=null, re-loop, NO emitGate — an added
          // directive is more poison). 2nd → context POISONED: abandon the turn (turnPoisoned + ack) so run()
          // sheds the window and re-runs on a lean context, escalating until clean / clean fallback at floor.
          captured = null
          if (leakedEnvelopeCount < 1) {
            leakedEnvelopeCount++
            // reject + re-loop (plain regenerate, NO added directive); do not ack.
          } else if (forceFallbackOnLeak) {
            // Final floor attempt still leaked — ship a clean fallback (never the fence, never blank).
            proseAnswer = gracefulFallback(turnPlan?.answerKind)
            answer = proseAnswer
            events.onGate?.({
              gate: 'leaked-envelope',
              detail:
                'provide_answer kept surfacing an internal fence even after shedding to the floor; delivered a clean fallback',
            })
            clearAllGates()
            if (!ctx.isSignalled) ctx.ack()
          } else {
            turnPoisoned = true
            proseAnswer = ''
            answer = ''
            events.onGate?.({
              gate: 'leaked-envelope',
              detail:
                'provide_answer surfaced an internal trust-tier fence twice this turn — context poisoned; shedding and re-running',
            })
            clearAllGates()
            if (!ctx.isSignalled) ctx.ack()
          }
        } else {
          // provide_answer FIRED and conforms — accept it. If its answer TEXT carries a redundant inline
          // doc-path ("… you assemble yourself (see /assembly)"), clean it via the specialist dispatch (the
          // real citations ride cap.sources → chips, so the inline path is dead redundant text). We do NOT
          // reject-and-regenerate over the PATH: that gives the 2B no productive next action and drove it into
          // a search-spam churn that committed a raw tool-call envelope (observed A#2). Runs here in the
          // dispatchOutput pipeline (part of the turn's normal flow), tool-free, fail-safe.
          captured = await cleanInlinePathsIfAny(captured, turnPlan?.answerKind)
          clearAllGates() // satisfied — drop every correction
          if (!ctx.isSignalled) ctx.ack() // (1) answer tool delivered + plan-conforming
        }
      } else if (newToolCalls > 0) {
        /* (2) a tool ran — let the loop continue so the model can use the result. If that retired the
           bad-name / plan-tools corrections above, they're already cleared. */
        if (planToolsSatisfied) clearGate('plan-violation')

        // RESULT-TOO-LARGE invariant. An artifact-tool result is an INLINE Tokenizable on the ToolCall, so
        // we can measure the EXACT text the model would see (readModelFacingPreview = the same renderer the
        // executor used) BEFORE the next dispatch assembles it. The cap leaves at least
        // RESULT_HEADROOM_FACTOR × maxTokens of the window free (see the constant): one output-budget for
        // the generation the model still owes, plus breathing room so one result can't starve the rest of
        // the context. This is NOT a bounded nudge — it is an invariant: while a handle is flagged, EVERY
        // over-cap read against it is retracted (ctx.deleteToolCall — removes it from turnToolCalls AND
        // decrements the checksum tally, then flushes the delete to the turn; deleteToolCallCallback is a
        // noop here, so it's dispatch-local) and the model re-steered to a NARROWER query. An over-cap blob
        // can therefore never reach a dispatch, no counter required — the model chooses HOW to narrow, the
        // loop only guarantees that nothing over the cap lands. A read that finally fits clears the flag.
        const artifactTc = latest as {
          id?: string
          tool?: string
          inline?: boolean
          fromArtifactTool?: boolean
          results?: unknown
          args?: { callId?: unknown }
        }
        if (artifactTc?.fromArtifactTool === true) {
          const handleId =
            typeof artifactTc.args?.callId === 'string' ? artifactTc.args.callId : undefined
          const resultCap = Math.max(
            RESULT_TOO_LARGE_MIN_TOKENS,
            contextWindow - RESULT_HEADROOM_FACTOR * maxTokens
          )
          const preview = await readModelFacingPreview(artifactTc)
          const resultTokens = preview ? Tokenizable.estimateTokens(preview, THRIFT_ENCODING) : 0
          if (resultTokens > resultCap) {
            if (handleId) tooLargeHandles.add(handleId)
            // Retract the over-cap read cleanly (dispatch-local) so it never assembles into the next
            // prompt — the subtractive pass / this-turn guard never even see it. The re-steer is the
            // emitGate synthetic thought below (the proven 2B lever), not a separate checker thought.
            if (typeof artifactTc.id === 'string') await ctx.deleteToolCall(artifactTc.id)
            // The nudge + thought are ACTION-ONLY and name the EXACT next call. The earlier wording led
            // with "the result was too large to fit / was discarded" — and a 2B PARROTED that straight back
            // as an abstention ("I cannot access it, the result was too large"), echoing the very words. So
            // we give it NO problem-description to lift: no "too large", no "discarded", no "cannot" — just
            // the single next tool call it must emit, keyed to THIS handle's callId (same shape as the
            // unread-handle steer). Parroting is a signal the nudge is mis-implemented; this removes the
            // surface. ([[read_consume_unbounded_invariant]], the parrot-guard lessons in memory.)
            const narrowExample = handleId
              ? `artifact_json_get(callId="${handleId}", path="$[0].excerpt")`
              : 'artifact_json_get with path "$[0].excerpt"'
            emitGate(
              'result-too-large',
              `Retracted an over-cap artifact read (${resultTokens} tok > cap ${resultCap}); steered the model to a narrower query`,
              'Your next action is to read ONE record from the SAME result, not the whole thing. Call ' +
                `${narrowExample} to pull a single passage, then answer from it. (A whole-array read like ` +
                '"$[*].excerpt" is too big; a single-record path like "$[0].excerpt" or "$[1].excerpt" ' +
                'fits.) Do this now — do not answer yet, and do not say you cannot access it.',
              handleId
                ? `My next output is exactly: ${narrowExample} — one record from the result, then I answer from it.`
                : 'My next output is a single-record read like artifact_json_get path "$[0].excerpt" — one passage, then I answer from it.'
            )
            await next()
            return
          }
          // Under cap: the read fits. If this handle was previously flagged, the model has successfully
          // narrowed — clear the flag and the gate so the correction retires.
          if (handleId && tooLargeHandles.has(handleId)) {
            tooLargeHandles.delete(handleId)
            clearGate('result-too-large')
          }
        }

        // CAPACITY CONVERGENCE (the real fix for the exhaustive-turn churn — NOT an iteration cap). A
        // capacity_scoped decline needs exactly ONE search: enough to know WHICH pages cover the topic, so
        // it can point at them. It does NOT need to gather-and-synthesise the whole exhaustive answer — that
        // is the thing it's declining to do. The observed wedge: after searching, the 2B keeps GATHERING
        // (another search / artifact_head / artifact_json_get) trying to actually fulfil the exhaustive
        // ask, iteration after iteration, never writing the decline. So the moment a capacity turn has
        // searched AND its latest action is yet another gather tool, we stop the gathering and commit the
        // decline ourselves, cited from the pages the search already surfaced. This is bounded by the turn's
        // OWN structure (a decline is done once it knows where to point), not by a blind counter.
        const GATHER_TOOLS = new Set([
          'search_docs_semantic',
          'search_docs_keyword',
          'artifact_head',
          'artifact_cat',
          'artifact_json_get',
        ])
        const latestIsGather = !!latest?.tool && GATHER_TOOLS.has(latest.tool)
        const gatherCount = calls.filter(
          (c) => c.tool && GATHER_TOOLS.has(c.tool) && !(c as { isError?: boolean }).isError
        ).length
        // Fire once the model has done its natural search→read (≥2 successful gathers) AND is STILL
        // gathering — i.e. reaching for a 3rd+ gather instead of writing. That preserves the legitimate
        // search→read→write path but stops the churn the moment it becomes over-gathering.
        if (
          turnPlan?.answerKind === 'capacity_scoped' &&
          searchedThisTurn &&
          latestIsGather &&
          gatherCount >= 3
        ) {
          const capLive = allRetrievableSources([...ctx.turnRetrievables] as never)
          const capSources = capLive.length > 0 ? capLive : prefetchedSources
          // The model has searched; a decline is a SHORT honest line + the pages. Commit it now, cited from
          // what the search surfaced — the model supplies the voice on the next turn if it wants, but the
          // turn is DONE gathering. (gracefulFallback(capacity) is the on-brand "too big, here's the docs".)
          if (capSources.length > 0) {
            captured = {
              kind: 'answer',
              text: gracefulFallback('capacity_scoped'),
              sources: capSources,
            }
            events.onGate?.({
              gate: 'needs-citation',
              detail: `Capacity turn kept gathering after searching; committed the decline cited from the ${capSources.length} page${capSources.length === 1 ? '' : 's'} the search surfaced`,
            })
            clearAllGates()
            if (!ctx.isSignalled) ctx.ack()
            await next()
            return
          }
        }
        // DUPLICATE-CALL gate. The harness keeps a per-execution frequency index keyed on each
        // ToolCall.checksum (a canonical hash over tool + args) and exposes it as ctx.toolCallCount —
        // so a repeat of the SAME call with the SAME args is detectable structurally, without a
        // hand-rolled args key. The observed livelock: after calling `calculate`, the 2B re-reads the
        // same artifact handle (identical artifact_cat{callId}) over and over, never answering. We
        // tolerate ZERO repeats: the FIRST time an identical (tool + args) call comes back (count == 2),
        // the result is already in hand and re-calling it cannot make progress, so we don't nudge — we
        // hard-stop and answer FROM what the model already has.
        const dupCount = latest?.tool && latest.checksum ? ctx.toolCallCount(latest.checksum) : 0
        if (dupCount >= DUPLICATE_CALL_HARD_STOP) {
          emitCheckerThought(
            'is the model re-issuing a tool call it already has the result for — if so I stop the loop and answer from what it already retrieved, rather than letting it repeat the same call forever'
          )
          // HARD STOP on the first identical repeat — the loop is provably stuck (a small model
          // re-reading a handle it already holds). The answer it needs is sitting in a tool result the
          // model already has; read THAT and synthesise the answer ourselves, so a wedged 2B still
          // produces the correct, grounded reply instead of dead-ending with nothing.
          //
          // Which result? NOT `calls[calls.length - 1]` — that is merely the call the model happened to
          // LOOP on, which is often the WRONG one. Observed failure: a `tool_computed` turn ("73291 ×
          // 8457") looped `artifact_head` on a STALE, relevance-seeded prior-turn `get_current_time`
          // artifact, so surfacing calls[last] committed the time string instead of the `calculate`
          // result the turn actually produced. Turn-of-origin is the wrong axis (a time result is a wrong
          // answer to a math question whether it came from this turn or a prior one); INTENT is the right
          // one. Pick the call whose result satisfies THIS turn's committed plan (its `toolsToUse`), so we
          // surface the on-plan result regardless of what the model looped on.
          const answerCall = selectAnswerBearingCall(calls, turnPlan)
          // `repeated` drives the errored-body guard + result read below. When no on-plan answer-bearing
          // call exists (planned tool never succeeded, or the plan is not tool_computed), fall through as
          // if errored so the ladder routes to retained own-words prose / graceful — NEVER an off-plan
          // tool body.
          const repeated = (answerCall ?? { isError: true }) as {
            tool?: string
            results?: unknown
            isError?: boolean
          }
          const rawBody = answerCall ? await readToolResultPreview(answerCall.results) : undefined
          // CRITICAL: if the chosen call ERRORED (e.g. a stale-callId artifact_json_get whose args failed
          // schema validation), its "result" is an ERROR MESSAGE, not an answer. The observed failure: the
          // 2B re-used a PRIOR turn's artifact callId, the forged tool rejected it with "The arguments
          // supplied to the tool failed input schema validation. 'callId' must be […]", the dup-stop
          // fired, and that error string was surfaced verbatim as the answer — parroting an ERROR. An
          // errored (or absent) body must NEVER be surfaced; treat it as having no usable body so the
          // rescue falls back to retained own-words prose, else a graceful message.
          const body = repeated.isError ? undefined : rawBody?.trim()
          // The BEST real prose the model produced this turn, captured by storeMessageCallback (whose
          // non-empty guard means a later pure-tool-call iteration never clobbers it). The observed
          // failure: the model wrote an excellent synthesis (gen 3), it was correctly bounced by the
          // unread-handle gate, the model then read the handle but looped a wrong-path artifact_json_get
          // returning `(empty list)` → dup-stop. We must NOT surface that empty body; the good answer is
          // sitting in proseAnswer. Strip any false-abstention preamble before reusing it.
          const retainedProse = stripFalseAbstentionPreamble((proseAnswer ?? '').trim())
          clearAllGates()
          events.onGate?.({
            gate: 'duplicate-call',
            detail: `Model re-issued an identical ${latest?.tool ?? 'tool'} call; hard-stopped and answered from the result it already had`,
          })
          // If the rescued body is the `calculate` tool's labeled output (`Result: N\nKaTeX: $…$`),
          // present a clean display-math answer (drop the Result:/KaTeX: labels, keep the LaTeX as a
          // `$$…$$` block the markdown KaTeX pass renders).
          const calcMatch = body ? /^Result:\s*(.+?)\s*\nKaTeX:\s*\$(.+)\$\s*$/.exec(body) : null
          // A body is only surfaceable verbatim if it's a SHORT, CLEAN scalar (e.g. a looped
          // get_current_time / calculate result) — NOT a raw documentation passage. The observed failure:
          // the rescue dumped a raw `artifact_json_get` doc excerpt ({@link …}, ::: containers, // comments)
          // as the "answer" — a verbatim QUOTE of the docs, not an own-words reply. A raw doc-source body,
          // a long body, JSON, or an empty/control body all route to the retained-prose / graceful fallback.
          const bodyIsCleanScalar =
            !!body &&
            body.length <= 400 &&
            !looksLikeEmptyOrControlResult(body) &&
            !looksLikeJsonNotProse(body) &&
            !looksLikeRawDocSource(body)
          // For a DOC/CAPACITY turn that already SEARCHED, the answer must be CITED — and we have the pages
          // in hand (ctx.turnRetrievables, the RAG the turn pre-fetched + searched). So instead of ever
          // surfacing uncited prose or the generic "couldn't compose" string, commit the best own-words
          // prose CITED with those RAG pages. This is what makes a wedged doc/capacity turn ALWAYS land a
          // real, cited answer rather than the fallback. (tool_computed keeps the scalar/calc path.)
          const docPlan =
            turnPlan?.answerKind === 'doc_cited' || turnPlan?.answerKind === 'capacity_scoped'
          // Live retrievables, else the never-shed pre-fetched set (evicted at high util otherwise).
          const ragLive = docPlan ? allRetrievableSources([...ctx.turnRetrievables] as never) : []
          const ragCite = ragLive.length > 0 ? ragLive : docPlan ? prefetchedSources : []
          const goodRetained =
            retainedProse.length >= 40 && !looksLikeRawDocSource(retainedProse)
              ? stripTrailingSourcesList(retainedProse)
              : ''
          if (calcMatch) {
            proseAnswer = `$$${calcMatch[2]}$$`
            answer = proseAnswer
          } else if (bodyIsCleanScalar) {
            // A clean scalar/text body the model already had — surface it verbatim (the original rescue
            // case: e.g. a looped get_current_time result). Plain prose, not provide_answer — the harness
            // is rescuing a wedged turn, not citing docs.
            proseAnswer = body as string
            answer = proseAnswer
          } else if (goodRetained && ragCite.length > 0) {
            // DOC/CAPACITY turn with real own-words prose AND searched RAG pages → commit it CITED. The
            // model did the work (synthesised from what it read); it just never routed through
            // provide_answer. Cite from the RAG results the turn already has — always-cited, no re-loop.
            captured = { kind: 'answer', text: goodRetained, sources: ragCite }
            captured = await cleanInlinePathsIfAny(captured, 'doc_cited')
          } else if (goodRetained) {
            // Own-words prose but no retrievables to cite (rare on a doc turn) — surface it as prose rather
            // than wedge. Better an uncited real answer than the generic fallback.
            proseAnswer = goodRetained
            answer = goodRetained
          } else if (docPlan && ragCite.length > 0) {
            // No usable prose, but this is a doc/capacity turn that searched — commit the answer-kind-aware
            // graceful message CITED with the pages found, so the user still gets real, clickable pointers.
            captured = {
              kind: 'answer',
              text: gracefulFallback(turnPlan?.answerKind),
              sources: ragCite,
            }
          } else {
            // Not a doc turn (or no retrievables at all) and no usable body/prose — answer-kind-aware
            // graceful fallback (never the old generic "couldn't compose" string).
            proseAnswer = gracefulFallback(turnPlan?.answerKind)
            answer = proseAnswer
          }
          if (!ctx.isSignalled) ctx.ack()
        } else {
          // First (and only) occurrence of this exact call — not a repeat. Nothing to correct.
          clearGate('duplicate-call')
        }
      } else if (answerHasXmlTag(proseAnswer)) {
        // (2a-envelope) LEAKED-XML gate (deterministic). The reply carried an XML-shaped tag OUTSIDE a code
        // fence — the model echoed a nonce-keyed trust-tier fence (`<thought_${nonce}…>` / `<message from=…>`
        // / `<retrieved_…>`) or a `<cite>`/`<provide_answer>` wrapper into its VISIBLE answer. The fences are
        // load-bearing security (the CoT-hijack defence + synthetic-thought injection); the bug is only that
        // the model surfaced one. THE FIX IS A PLAIN REGENERATE: discard this output and re-roll from the SAME
        // context — do NOT emitGate a corrective nudge/thought. A "you emitted XML, don't" directive just adds
        // more instructions to the window (the wire showed the model then parrots "I apologize for including
        // internal system markers…"), compounding the context bloat that is the real cause. So: reject
        // silently, let the loop generate again. Bounded by LEAK_GATE_LIMIT so a model that won't stop can't
        // livelock — on exhaustion, commit a clean gracefulFallback, never the raw fence. The !answerHasXmlTag
        // guard on the no-progress backstop keeps a leaked answer out of history (stops the spiral).
        // 1st leak this turn → PLAIN REGENERATE (re-roll from the SAME context, no added directive). 2nd →
        // context POISONED: abandon the turn (raise turnPoisoned + ack to break the loop, no committed text)
        // so run() sheds the window and re-runs on a lean context, escalating until clean or a clean fallback
        // ships at the floor. No nudge/thought is added — that would be more poison.
        if (leakedEnvelopeCount < 1) {
          leakedEnvelopeCount++
          proseAnswer = ''
          answer = ''
          // reject + re-loop (plain regenerate, NO added directive); do not ack.
        } else if (forceFallbackOnLeak) {
          // Final floor attempt still leaked — ship a clean fallback (never the fence, never blank).
          captured = null
          proseAnswer = gracefulFallback(turnPlan?.answerKind)
          answer = proseAnswer
          events.onGate?.({
            gate: 'leaked-envelope',
            detail:
              'Model kept surfacing an internal fence even after shedding to the window floor; delivered a clean fallback',
          })
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        } else {
          turnPoisoned = true
          captured = null
          proseAnswer = ''
          answer = ''
          events.onGate?.({
            gate: 'leaked-envelope',
            detail:
              'Model surfaced an internal trust-tier fence twice this turn — context poisoned; shedding and re-running',
          })
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        }
      } else if (looksLikeFakeCitation(proseAnswer) && fakeCitationCount < LEAK_GATE_LIMIT) {
        emitCheckerThought(
          'answer avoids fake citation markup — a real @nhtio/adk source is delivered through provide_answer’s sources, never an invented inline <cite> tag'
        )
        // (2a-cite) FAKE-CITATION gate (deterministic). The prose reply carried a hallucinated citation
        // tag (`<cite>…</cite>` / `[cite: …]`) the model was never taught — invented markup, not a real
        // source. Reject + re-loop: if it's an @nhtio/adk answer, cite through provide_answer's sources;
        // otherwise just answer in plain prose with no cite tag. Runs on ANY answerKind (the observed leak
        // was a conversational turn). Gate only — no silent strip. Bounded by LEAK_GATE_LIMIT.
        fakeCitationCount++
        emitGate(
          'fake-citation',
          'Answer contained a fabricated inline citation tag; asked the model to re-answer without it',
          'Your last answer contained a MADE-UP citation tag like <cite>…</cite>. Do NOT invent citation ' +
            'markup — that is not how you cite. If this is an @nhtio/adk answer, cite by CALLING ' +
            'provide_answer(answer="<your answer>", sources=["<real doc path>"]). Otherwise just answer in ' +
            'plain prose with NO <cite> tag at all.',
          'I added a fake <cite> tag that is not a real citation. I will rewrite my answer without any ' +
            'invented citation markup — citing through provide_answer if it is an @nhtio/adk answer.'
        )
      } else if (
        turnPlan?.answerKind === 'conversational' ||
        turnPlan?.answerKind === 'rhetorical' ||
        turnPlan?.answerKind === 'capacity_scoped'
      ) {
        emitCheckerThought(
          'conversational/rhetorical/capacity reply is plain prose the user can read — ordinary sentences, not JSON or a dump of tool definitions'
        )
        // (2a) PROSE-DELIVERED plan (conversational / rhetorical / capacity_scoped). These are answered as
        // PLAIN PROSE and accepted directly — we do NOT run the doc-answer / unverified-claim / parroting
        // classifiers on them (they misfire: a greeting gets re-looped into the catalog; a capacity decline
        // gets bounced as an "abstention" or dragged into the doc-cite loop). This branch runs BEFORE all
        // those gates precisely so a capacity decline can never be mishandled by them — the observed
        // inconsistency where a perfectly good decline committed UNCITED or as a weak "couldn't find".
        //   • JSON/structured dump → PROSE-REQUIRED gate: reject + re-loop for plain prose.
        //   • capacity_scoped → accept AND pin the pre-fetched RAG pages as citation chips (must-cite: a
        //     decline's whole value is pointing at the real pages; the harness supplies them so we never
        //     depend on the 2B routing a decline through provide_answer).
        //   • conversational/rhetorical → accept as-is (nothing to cite).
        //   • TRUNCATED (cut off mid-sentence by the output budget) → reject + re-loop for a shorter, complete
        //     reply. A prose-delivered plan is accepted directly here, so without this a rhetorical/capacity
        //     answer that trailed off ("…It's less of a", "…Start with the") would ship half-finished (the
        //     later incomplete-answer gate never sees prose-delivered kinds). Bounded by INCOMPLETE_ANSWER_
        //     LIMIT so a model that can't shorten falls through rather than livelocking.
        if (looksTruncated(proseAnswer) && incompleteAnswerCount < INCOMPLETE_ANSWER_LIMIT) {
          incompleteAnswerCount++
          emitGate(
            'incomplete-answer',
            'Prose reply was cut off mid-sentence by the output budget; asked the model to be more succinct and finish',
            'Your last reply was CUT OFF before it finished — it stopped mid-sentence. Write it AGAIN, much ' +
              'SHORTER: say the point in two or three complete sentences and make sure the final sentence ' +
              'actually ends. Do not call any tool; just reply in plain, finished prose.',
            'My reply overran and got cut off, so it was too long. I will be RUTHLESSLY succinct: at most 3 ' +
              'short sentences, just the core point, dropping every secondary detail. A brief COMPLETE reply ' +
              'beats a long truncated one. My next reply is that short version, in plain prose whose final ' +
              'sentence actually ends.'
          )
        } else if (looksLikeJsonNotProse(proseAnswer)) {
          emitGate(
            'prose-required',
            'Prose-delivered reply came back as JSON/structured data; required plain prose instead',
            'Your reply must be PLAIN PROSE — ordinary sentences, not JSON, not a list of fields, not a ' +
              'dump of tool definitions. Answer the user directly in normal text, with no braces, no ' +
              'quotes-as-keys, and no tool call.',
            'My reply came out as JSON/structured data. My next output must be plain sentences, no braces, ' +
              'no tool call.'
          )
        } else if (
          turnPlan?.answerKind === 'capacity_scoped' &&
          (looksLikeDirectiveAcknowledgment(proseAnswer) ||
            looksLikeDegenerateReply(proseAnswer) ||
            looksLikeRawDocSource(proseAnswer))
        ) {
          // A capacity decline is still PROSE and must be the model's OWN words about its limits — never a
          // parroted format-correction ("I apologize… I will adhere strictly to plain prose…"), a degenerate
          // reply, or a raw doc quote. The prose-accept branch runs before the generic gates, so without
          // this the capacity branch below would BANK a directive-ack as the "decline" (observed B#0 after
          // an A-thread format nudge bled forward). Reject + nudge for the real capacity statement.
          emitGate(
            'prose-required',
            'Capacity reply was a directive-acknowledgment / non-answer, not the honest capability statement',
            'Do NOT apologize about formatting or talk about how you WILL answer. This request is broader than ' +
              'you can write out in full as a small on-device model — say THAT plainly in one or two sentences, ' +
              'in your own words, then let the cited pages point to the full detail. Plain prose, no tool call.',
            'My reply talked about how I will answer instead of answering. My next output is the actual ' +
              'capacity statement: this ask is too broad to render in full; here is where the full detail lives.'
          )
        } else if (turnPlan?.answerKind === 'capacity_scoped') {
          // Commit the capacity decline CITED from the pre-fetched RAG pages (never-shed fallback). The
          // model supplies the voice; the harness supplies the citations it already fetched — always-cited,
          // no dependence on the 2B calling provide_answer. Strip any trailing "Sources:" list the model
          // appended so the SOURCES chip is the single source of truth.
          const capLive = allRetrievableSources([...ctx.turnRetrievables] as never)
          const capSources = capLive.length > 0 ? capLive : prefetchedSources
          const capBody = stripTrailingSourcesList(
            stripFalseAbstentionPreamble(proseAnswer) || proseAnswer
          )
          if (capSources.length > 0) {
            captured = { kind: 'answer', text: capBody, sources: capSources }
            events.onGate?.({
              gate: 'needs-citation',
              detail: `Capacity decline committed with the pre-fetched RAG pages as sources (${capSources.length})`,
            })
            // Strip any redundant inline doc-path from the decline text (real cites ride the chips).
            captured = await cleanInlinePathsIfAny(captured, 'capacity_scoped')
          }
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        } else if (!ctx.isSignalled) {
          clearAllGates()
          ctx.ack()
        }
      } else if (emptyGenCount >= EMPTY_GEN_LIMIT) {
        // EMPTY-GENERATION BACKSTOP — the case every content-bearing branch of this cascade misses. The
        // model produced NOTHING (no call, no answer, no prose) EMPTY_GEN_LIMIT times running; with
        // MAX_ITERATIONS gone it would otherwise loop forever (root-caused: gemma-31b degenerating into
        // empty completions at a deep media-doc turn on a full context — dispatch count climbing, every
        // response empty, matching no gate). Stop via the same ack idiom the no-progress backstop uses:
        // commit the best own-words answer if we banked one, else a clean fallback. Never wedge.
        emitCheckerThought(
          'the model has returned nothing — no tool call, no answer, no prose — repeatedly; it has degenerated into empty output, so I stop looping and ship the best answer I have (or a clean fallback) rather than spin forever'
        )
        const emptyFallback = bestOwnWordsProse.trim() || gracefulFallback(turnPlan?.answerKind)
        captured = { kind: 'answer', text: emptyFallback, sources: [] }
        answer = emptyFallback
        events.onGate?.({
          gate: 'force-answer',
          detail: `model produced ${emptyGenCount} consecutive empty generations (no call/answer/prose); committed ${bestOwnWordsProse.trim() ? 'the best prior own-words answer' : 'a clean fallback'} to avoid an infinite empty-gen loop`,
        })
        clearAllGates()
        if (!ctx.isSignalled) ctx.ack()
      } else if (
        !looksLikeDegenerateReply(proseAnswer) &&
        !looksLikeRawDocSource(proseAnswer) &&
        !looksLikeJsonNotProse(proseAnswer) &&
        !looksLikeDirectiveAcknowledgment(proseAnswer) &&
        // THE SPIRAL FIX. An answer carrying an XML-shaped tag (a leaked `<thought___…>` envelope, a `<cite>`,
        // etc.) is NOT a deliverable answer — never bank it as bestOwnWordsProse and never let it count toward
        // the force-commit. Without this guard, after the leaked-envelope gate exhausted LEAK_GATE_LIMIT the
        // leaked answer fell through here, got banked, and was force-committed → it entered the thread's
        // history → the next turn parroted it verbatim → poisoned-context spiral (observed T2#3→T2#4 identical,
        // then T2#5). The gate makes the model REGENERATE clean; on fall-through the turn takes gracefulFallback.
        !answerHasXmlTag(proseAnswer) &&
        // A FALSE abstention (gave up despite a tool result this turn) is not a deliverable answer — never
        // bank it as bestOwnWordsProse, or the no-progress backstop could force-commit "I don't have that"
        // while the real result sits in context. The false-abstention gate above handles it instead.
        !(isAbstention && hasSuccessfulResult) &&
        normaliseForCompare(proseAnswer).length >= 40 &&
        (() => {
          // UNIFIED NO-PROGRESS BACKSTOP. This is a real OWN-WORDS prose reply (not degenerate, not a raw
          // doc quote, not JSON). Bank it as the best answer we've seen and count it. If a gate keeps
          // rejecting equally-good answers (parroting OR needs-citation) and we've now seen
          // NO_PROGRESS_PROSE_LIMIT of them with no tool progress, STOP looping and force-commit — the
          // model has given us a deliverable answer it just won't reroute the way a gate wants. Bounds the
          // observed wedge (good answer rejected → re-emitted → context climbs to 100%) across ALL gates,
          // not only the doc-answer branch. (Evaluated as a guard expression so it only fires when capped.)
          bestOwnWordsProse = stripFalseAbstentionPreamble(proseAnswer) || proseAnswer
          noProgressProseCount++
          return noProgressProseCount >= NO_PROGRESS_PROSE_LIMIT
        })()
      ) {
        emitCheckerThought(
          'model keeps re-emitting the same good own-words answer that a gate will not route the way it wants — if it has, I stop fighting it and force-commit that answer (cited if I can pin the source) rather than loop forever'
        )
        // Force-commit the banked own-words answer. Cite it if we can confidently pin the source doc
        // (grounded-but-cite), else accept it uncited rather than wedge.
        const grounding = !searchedThisTurn
          ? findGroundingRetrievable(bestOwnWordsProse, [...ctx.turnRetrievables] as never)
          : null
        // An answer ABOUT @nhtio/adk must ALWAYS be cited. When this is a doc answer, attach the docs the
        // agent was actually grounded in: ALL of this turn's pre-fetched retrievables (deduped). If one
        // source confidently dominates, lead with it. We don't guess WHICH single source the model drew
        // from — we hand it the whole grounded set, so a force-committed doc answer always carries real,
        // validated sources. (Conversational/general-knowledge answers have nothing to cite → plain prose.)
        const isDocAns = await classifyIsDocAnswer(bestOwnWordsProse)
        let sources: Array<{ path: string; title: string }> = []
        if (isDocAns) {
          const all = allRetrievableSources([...ctx.turnRetrievables] as never)
          sources = grounding ? [grounding, ...all.filter((s) => s.path !== grounding.path)] : all
        }
        if (sources.length > 0) {
          captured = { kind: 'answer', text: bestOwnWordsProse, sources }
          captured = await cleanInlinePathsIfAny(
            captured,
            isDocAns ? 'doc_cited' : turnPlan?.answerKind
          )
        } else {
          proseAnswer = bestOwnWordsProse
          answer = bestOwnWordsProse
        }
        events.onGate?.({
          gate: 'needs-citation',
          detail:
            sources.length > 0
              ? `Model kept re-emitting a good own-words answer; force-committed it WITH the grounded doc sources (${sources.length})`
              : 'Model kept re-emitting a good own-words answer a gate would not accept; force-committed it to avoid an infinite loop',
        })
        clearAllGates()
        if (!ctx.isSignalled) ctx.ack()
      } else if (looksLikeDirectiveAcknowledgment(proseAnswer)) {
        emitCheckerThought(
          'reply actually answers the question, rather than just agreeing to my instructions — "I understand, I will…" is talking ABOUT answering, not an answer'
        )
        // (2a-ack) DIRECTIVE-ACKNOWLEDGMENT: the model replied by AGREEING to a control directive ("I
        // understand the directive. I will ensure that for all future answers…") instead of answering —
        // a meta-conversation with the gate, caught by the context tap. Never an answer: reject + re-loop,
        // and do NOT let it bank as bestOwnWordsProse (excluded from the backstop guard above). Nudge it
        // to answer the ACTUAL question now, not to talk about how it will answer.
        emitGate(
          'prose-required',
          'Model acknowledged the directive instead of answering; redirected it to answer now',
          'Do NOT acknowledge these instructions or describe what you will do — just DO it. Answer the ' +
            'user\'s actual question NOW, in your own words. No "I understand", no "I will ensure", no ' +
            'talking about future answers — only the answer itself. For an @nhtio/adk question that means a ' +
            'provide_answer TOOL CALL citing the page you used — not chat prose, not "<call:…" text.',
          'I was talking ABOUT answering instead of answering. My next output is the answer itself — for an ' +
            '@nhtio/adk question, a provide_answer TOOL CALL citing the page (not prose, not "I understand").'
        )
      } else if (looksLikeDegenerateReply(proseAnswer)) {
        emitCheckerThought(
          'reply is actually an answer and not essentially nothing — an empty string, a stray single token, or bare punctuation is never a real answer'
        )
        // (2a-bis) DEGENERATE reply: the model emitted essentially nothing — an empty string, a stray
        // single token ("0"), or punctuation. Observed in the paraphrase livelock: between real answers
        // the 2B emitted a bare "0", which would otherwise be accepted as a plain-prose answer (it's not
        // a doc answer, not JSON) and committed as the literal answer "0". Never accept it and never let
        // it reset the repeated-prose tracking — just re-loop and let the model try again.
        emitGate(
          'prose-required',
          'Model emitted an empty/degenerate reply; asked it to actually answer',
          'Your last reply was empty or a stray token, not an answer. Answer the question directly: ' +
            'either call the right tool, or write a real reply in plain sentences.',
          'My last output was empty/degenerate. My next output must be a real answer or a tool call.'
        )
      } else if (unreadHandles !== null) {
        emitCheckerThought(
          'model actually READ the tool result it requested before answering — it must not describe or guess the contents of a handle it never opened'
        )
        // (2a-read) UNREAD-HANDLE GATE (deterministic) — PRECEDES the content-quality gates below
        // (truncation / bare-args / parroting / needs-citation) ON PURPOSE. An answer written WITHOUT
        // opening the search result it requested is ungrounded at the root; there is no point judging
        // whether that ungrounded prose is truncated, parroted, or cited — the fundamental violation is
        // that the handle was never read. So this fires FIRST among the answer-quality checks (only the
        // "not an answer at all" checks above it — empty/degenerate/directive-ack — take precedence). Same
        // unbounded-invariant stance as the answer-tool path: reject EVERY time until the handle is read,
        // no give-up branch. The model saw the handle and never opened it (observed: ask tool_catalog /
        // search, then fabricate contents instead of browsing). We do NOT inline the body (that would
        // defeat the handle/thrift pattern); the model reads what it asked for. Shares the single
        // first-person "read it now" thought with the answer-tool path (emitUnreadHandleGate).
        emitUnreadHandleGate(unreadHandles)
      } else if (looksTruncated(proseAnswer) && incompleteAnswerCount < INCOMPLETE_ANSWER_LIMIT) {
        emitCheckerThought(
          'answer the model just wrote actually FINISHED — a reply cut off mid-sentence by the output budget is not a complete answer and must not be delivered'
        )
        // (2a-trunc) INCOMPLETE-ANSWER gate (deterministic). The reply was cut off mid-thought by the
        // output cap (no terminal punctuation / an unclosed code fence — see looksTruncated). The fix is
        // not a bigger budget; it's a SHORTER answer. Reject + re-loop telling the model, in its own
        // voice, that it ran long and must be more succinct so the next answer FINISHES inside the budget.
        // Bounded by INCOMPLETE_ANSWER_LIMIT so a model that can't get shorter falls through rather than
        // livelocking. (Does NOT bank as bestOwnWordsProse — a truncated answer must never be force-committed.)
        //
        // CRITICAL (per user): a doc answer must be DELIVERED THROUGH provide_answer, not self-cited
        // prose. The observed result: the model shortened correctly but committed via a trailing
        // "Sources:" prose list (no SOURCES chip). So when this is a doc answer (plan says doc_cited OR
        // doc retrievables are present — the same robust signal the parroting gate uses, since the 2B
        // planner often misclassifies), the nudge ORDERS a shorter answer routed through provide_answer
        // with its sources arg — not a prose sources list.
        // (A rhetorical/conversational turn never reaches here — it's accepted as prose at the branch
        // above — so this gate is already free of the doc-citation conflict for those kinds.)
        // (capacity_scoped is prose-delivered + harness-cited and is fully handled by the earlier
        // prose-delivered branch, so it can never reach here — no exclusion needed.)
        const truncWantsCite = turnPlan?.answerKind === 'doc_cited' || ctx.turnRetrievables.size > 0
        incompleteAnswerCount++
        emitGate(
          'incomplete-answer',
          'Answer was cut off mid-sentence by the output budget; asked the model to be more succinct and finish' +
            (truncWantsCite ? ' via provide_answer' : ''),
          'Your last answer was CUT OFF before it finished — it ran past the output budget and stopped ' +
            'mid-sentence, so it cannot be delivered. Do not just continue it. Write the answer AGAIN, ' +
            'much SHORTER: lead with only the few most important points, keep each to a sentence or two, ' +
            'and make sure the final sentence actually completes.' +
            (truncWantsCite
              ? ' Then DELIVER it by CALLING provide_answer(answer="<your short answer>", sources=["<doc ' +
                'path>"]) — put the source path(s) in the sources argument. Do NOT end your prose with a ' +
                '"Sources:" list; that is not how you cite. The answer must go through the provide_answer ' +
                'tool call so it can be delivered.'
              : ' A short, complete answer is required; a long, truncated one is not acceptable.'),
          'My answer overran the budget and got cut off, so it was too long. I will be RUTHLESSLY succinct ' +
            'this time: at most 3 short sentences, leading with only the single most important point and ' +
            'dropping every secondary detail, and make sure my final sentence completes well inside the ' +
            'budget. A brief COMPLETE answer beats a long truncated one.' +
            (truncWantsCite
              ? ' I will deliver that short answer by CALLING provide_answer with it and the source path in ' +
                'its sources argument — not as a prose "Sources:" list.'
              : '')
        )
      } else if (looksLikeBareAnswerArgs(proseAnswer)) {
        emitCheckerThought(
          'model actually CALLED provide_answer rather than just printing its arguments as raw JSON text — printed args are not a real tool call'
        )
        // (2b) The model printed provide_answer's ARGS as bare JSON with no call wrapper. Reject it
        // and re-loop (UNBOUNDED) with a sticky corrective ephemeral. A malformed call is not an answer.
        emitGate(
          'bare-args',
          'Rejected raw-JSON args; re-issue as a real provide_answer call',
          'Your last reply was the ARGUMENTS to provide_answer printed as raw JSON, not an actual ' +
            'tool call — so it was not accepted. Do not print the arguments as text. Instead CALL ' +
            'the tool: emit `provide_answer(answer="<your answer>", sources=["<doc path>"])` with at ' +
            'least one real documentation source path. Use the same answer content you just wrote.',
          'I printed the provide_answer arguments as raw JSON instead of calling the tool. My next output must be the actual provide_answer call.'
        )
      } else if (parrotedContext) {
        emitCheckerThought(
          'answer is in the model’s OWN words and not a near-verbatim copy of something it was shown — a pasted documentation passage or a prior message is not an answer'
        )
        // (2c) PARROTING GUARD (deterministic, Levenshtein). The model's prose reply closely mirrors
        // something it was SHOWN — a prior message or a tool result (e.g. provide_answer's "Answer
        // delivered…" confirmation, a rejection notice, a search blob) — rather than answering in its own
        // words. Reject + re-loop. Per the user: nudge it to "answer in your own words" AND — when this is
        // a documentation answer — to cite. We do NOT gate the citation guidance on the PLANNER's
        // answerKind: the 2B planner often misclassifies a doc question as conversational/tool_computed,
        // which would silently drop the cite instruction even though the answer is plainly about ADK and
        // gets cited at commit. Instead use a robust deterministic signal: the model parroted a doc
        // passage, and this turn carries pre-fetched ADK doc retrievables (the Ask-ADK baseline is present
        // for any ADK question). Either the plan says doc_cited OR there are doc retrievables → cite.
        // (capacity_scoped never reaches here — it's prose-delivered + harness-cited by the earlier
        // prose-delivered branch. So this gate only governs genuine doc_cited answers.)
        const wantsCite = turnPlan?.answerKind === 'doc_cited' || ctx.turnRetrievables.size > 0
        emitGate(
          'parroting',
          'Reply closely repeated context shown to the model (a message or tool result); asked it to answer in its own words',
          'That was not an answer — it copies text you were shown (a documentation passage you read, a ' +
            'previous message, or a tool/system line) almost verbatim. Do NOT paste or quote the ' +
            'documentation back; the reader wants a real answer. SUMMARISE what you learned IN YOUR OWN ' +
            'WORDS — your own sentences, not the docs’ wording, and no authoring markup ({@link …}, ' +
            ':::, // comments, headings)' +
            (wantsCite
              ? '. Then call provide_answer with that own-words answer, citing the page you used.'
              : '.'),
          wantsCite
            ? 'I pasted the documentation instead of answering. My next output: summarise it in my OWN ' +
                'words (my own sentences, no quoted markup), then call provide_answer citing the page ' +
                'path of the passage I used.'
            : 'I pasted the documentation instead of answering. My next output must summarise it in my own ' +
                'words — my own sentences, no quoted markup.'
        )
      } else if (isAbstention && hasDefinitiveResult) {
        emitCheckerThought(
          'this abstention is HONEST — I only say "I don\'t have that" when the information genuinely is not in front of me, NOT when a tool this turn already returned it'
        )
        // (3b-bis) FALSE-ABSTENTION gate (deterministic, DEFINITIVE-result case). The model said it can't /
        // doesn't have the information ("I don't have the current date and time…") WHILE a DEFINITIVE tool
        // (calculate → the number, get_current_time → the date — NOT retrieval) already returned a usable
        // result THIS TURN. For a definitive tool the answer literally IS the tool output, so it is provably
        // in the turn's context. This is reached only after the unread-handle branch (unreadHandles === null
        // here), so the result is NOT an unopened spooled handle: it made it into the model's dispatch window
        // and the model simply ignored it. So the abstention is FALSE: reject it EVERY time (UNBOUNDED — the
        // answer really is right there) and send the model back to answer FROM the result it already has.
        // (Not banked as bestOwnWordsProse — the backstop guard excludes a false abstention.) The
        // RETRIEVAL-only case is handled by the two branches below, which are BOUNDED because a search
        // "succeeding" does not prove the answer is in the passages. [[no_pathological_cases_dont_cheat]]
        emitGate(
          'false-abstention',
          'Abstained even though a tool already returned the answer this turn; sent the model back to answer from that result',
          'You said you do not have the information — but a tool THIS TURN already returned it, and its ' +
            'result is right here in your context. Do NOT abstain. Read the tool result you already have ' +
            '(open the handle with the artifact_* tools if needed), then answer from it — for an @nhtio/adk ' +
            'question, via a provide_answer TOOL CALL citing the page (not chat prose, not an apology).',
          'A tool already gave me this answer this turn — the result is in front of me. I will not say I ' +
            'don’t have it; I read it, then deliver via a provide_answer TOOL CALL citing the page.'
        )
      } else if (
        isAbstention &&
        hasSuccessfulResult &&
        retrievalAbstentionCount < RETRIEVAL_ABSTENTION_LIMIT
      ) {
        // (3b-ter) RETRIEVAL-ABSTENTION gate (BOUNDED). The model abstained while the only successful
        // results this turn were RETRIEVAL (search_docs_* / artifact_* reads), NOT a definitive tool. A
        // search succeeding means passages CAME BACK, not that the answer is IN them — so this abstention
        // MIGHT be honest (the docs may genuinely not cover the question). While under the bound we still
        // push back — re-searching does real work, and legit recoveries re-searched several times before
        // finding the right passage — but every dispatch WITHOUT a new tool call increments the counter
        // (reset above on any tool call), so a model that has stopped searching and is just re-abstaining
        // cannot loop forever. At the limit we fall through to the accept branch below.
        retrievalAbstentionCount++
        emitCheckerThought(
          'the passages a search returned may simply not cover this question — before accepting "the docs don\'t have it" I try another search / read another hit, but I do not fight an honest abstention forever'
        )
        emitGate(
          'false-abstention',
          'Abstained after a search; pushed the model to try another search / read another hit before giving up',
          'You said the documentation does not cover this — but the passages you have may just be the wrong ' +
            'ones. Before giving up, try ONE more angle: run search_docs_semantic (or search_docs_keyword) ' +
            'with different terms, or read another hit with the artifact_* tools. If a page really answers ' +
            'this, deliver via a provide_answer TOOL CALL citing it. If after searching the docs genuinely ' +
            'do not cover it, say so plainly.',
          'The passages I have may be the wrong ones. My next output is another search_docs_semantic with ' +
            'different terms, or reading another hit — then a provide_answer citing it if it fits.'
        )
      } else if (isAbstention && hasSuccessfulResult) {
        // (3b-quater) HONEST-ABSTENTION ACCEPT. The model has re-abstained past RETRIEVAL_ABSTENTION_LIMIT
        // with no new tool call: it searched, read the passages, and they genuinely do not cover the
        // question (root-caused: a "PDF redaction / SSN" question whose only hits were Trust-Tiers / Media /
        // Store-Delete). "I couldn't find this in the documentation" is now the CORRECT answer, not a
        // give-up — a genuinely-unanswerable question has no upstream fix. Accept it: commit the model's own
        // honest wording (or a clean doc fallback), uncited (there is nothing real to cite). This is the
        // escape the old unbounded gate lacked — without it the model oscillates abstention <-> a degenerate
        // gen forever (the observed wedge). [[no_pathological_cases_dont_cheat]]
        emitCheckerThought(
          'I have searched and read the passages; they do not cover this question. The honest answer is that the documentation does not contain it — I say that plainly rather than fight my own correct abstention'
        )
        const honestBody =
          stripFalseAbstentionPreamble(proseAnswer).trim().length >= 40
            ? proseAnswer.trim()
            : proseAnswer.trim() || gracefulFallback(turnPlan?.answerKind)
        captured = { kind: 'answer', text: honestBody, sources: [] }
        answer = honestBody
        events.onGate?.({
          gate: 'false-abstention',
          detail: `Accepted an honest abstention: the model searched and re-abstained ${retrievalAbstentionCount} times with no new tool call; the retrieved passages do not cover the question, so "not in the docs" is the correct answer`,
        })
        clearAllGates()
        if (!ctx.isSignalled) ctx.ack()
      } else if (isAbstention && !browsedCatalog) {
        emitCheckerThought(
          'model checked the tool catalog for a tool that could help before giving up — it must not say "I can\'t" without first looking for a way to'
        )
        // (3c) DON'T-GIVE-UP gate. Abstained, nothing usable, never even browsed the catalog. Make it
        // check tool_catalog for a better tool / a better way to call one before we accept "I can't".
        emitGate(
          'check-catalog',
          'Premature give-up; pushed the model to check the catalog for a better tool first',
          'Do not give up yet. Before saying you cannot answer, call tool_catalog and look for a tool ' +
            'that fits this request — or a better way to call one you already tried (check the exact ' +
            'name and its arguments). Only if nothing in the catalog can help should you tell the user, ' +
            'in plain prose, that you do not know.',
          'I gave up without checking the catalog. My next output must be a tool_catalog call to look for a tool that fits.'
        )
      } else if (
        !isAbstention &&
        !hasSuccessfulResult &&
        (await classifyNeedsToolVerification(input.text, proseAnswer))
      ) {
        emitCheckerThought(
          'any specific computed value or live fact in the answer actually came from a tool result this turn — a 2B cannot reliably compute or recall such values from its head'
        )
        // (3d) UNVERIFIED-CLAIM gate. The model ASSERTED a hard computed value / live fact but NO
        // successful tool produced it this turn — from-the-head (a 2B states 73291×8457 wrong) or after
        // a failed tool. Reject + re-loop (UNBOUNDED) with an order to compute it via a tool first.
        emitGate(
          'unverified-claim',
          'Rejected a computed/live value the model asserted without a tool; made it verify',
          'Your last reply stated a specific computed value or fact that you did NOT get from a tool — ' +
            'do not do that. You cannot reliably compute or recall such values yourself. Use the right ' +
            'tool first: call tool_catalog to find it (e.g. calculate for math), call it via call_a_tool ' +
            'with the EXACT arguments from its catalog entry, READ the result, and answer from THAT. Do ' +
            'not state the value again until a tool has produced it.',
          'I stated a computed value from my head without a tool. My next output must be a tool call that actually produces it.'
        )
      } else {
        emitCheckerThought(
          'is a documentation answer that needs a citation, or an ordinary prose reply that does not — and, if it cites, whether the source is a page I actually searched and not a guess'
        )
        // Prose with no tool call and no false abstention / unverified claim. Classify: uncited DOC
        // answer, or a valid plain-prose reply (conversational, general-knowledge, or honest IDK)?
        events.onActivity?.('Checking the answer…')
        const isDocAnswer = await classifyIsDocAnswer(proseAnswer)
        // REPEATED-PROSE check: is THIS prose a near-identical re-emission of the prose we just rejected?
        // (The 2B livelocks here — it keeps regenerating the same uncited doc answer while needs-citation
        // keeps bouncing it.) Track consecutive near-identical repeats; once stuck, stop fighting it.
        const normNew = normaliseForCompare(proseAnswer)
        const normPrev = normaliseForCompare(lastRejectedProse)
        const proseStuck =
          normNew.length >= 12 &&
          normPrev.length > 0 &&
          levenshteinSimilarity(normNew, normPrev) >= similarityThresholdFor(normNew, normPrev)
        // GROUNDED-BUT-CITE (the root fix). An uncited doc answer that is actually SUPPORTED by a
        // pre-fetched retrievable already in context (the Ask-ADK baseline RAG) is grounded — it just
        // isn't routed through provide_answer. The user's rule: grounded RAG answers are fine, but they
        // MUST be cited. So instead of bouncing it back into a search/provide_answer loop the 2B won't
        // complete, commit it AS A CITED answer, citing the retrievable that grounded it. (Only when the
        // model never searched — if it DID search, the normal cite path applies.)
        const grounding =
          isDocAnswer && !searchedThisTurn
            ? findGroundingRetrievable(proseAnswer, [...ctx.turnRetrievables] as never)
            : null
        // SELF-CITED COMPLETION (the gen4 failure). The 2B sometimes writes the WHOLE answer in prose and
        // ends it with its OWN sources list ("Sources:\n* /assembly/byo-llm …") instead of routing through
        // provide_answer — a finished, grounded answer that just used the wrong delivery. The old code
        // bounced it into needs-citation (grounding is null once searchedThisTurn), the model wandered
        // re-reading artifacts, and committed a TRUNCATED re-gen. Detect that self-cite tail; when present
        // on a doc answer this turn, the work is DONE — commit it cited from the grounded retrievable set
        // (same never-commit-a-doc-answer-uncited rule as the force-commit backstop), no wasted re-loop.
        // TRAILING NAKED-LINK gate (deterministic — no "Sources:" heading needed). The 2B often ends a
        // doc/capacity answer with the pages it used dangling as bare relative URLs ("… see /assembly/byo-llm
        // and /assembly/assembly.") — functionally-dead text, not clickable citations, and provide_answer's
        // structured `sources` stays empty. Detect that trailing link-run, validate the paths, STRIP them
        // from the body, and commit the answer cited with those as chips. Runs BEFORE the "Sources:"-heading
        // path (which needs the heading) and before the needs-citation re-loop. Conservative: only fires when
        // the tail is essentially just links and a real answer body remains (see extractTrailingNakedLinks).
        const nakedCite = isDocAnswer ? await validatedTrailingNakedSources(proseAnswer) : null
        const selfCited = isDocAnswer && hasTrailingSourcesList(proseAnswer)
        // (No-progress counting happens in the UNIFIED backstop above — which runs before this branch for
        // any own-words reply >= 40 chars — so we do NOT increment again here.)
        // NB: an inline doc-path embedded mid-prose is NOT reject-and-regenerated (that churns a 2B: grinds,
        // truncation, once a raw tool-call envelope after 24 searches). Instead #cleanInlinePathsIfAny runs the
        // specialist on the committed text below (before ack), tool-free and fail-safe.
        if (nakedCite && !selfCited) {
          captured = {
            kind: 'answer',
            text: stripFalseAbstentionPreamble(nakedCite.body) || nakedCite.body,
            sources: nakedCite.sources,
          }
          events.onGate?.({
            gate: 'needs-citation',
            detail: `Stripped ${nakedCite.sources.length} trailing naked doc link${nakedCite.sources.length === 1 ? '' : 's'} and committed them as citation chips`,
          })
          captured = await cleanInlinePathsIfAny(captured, 'doc_cited')
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        } else if (selfCited) {
          // The answer is complete and the model named its own sources. Strip the prose Sources: tail (the
          // SOURCES chip renders them structurally) and cite the grounded set: prefer the paths the model
          // itself named that are REAL doc paths, else fall back to all this turn's retrievables. Never
          // commits uncited — if nothing validates we still attach the grounded retrievables.
          const body = stripTrailingSourcesList(
            stripFalseAbstentionPreamble(proseAnswer) || proseAnswer
          )
          const named = await validatedNamedSources(proseAnswer)
          const sources =
            named.length > 0 ? named : allRetrievableSources([...ctx.turnRetrievables] as never)
          if (sources.length > 0) {
            captured = { kind: 'answer', text: body, sources }
            events.onGate?.({
              gate: 'needs-citation',
              detail: `Model wrote a complete answer with its own sources list; committed it cited (${sources.length} source${sources.length === 1 ? '' : 's'}) instead of re-looping`,
            })
            captured = await cleanInlinePathsIfAny(captured, 'doc_cited')
            clearAllGates()
            if (!ctx.isSignalled) ctx.ack()
          } else {
            // No validated sources at all (no retrievables, no real named paths) — fall through to the
            // normal needs-citation re-loop rather than commit an ADK answer with zero sources.
            repeatedProseCount = proseStuck ? repeatedProseCount + 1 : 1
            lastRejectedProse = proseAnswer
            emitGate(
              'needs-citation',
              'Self-cited doc answer but no source validated; sent back to search + provide_answer',
              'Your answer cites pages, but none of them are real @nhtio/adk documentation paths I can ' +
                'verify. Call search_docs_semantic for this question, then provide_answer citing a page ' +
                'the search actually returned.',
              'My cited pages did not validate. My next output must be a search_docs_semantic call, then a provide_answer citing a page it returns.'
            )
          }
        } else if (isDocAnswer && grounding) {
          // Commit the grounded answer WITH the retrievable's citation. Strip any false-abstention
          // preamble first. This ends the loop the right way: a cited, grounded answer.
          captured = {
            kind: 'answer',
            text: stripFalseAbstentionPreamble(proseAnswer) || proseAnswer,
            sources: [{ path: grounding.path, title: grounding.title }],
          }
          events.onGate?.({
            gate: 'needs-citation',
            detail: `Grounded the answer in a pre-fetched doc passage and cited it (${grounding.path})`,
          })
          captured = await cleanInlinePathsIfAny(captured, 'doc_cited')
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        } else if (
          isDocAnswer &&
          ((proseStuck && repeatedProseCount + 1 >= STUCK_PROSE_LIMIT) ||
            noProgressProseCount >= NO_PROGRESS_PROSE_LIMIT)
        ) {
          // PROSE HARD-STOP. Either the model re-emitted a near-identical uncited answer too many times
          // (repeated-prose stop), or it has produced uncited doc-prose with NO tool progress for too many
          // iterations (the paraphrase-every-time livelock that inflates context to the engine cap). Stop
          // looping and ACCEPT the prose — a real answer, just uncited; an unbounded loop is far worse.
          events.onGate?.({
            gate: 'needs-citation',
            detail:
              'Model would not route an uncited answer through provide_answer; accepted the prose to avoid an infinite loop',
          })
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        } else if (isDocAnswer && !answerToolAvailable) {
          // (3-dissolve) THE CITATION REQUIREMENT DISSOLVES when the answer tool was shed (tight window —
          // the pass dropped provide_answer to free room for RAG; see agent_subtractive_pass.ts). The model
          // CANNOT call provide_answer, so it answered in PROSE — commit it via auto-capture, grounded from
          // the prefetched RAG pages that survived (they exist independently of the shed tools), else
          // uncited. Demanding a cited answer-tool call the budget can't hold is the deadlock we're
          // dissolving. A delivered, RAG-grounded answer beats an unsatisfiable citation loop at a window
          // the user deliberately made small. (At a normal window provide_answer is visible, so this never
          // fires and the strict cite re-loop below still governs.) [[no_pathological_cases_dont_cheat]]
          const groundSrc = allRetrievableSources([...ctx.turnRetrievables] as never)
          const src = groundSrc.length > 0 ? groundSrc : prefetchedSources
          captured = {
            kind: 'answer',
            text: stripFalseAbstentionPreamble(proseAnswer) || proseAnswer,
            sources: src,
          }
          events.onGate?.({
            gate: 'needs-citation',
            detail:
              src.length > 0
                ? `Answer tool shed (window too small); committed the prose answer grounded from the ${src.length} pre-fetched page${src.length === 1 ? '' : 's'}`
                : 'Answer tool shed (window too small); committed the prose answer uncited rather than deadlock',
          })
          captured = await cleanInlinePathsIfAny(captured, 'doc_cited')
          clearAllGates()
          if (!ctx.isSignalled) ctx.ack()
        } else if (isDocAnswer) {
          // (3) Re-loop toward a CITED provide_answer. Sticky until a cited answer lands — but bounded by
          // both the repeated-prose stop AND the no-progress backstop above so it cannot livelock.
          repeatedProseCount = proseStuck ? repeatedProseCount + 1 : 1
          lastRejectedProse = proseAnswer
          emitGate(
            'needs-citation',
            'Uncited @nhtio/adk answer; sent back to provide_answer for sources',
            'That looks like a documentation answer, but answers about @nhtio/adk must be cited. ' +
              'Re-issue it by calling provide_answer with your answer AND at least one real ' +
              'documentation source path you used. If the documentation genuinely does not cover it, ' +
              'reply in plain prose that you do not know. (A purely conversational reply is fine as-is.)',
            'My @nhtio/adk answer was uncited. My next output must be a provide_answer call with at least one real documentation source path.'
          )
        } else if (!ctx.isSignalled) {
          // (4) Accept the prose: conversational / general-knowledge / honest IDK that passed every
          // gate. Clear any lingering corrections and end the turn.
          clearAllGates()
          ctx.ack()
        }
      }
      await next()
    } finally {
      state.prevToolCallCount = prevToolCallCount
      state.lastReportedTool = lastReportedTool
      state.dispatchError = dispatchError
      state.dispatchErrorIsOom = dispatchErrorIsOom
      state.dispatchErrorIsOverflow = dispatchErrorIsOverflow
      state.dispatchErrorIsWindowTooSmall = dispatchErrorIsWindowTooSmall
      state.turnPoisoned = turnPoisoned
      state.sawSourcelessAnswer = sawSourcelessAnswer
      state.captured = captured
      state.proseAnswer = proseAnswer
      state.lastRejectedProse = lastRejectedProse
      state.repeatedProseCount = repeatedProseCount
      state.noProgressProseCount = noProgressProseCount
      state.bestOwnWordsProse = bestOwnWordsProse
      state.incompleteAnswerCount = incompleteAnswerCount
      state.fakeCitationCount = fakeCitationCount
      state.leakedEnvelopeCount = leakedEnvelopeCount
      state.emptyGenCount = emptyGenCount
      state.retrievalAbstentionCount = retrievalAbstentionCount
      state.turnPlan = turnPlan
      state.answer = answer
    }
  }

  return dispatchOutput
}

```

#### agent\_context\_assembly.ts

```ts
import { drainUserMessages } from './agent_state'
import { MODELS, type ModelChoice } from './agent_models'
import { normaliseForCompare } from './agent_prose_heuristics'
import { readModelFacingPreview } from './agent_artifact_handles'
import { RELEASED_ARTIFACTS_STASH_KEY, type PlanAnswerScope } from './agent_tools'
import {
  CITE_TEXT_CAPACITY,
  CITE_TEXT_PROSE,
  CITE_TEXT_TOOL,
  CITE_THOUGHT_ID,
  NUDGE_THOUGHT_PREFIX,
  PLAN_THOUGHT_ID,
} from './agent_turn_policy'
import {
  subtractToFit,
  ENCODING as THRIFT_ENCODING,
  type WorkingImage,
  type WorkingSet,
  type WorkingToolCall,
  type ThriftTrace,
} from './agent_subtractive_pass'
import {
  LiteRtLmAdapter,
  Message,
  Memory,
  Retrievable,
  Thought,
  Tokenizable,
  renderToolsAsPromptText,
  looksLikeSpooledArtifact,
  type DispatchContext,
} from './agent_adk'
import type { TurnState, EmitThought } from './agent_turn_state'
import type { HarnessAdapter, AgentEvents } from './agent_harness_types'

export function makeDispatchInput(deps: {
  state: TurnState
  events: AgentEvents
  input: { text: string }
  image: WorkingImage | undefined
  imageMessage: Message | null
  visibleToolNames: string[]
  adapter: HarnessAdapter | null
  contextWindow: number
  maxTokens: number
  model: ModelChoice
  injectSyntheticThoughts: boolean
  resolveOutputTokens: (scope?: PlanAnswerScope) => number
  budgetWords: (scope?: PlanAnswerScope) => number
  emitThought: EmitThought
  makeWindowTooSmallError: (trace: ThriftTrace) => Error & { code: string }
  renderPlanThought: (
    plan: NonNullable<TurnState['turnPlan']>,
    budgetWords: number,
    capacityNote?: string
  ) => (ectx?: { tools?: { visible: () => { name?: string }[] } }) => string
}) {
  const {
    state,
    events,
    input,
    image,
    imageMessage,
    visibleToolNames,
    adapter,
    contextWindow,
    maxTokens,
    model,
    injectSyntheticThoughts,
    resolveOutputTokens,
    budgetWords,
    emitThought,
    makeWindowTooSmallError,
    renderPlanThought,
  } = deps

  // dispatchInputPipeline (PER iteration, before the model): hydrate the conversational state,
  // run the force-answer guard, then the subtractive pass. Conversational hydration happens
  // here (not turn-scoped) so a MID-TURN interrupt folds into the very next dispatch.
  const dispatchInput = async (ctx: DispatchContext, next: () => Promise<void>): Promise<void> => {
    const seededIds = state.seededIds
    const priorTurnToolCallIds = state.priorTurnToolCallIds
    const priorThoughtIds = state.priorThoughtIds
    const activeNudges = state.activeNudges
    const activeNudgeThoughts = state.activeNudgeThoughts
    const turnPlan = state.turnPlan
    const sawSourcelessAnswer = state.sawSourcelessAnswer
    let lastTrace = state.lastTrace
    let refused = state.refused
    try {
      events.onPhase?.('generating')

      // (a) Seamless interruption: messages typed mid-turn (already persisted by the dialog)
      // are drained here and folded into THIS dispatch — no "busy" gate.
      for (const pm of drainUserMessages()) {
        if (seededIds.has(pm.id)) continue
        seededIds.add(pm.id)
        ctx.turnMessages.add(
          new Message({
            id: pm.id,
            role: 'user',
            content: pm.content,
            createdAt: pm.createdAt,
            updatedAt: pm.createdAt,
          })
        )
      }

      // (b) First iteration: hydrate the conversational state the loop reasons over. (memories
      // a `store_memory` call adds mid-turn arrive live via ctx.storeMemory; current-turn tool
      // calls accumulate in ctx.turnToolCalls — so a one-time seed here is correct.)
      if (ctx.iteration === 0) {
        for (const m of await ctx.fetchMessages()) {
          if (seededIds.has(m.id)) continue
          seededIds.add(m.id)
          ctx.turnMessages.add(m)
        }
        if (imageMessage && !seededIds.has(imageMessage.id)) {
          seededIds.add(imageMessage.id)
          ctx.turnMessages.add(imageMessage)
        }
        for (const mem of await ctx.fetchMemories()) ctx.turnMemories.add(mem)
        for (const tc of await ctx.fetchToolCalls()) {
          ctx.turnToolCalls.add(tc)
          const tcId = (tc as { id?: string }).id
          if (typeof tcId === 'string') priorTurnToolCallIds.add(tcId) // seeded = prior-turn = sheddable
        }
        // NOTE: baseline RAG is NO LONGER seeded here. It is a TURN CONSTANT, hydrated once at turn-in
        // (hydrateRetrievables in turnInputPipeline) so every dispatch gets a fresh full copy and the
        // per-dispatch subtractive shed is non-destructive. Seeding it in this iteration-0 block was the
        // legacy bug: once step-4 shed the passages, later iterations never re-hydrated them and the turn
        // ran RAG-less (which also disabled the subtractive RAG-tools eject). See hydrateRetrievables.
        // Prior-turn SYNTHETIC reasoning (plan + nudges; the model's own CoT was filtered out in
        // fetchThoughtsCallback). These are harness-authored guidance, not model CoT, so they're exempt
        // from the Gemma §3 prior-turn-thought strip — track their ids so the subtractive pass KEEPS them.
        for (const th of await ctx.fetchThoughts()) {
          if (seededIds.has(th.id)) continue
          seededIds.add(th.id)
          ctx.turnThoughts.add(th)
          priorThoughtIds.add(th.id)
        }
      }

      // (c) The PLAN THOUGHT (iteration 0). Inject the validated plan as a synthetic THIS-TURN thought
      // — fresh guidance generated for THIS request (NOT prior-turn CoT, which Gemma's guidance strips;
      // different use case). It names the exact tools the worker should call, so the worker stops
      // fabricating the catalog / inventing tool names. Marked so the subtractive pass keeps it (see
      // stripPriorTurnThoughts' planThoughtId carve-out).
      // (b-bis) DYNAMIC OUTPUT BUDGET (iteration 0). Tighten THIS turn's output cap to the planner's
      // committed answer_scope, BELOW the user's slider ceiling — so a `brief` broad answer is asked to
      // finish in a small budget instead of being clipped mid-sentence at the full cap (the 2048→512
      // truncation regression). Only for answer kinds that PRODUCE a written answer; a `tool_computed`
      // turn's final text is whatever the tool returns, so we never starve its loop. Set via the LiteRT
      // per-dispatch stash override (the merge re-reads it each dispatch; no engine reload). Merge into
      // any existing stash so we don't clobber another override.
      if (
        ctx.iteration === 0 &&
        turnPlan &&
        (turnPlan.answerKind === 'doc_cited' ||
          turnPlan.answerKind === 'conversational' ||
          turnPlan.answerKind === 'rhetorical' ||
          turnPlan.answerKind === 'capacity_scoped')
      ) {
        const out = resolveOutputTokens(turnPlan.answerScope)
        if (out < maxTokens) {
          const prior = ctx.stash.get(LiteRtLmAdapter.STASH_KEY, {}) as Record<string, unknown>
          ctx.stash.set(LiteRtLmAdapter.STASH_KEY, {
            ...prior,
            maxTokens: out,
          })
        }
      }

      if (ctx.iteration === 0 && turnPlan && !seededIds.has(PLAN_THOUGHT_ID)) {
        seededIds.add(PLAN_THOUGHT_ID)
        // The REAL model identity + output cap — quoted verbatim by a capacity_scoped plan so the honest
        // "I'm a small on-device model capped at N tokens" statement uses true numbers, never a literal.
        const capacityNote = `${MODELS[model].label}, an on-device model capped near ${maxTokens} output tokens (~${budgetWords()} words)`
        // EVALUATABLE plan-thought: its tool-commitment line resolves at ASSEMBLY against the live dispatch
        // (only asserts a planned tool call when that tool is actually visible; see renderPlanThought).
        const planEvaluator = renderPlanThought(
          turnPlan,
          budgetWords(turnPlan.answerScope),
          capacityNote
        )
        // Suppress the in-PROMPT plan thought when the model can't take a trailing-assistant turn (opt-in);
        // it still emits to the UI below. The plan still drives tool routing + gates regardless.
        if (injectSyntheticThoughts) {
          ctx.turnThoughts.add(
            new Thought({
              id: PLAN_THOUGHT_ID,
              // Wrap the evaluator in a Tokenizable INSTANCE — Thought's content schema accepts string |
              // Tokenizable, not a bare function. The instance carries the dynamic evaluator (render(ctx)
              // resolves it at assembly).
              content: new Tokenizable(planEvaluator as never),
              createdAt: new Date().toISOString(),
              updatedAt: new Date().toISOString(),
            })
          )
        }
        // Surface + persist the plan as a Thought (always shown — independent of the model-CoT toggle). The
        // UI gets the no-ctx rendering (the pre-shed plan, all planned tools present); the MODEL gets the
        // ctx-resolved variant at assembly.
        emitThought({
          id: PLAN_THOUGHT_ID,
          kind: 'plan',
          text: planEvaluator(),
          isComplete: true,
        })
      }

      // CITATION REINFORCEMENT — DYNAMIC (was unconditional at iteration 0). The 2B gets a CLEAN first
      // shot: the planner already reasons it into doc_cited and self-cites reliably, so we do NOT pre-load
      // citation directives every doc_cited turn — that verbose guidance is exactly what a 2B starts a
      // META-CONVERSATION with ("I understand the directive, I will ensure…"), committing the
      // acknowledgment instead of an answer. We inject the first-person cite-thought ONLY AFTER a
      // citation-related gate has actually fired this turn (needs-citation or parroting) — i.e. once the
      // model has shown it needs the reminder. Escalate on failure, not preemptively. (Static-preload
      // version preserved in [[flagship_directive_acknowledgment_bug]] memory if we need to revert.)
      // Inject the cite-thought once a citation gate has fired (needs-citation/parroting) OR the model has
      // already tripped the sourceless-answer path (provide_answer with a good answer but no sources). The
      // latter returns a TOOL STRING and never arms a gate, so without `sawSourcelessAnswer` the one thought
      // that would fix the loop never appears.
      const citationGateActive =
        activeNudges.has('needs-citation') || activeNudges.has('parroting') || sawSourcelessAnswer
      if (
        (turnPlan?.answerKind === 'doc_cited' || turnPlan?.answerKind === 'capacity_scoped') &&
        citationGateActive &&
        !seededIds.has(CITE_THOUGHT_ID)
      ) {
        seededIds.add(CITE_THOUGHT_ID)
        // EVALUATABLE cite-thought: its text resolves AT ASSEMBLY against the live dispatch ctx, so it is
        // always coherent with the post-shed dispatch it ships in (no stale prior-iteration `visible()`
        // guard). capacity_scoped → a short prose decline, harness-cited (never hand-types paths). doc_cited
        // → picks by whether provide_answer is VISIBLE in that dispatch: visible → deliver via provide_answer
        // (harness attaches pages); shed at a tight window → answer in plain prose from the pages already in
        // context. The no-ctx fallback is the PROSE variant (safe default: never demand a tool that might be
        // shed). [[flagship_window_wall_and_toolcalls_phantom]] — this REPLACES the scattered
        // provideAnswerVisibleForCite / answerToolAvailable guards with correct-by-construction content.
        const isCapacity = turnPlan?.answerKind === 'capacity_scoped'
        const citeEvaluator = (ectx?: {
          tools: { visible: () => { name?: string }[] }
        }): string => {
          if (isCapacity) return CITE_TEXT_CAPACITY
          const answerToolVisible =
            ectx?.tools.visible().some((t) => t.name === 'provide_answer') ?? false
          return answerToolVisible ? CITE_TEXT_TOOL : CITE_TEXT_PROSE
        }
        if (injectSyntheticThoughts) {
          ctx.turnThoughts.add(
            new Thought({
              id: CITE_THOUGHT_ID,
              // Wrap the evaluator in a Tokenizable INSTANCE (Thought's schema accepts string | Tokenizable,
              // not a bare function).
              content: new Tokenizable(citeEvaluator as never),
              createdAt: new Date().toISOString(),
              updatedAt: new Date().toISOString(),
            })
          )
        }
        // The UI panel gets a representative string (evaluated with no ctx = the fallback/capacity text);
        // the MODEL gets the ctx-resolved variant at assembly.
        emitThought({
          id: CITE_THOUGHT_ID,
          kind: 'plan',
          text: citeEvaluator(),
          isComplete: true,
        })
      }

      // (c') Corrective nudges. STICKY: every active entry in `activeNudges` is re-emitted each
      // iteration and only cleared (in dispatchOutput) once its goal is met — so a correction persists
      // until the model actually complies, instead of vanishing the moment its exact detector stops
      // firing. The gate text + onGate event are produced in dispatchOutput where the condition is
      // detected; here we just render whatever is still active.
      //
      // DUAL-CHANNEL delivery (per the user): a bare user-role nudge gets ARGUED WITH by the 2B — it
      // reads it as the human talking and rebuts conversationally ("I have already read the result").
      // So we deliver the SAME correction on two channels at once:
      //   1. an external control directive framed as [SYSTEM DIRECTIVE] (not the user speaking), and
      //   2. a synthetic FIRST-PERSON thought ("I need to call X now") injected as the model's own
      //      latest reasoning — the model is far likelier to ACT on its own next-action thought than to
      //      argue with it.
      // The thoughts are ephemeral (added straight to the Set, never persisted) and re-derived from the
      // active set each iteration: stale nudge-thoughts are removed first so only currently-active
      // corrections appear as "latest reasoning".
      events.onActivity?.('Thinking…')
      for (const t of [...ctx.turnThoughts]) {
        const tid = (t as { id?: string }).id
        if (typeof tid === 'string' && tid.startsWith(NUDGE_THOUGHT_PREFIX)) {
          ctx.turnThoughts.delete(t)
        }
      }
      // EPHEMERALS ARE DISPATCH-SCOPED. A `__eph-<iteration>` control directive is re-derived from the
      // CURRENTLY-active nudges every iteration (below), so any prior iteration's ephemeral is stale by
      // definition. Purge them ALL before re-adding this iteration's single directive — unconditionally, not
      // only under budget pressure — so a multi-dispatch turn never carries a back-catalogue of `__eph-0…N`
      // directives. Mirrors the nudge-THOUGHT purge just above. (The subtractive pass still sheds oldest-
      // first under budget; this makes the common case never reach that state.)
      for (const m of [...ctx.turnMessages]) {
        const mid = (m as { id?: string }).id
        if (typeof mid === 'string' && mid.startsWith('__eph-')) {
          ctx.turnMessages.delete(m)
        }
      }
      // DEDUP the model's own re-emitted generations. A gate that rejects-and-reloops leaves the model's
      // assistant message in ctx.turnMessages (ADK flush); a model re-emitting the SAME rejected answer each
      // iteration stacks identical copies (observed 19× `<provide_answer answer="…">`), drowning the 2B in its
      // own thrash. A re-emitted identical rejected answer is transient noise, not conversation: keep the most
      // recent assistant message of each normalised content, drop the older duplicates. Non-assistant messages
      // (user turns, tool results) are never touched.
      {
        const seen = new Set<string>()
        const assistants = [...ctx.turnMessages].filter(
          (m) => (m as { role?: string }).role === 'assistant'
        )
        // Walk newest→oldest so the FIRST time we see a given content is the most-recent copy (kept); any
        // earlier identical copy is a stale duplicate (dropped).
        for (let i = assistants.length - 1; i >= 0; i--) {
          const m = assistants[i]
          const content = (m as { content?: { toString(): string } }).content
          const key = normaliseForCompare(content !== undefined ? content.toString() : '')
          if (!key) continue
          if (seen.has(key)) ctx.turnMessages.delete(m)
          else seen.add(key)
        }
      }
      // CAP THE THIS-TURN SELF-ECHO STACK. The identical-dedup above only collapses byte-identical repeats,
      // but a rejected non-answer is re-emitted with SLIGHT variation each loop ("I don't have the date" →
      // "I apologize, unknown value" → "I sincerely apologize, I'll summarize"), so distinct copies ACCUMULATE.
      // PROVEN (Node E2B ablation on the real steady-state prompt): with 3 accumulated self-apologies the
      // flagship temp=1.0 sampler emits ANOTHER apology 3/6; remove them and it recovers 6/6. Each apology the
      // model emits is flushed back into ctx.turnMessages and re-fed, compounding the odds of the next apology
      // until the turn locks into an apology fixed point. Fix: keep only the MOST-RECENT this-turn assistant
      // generation; drop older ones. "This-turn" = a flushed generation NOT in seededIds (which holds only the
      // explicitly-seeded prior-turn history + drained user msgs), so genuine prior-turn assistant answers are
      // never touched. Capping at 1 matches the evidence (a single prior attempt recovers; the ACCUMULATION is
      // what breaks it). [[tool_computed_loop_is_self_apology_accumulation]] [[cross_turn_stale_artifact_bleed]]
      {
        const thisTurnAssistants = [...ctx.turnMessages].filter(
          (m) =>
            (m as { role?: string }).role === 'assistant' &&
            !seededIds.has((m as { id?: string }).id ?? '')
        )
        // Keep the newest; delete the rest (oldest-first order is preserved by the Set iteration).
        for (let i = 0; i < thisTurnAssistants.length - 1; i++) {
          ctx.turnMessages.delete(thisTurnAssistants[i])
        }
      }
      if (activeNudges.size > 0) {
        ctx.turnMessages.add(
          new Message({
            id: `__eph-${ctx.iteration}`,
            role: 'user',
            content:
              '[SYSTEM DIRECTIVE — not from the user; an automated control gate]\n' +
              [...activeNudges.values()].join('\n\n'),
            createdAt: new Date().toISOString(),
            updatedAt: new Date().toISOString(),
          })
        )
        // Channel 2: inject each active gate's first-person next-action line as the model's own thought.
        // (Skipped in the prompt when synthetic-thought injection is off — the __eph SYSTEM DIRECTIVE user
        // message above still carries the gate's correction, which is a USER turn, so the prompt still ends
        // on a non-assistant turn for a model like qwen3.)
        for (const [gate, thought] of activeNudgeThoughts) {
          const now = new Date().toISOString()
          if (injectSyntheticThoughts) {
            ctx.turnThoughts.add(
              new Thought({
                id: `${NUDGE_THOUGHT_PREFIX}${gate}`,
                content: thought,
                createdAt: now,
                updatedAt: now,
              })
            )
          }
          // Surface + persist the course-correction as a Thought. Re-emitted per iteration for sticky
          // nudges — idempotent because both the UI and the record map key by id and replace in place.
          emitThought({
            id: `${NUDGE_THOUGHT_PREFIX}${gate}`,
            kind: 'nudge',
            text: thought,
            isComplete: true,
          })
        }
      }

      // (d) The subtractive pass — the Token-Thrift trace + tool-hiding (the thrift lever).
      // Measure each prior-turn tool RESULT as the model-facing string the battery renders into the
      // prompt (readModelFacingPreview = the SAME renderer the executor + the battery's overflow guard
      // use: a spooled-artifact handle note, or the inlined body). Historically the pass ignored tool
      // results entirely, so accumulated search blobs / artifact handles were invisible to thrift while
      // the battery counted them — the multi-turn undercount that fired the overflow guard. Pre-measured
      // here (async) so subtractToFit stays synchronous; `ref` lets us reconcile ctx.turnToolCalls after.
      const workingToolCalls: WorkingToolCall[] = []
      // RELEASED set: callIds the model explicitly freed this turn via release_artifact (recorded in stash by
      // the tool's handler). A released this-turn result is treated as SHEDDABLE — the model said it is done,
      // so the anti-re-search-loop protection no longer applies and its body can leave the window. Read once
      // per dispatch; the tool rebuilds the set as the model releases more.
      const releasedIds = new Set(
        (ctx.stash.get(RELEASED_ARTIFACTS_STASH_KEY, []) as string[]) ?? []
      )
      // DEV timing tap: how long the per-tool-call readModelFacingPreview loop takes (suspected 4K stall —
      // this awaits byteLength()/lineCount() on each spooled-artifact handle every iteration, so an OPFS
      // read that blocks would freeze dispatchInput between generations). Gated on __ADK_WIRETAP__.
      const tcTimingStart = __ADK_WIRETAP__ ? performance.now() : 0
      for (const tc of ctx.turnToolCalls) {
        // Measure the tool-result the SAME way the active adapter's overflow guard will. When the adapter
        // supplies measureToolResultAsText (e.g. the Node/Ollama harness → renderOllamaToolCallResult, which
        // adds the tool_name + trust envelope the bare handle-body omits), use it so the pass's toolCalls
        // bucket equals the guard's. Fall back to the handle-body preview for the built-in LiteRT path (whose
        // guard measures exactly that body). Correctness: pass total == guard total per bucket → no near-miss.
        const preview = adapter?.measureToolResultAsText
          ? await adapter.measureToolResultAsText(tc)
          : await readModelFacingPreview(tc as Parameters<typeof readModelFacingPreview>[0])
        const tcId = (tc as { id?: string }).id
        workingToolCalls.push({
          ref: tc,
          tokenCost: preview ? Tokenizable.estimateTokens(preview, THRIFT_ENCODING) : 0,
          createdAtMs:
            (tc as { createdAt?: { toMillis?: () => number } }).createdAt?.toMillis?.() ?? 0,
          // NOT seeded-from-history ⇒ the model produced it THIS turn ⇒ protect it from budget-shedding —
          // UNLESS the model explicitly released it (release_artifact), in which case it is sheddable now.
          thisTurn:
            typeof tcId === 'string'
              ? !priorTurnToolCallIds.has(tcId) && !releasedIds.has(tcId)
              : true,
        })
      }
      if (__ADK_WIRETAP__) {
        const ring = ((
          globalThis as unknown as {
            __agentIterTiming?: Array<{ iter: number; tcCount: number; previewMs: number }>
          }
        ).__agentIterTiming ??= [])
        ring.push({
          iter: ctx.iteration,
          tcCount: ctx.turnToolCalls.size,
          previewMs: Math.round(performance.now() - tcTimingStart),
        })
        if (ring.length > 100) ring.shift()
      }
      const ws: WorkingSet = {
        systemPrompt: ctx.systemPrompt as Tokenizable,
        // Standing instructions are a fixed prompt cost the battery guard counts; pass them so thrift's
        // total matches the guard exactly. The flagship uses none (refreshStandingInstructions is a noop),
        // so this is [] today — but wiring it keeps thrift honest if that ever changes.
        standingInstructions: [...ctx.standingInstructions] as never,
        messages: [...ctx.turnMessages] as never,
        memories: [...ctx.turnMemories] as never,
        retrievables: [...ctx.turnRetrievables] as never,
        thoughts: [...ctx.turnThoughts] as never,
        tools: ctx.tools as never,
        toolCalls: workingToolCalls,
        image,
      }
      // Reserve EXACTLY the configured max-output tokens for the model's reply — not a flat fraction of
      // the window. We know the cap (it's the adapter's maxTokens), so holding back more would discard
      // real input budget (RAG chunks, history) for output that can never be generated.
      // Keep set for the prior-turn-thought strip: the planner's this-turn plan thought, every active
      // nudge thought (channel 2), AND the prior-turn SYNTHETIC thoughts seeded from the store. All are
      // harness-authored guidance, not the model's own prior-turn CoT (which §3 rightly strips).
      const keepThoughtIds = new Set<string>([
        PLAN_THOUGHT_ID,
        CITE_THOUGHT_ID,
        ...priorThoughtIds,
        ...[...activeNudgeThoughts.keys()].map((g) => `${NUDGE_THOUGHT_PREFIX}${g}`),
      ])
      // The thoughts the worker NEEDS to answer at all — never shed for budget, unlike the accumulating
      // per-iteration nudge thoughts (which the pass may shed oldest-first when the dispatch won't fit).
      // A strict SUBSET of keepThoughtIds: keepThoughtIds survives the §3 prior-turn strip; this survives
      // the budget shed too.
      const protectThoughtIds = new Set<string>([PLAN_THOUGHT_ID, CITE_THOUGHT_ID])
      // PLANNED-BUT-UNCALLED tools are UNSHEDDABLE: the plan-thought instructs the model to call them, so
      // thrift must not remove them from the rendered tool_definitions block (the proven tool_computed
      // failure — planner chose get_current_time, thrift shed it to just provide_answer, the 2B abstained
      // "I don't have the date"). Bounded: a tool already called this turn (its result is in
      // ctx.turnToolCalls) drops off the protected set and sheds normally, so this is never a permanent
      // floor. Computed here (dispatchInput) from the plan minus tools already successfully called.
      const calledSoFar = new Set(
        [...ctx.turnToolCalls]
          .filter((tc) => !(tc as { isError?: boolean }).isError)
          .map((tc) => (tc as { tool?: string }).tool)
      )
      const protectedPlanTools = new Set(
        (turnPlan?.toolsToUse ?? []).filter((t) => !calledSoFar.has(t))
      )
      // Artifact readers are forged into ctx.tools by the CORE (before this input pipeline). They are NOT in
      // the planner's visibleToolNames shortlist, so without this the pass's setHidden sweep would hide every
      // forged reader — dropping the model's only path to read a handle it just produced. Add the forged
      // reader names to the visible set so they START visible and are shed only by budget rank (exotic-first,
      // core artifact_head/json_get/grep last) like any other tool. This is the read-consume invariant made
      // real: "an unread handle exists" now genuinely implies "its readers are in the prompt (unless budget
      // shed them)", instead of the old structurally-always-false ctx.tools.visible() check.
      const forgedReaderNames = ctx.tools
        .all()
        .map((t) => (t as { name?: string }).name ?? '')
        .filter((n) => n.startsWith('artifact_'))
      // release_artifact is shown ONLY when there is a this-turn artifact to release (a forged reader exists
      // for it) — no point offering "I'm done with X" when there is no X. It pairs with the forged readers:
      // read via artifact_*, then release_artifact(callId) to free the body. Shed by rank under budget like
      // any other tool (it is a luxury, not load-bearing — prose auto-capture delivers without it).
      const releaseName = forgedReaderNames.length > 0 ? ['release_artifact'] : []
      const visibleToolNamesForDispatch = [
        ...new Set([...visibleToolNames, ...forgedReaderNames, ...releaseName]),
      ]
      lastTrace = subtractToFit(
        ws,
        contextWindow,
        visibleToolNamesForDispatch,
        maxTokens,
        keepThoughtIds,
        // Measure the tools bucket in the SAME representation the ACTIVE adapter puts on the wire, so the
        // pass and the battery's own overflow guard agree. The adapter supplies measureToolsAsText when its
        // wire format is not prompt-text (e.g. the Node/Ollama harness sends JSON tool schemas — ~180 tok/
        // tool vs ~100 for prompt-text; measuring the wrong one under-counts and the guard then overflows).
        // Fall back to the LiteRT prompt-text renderer (the built-in browser path's real format).
        (tools) =>
          adapter?.measureToolsAsText
            ? adapter.measureToolsAsText(tools as never)
            : renderToolsAsPromptText(tools as never),
        protectThoughtIds,
        // The live dispatch ctx: a DYNAMIC thought (e.g. the evaluatable cite-thought) is counted for the
        // string it resolves to THIS dispatch, matching what the battery assembles with the same ctx.
        ctx,
        protectedPlanTools
      )
      events.onTrace?.(lastTrace)
      refused = lastTrace.refused

      // APPLY the shed back onto the dispatch context. CRITICAL: subtractToFit mutates the working-set
      // COPIES (`ws.messages` = `[...ctx.turnMessages]`, etc.), and tool-hiding takes effect only because
      // `ws.tools` is the SAME ToolRegistry reference. But the message / retrievable / memory / thought
      // sheds were computed on throwaway arrays and never written back — so the battery kept rendering the
      // FULL `ctx.turnMessages` timeline while the meter reported the shed total. In a multi-turn session
      // that gap is the whole accumulated history: the meter read ~4.2k while the battery assembled ~13.9k
      // and its armed guard (correctly) threw E_LITERT_LM_CONTEXT_OVERFLOW. Reconcile each ctx Set to the
      // survivors the pass kept, so the dispatched prompt IS what thrift measured. Preserve iteration order
      // (Set insertion order = chronological); the pass already sorted/kept the right slice.
      const reconcileSet = <T>(target: Set<T>, survivors: readonly T[]): void => {
        const keep = new Set(survivors)
        for (const item of [...target]) if (!keep.has(item)) target.delete(item)
      }
      reconcileSet(ctx.turnMessages, ws.messages as unknown as Message[])
      reconcileSet(ctx.turnRetrievables, ws.retrievables as unknown as Retrievable[])
      reconcileSet(ctx.turnMemories, ws.memories as unknown as Memory[])
      reconcileSet(ctx.turnThoughts, ws.thoughts as unknown as Thought[])
      // Tool-call survivors: the pass shed the oldest prior-turn tool results that didn't fit; drop those
      // same calls from ctx.turnToolCalls so the battery no longer renders their (shed) results.
      reconcileSet(
        ctx.turnToolCalls as unknown as Set<unknown>,
        (ws.toolCalls ?? []).map((c) => c.ref)
      )

      // DEBUG TAP (observability): record what the model ACTUALLY receives this dispatch — the messages
      // and tool calls that survived BOTH the relevance selection (getSelectedTurns) AND the subtractive
      // pass. Lets us confirm the relevance filtration is shaping context (e.g. an irrelevant prior-turn
      // artifact handle is NOT present). Ring of the last 20 dispatches on globalThis, like raw-generations.
      try {
        const dbgRing = ((
          globalThis as unknown as { __agentContextDispatches?: unknown[] }
        ).__agentContextDispatches ??= [])
        dbgRing.push({
          iteration: ctx.iteration,
          query: input.text.slice(0, 80),
          // The MESSAGES that survived relevance-selection + subtractive trim (what the model sees).
          messages: ws.messages.map((m) => ({
            role: (m as { role?: string }).role,
            preview: (
              (m as { content?: { toString(): string } }).content?.toString?.() ?? ''
            ).slice(0, 90),
          })),
          // The TOOL CALLS in context — incl. any replayed prior-turn artifact handles (by callId) so we
          // can confirm an IRRELEVANT one is absent and a stale callId can't be grabbed.
          toolCalls: [...ctx.turnToolCalls].map((tc) => ({
            tool: (tc as { tool?: string }).tool,
            callId: (tc as { id?: string }).id,
            isError: (tc as { isError?: boolean }).isError,
            // DIAGNOSTIC: is the rehydrated result a real SpooledArtifact (→ adapter forges readers) or a
            // downgraded Tokenizable (encode failed on persist / no `encoded` snapshot → NO readers forged)?
            // This is the fork for "why is no artifact_* reader visible": handle present but un-forgeable.
            isSpooledArtifact: looksLikeSpooledArtifact((tc as { results?: unknown }).results),
            inline: (tc as { inline?: boolean }).inline,
            fromArtifactTool: (tc as { fromArtifactTool?: boolean }).fromArtifactTool,
          })),
          retrievables: ws.retrievables.map((r) => (r as { source?: string }).source ?? '(no-src)'),
          totalAfter: lastTrace.totalAfter,
          // DIAGNOSTIC (dev tap): the VISIBLE tool names the model receives THIS dispatch, so we can see
          // whether the artifact_* readers (and the capstone tools) actually reached the prompt — the
          // read-consume invariant yields when no artifact_* reader is visible, so `toolsLen:0`/no-reader
          // is the difference between "enforce the read" and "let the answer stand ungrounded".
          visibleTools: ctx.tools
            .visible()
            .map((t) => (t as { name?: string }).name)
            .filter(Boolean),
          anArtifactReadToolVisible: ctx.tools
            .visible()
            .some((t) => ((t as { name?: string }).name ?? '').startsWith('artifact_')),
          refused: lastTrace.refused,
        })
        if (dbgRing.length > 20) dbgRing.shift()
      } catch {
        /* observability only — never break a dispatch on the debug tap */
      }

      // THE WALL. The subtractive pass sheds to fit the user's window budget; `refused` means it could NOT
      // — after dropping all history, RAG, prior tool output, and hidden tools, the unsheddable floor
      // (system + standing + shortlist tools + reserve + this-turn results + protected plan thought) STILL
      // exceeds the budget. Historically the loop dispatched anyway, so the window slider was a soft target
      // the engine cap (~11,776) silently absorbed — "a limit that isn't a limit." Enforce it: nack with a
      // typed window-too-small error BEFORE the model runs, so the executor is skipped (the runner checks
      // isSignalled after the input pipeline) and NO over-budget prompt is ever sent. The turn ends with a
      // specific banner naming the math (raise the window / cut tools) — never a silent overspend. `run()`
      // does NOT retry this (unlike an engine overflow): the pass already shed everything, so a same-window
      // retry would refuse identically. [[no_pathological_cases_dont_cheat]] — this fails loud, it does not
      // cap or truncate.
      if (refused) {
        ctx.nack(makeWindowTooSmallError(lastTrace))
        await next()
        return
      }

      await next()
    } finally {
      state.lastTrace = lastTrace
      state.refused = refused
    }
  }

  return dispatchInput
}

```

#### agent\_planner.ts

````ts
import { isGpuOomError } from './agent_errors'
import { VOICE_BY_KIND } from './agent_turn_policy'
import { requestIsOverScope } from './agent_citations'
import { MODELS, type ModelChoice } from './agent_models'
import { makePlanTool, type TurnPlan } from './agent_tools'
import { DispatchRunner, Thought, Tokenizable, Message, type DispatchContext } from './agent_adk'
import type { HarnessAdapter } from './agent_harness_types'

export interface MakePlanDeps {
  userText: string
  toolBlurbs: string
  priorContext?: string
  priorAnswer?: string
  highConfidenceHits?: number
  budgetWords: number
  model: ModelChoice
  maxTokens: number
  contextWindow: number
}

/**
 * THE PLANNER (turn open). A dedicated dispatch run BEFORE the main loop: the model is shown the
 * user request + the REAL tool catalog (names + one-line descriptions, not full schemas) and forced
 * to emit exactly one `make_plan` call committing to an answer kind + the exact tools it will use.
 * The plan is the book-end contract — `run()` holds it and the close gate refuses to finish until
 * the answer conforms. Because the planner SEES the catalog and `make_plan` validates tool names, it
 * cannot commit to a fabricated tool (the failure where a 2B invents `dateAddTool` then gives up).
 *
 * Returns the validated plan, or null on any failure (fail-open: a flaky planner must never block a
 * turn — the loop then runs unplanned, exactly as before this feature). OOM rethrows (capacity).
 */
export async function makePlan(
  adapter: HarnessAdapter,
  {
    userText,
    toolBlurbs,
    priorContext,
    priorAnswer,
    highConfidenceHits = 0,
    budgetWords,
    model,
    maxTokens,
    contextWindow,
  }: MakePlanDeps
): Promise<TurnPlan | null> {
  const trimmed = userText.trim()
  if (!trimmed) return null
  let plan: TurnPlan | null = null
  const planTool = makePlanTool((p) => {
    plan = p
  })
  // SYNTHETIC PLANNER THOUGHT — the highest-leverage classification steer. A 2B argues with a system
  // directive but ADOPTS its own first-person reasoning ([[flagship_directive_acknowledgment_bug]]). The
  // planner-prompt bullet for `rhetorical` alone did NOT beat the doc_cited pull of a prompt like "so
  // it's not much of a kit, is it?" (the word "kit" + library context dominate). So we seed the planner's
  // OWN reasoning channel — read right before it calls make_plan — with a first-person recognition of
  // whether this is a reaction to the last answer (rhetorical) vs a new lookup (doc_cited). Only when a
  // prior turn exists (a first message can't be rhetorical).
  const rhetoricalThought =
    priorContext && priorAnswer
      ? 'Before I classify: the user just said "' +
        trimmed.slice(0, 200) +
        '" right after I answered "' +
        priorAnswer.slice(0, 200) +
        '". First: is this a NEW question or a REACTION? If it ASKS FOR INFORMATION — "what/which/how/why", ' +
        'the important parts, an overview, how to do something, any fact I have not already given — then it ' +
        'is a NEW question and it is "doc_cited" (search + cite), NOT rhetorical, even though it follows my ' +
        'last answer. Only if it is a short reaction or tag-question about what I JUST said — "is it?", ' +
        '"isn\'t it?", opens with "so"/"but", "not much of a…", seeking NO new information — is it ' +
        '"rhetorical" (engage and reframe in my own words, NO search, NO citation).'
      : null
  // CAPACITY THOUGHT — the soft steer toward capacity_scoped (a deterministic post-plan override backs
  // it up at the call site). A prompt bullet alone classified an exhaustive ask as capacity_scoped only
  // SOMETIMES; the synthetic thought helps, but the RELIABLE signal is requestIsOverScope: explicit-
  // exhaustive phrasing OR many high-confidence pre-retrieved hits (the topic spans a lot of the docs, so
  // it won't fit). Seed the planner's own voice with the recognition that a small on-device model cannot
  // render all of it. A broad-but-ANSWERABLE question (few strong hits, no exhaustive wording) is unaffected.
  const wantsExhaustive = requestIsOverScope(trimmed, highConfidenceHits)
  const capacityThought = wantsExhaustive
    ? 'This request asks me to be EXHAUSTIVE — to cover every item completely, leaving nothing out. I am ' +
      `${MODELS[model].label}, a small on-device model capped near ${maxTokens} output ` +
      `tokens (~${budgetWords} words). I cannot write all of that out in full without being cut off. The ` +
      'honest, correct classification here is "capacity_scoped": I will say plainly that this is bigger ' +
      'than I can render in full, then search and cite the documentation pages that cover it — NOT try to ' +
      'produce the whole exhaustive answer (which would truncate) and NOT plain "doc_cited".'
    : null
  const plannerThoughtText =
    [rhetoricalThought, capacityThought].filter(Boolean).join('\n\n') || null
  try {
    const noop = async (): Promise<void> => undefined
    const noopArr = async (): Promise<never[]> => []
    // Retry the planner up to 3x when its make_plan call fails to PARSE (plan stays null). A 2B
    // occasionally emits a make_plan whose args are unparseable — most often `steps` prose with nested
    // quotes / <|"|> delimiters — which drops the whole call, leaving the turn planless (search visible →
    // wrong routing). A fresh greedy generation almost always parses on the next attempt. Each iteration
    // is a FRESH DispatchRunner.dispatch (the runner is single-use — see the fresh-runner-per-iteration
    // rule); `plan` is set by makePlanTool's callback the moment a valid call parses.
    for (let attempt = 1; attempt <= 3 && !plan; attempt++) {
      await DispatchRunner.dispatch({
        raw: {
          systemPrompt: new Tokenizable(
            'You are the PLANNER for an on-device documentation agent. Decide how the agent should ' +
              'answer the user request, then call make_plan EXACTLY ONCE with your decision.\n' +
              // The immediately-prior turn (if any) — WITHOUT it the planner cannot tell a fresh library
              // question from a reaction to what was just said, and a follow-up like "so it's not much of a
              // kit, is it?" wrongly classifies doc_cited. This is the deciding signal for rhetorical.
              (priorContext ? priorContext + '\n' : '') +
              'First THINK briefly about what the request is really asking — especially whether it is ' +
              'ABOUT the @nhtio/adk library (its concepts, APIs, behavior, how-to) vs a general/' +
              'conversational request vs a REACTION to your previous answer (see the prior turn above) vs ' +
              'a live value a tool must compute. Getting answer_kind right is ' +
              'the most important part of your job. Then:\n' +
              'Choose answer_kind:\n' +
              '- "tool_computed" — the answer is a VALUE a tool must produce: any math/arithmetic, AND ' +
              'anything about the CURRENT date, time, day of the week, or "today"/"now" (a clock/calendar ' +
              'tool must provide it — the agent has no built-in clock), and other live lookups. If the ' +
              'request asks what day/date/time it is, this is ALWAYS tool_computed — never conversational.\n' +
              '- "doc_cited" — a factual question ABOUT the @nhtio/adk library (needs documentation ' +
              'sources). For this kind you MUST put a search tool (search_docs_semantic) in tools_to_use: ' +
              'the answer has to be grounded in a real doc page found by searching, never a guessed path.\n' +
              '- "rhetorical" — the user is REACTING TO or CHALLENGING what was just said: a rhetorical ' +
              'question, a leading statement, or an invited opinion about the PRIOR answer (e.g. "so it\'s ' +
              'not much of a kit, is it?", "isn\'t that overkill?"). Engage with their point and reframe in ' +
              'your own words. Needs NO tool and NO documentation search — do NOT put search_docs_semantic ' +
              'here; you are discussing what you already said, not looking something up. ' +
              'IMPORTANT disqualifier: a NEW question that ASKS FOR INFORMATION is NOT rhetorical, even if ' +
              'it follows a rhetorical turn. If the message asks "what/which/how/why …", asks for the ' +
              'important parts / an overview / how to do something, or otherwise requests facts I have not ' +
              'given yet, it is "doc_cited" (search + cite) — NOT rhetorical. Rhetorical is ONLY a reaction ' +
              'to my last answer that seeks no new information.\n' +
              '- "conversational" — greetings, small-talk, thanks, or clarifying questions that need NO ' +
              'external fact and NO tool. (An opinion or push-back about the PRIOR answer is "rhetorical", ' +
              'not this.) Do NOT pick this when the user is asking for a real value ' +
              '(date/time/math/lookup) — that is tool_computed.\n' +
              '- "capacity_scoped" — the request demands an EXHAUSTIVE / complete answer across the whole ' +
              'product ("everything", "every single X", "be exhaustive", "comprehensive overview of ALL …") ' +
              'that is simply too large to write out in full within the word budget above. You are a small ' +
              'on-device model; rather than truncate mid-answer, be honest: say plainly it is too big to ' +
              'render in full here, then search and cite the pages that DO cover it. You MUST put ' +
              'search_docs_semantic in tools_to_use. (A request that is broad but still ANSWERABLE in a ' +
              'few key points is NOT this — that is doc_cited with answer_scope "brief". Reserve ' +
              'capacity_scoped for explicitly-everything, whole-product asks.)\n' +
              '- "abstention" — only if it genuinely cannot be answered at all.\n' +
              'When tools are needed, put their EXACT names from the catalog below into tools_to_use — ' +
              'never invent a name. For the current date/time/day, pick the clock/time tool from the ' +
              'catalog (e.g. get_current_time).\n\n' +
              `Also choose answer_scope. The agent can write at most about ${budgetWords} words before it ` +
              'is cut off mid-sentence, so SCOPE the answer to fit:\n' +
              '- "brief" — a BROAD or open-ended request ("what are the important parts", "tell me about ' +
              'X", "give me an overview") where covering everything would overflow. Commit to the few ' +
              'MOST important points so the answer finishes cleanly. Prefer this whenever in doubt on a ' +
              'broad question — a complete short answer beats a truncated long one.\n' +
              '- "standard" — a normal, focused question.\n' +
              '- "thorough" — a narrow, specific question whose complete answer easily fits the budget.\n\n' +
              'Keep each entry in "steps" a SHORT plain imperative fragment (a few words) with NO quotation ' +
              'marks and NO nested quotes — e.g. steps:["search the docs","synthesise the answer"], never a ' +
              'full sentence containing quoted words. Long or quote-laden steps break the call.\n\n' +
              `Available tools:\n${toolBlurbs}`
          ),
          standingInstructions: [],
          // CRITICAL: `tools` is the field DispatchContext actually renders to the model (ctx.tools is
          // `new ToolRegistry(resolved.tools)`); `fetchTools` is a separate lazy callback that does NOT
          // seed ctx.tools on the raw path. Omitting `tools` here left the planner with ZERO visible
          // tools — which is why the 2B "ignored" make_plan: it never actually saw it.
          tools: [planTool] as never,
          messages: [
            new Message({
              id: `__plan-${Date.now()}`,
              role: 'user',
              content: `Request to plan:\n"""\n${trimmed.slice(0, 1200)}\n"""\nCall make_plan now.`,
              createdAt: new Date().toISOString(),
              updatedAt: new Date().toISOString(),
            }),
          ],
          fetchMemories: noopArr,
          fetchRetrievables: noopArr,
          fetchMessages: noopArr,
          // Seed the planner's first-person reasoning (a rhetorical-vs-doc_cited recognition) when there's a
          // prior turn to react to — the classification steer a system directive can't reliably land.
          fetchThoughts: async () =>
            plannerThoughtText
              ? ([
                  new Thought({
                    id: `__planner-${Date.now()}`,
                    content: plannerThoughtText,
                    createdAt: new Date().toISOString(),
                    updatedAt: new Date().toISOString(),
                  }),
                ] as never)
              : ([] as never),
          fetchToolCalls: noopArr,
          fetchTools: async () => [planTool] as never,
          refreshStandingInstructions: noopArr,
          storeStandingInstruction: noop,
          mutateStandingInstruction: noop,
          deleteStandingInstruction: noop,
          storeMemory: noop,
          mutateMemory: noop,
          deleteMemory: noop,
          storeRetrievable: noop,
          mutateRetrievable: noop,
          deleteRetrievable: noop,
          storeMessage: noop,
          mutateMessage: noop,
          deleteMessage: noop,
          storeThought: noop,
          mutateThought: noop,
          deleteThought: noop,
          storeToolCall: noop,
          mutateToolCall: noop,
          deleteToolCall: noop,
          storeMediaBytes: noop,
          storeRetrievableBytes: noop,
        } as never,
        executor: await adapter.executor({
          // Enable REASONING for the planner specifically. The classification (doc_cited vs
          // conversational vs tool_computed) is the one decision a 2B most often gets wrong when it
          // answers in a single greedy shot — and a wrong answer_kind cascades (drops the cite guidance,
          // mis-routes the worker). Letting the planner THINK before it emits make_plan trades a few
          // hundred tokens for a far more reliable classification. The gemma_channel reasoning parser
          // (active on the adapter) strips the thinking so the make_plan tool call still parses cleanly.
          enableThinking: true,
          // Bump the budget so the thinking channel AND the make_plan call both fit (256 was sized for a
          // no-thinking single shot; reasoning needs headroom or it gets truncated before the call).
          maxTokens: 768,
          sampler: 'greedy',
          toolCallParser: 'auto',
          stream: false,
          autoAck: true,
          // Guard = thrift window (Option A) — this site's own output budget is 768, so the input budget
          // is #contextWindow − 768. See the worker executor note.
          contextWindow: Math.max(2048, contextWindow - 768),
        } as never),
        // #region terminator
        // TERMINATOR (the fix for the planner thrash). autoAck on the LiteRT adapter only acks a
        // generation that emitted NO tool call (adapter: `if (calls.length === 0) ctx.ack()`). But the
        // planner's whole job is to emit ONE tool call — make_plan — so autoAck NEVER fires, the dispatch
        // never acks, and it loops on "Call make_plan now" until the abort signal — re-planning 6–12× and
        // burning the turn (a contradictory-directive thrash: "call make_plan now" persisting after
        // make_plan was already called). So own termination explicitly: after each planner generation,
        // ack — one generation per dispatch. If a plan was captured we're done; if make_plan failed to
        // parse (plan still null), the ack ends THIS dispatch so the outer `for attempt<=3` mints a fresh
        // one (the intended "3 fresh single-shot retries" design, which the missing terminator defeated).
        turnOutputPipeline: [
          (async (ctx: DispatchContext, next: () => Promise<void>): Promise<void> => {
            if (!ctx.isSignalled) ctx.ack()
            await next()
          }) as never,
        ],
        // #endregion terminator
      })
    }
  } catch (err) {
    if (isGpuOomError(err)) throw err
    return null // fail open — an unplanned turn is better than a blocked one
  }
  return plan
}

/**
 * Render the validated plan into the synthetic this-turn thought the worker sees. Concrete and
 * directive — it names the EXACT tools to call so the worker stops inventing tool names, and states
 * the answer kind so it routes correctly. Kept deliberately short (it rides in the context budget).
 */
// Returns an EVALUATABLE plan-thought: the tool-commitment line is recomputed at ASSEMBLY against the live
// dispatch context, so it only ever claims a planned tool is "directly available" when that tool is ACTUALLY
// VISIBLE in this dispatch (the subtractive pass may have shed it at a tight window — see the tool-shed
// step). A frozen "MUST call search_docs_semantic — it is directly available" contradicts a dispatch that no
// longer carries search, and a 2B floundered in that contradiction. When NONE of the planned tools survive,
// the line becomes "answer from the pages already in context" (deliver via provide_answer if IT survived,
// else plain prose). This dissolves ONLY on shed — when the tools ARE visible the search-to-cite contract is
// unchanged. The rest of the thought (voice, kind directives, scope/length) is static within the closure.
// [[evaluatable_tokenizable]] [[flagship_window_wall_and_toolcalls_phantom]]
// #region plan_thought
export function renderPlanThought(
  plan: TurnPlan,
  budgetWords: number,
  capacityNote?: string
): (ectx?: { tools?: { visible: () => { name?: string }[] } }) => string {
  return (ectx?: { tools?: { visible: () => { name?: string }[] } }): string => {
    // Which planned tools are ACTUALLY visible in THIS dispatch? (No ctx → assume all planned tools are
    // present: the no-ctx fallback is the pre-shed plan, matching how the thought reads outside assembly.)
    const visibleNames = ectx?.tools ? new Set(ectx.tools.visible().map((t) => t.name)) : undefined
    const availablePlannedTools =
      visibleNames === undefined
        ? plan.toolsToUse
        : plan.toolsToUse.filter((name) => visibleNames.has(name))
    // A planned tool that is NOT directly visible is usually still REACHABLE via the catalog capstone
    // (call_a_tool) — the flagship hides utility tools (get_current_time, calculate, …) behind
    // tool_catalog/call_a_tool to save prompt budget; they are callable, just not in the direct list.
    // This is the THIRD state the old binary (visible ⇒ "call it" / else ⇒ "answer from docs") missed:
    // the proven tool_computed failure was a planned get_current_time landing in the "not available →
    // answer from the documentation" branch, so the 2B abstained ("I don't have the date") instead of
    // reaching the tool through the capstone. Only treat a planned tool as genuinely unreachable when the
    // capstone ITSELF is also gone (a tight-window shed of everything).
    const capstoneVisible = visibleNames === undefined || visibleNames.has('call_a_tool')
    const plannedViaCapstone =
      visibleNames === undefined ? [] : plan.toolsToUse.filter((name) => !visibleNames.has(name))
    // #endregion plan_thought
    // IMPORTANT framing: this is a PLAN for what to do NEXT — written in the imperative, never past
    // tense. A 2B reading a thought in its own voice ("Calculate the product…") pattern-matches it to
    // "I already did this" and then loops claiming the tool was called. So we state, unmistakably, that
    // nothing has happened yet and the very next output must be the tool call.
    const lines: string[] = ['My plan (NOT yet started — I have not called any tool yet):']
    lines.push(`- answer kind: ${plan.answerKind}`)
    // Per-kind VOICE directive (in my own voice) — sets the register before I answer so a conversational
    // turn stays warm and human and a doc answer stays plain-spoken, instead of defaulting to doc-prose.
    lines.push(VOICE_BY_KIND[plan.answerKind])
    if (availablePlannedTools.length > 0) {
      lines.push(
        `- My NEXT action MUST be to call this tool (it is directly available — just emit the call): ${availablePlannedTools.join(', ')}.`
      )
      lines.push(
        '- I have NOT called it yet. I will not state any result until AFTER I emit the call and read what it returns.'
      )
    } else if (plannedViaCapstone.length > 0 && capstoneVisible) {
      // The planned tool is not in my direct tool list, but it EXISTS behind the catalog capstone — I reach
      // it through call_a_tool. This is the bridge the plan-thought was missing: without it, a planned
      // get_current_time read as "unavailable" and the 2B abstained. The tool is real and callable; an
      // "I don't have that" answer is NOT valid here — I have not tried the tool yet.
      const first = plannedViaCapstone[0]
      lines.push(
        `- The tool I planned (${plannedViaCapstone.join(', ')}) is not in my direct list, but it EXISTS — ` +
          `it is reached through the catalog. My NEXT action MUST be: call call_a_tool with name "${first}" ` +
          'and its arguments (the tool name is exact, lower_snake_case). I have NOT called it yet; I will ' +
          'NOT say "I don’t have that / I don’t have access" — the tool provides it, and I have not tried ' +
          'it. I state no value until AFTER I call it and read what it returns.'
      )
    } else if (plan.toolsToUse.length > 0) {
      // The plan wanted tools, but NONE are reachable this dispatch — neither directly visible NOR via the
      // capstone (a tight-window shed of everything, incl. call_a_tool). Only NOW is "answer from context"
      // correct: do not assert a call the model genuinely cannot make. (Plan-level twin of the cite-thought
      // shed→prose branch.)
      lines.push(
        '- The tools I planned to call are not available in this focused context, so I do NOT call a tool — ' +
          'I answer directly from the documentation already in front of me, in my own words, and do not ' +
          'invent tool calls or page paths.'
      )
    }
    if (plan.answerKind === 'tool_computed') {
      // Name the FULL arc. The observed 2B failure: it calls the tool, the result comes back as a HANDLE
      // (not the value), and the model skips the read step and fabricates the value (e.g. states the wrong
      // day). Spell out the middle step so the model expects to read the handle before stating anything.
      lines.push(
        '- I must NOT state the value from my own head. The arc: (1) call the tool; (2) its result comes ' +
          'back as a HANDLE, not the value itself — so my NEXT output must be an artifact_* read ' +
          '(artifact_head) using that result’s callId; (3) ONLY after I read the real ' +
          'value do I state it. I will not state any date, number, or value I have not actually read from a ' +
          'tool result this turn.'
      )
    } else if (plan.answerKind === 'doc_cited') {
      // Name the FULL delivery arc, not just the tool's fields. The observed 2B loop (full4): it reaches the
      // right facts, then wastes 15-37 dispatches because it writes the answer as CHAT PROSE (not a tool
      // call), or types the call as text ("<call:provide_answer…"/```json), or answers BEFORE reading the
      // handle (so no citation), or re-searches/re-plans, or replies to a gate nudge with "I understand/I
      // apologize" instead of acting. Spell the arc + the anti-patterns so it never enters that basin.
      lines.push(
        '- The arc: (1) search the docs ONCE; (2) its result is a HANDLE to a JSON ARRAY of hit objects — my ' +
          'NEXT output reads it with artifact_json_get on that callId (the search tool’s description gives the ' +
          'exact "$[*].<field>" path — "$[*].excerpt" for semantic, "$[*].title"/"$[*].page" for keyword) or ' +
          'artifact_head to see the whole array; (3) ONLY then, IMMEDIATELY, I emit a provide_answer TOOL ' +
          'CALL. I do not search or plan again once I have a result.'
      )
      lines.push(
        '- provide_answer has exactly two fields: "answer" (my own-words answer) and "sources" (the real doc ' +
          'path(s) I read). Reading the handle BEFORE I call it is what makes the citation right first try.'
      )
      lines.push(
        '- Writing the answer as ordinary chat text does NOT deliver it — that gets discarded and I loop. The ' +
          'ONLY delivery is the provide_answer TOOL CALL. I never type it as text ("<call:provide_answer…", a ' +
          '```json block, "<tool_call…"/"<tool_code…") — a real tool call or nothing. If a gate rejects my ' +
          'answer, I re-issue provide_answer with the fix; I do NOT reply "I understand"/"I apologize" or ' +
          'narrate what I will do — that is not an answer.'
      )
    } else if (plan.answerKind === 'capacity_scoped') {
      // Honest capacity-decline, THEN point to the docs. The observed wedge: the 2B reads "answer the
      // exhaustive request" and tries to WRITE it all, overruns, loops, dies — OR it searches but won't route
      // a decline through provide_answer. So: do NOT synthesize the content, and do NOT depend on the model
      // citing — the harness pins the pre-fetched RAG pages as the sources automatically. The model's ONLY
      // job is a short, in-character decline in prose.
      lines.push(
        `- This asks for an EXHAUSTIVE/complete answer. I am ${capacityNote ?? 'a small on-device model with a limited output budget'} — I CANNOT write all of it out in full. I will NOT attempt the full content; trying to would overrun and fail.`
      )
      lines.push(
        '- Step 1: call search_docs_semantic once (so the right pages are on hand). Step 2: reply in PROSE ' +
          'with a SHORT (2 sentences MAX), in-character decline — ONE dry, characterful line that this ask is ' +
          'bigger than a small on-device model can do in full (name what I am + my limit), then "but here is ' +
          'where it lives." I do NOT need to list the page paths myself; the harness attaches the pages the ' +
          'search found as the citation. I will NOT write the exhaustive content, and I will keep it to 2 ' +
          'sentences so it finishes cleanly.'
      )
    }
    // SCOPE the final answer to the output budget so it finishes cleanly instead of being cut off
    // mid-sentence at the max-output cap. 'brief' is the load-bearing case (a broad question): tell the
    // worker, in its own voice, to lead with the few most important points and stop — a complete short
    // answer beats a truncated long one. Only emit a length directive when an answer is actually produced
    // (not for a pure tool_computed value, which is whatever the tool returns).
    if (
      plan.answerKind === 'doc_cited' ||
      plan.answerKind === 'conversational' ||
      plan.answerKind === 'rhetorical' ||
      plan.answerKind === 'capacity_scoped'
    ) {
      // HARD length commitment. A soft "keep it near N words" does NOT land on a 2B — it writes a full
      // multi-paragraph doc answer and gets clipped mid-sentence at the output cap (observed: "core thesis"
      // and "important parts upfront" both truncated at "…enforces a" / "…understanding the"). The output
      // budget here is genuinely small (often ~380 words), so the thought must (a) frame truncation as a
      // FAILURE I will not allow, (b) commit to a concrete, countable shape that DEMONSTRABLY fits, and (c)
      // reserve the last sentence for a clean close. First-person, self-committing — the proven 2B lever.
      // sentenceCap is DERIVED from budgetWords, and budgetWords = #budgetWords(scope) resolves through
      // #resolveOutputTokens — which returns the ACTUAL applied cap (the full #maxTokens while the dynamic
      // budget is off; the scoped cap if it's ever turned on). So both the word number and the sentence count
      // always track the real budget: the plan-thought can never promise a budget the runtime won't honour.
      const sentenceCap = budgetWords <= 220 ? 4 : budgetWords <= 400 ? 6 : 8
      if (plan.answerScope === 'brief') {
        // 'brief' steer is SCOPE-RELATIVE (lead with the few most important points and stop early) rather than
        // a hard absolute — so it can't contradict a large real cap; the FAILURE framing is the load-bearing part.
        lines.push(
          `- My answer is HARD-CAPPED near ${budgetWords} words and being cut off mid-sentence is a FAILURE. ` +
            `So I will lead with ONLY the 2–4 MOST important points (about ${sentenceCap} short sentences), ` +
            'one tight sentence each, no preamble, and stop early — making the FINAL sentence a clean close ' +
            'well inside the budget. A complete short answer is the goal; a long truncated one is a failure. ' +
            'I can offer to expand on any point if asked.'
        )
      } else {
        lines.push(
          `- My answer is HARD-CAPPED near ${budgetWords} words and being cut off mid-sentence is a FAILURE, ` +
            `so I must plan to FINISH inside it. I will write AT MOST ${sentenceCap} short sentences total: ` +
            'lead with the single most important point, cover only the few essentials with one tight sentence ' +
            'each, drop every secondary detail, and reserve my LAST sentence to close cleanly with words to ' +
            'spare. I would rather say less and finish than say more and get cut off.'
        )
      }
    }
    return lines.join('\n')
  }
}

````

#### agent\_turn\_policy.ts

````ts
import type { PlanAnswerKind, PlanAnswerScope } from './agent_tools'

// Loop-control policy. The book-ended planner replaced the old hard backstops (MAX_ITERATIONS /
// REPEAT_LIMIT): the close gate now refuses to ack until the answer CONFORMS to the plan, and per the
// current directive that re-loop is intentionally UNBOUNDED — we want to observe the raw behaviour
// (the user can abort from the UI). If a runaway cap is wanted later, reintroduce it in the gates.
// Stable id for the planner's synthetic this-turn thought, so the subtractive pass can recognise +
// keep it (it strips all OTHER thoughts per Gemma's no-prior-CoT rule) and it's seeded only once.
const PLAN_THOUGHT_ID = '__plan-thought'
// Stable id for the synthetic citation-reinforcement thought (a doc_cited turn only): the model's OWN
// first-person commitment to cite, injected at iteration 0 alongside the plan thought. As we've seen,
// a first-person thought steers the 2B far better than a system directive (which it argues with).
const CITE_THOUGHT_ID = '__cite-thought'
// Cite-thought instruction variants (module-level constants so the evaluatable Tokenizable that selects
// among them stays serializer-friendly — it captures only these strings + the ctx passed as its arg).
// A capacity_scoped turn always uses CITE_TEXT_CAPACITY (a short prose decline, harness-cited — it never
// hand-types paths regardless of window). A doc_cited turn picks by whether provide_answer is VISIBLE in
// the assembled dispatch: visible → deliver via provide_answer (harness attaches pages); shed (tight
// window) → answer in plain prose from the pages already in context. The selection happens at ASSEMBLY
// (Tokenizable.render(ctx)) so it always matches the post-shed dispatch — no stale prior-iteration guard.
const CITE_TEXT_CAPACITY =
  'This request is too broad for me to write out in full, and I have already searched. My next ' +
  'reply is a SHORT prose decline — 2 sentences MAX: ONE dry, characterful line that the ask is ' +
  'bigger than a small on-device model can do in full (name what I am + my limit), then "but ' +
  'here is where it lives." I do NOT need to list page paths myself — the harness cites the ' +
  'pages the search found. I will NOT write the exhaustive content, and I will keep it to 2 sentences.'
const CITE_TEXT_TOOL =
  'This is an @nhtio/adk question. I answer in my own words and deliver it by calling ' +
  'provide_answer with my written answer. The documentation pages I searched are attached for me ' +
  'automatically — I do NOT hand-pick or type out page paths, and I do NOT need a "sources" list. ' +
  'I will not reply as bare prose; I call provide_answer with my answer.'
const CITE_TEXT_PROSE =
  'This is an @nhtio/adk question, and the context window is tight enough that I answer directly in ' +
  'plain prose from the documentation already in front of me — I do NOT call any tool, and I do NOT ' +
  'type out or invent page paths (there is no room to attach them and I cannot format them reliably). ' +
  'I write my own-words answer as the reply itself.'
// Stable id for the synthetic CHECKER thought — the rail ANNOUNCING, in the worker's own voice, the
// criterion it is about to evaluate the latest generation against ("I must evaluate if this …"). One id
// (replace-in-place) because only ONE checker evaluates per dispatchOutput pass; the UI replaces by id so
// it always shows the MOST RECENT criterion the rails are weighing. Like the plan/nudge thoughts it is
// THIS-turn UI narration + channel-2 self-talk, never replayed into the MODEL context next turn (it's
// meta-evaluation, not an answer or guidance — fetchThoughts filters it out alongside 'reasoning').
const CHECKER_THOUGHT_ID = '__checker-thought'
// Prefix for the synthetic FIRST-PERSON nudge thoughts (channel 2 of dual-channel gate delivery). One
// per active gate, id `${NUDGE_THOUGHT_PREFIX}${gate}`. Like the plan thought these are THIS-turn
// guidance (not prior-turn CoT), so they must survive the subtractive pass — the keep set is built
// dynamically each dispatch from the active gate ids.
const NUDGE_THOUGHT_PREFIX = '__nudge:'
// Duplicate-call HARD-STOP threshold, expressed as `ctx.toolCallCount(checksum)` — the per-execution
// count of how many times THIS exact (tool + args) call has been stored. A count of 2 is the FIRST
// repeat: the model has emitted an identical call a second time, so the result is already in hand and
// re-calling makes zero progress. One repeat is enough to prove the loop is stuck (a small model
// re-reading a handle it already holds), so we don't waste iterations nudging — we stop immediately and
// SYNTHESISE the answer from the value sitting in the repeated tool's result. This is the only place the
// otherwise-unbounded loop is capped, and only for a PROVEN-stuck identical repeat (a normal multi-step
// turn never re-issues the SAME call with the SAME args).
const DUPLICATE_CALL_HARD_STOP = 2
// Prose analog of DUPLICATE_CALL_HARD_STOP: how many times the model may re-emit a near-identical PROSE
// reply that a gate keeps rejecting before we stop looping and ACCEPT it (it's a real answer the 2B just
// won't reroute the way a gate wants — e.g. an uncited doc answer it regenerates verbatim). 2 = the first
// identical repeat. Similarity judged by Levenshtein >= PARROT_SIMILARITY_THRESHOLD.
const STUCK_PROSE_LIMIT = 2
// ABSOLUTE backstop: the most consecutive iterations the worker may produce uncited doc-prose with NO
// tool-call progress before the harness force-commits the best answer it has. Distinct from the
// repeated-prose stop (which keys on near-identical text): the observed livelock was the 2B answering
// correctly from pre-fetched RAG, never searching, and PARAPHRASING the answer every iteration — each
// reworded enough to slip the similarity check — until context hit 100%. This cap fires on the COUNT of
// fruitless prose iterations regardless of wording, so a turn can never wedge at the engine cap.
const NO_PROGRESS_PROSE_LIMIT = 4
// How many times the incomplete-answer gate may bounce a TRUNCATED answer ("be more succinct") before it
// gives up and lets the answer through to the normal commit/backstop path. Small — the nudge works in one
// or two retries or not at all, and we must not livelock a model that can't get shorter.
const INCOMPLETE_ANSWER_LIMIT = 2
// How many times a leak gate (fake-citation / leaked-envelope) may bounce an answer that carries invented
// citation markup or an echoed internal envelope before it gives up and lets the answer through. Same
// small bound + rationale as INCOMPLETE_ANSWER_LIMIT: the re-answer nudge lands in one or two tries or not
// at all, and we must never livelock a model that keeps re-emitting the junk.
const LEAK_GATE_LIMIT = 2
// EMPTY-GENERATION cap. The most consecutive iterations the model may produce NOTHING — no tool call, no
// captured answer, no prose (doneReason:stop|tool_calls with content=='' AND newToolCalls==0) — before the
// harness stops the turn. This is the gap the MAX_ITERATIONS removal left: every OTHER backstop counter
// (no-progress-prose, incomplete-answer, leak, duplicate-call) keys on CONTENT the model DID produce and a
// gate rejected — but an EMPTY generation produces nothing to reject, matches no signal, increments no
// counter, so a small model that degenerates into empty completions loops FOREVER (observed: gemma-31b at a
// deep media-doc turn with a full context, dispatch count climbing with every response empty). 2 = one
// repeat proves the degeneration (same rationale as DUPLICATE_CALL_HARD_STOP): a single empty gen can be a
// transient blip, two consecutive is a stuck model. On hitting it we commit the best prior answer if we have
// one, else fail the turn cleanly (nack) — never wedge.
const EMPTY_GEN_LIMIT = 2
// RETRIEVAL-ONLY ABSTENTION cap. The false-abstention gate rejects an abstention WHENEVER a non-error tool
// returned a result this turn (hasSuccessfulResult) — its premise being "the answer is literally in the
// turn's context, the model is just ignoring it." That premise HOLDS for a DEFINITIVE tool (calculate
// returned the number; get_current_time returned the date — the answer provably IS the result) but is
// FALSE for RETRIEVAL: search_docs_semantic "succeeding" means it returned PASSAGES, not that the answer is
// IN them. When RAG returns passages that don't cover the question (observed: a "PDF redaction / SSN"
// question whose search hits were all Trust-Tiers / Media / Store-Delete), the model's "the docs don't
// contain this" is an HONEST abstention — but hasSuccessfulResult is true, so the gate rejects it, forever
// (the gate is UNBOUNDED, and the no-progress backstop explicitly EXCLUDES abstentions). The model then
// oscillates abstention <-> the abstention-classifier's "1" and NOTHING commits: a wedge (root-caused live
// on 31b_32k/naive T11#6, which ran 724 consecutive no-tool dispatches). So when the ONLY successful results
// are retrieval (no definitive tool fired), the abstention MIGHT be honest and we bound the gate: it still
// fires (re-search does real work), but after RETRIEVAL_ABSTENTION_LIMIT dispatches with NO new tool call —
// i.e. the model has stopped trying new searches and is just re-abstaining — we ACCEPT the honest "not in
// the docs" answer rather than loop. Reset on ANY tool call (searching IS progress).
// CALIBRATION (measured on the recorded run, max CONSECUTIVE no-tool-call dispatches per cell via the REAL
// toolCalls field — the trustworthy signal): legit SETTLED abstention turns reached a 46-long no-tool tail
// (31b_128k_thrift T11#2, settled at 60 dispatches after interleaving searches), next highest 22
// (31b_32k_thrift), most cells 7-9; the WEDGE ran 724 consecutive. A clean 46 -> 724 gap, so 80 sits well
// above every legit recovery and far below the wedge. (An earlier cut used 24 — it would have truncated the
// legit 46-run; that number came from a dump abstention-regex sim that under-counted. The real toolCalls
// field corrected it.) A DEFINITIVE result keeps the strong unbounded gate. [[no_pathological_cases_dont_cheat]]
// — not a MAX_ITERATIONS cap: it corrects a gate whose premise is false for retrieval; a genuinely-
// unanswerable question has no upstream fix, so accepting the honest abstention IS the right terminal state.
const RETRIEVAL_ABSTENTION_LIMIT = 80
// RESULT-TOO-LARGE cap. An artifact-tool result is an INLINE Tokenizable on ToolCall.results, so the loop
// can measure it before the next dispatch assembles it. The rule is an invariant, not a nudge-counter: a
// single tool result must leave at least RESULT_HEADROOM_FACTOR × maxTokens of the window free — one
// output-budget for the generation the model still has to do, plus (FACTOR−1) more as breathing room so one
// result can't swallow the whole input budget and starve the system prompt / plan thought / prior context.
// FACTOR is a TUNABLE we set from the real constrained-window wire (a result that clears 2× may still crowd
// the assembled dispatch, in which case we raise it). Any read whose produced result exceeds the cap is
// retracted (deleteToolCall) and the model re-steered to a narrower query — so an over-cap blob is
// structurally unable to reach a dispatch, no counter required. See the newToolCalls>0 seam.
const RESULT_HEADROOM_FACTOR = 2
// Floor so a degenerate config (FACTOR × maxTokens ≥ window) yields a positive cap instead of ≤0 (which
// would reject every read). A result under this many tokens is never "too large".
const RESULT_TOO_LARGE_MIN_TOKENS = 256
// LiteRT's engine context cap (maxNumTokens), fixed at Engine.create. It must hold the ENTIRE prompt
// (the subtractive-pass input window + the model's generated output), so it has to exceed the agent's
// largest input window plus its max output reserve. The dialog caps the window slider so window+output
// never exceeds this. 12288 comfortably covers the 8192 default window + 2048 output with headroom;
// raising it requires confirming the gemma `.litertlm` web build honors a larger maxNumTokens at load.
const LITERT_ENGINE_MAX_TOKENS = 12288
// The floor the one-shot overflow retry tightens the context window TO (never below). A window this
// small still fits the system prompt + newest turn + a tool shortlist; if even that overflows, the
// retry can't help and the dialog shows the too-long banner. Keeps the retry bounded and meaningful.
const CONTEXT_OVERFLOW_RETRY_FLOOR = 2048
// Dynamic per-turn output budget. The user's max-output slider is the HARD CEILING; the planner's
// committed answer_scope tightens the cap BELOW it for THIS turn (never above). A `brief` answer (the
// load-bearing case for a broad question) gets a fraction of the ceiling so the 2B's natural ~500-token
// answer is asked to FINISH inside a small budget instead of being clipped mid-sentence at the cap;
// `thorough` keeps the whole ceiling. This is the "tighten dynamically based on what is chosen" lever:
// the cap the worker actually gets matches the scope it planned. Applied per-dispatch via the LiteRT
// stash override (no engine reload — maxOutputTokens lives in the per-dispatch SessionConfig).
const SCOPE_OUTPUT_FRACTION: Record<PlanAnswerScope, number> = {
  brief: 0.45,
  standard: 0.75,
  thorough: 1.0,
}
// Never tighten below this — enough output room to finish a small cited answer (and to emit a tool call).
const MIN_ANSWER_TOKENS = 256
// Per-answer-kind VOICE / tone directive, rendered (in the model's OWN first-person voice) into the plan
// thought the worker reads at iteration 0. This is the highest-leverage tone steer: it lands in the
// model's voice right before it answers, so a 2B with no other tone signal doesn't fall back on its
// doc-heavy context. One terse sentence per kind (context budget is tight). The observed failure this
// fixes: a conversational turn answered like an architecture doc, with no warmth. Kept in step with the
// task-guidance branches inside renderPlanThought (tool_computed / doc_cited already state HOW to deliver;
// this states in what VOICE).
// #region voice_by_kind
const VOICE_BY_KIND: Record<PlanAnswerKind, string> = {
  conversational:
    '- Voice: I will answer like a warm, friendly person — not a documentation page. I will acknowledge ' +
    'what the user actually said or feels, keep it to a sentence or two, and NOT recite architecture or ' +
    'product description unless they ask.',
  rhetorical:
    '- Voice: the user is reacting to or challenging what I JUST said. I engage directly with their point ' +
    '— acknowledge their framing, then agree or reframe in my OWN words in a sentence or two. No ' +
    'documentation recitation, no citations, no search — this is a conversation about my last answer.',
  doc_cited:
    '- Voice: I will be clear, helpful, and plain-spoken — I explain it in my own words as if to a ' +
    'colleague, lead with the direct answer, then support it. Accurate and grounded, never a wall of ' +
    'jargon or a raw doc dump.\n' +
    '- Delivery (this is how I actually answer, not optional): I search the docs ONCE, then read the ' +
    'result passages with artifact_json_get, then IMMEDIATELY emit ONE provide_answer TOOL CALL — its text is my ' +
    'own-words answer and it carries the doc page path as the citation. Writing the answer as ordinary ' +
    'chat text is NOT delivering it (that gets discarded); the ONLY delivery is the provide_answer tool ' +
    'call. I never type the call as text (never "<call:provide_answer…", a ```json block, or ' +
    '"<tool_call…"); I emit a real tool call or nothing. I read the result BEFORE calling provide_answer ' +
    'so the citation is right the first time. I do NOT search or plan again once I have a result. The ' +
    'search result is a JSON ARRAY of hit objects — I read it with artifact_json_get (the search tool’s ' +
    'description gives the exact "$[*].<field>" path for the search I ran) or artifact_head, then answer.',
  tool_computed:
    '- Voice: once I have READ the tool’s result this turn, I will state THAT value directly and plainly ' +
    'in a short natural sentence — no preamble, no filler. I do NOT invent or guess the value, and I do ' +
    'NOT state any value before I have read it from the tool result (a made-up number or day is never ' +
    'acceptable — the tool’s output is the only source).',
  capacity_scoped:
    '- Voice: dry, opinionated, a little self-aware — ADK is an opinionated library and I have some ' +
    'character too. ONE short wry line acknowledging the ask is bigger than a small on-device model can ' +
    'reasonably produce in full (name what I am + my limit, with a bit of personality — not a limp ' +
    'apology, not a truncated attempt), then IMMEDIATELY the useful hand-off: "here is exactly where it ' +
    'lives." Keep it TIGHT — the personality is one line, the pointer is the point. Do not ramble or ' +
    'perform; land the joke and get to the docs.',
  abstention:
    '- Voice: I will say honestly and kindly that I don’t have that, in one plain sentence, and (if I ' +
    'can) point at what WOULD help — no apology spiral.',
}
// #endregion voice_by_kind
// TEMPORARILY DISABLED (kept, not removed) so the new incomplete-answer gate can be evaluated on its
// own. When false, the scope→budget tightening is inert: the output cap stays at the user's slider and
// the plan thought reports the full-ceiling word target — i.e. exactly the pre-change behavior. Flip to
// true to re-enable dynamic per-turn tightening (the #resolveOutputTokens lever) alongside the gate.
const DYNAMIC_OUTPUT_BUDGET_ENABLED = false
// Context-assembly-by-relevance (walks ALL history, selects relevant TURNS — the continuity mechanism).
// There is NO turn-count budget: every turn relevant to the current question is kept, however far back.
// KEEP_RECENT_TURNS = the most-recent turns ALWAYS kept (immediate coreference: "it", "that", "so it's
// not a kit") regardless of token overlap. The relevance floor = min Q&A token-overlap for an OLDER turn
// to be kept. The subtractive pass trims the assembled set to the actual token budget downstream.
const KEEP_RECENT_TURNS = 2
// The relevance floor SCALES with window pressure, and the compact-arm baseline config (keep-verbatim
// turns, summarise-at-tokens threshold, the FAITHFUL Claude Code compaction system prompt) all now live in
// the shipped @nhtio/adk/batteries/context/thrift + /compact batteries (consumed via the agent_adk.ts shim
// as selectRelevantTurns/selectNaiveTurns/assembleCompactedTurns/COMPACTION_SYSTEM_PROMPT below) — this
// showcase no longer carries a private copy of those algorithms/constants.

// Baseline pre-retrieval breadth → the DETERMINISTIC capacity_scoped signal. If MANY of the pre-fetched RAG
// passages are strongly on-topic, the request spans a lot of material and cannot be answered exhaustively
// within the small on-device model's output budget — so it should be capacity_scoped (honest decline +
// cite), not doc_cited (which would truncate). A NARROW question returns a couple of strong hits + a weak
// tail, so it stays under the count. RAG_TOPK is raised from 5 so "breadth" has room to show.
const RAG_TOPK = 10
const CAPACITY_RAG_SCORE_FLOOR = 0.55 // cosine similarity that counts a hit as "high-confidence/on-topic"
const CAPACITY_RAG_HIT_COUNT = 6 // this many high-confidence hits ⇒ too broad to render in full
// The RAG-breadth signal only counts if the request has at least this many content words — a doc-heavy
// corpus returns many hits even for "hey, how are you?", so a bare greeting must not trip the breadth arm.
const CAPACITY_MIN_CONTENT_WORDS = 4
// How many SYNTHETIC prior-turn thoughts (plan + nudges) to replay into the model context. The model's
// own CoT is never replayed (Gemma §3); these are harness-authored guidance, so a short tail is enough.
const THOUGHT_REPLAY_LIMIT = 12
const IMAGE_TOKEN_COST = 1300 // a single image is a large flat hog (the subtractive-pass headline)

export {
  PLAN_THOUGHT_ID,
  CITE_THOUGHT_ID,
  CITE_TEXT_CAPACITY,
  CITE_TEXT_TOOL,
  CITE_TEXT_PROSE,
  CHECKER_THOUGHT_ID,
  NUDGE_THOUGHT_PREFIX,
  DUPLICATE_CALL_HARD_STOP,
  STUCK_PROSE_LIMIT,
  NO_PROGRESS_PROSE_LIMIT,
  INCOMPLETE_ANSWER_LIMIT,
  LEAK_GATE_LIMIT,
  EMPTY_GEN_LIMIT,
  RETRIEVAL_ABSTENTION_LIMIT,
  RESULT_HEADROOM_FACTOR,
  RESULT_TOO_LARGE_MIN_TOKENS,
  LITERT_ENGINE_MAX_TOKENS,
  CONTEXT_OVERFLOW_RETRY_FLOOR,
  SCOPE_OUTPUT_FRACTION,
  MIN_ANSWER_TOKENS,
  VOICE_BY_KIND,
  DYNAMIC_OUTPUT_BUDGET_ENABLED,
  KEEP_RECENT_TURNS,
  RAG_TOPK,
  CAPACITY_RAG_SCORE_FLOOR,
  CAPACITY_RAG_HIT_COUNT,
  CAPACITY_MIN_CONTENT_WORDS,
  THOUGHT_REPLAY_LIMIT,
  IMAGE_TOKEN_COST,
}

````

#### agent\_turn\_state.ts

```ts
import { PROSE_ONLY_GATES, TOOL_REQUIRING_GATES } from './agent_harness_types'
import type { ThriftHistoryTurnType } from './agent_adk'
import type { ThriftTrace } from './agent_subtractive_pass'
import type { CapturedAnswer, TurnPlan } from './agent_tools'
import type { ConversationMessage, ToolCallRecord } from './agent_store_facade'
import type { AgentEvents, AgentGateKind, AgentThoughtKind } from './agent_harness_types'

export interface TurnState {
  lastTrace: ThriftTrace | null
  refused: boolean
  answer: string
  lastReportedTool: string | null
  /**
   * Captured from ctx.nackError when the adapter NACKs a dispatch (e.g. a WebGPU OOM during
   * generate). A nack ends the turn WITHOUT throwing out of runner.run(), so without capturing it
   * here run() would return a normal "no answer" outcome and the dialog's OOM banner + Retry would
   * never show. dispatchOutput reads ctx.nackError and stashes it; run() surfaces it as the error.
   */
  dispatchError: string | null
  /**
   * Set true when dispatchError is specifically a WebGPU out-of-memory (the typed
   * E_LLM_GPU_OUT_OF_MEMORY the battery now nacks). The dialog reads this flag to show the friendly
   * capacity banner + Retry instead of a generic error — structural, not a message regex.
   */
  dispatchErrorIsOom: boolean
  /**
   * Set true when dispatchError is specifically a CONTEXT OVERFLOW (the prompt exceeded the engine's
   * fixed token cap) — the typed E_LITERT_LM_CONTEXT_OVERFLOW from either the pre-dispatch guard or the
   * engine's own "too long" throw. Distinct from OOM: the recovery is shed-and-retry / shrink-window,
   * and the dialog shows a "conversation got too long for this window" banner instead of the capacity one.
   */
  dispatchErrorIsOverflow: boolean
  /**
   * Set true when dispatchError is specifically the window-too-small WALL (the subtractive pass refused
   * the user's budget). Distinct from the engine overflow so run() won't retry it and the dialog shows
   * the budget-math banner. Primary surfacing is the thrown-error catch (the wall nacks pre-generation,
   * so the dispatch REJECTS); this flag covers the belt-and-suspenders nack-during-flush path too.
   */
  dispatchErrorIsWindowTooSmall: boolean
  /**
   * Raised when the model leaks an XML-shaped trust-tier fence into its answer a SECOND time this turn —
   * the context is poisoned. The turn is abandoned (no leaked text committed) and `run()` re-runs it with
   * a tighter window so the subtractive pass sheds the accumulated cruft. Escalation, not a commit.
   */
  turnPoisoned: boolean
  /**
   * Set when the model called provide_answer with a real ANSWER but no (or all-invalid) sources — the
   * tool returns a correction STRING (never arming a gate), so this flag is what lets dispatchInput inject
   * the cite-thought ("I write the answer; the harness attaches the pages") on the next iteration.
   */
  sawSourcelessAnswer: boolean
  /**
   * The structured answer captured when an answer tool fires (provide_answer / say_i_dont_know),
   * carrying its validated sources. null when the model answered in plain prose instead.
   */
  captured: CapturedAnswer | null
  /**
   * The latest plain-prose assistant text (conversational replies that don't go through an
   * answer tool). Used as the persisted answer when `captured` is null.
   */
  proseAnswer: string
  /**
   * REPEATED-PROSE guard state (the prose analog of the duplicate-call gate). When a gate rejects a
   * prose reply and the model just re-emits the SAME prose next iteration, the turn livelocks (observed:
   * an uncited doc answer the 2B keeps regenerating verbatim while needs-citation keeps bouncing it).
   * Track the last rejected prose + how many times it's repeated; after STUCK_PROSE_LIMIT near-identical
   * rejections we stop looping and ACCEPT the prose — it's a real answer the model just won't reroute.
   */
  lastRejectedProse: string
  repeatedProseCount: number
  /**
   * ABSOLUTE no-progress backstop state: count of gate-rejected prose-no-tool iterations (ANY gate —
   * parroting, needs-citation, unread-handle, etc.), and the best OWN-WORDS prose seen so far. Once the
   * count hits NO_PROGRESS_PROSE_LIMIT the harness force-commits bestOwnWordsProse instead of looping
   * forever. The observed wedge: the model emits a good own-words answer, a gate (parroting OR
   * needs-citation) keeps rejecting it, and it re-emits — interleaved with degenerate "0" gens — until
   * context hits 100%. Counting ALL rejected prose iterations (not just the doc-answer branch) bounds it.
   */
  noProgressProseCount: number
  bestOwnWordsProse: string
  /**
   * Count of consecutive iterations rejected as TRUNCATED (incomplete-answer gate). Bounds the
   * "be more succinct" retry so a model that keeps overshooting can't livelock: after
   * INCOMPLETE_ANSWER_LIMIT truncated answers we stop firing the gate and let the answer fall through
   * to the normal commit/backstop path (a complete-enough short answer, or the no-progress backstop).
   */
  incompleteAnswerCount: number
  /**
   * Counts of consecutive answers rejected by a leak gate. `fake-citation` = the answer carried invented
   * `<cite>` markup; `leaked-envelope` = it echoed the runtime's internal `<thought_…>` envelope. Each is
   * bounded by LEAK_GATE_LIMIT so a model that can't drop the junk falls through rather than livelocking.
   */
  fakeCitationCount: number
  leakedEnvelopeCount: number
  /**
   * Count of CONSECUTIVE empty generations (no tool call, no captured answer, no prose). Bounded by
   * EMPTY_GEN_LIMIT — the case the content-bearing backstops above all miss. Reset to 0 the moment the
   * model produces ANY real output (a call, an answer, or prose); increments only on a truly empty turn.
   */
  emptyGenCount: number
  /**
   * Count of consecutive dispatches where the model re-abstained with NO new tool call while the only
   * successful results this turn were RETRIEVAL (no definitive tool). Bounded by RETRIEVAL_ABSTENTION_LIMIT
   * so an honest "the docs don't cover this" (search returned passages that don't answer the question) is
   * eventually ACCEPTED instead of rejected forever. Reset to 0 on ANY new tool call this turn (re-searching
   * is real progress — legit settled abstention turns interleaved searches, reaching a 46-long no-tool tail
   * before committing); increments only when the model re-abstains with no new tool call. A DEFINITIVE tool
   * result never touches this — its abstention gate stays strong (unbounded).
   */
  retrievalAbstentionCount: number
  /**
   * Artifact handles (by callId) that have produced a result too large to fit the window THIS turn. NOT a
   * counter — an invariant set: while a handle is flagged, every over-cap read against it is retracted and
   * re-steered (see the newToolCalls>0 seam), so an over-cap blob can never reach a dispatch. A read that
   * finally fits clears the flag. Per-turn, like the counters above.
   */
  tooLargeHandles: Set<string>
  /**
   * The validated plan from the planner book-end (null = planner failed/none → turn runs unplanned,
   * exactly as before this feature). The close gate validates the final answer against this.
   */
  turnPlan: TurnPlan | null
  /**
   * This turn's reasoning, accumulated as it's surfaced (plan + nudges + model CoT), keyed by id so a
   * re-emitted sticky nudge / streamed CoT delta REPLACES in place. Persisted at turn end against the
   * assistant message's id so the Thinking panel rehydrates on reload (durable turn history, not just a
   * live signal). `emitThought` is the single seam: it fires the UI event AND records here.
   */
  turnThoughtRecords: Map<string, { kind: AgentThoughtKind; text: string }>
  /**
   * STICKY corrective nudges, keyed by gate. A gate SETS its entry when it fires (via emitGate) and
   * the entry is only DELETED once its goal is met — so a nudge persists across iterations until the
   * model actually complies (it cannot escape sideways by simply not re-triggering the exact
   * detector). dispatchInput re-emits every active entry each iteration. (Per the user: don't drop
   * the ephemeral, or any other nudge, until the model is compliant.) The gate flags of old are gone:
   * the Map IS the state, set+cleared in dispatchOutput where each condition is detected.
   */
  activeNudges: Map<AgentGateKind, string>
  /**
   * Companion to activeNudges: a SHORT first-person "next action" line per active gate, injected as a
   * synthetic THOUGHT (the model's own voice) in addition to the directive. A 2B treats a user-role
   * nudge as the user talking and rebuts it conversationally ("I have already…"); pairing the control
   * directive with a self-thought ("I need to call X now") makes it continue its OWN reasoning toward
   * the action instead of arguing back. Set/cleared in lockstep with activeNudges via emitGate.
   */
  activeNudgeThoughts: Map<AgentGateKind, string>
  /**
   * Live grounding-source holder: the turn's REAL never-shed pre-fetched doc pages, assigned once
   * `prefetchedSources` is computed below (before any dispatch runs). `provide_answer` reads it via
   * `groundSources` to ground a good-but-sourceless answer from real retrieved pages instead of bouncing.
   */
  groundingSources: Array<{ path: string; title?: string }>
  selectedTurnsPromise: Promise<
    Array<ThriftHistoryTurnType<ConversationMessage, ToolCallRecord>>
  > | null
  seededIds: Set<string>
  /**
   * Ids of the tool calls SEEDED FROM HISTORY at iteration 0 (fetchToolCalls) — i.e. PRIOR-turn results
   * the model already acted on. Everything else in ctx.turnToolCalls is a THIS-turn call the model just
   * made. The subtractive pass may budget-shed prior-turn results but must NEVER shed this-turn ones
   * (evicting a result the model just fetched makes it re-request the same call — the identical-search
   * loop). Turn-scoped so the per-iteration workingToolCalls build can tag `thisTurn` correctly.
   */
  priorTurnToolCallIds: Set<string>
  /**
   * Ids of the PRIOR-TURN synthetic thoughts seeded from the store this turn — kept through the
   * subtractive pass's Gemma-§3 strip (they're harness guidance, not the model's own prior CoT).
   */
  priorThoughtIds: Set<string>
  prevToolCallCount: number
}

export type EmitThought = (t: {
  id: string
  kind: AgentThoughtKind
  text: string
  isComplete: boolean
}) => void

export function makeEmitThought(deps: { state: TurnState; events: AgentEvents }): EmitThought {
  const { state, events } = deps
  return (t): void => {
    state.turnThoughtRecords.set(t.id, { kind: t.kind, text: t.text })
    events.onThought?.(t)
  }
}

export function makeEmitCheckerThought(deps: {
  emitThought: EmitThought
  checkerThoughtId: string
}): (criteria: string) => void {
  const { emitThought, checkerThoughtId } = deps
  return (criteria: string): void => {
    emitThought({
      id: checkerThoughtId,
      kind: 'checker',
      text: `I must evaluate if this ${criteria}`,
      isComplete: true,
    })
  }
}

export function makeGateControls(deps: { state: TurnState; events: AgentEvents }): {
  clearGate: (gate: AgentGateKind) => void
  emitGate: (gate: AgentGateKind, detail: string, nudge: string, thought?: string) => void
  clearAllGates: () => void
} {
  const { state, events } = deps
  const clearGate = (gate: AgentGateKind): void => {
    state.activeNudges.delete(gate)
    state.activeNudgeThoughts.delete(gate)
  }
  const emitGate = (gate: AgentGateKind, detail: string, nudge: string, thought?: string): void => {
    if (TOOL_REQUIRING_GATES.has(gate)) {
      for (const k of PROSE_ONLY_GATES) if (state.activeNudges.has(k)) clearGate(k)
    } else if (PROSE_ONLY_GATES.has(gate)) {
      for (const k of TOOL_REQUIRING_GATES) if (state.activeNudges.has(k)) clearGate(k)
    }
    if (!state.activeNudges.has(gate)) events.onGate?.({ gate, detail })
    state.activeNudges.set(gate, nudge)
    if (thought) state.activeNudgeThoughts.set(gate, thought)
  }
  const clearAllGates = (): void => {
    state.activeNudges.clear()
    state.activeNudgeThoughts.clear()
  }
  return { clearGate, emitGate, clearAllGates }
}

```

#### agent\_classifiers.ts

```ts
import { isGpuOomError } from './agent_errors'
import { stripTrailingToolCallLeak } from './agent_prose_heuristics'
import { ENCODING as THRIFT_ENCODING } from './agent_subtractive_pass'
import { DispatchRunner, Message, Thought, Tokenizable } from './agent_adk'
import { hasInlineDocPath, INLINE_DOC_PATH_RE, trailingRunFallback } from './agent_citations'
import type { CapturedAnswer, PlanAnswerKind } from './agent_tools'
import type { AgentEvents, HarnessAdapter } from './agent_harness_types'

/**
 * Shared binary-classifier dispatch: runs a one-shot, 1-token, greedy, tool-free generation on the
 * already-loaded adapter with the given classifier `systemPrompt`, feeds it `userContent`, and
 * returns true iff the single output character is "1". This is THE way the harness asks the model a
 * yes/no question about its OWN output — far more robust than regex-matching free text, which a
 * generative model will always escape. Fails OPEN (returns false) on any non-OOM error so a flaky
 * guard never blocks a turn; an OOM rethrows (it's a real capacity signal the dialog must surface).
 */
export async function classifyBinary(
  adapter: HarnessAdapter,
  systemPrompt: string,
  userContent: string
): Promise<boolean> {
  const trimmed = userContent.trim()
  if (!trimmed) return false
  let out = ''
  try {
    const noop = async (): Promise<void> => undefined
    const noopArr = async (): Promise<never[]> => []
    await DispatchRunner.dispatch({
      raw: {
        systemPrompt: new Tokenizable(systemPrompt),
        standingInstructions: [],
        messages: [
          new Message({
            id: `__classify-${Date.now()}`,
            role: 'user',
            content: trimmed.slice(0, 1200),
            createdAt: new Date().toISOString(),
            updatedAt: new Date().toISOString(),
          }),
        ],
        fetchMemories: noopArr,
        fetchRetrievables: noopArr,
        fetchMessages: noopArr,
        fetchThoughts: noopArr,
        fetchToolCalls: noopArr,
        fetchTools: noopArr,
        refreshStandingInstructions: noopArr,
        storeStandingInstruction: noop,
        mutateStandingInstruction: noop,
        deleteStandingInstruction: noop,
        storeMemory: noop,
        mutateMemory: noop,
        deleteMemory: noop,
        storeRetrievable: noop,
        mutateRetrievable: noop,
        deleteRetrievable: noop,
        storeMessage: async (_c: unknown, v: { content?: { toString(): string } }) => {
          out += v.content?.toString?.() ?? ''
        },
        mutateMessage: noop,
        deleteMessage: noop,
        storeThought: noop,
        mutateThought: noop,
        deleteThought: noop,
        storeToolCall: noop,
        mutateToolCall: noop,
        deleteToolCall: noop,
        storeMediaBytes: noop,
        storeRetrievableBytes: noop,
      } as never,
      // Tiny, deterministic, tool-free: just the verdict character. Thinking is FORCED OFF here regardless
      // of the turn's enableThinking — a 1-token greedy verdict has no room for a reasoning channel, and a
      // thinking model (e.g. qwen with think:on) otherwise spends its single token on reasoning noise
      // instead of the 0/1, so the loop never reads a clean verdict and re-dispatches unbounded (observed:
      // a 307-dispatch spin where the classifier emitted "1" every iteration but it never terminated the
      // loop). The classifier is a deterministic judgment, not a place to deliberate.
      executor: await adapter.executor({
        maxTokens: 1,
        sampler: 'greedy',
        toolCallParser: 'none',
        stream: false,
        autoAck: true,
        enableThinking: false,
      }),
    })
  } catch (err) {
    // An OOM in the classifier is NOT a "guard couldn't decide" — it's the device out of VRAM, and
    // it must surface to the user (friendly banner + Retry), not be silently swallowed into a
    // fallback answer. Rethrow OOMs so run()'s catch turns them into the turn error; fail open on
    // anything else (a flaky classifier must never block answering).
    if (isGpuOomError(err)) throw err
    return false // fail open — never let the guard block answering
  }
  return out.trim().startsWith('1')
}

/**
 * "Is this assistant text a substantive ANSWER about @nhtio/adk (vs. greeting / chit-chat /
 * clarifying question)?" Decides whether an UNCITED prose reply must be re-routed through
 * provide_answer. See {@link classifyBinary}.
 */
export async function classifyIsDocAnswer(adapter: HarnessAdapter, text: string): Promise<boolean> {
  return classifyBinary(
    adapter,
    "You are a binary classifier. The user gives you a chat assistant's reply. " +
      'Answer "1" if it is a substantive factual answer to a question about the @nhtio/adk ' +
      'software library (definitions, APIs, how-to, behavior). Answer "0" if it is a ' +
      'greeting, small talk, a clarifying question, a refusal, or otherwise not a doc answer. ' +
      'Reply with exactly one character: 0 or 1.',
    `Reply to classify:\n"""\n${text}\n"""\n0 or 1:`
  )
}

/**
 * "Did this reply actually ANSWER the user's question, or did it defer/refuse?" GROUNDED in the
 * real question — a far more reliable judgement for a small model than the abstract "is this an
 * abstention?" (a 2B, having just produced the deferral, will happily rate its own "I can't, but I
 * could try if you'd like" as a fine answer). Posing the concrete pair — question + reply — and
 * asking "answered or not?" is the robust form. Returns true when the reply is a NON-answer
 * (deferral / refusal / "I don't know" / offer to act instead of acting). Drives the
 * false-abstention gate (combined with "did a tool actually return a usable result this turn?").
 */
export async function classifyIsAbstention(
  adapter: HarnessAdapter,
  userQuestion: string,
  reply: string
): Promise<boolean> {
  return classifyBinary(
    adapter,
    'You judge whether an assistant actually ANSWERED the user. You are given the USER QUESTION and ' +
      'the ASSISTANT REPLY. Answer "1" if the reply does NOT actually answer the question — i.e. it ' +
      'says it does not know / cannot / lacks the ability or a tool, could not find or retrieve it, ' +
      'a tool failed, or it merely OFFERS or ASKS to do something (e.g. "I can use X if you\'d like") ' +
      'instead of giving the answer. Answer "0" if the reply actually gives the answer, or is normal ' +
      'greeting/small-talk. Reply with exactly one character: 0 or 1.',
    `USER QUESTION:\n"""\n${userQuestion}\n"""\n\nASSISTANT REPLY:\n"""\n${reply}\n"""\n\nIs the reply a NON-answer? 0 or 1:`
  )
}

/**
 * "Does this reply ASSERT a hard computed value or live/external fact that the model cannot reliably
 * produce on its own — and therefore should have come from a TOOL?" Catches the inverse of
 * abstention: the 2B that confidently states `73291 × 8457 = 628,111,187` (wrong) from its head, or
 * the current date/time, an exchange rate, etc., without (or despite a failed) tool call. Grounded
 * in the real question + reply so a small model can judge concretely. Drives the unverified-claim
 * gate, which only engages when NO successful tool result backs the claim this turn. See
 * {@link classifyBinary}.
 */
export async function classifyNeedsToolVerification(
  adapter: HarnessAdapter,
  userQuestion: string,
  reply: string
): Promise<boolean> {
  return classifyBinary(
    adapter,
    'You judge whether an assistant answer should have been VERIFIED with a tool. You are given the ' +
      'USER QUESTION and the ASSISTANT REPLY. Answer "1" if the reply states a specific value that a ' +
      'model cannot reliably know on its own and that a tool should produce — e.g. the result of a ' +
      'non-trivial arithmetic/math computation, the current date or time, a live exchange rate, or ' +
      'any concrete external/looked-up fact. Answer "0" if the reply is general knowledge it can ' +
      'safely state, an opinion, a refusal, or normal conversation. Reply with exactly one ' +
      'character: 0 or 1.',
    `USER QUESTION:\n"""\n${userQuestion}\n"""\n\nASSISTANT REPLY:\n"""\n${reply}\n"""\n\nShould this have been verified with a tool? 0 or 1:`
  )
}

/**
 * SEMI-DETERMINISTIC plan-conformance judge. The DETERMINISTIC part (computed by the caller) is the
 * list of planned tools the model committed to but NEVER successfully called this turn — a strong
 * signal the answer doesn't honour the plan. We FEED that fact into the classifier (rather than gate
 * on it alone) so the model is far more likely to fail the condition, while still allowing the rare
 * legitimate case (the plan named a tool that turned out unnecessary). Returns true when the answer
 * does NOT conform to the plan. See {@link classifyBinary}.
 */
export async function classifyPlanViolation(
  adapter: HarnessAdapter,
  plannedKind: string,
  uncalledTools: string[],
  reply: string
): Promise<boolean> {
  const uncalledNote =
    uncalledTools.length > 0
      ? `FACT: the plan committed to using these tools but they were NEVER successfully called this turn: ${uncalledTools.join(', ')}. An answer that needed them but skipped them is a violation.`
      : 'FACT: all planned tools were called.'
  return classifyBinary(
    adapter,
    'You check whether an assistant answer FOLLOWED ITS PLAN. You are given the planned answer kind, ' +
      'a FACT about which planned tools went uncalled, and the ASSISTANT REPLY. Answer "1" if the ' +
      'reply VIOLATES the plan — e.g. the plan was to compute/look up a value with a tool, that tool ' +
      'was never called, yet the reply states the value anyway (or claims it cannot, while a planned ' +
      'tool was skipped). Answer "0" if the reply is consistent with the plan. Reply with exactly ' +
      'one character: 0 or 1.',
    `PLANNED ANSWER KIND: ${plannedKind}\n${uncalledNote}\n\nASSISTANT REPLY:\n"""\n${reply}\n"""\n\nDoes the reply VIOLATE the plan? 0 or 1:`
  )
}

/**
 * If a just-accepted CITED answer ({@link CapturedAnswer}) carries an inline doc-path in its text, run the
 * {@link rewriteRemoveInlinePaths} specialist to strip it and return the cleaned capture; otherwise return
 * the capture unchanged. Only touches doc_cited / capacity_scoped answers (the kinds that cite). A no-op for
 * a null capture, a non-answer, or text with no inline path — so it costs a dispatch ONLY on the rare turn
 * that actually needs cleaning. Called from the dispatchOutput accept branches, before ctx.ack().
 */
export async function cleanInlinePathsIfAny(
  adapter: HarnessAdapter,
  events: AgentEvents,
  cap: CapturedAnswer | null,
  _kind?: PlanAnswerKind | undefined
): Promise<CapturedAnswer | null> {
  void _kind
  // Clean ANY answer that carries structured citations (cap.sources → SOURCES chips): an inline /path in
  // such an answer is redundant with the chips, whatever the plan's answer_kind. We deliberately do NOT
  // gate on kind — the 2B planner is unreliable, and the observed intermittent leak (run-2 A#0 "(see
  // /assembly)") was a doc answer the planner had NOT tagged doc_cited, so a kind-gated cleanup skipped it
  // even though it routed through provide_answer with real sources. Keying on cap.sources closes that gap.
  if (!cap || cap.kind !== 'answer' || cap.sources.length === 0) return cap
  // (1) DETERMINISTIC: strip a trailing dangling tool-call fragment the 2B appended after finishing its
  // prose ("…of your application. provide_answer: {"). Cheap string-cut, no dispatch; the pseudo-XML tag
  // stripper (stripLeakedToolTag) misses this bare non-bracketed tail. Do this first so a leaked tail
  // never ships and never confuses the inline-path check below.
  let text = stripTrailingToolCallLeak(cap.text)
  const noteToolLeak = text !== cap.text
  // (2) SPECIALIST: remove a redundant inline doc-path (the real citation is a SOURCES chip).
  if (await hasInlineDocPath(text)) {
    const cleaned = await rewriteRemoveInlinePaths(adapter, text)
    if (cleaned !== text) text = cleaned
  }
  if (text === cap.text) return cap
  events.onGate?.({
    gate: 'needs-citation',
    detail: noteToolLeak
      ? 'Removed a dangling tool-call fragment the model appended after its answer'
      : 'Removed a redundant inline doc-path from the answer (the real citation is a SOURCES chip)',
  })
  return { ...cap, text }
}

/**
 * SPECIALIST DISPATCH — remove inline documentation-paths from a finished answer's DISPLAY text. A cited
 * answer sometimes writes a bare relative doc-path INTO its prose ("… you assemble yourself (see /assembly)"
 * or "… the callbacks at /assembly/batteries-llm-27"). A relative URL is a dead link, and a small model
 * often INVENTS the path — but the REAL citations already ride the answer's structured `sources` (→ SOURCES
 * chips), so the inline path is redundant. Rather than reject-and-regenerate in the MAIN loop (which, on a
 * 2B, churns: multi-minute rewrite loops, truncation, once a raw tool-call envelope committed after 24
 * searches) or naively string-strip it (which clips mid-sentence prose), we hand the text to a SEPARATE,
 * single-purpose dispatch whose ONLY job is: rewrite this so it reads naturally with every /path removed,
 * changing nothing else. No tools, no search, no gates — pure text-in/text-out, so it can't churn the turn.
 */
export async function rewriteRemoveInlinePaths(
  adapter: HarnessAdapter,
  text: string
): Promise<string> {
  const original = text
  const trimmed = text.trim()
  if (!trimmed) return original
  // Size the output budget to the INPUT: the specialist only REMOVES paths, so the cleaned answer is never
  // longer than the original. Cap at input-token-count + a small margin (for minor seam rephrasing) so the
  // model emits the cleaned answer and STOPS — a fixed oversized cap (1024) let a 2B ramble past the answer
  // and turned a ~250-token rewrite into a multi-minute generation. Floored so tiny answers still fit.
  const inputTokens = Tokenizable.estimateTokens(trimmed, THRIFT_ENCODING)
  const rewriteMaxTokens = Math.min(2048, Math.max(128, Math.ceil(inputTokens * 1.15) + 24))
  // Enumerate the EXACT paths present so the synthetic thought can name each one — naming them is what makes
  // a 2B remove ALL of them (the observed failure was 4 scattered paths, some missed). Deduped, capped.
  const pathsPresent = [
    ...new Set([...trimmed.matchAll(/\/[a-z][\w-]*(?:\/[\w-]+)*/gi)].map((m) => m[0])),
  ].slice(0, 12)
  let out = ''
  try {
    const noop = async (): Promise<void> => undefined
    const noopArr = async (): Promise<never[]> => []
    await DispatchRunner.dispatch({
      raw: {
        systemPrompt: new Tokenizable(
          'You are a text editor with ONE job: remove documentation paths and URLs from an answer, and ' +
            'return the answer otherwise UNCHANGED. A documentation path looks like /assembly or ' +
            '/assembly/byo-llm — a leading slash followed by words. Remove every such path AND any now-' +
            'pointless lead-in that introduced it ("(see /assembly)", "at /assembly/x", "path: /assembly"), ' +
            'so each sentence still reads naturally and completely. Do NOT remove or reword anything else — ' +
            'keep the wording, facts, tone, and structure exactly. Do NOT add commentary, headings, or a ' +
            'sources list. Do NOT mention that you removed anything. Output ONLY the cleaned answer text.'
        ),
        standingInstructions: [],
        messages: [
          new Message({
            id: `__depath-${Date.now()}`,
            role: 'user',
            content: `ANSWER TO CLEAN:\n"""\n${trimmed.slice(0, 4000)}\n"""\n\nReturn ONLY the cleaned answer, with every /path removed and every sentence left complete:`,
            createdAt: new Date().toISOString(),
            updatedAt: new Date().toISOString(),
          }),
        ],
        fetchMemories: noopArr,
        fetchRetrievables: noopArr,
        fetchMessages: noopArr,
        // SYNTHETIC THOUGHT injection — the steer that lands on a 2B where a plain instruction doesn't (the
        // pattern that fixed rhetorical/capacity/coref this session). Prime the model, in first person, to
        // commit to removing EACH specific path before it rewrites — naming them is what stops it from
        // missing some when several are scattered mid-prose (the observed run-3 B#1 failure: 4 paths, missed).
        fetchThoughts: async () =>
          pathsPresent.length > 0
            ? ([
                new Thought({
                  id: `__depath-th-${Date.now()}`,
                  content:
                    `The answer contains these documentation paths I must delete: ${pathsPresent.join(', ')}. ` +
                    `I will rewrite the answer removing every one of them (and any "(see …)"/"at …"/"path:" ` +
                    `lead-in that introduced each), keeping all other wording and every sentence complete. ` +
                    `My output is ONLY the cleaned answer, with none of those paths remaining.`,
                  createdAt: new Date().toISOString(),
                  updatedAt: new Date().toISOString(),
                }),
              ] as never)
            : ([] as never),
        fetchToolCalls: noopArr,
        fetchTools: noopArr,
        refreshStandingInstructions: noopArr,
        storeStandingInstruction: noop,
        mutateStandingInstruction: noop,
        deleteStandingInstruction: noop,
        storeMemory: noop,
        mutateMemory: noop,
        deleteMemory: noop,
        storeRetrievable: noop,
        mutateRetrievable: noop,
        deleteRetrievable: noop,
        storeMessage: async (_c: unknown, v: { content?: { toString(): string } }) => {
          out += v.content?.toString?.() ?? ''
        },
        mutateMessage: noop,
        deleteMessage: noop,
        storeThought: noop,
        mutateThought: noop,
        deleteThought: noop,
        storeToolCall: noop,
        mutateToolCall: noop,
        deleteToolCall: noop,
        storeMediaBytes: noop,
        storeRetrievableBytes: noop,
      } as never,
      // Enough tokens to rewrite the whole answer; tool-free, non-streaming, low-temperature for fidelity.
      executor: await adapter.executor({
        maxTokens: rewriteMaxTokens,
        sampler: 'greedy',
        toolCallParser: 'none',
        stream: false,
        autoAck: true,
      }),
    })
  } catch (err) {
    if (isGpuOomError(err)) throw err
    return trailingRunFallback(original) // specialist errored — try the deterministic trailing-run strip
  }
  // Normalise: strip a stray wrapping """/quotes the specialist may echo, collapse seams.
  const cleaned = out
    .trim()
    .replace(/^"""\s*|\s*"""$/g, '')
    .replace(/^"|"$/g, '')
    .trim()
  // Accept the specialist's rewrite ONLY if it truly removed the path(s), kept the substance, and isn't
  // degenerate. If it FAILED any check (a 2B can't always cleanly excise a path — observed on a trailing
  // "…ownership: /a and /b." run), fall back to the DETERMINISTIC trailing-run strip rather than shipping
  // the original-with-paths: a path dangling at the very END is a pure citation run, safe to cut cleanly
  // (unlike a mid-sentence path). trailingRunFallback returns the original untouched if it can't do so.
  if (!cleaned || cleaned.length < 40) return trailingRunFallback(original)
  if (cleaned.length < trimmed.length * 0.6) return trailingRunFallback(original) // amputated too much
  if (INLINE_DOC_PATH_RE.test(cleaned)) return trailingRunFallback(original) // a path survived
  return cleaned
}

```

#### agent\_prose\_heuristics.ts

````ts
/**
 * Levenshtein edit distance (iterative, single-row, O(n) space). Small + dependency-free — the strings
 * compared here are a model reply vs. individual context messages, capped before calling.
 */
export function levenshtein(a: string, b: string): number {
  if (a === b) return 0
  if (a.length === 0) return b.length
  if (b.length === 0) return a.length
  let prev = new Array<number>(b.length + 1)
  let curr = new Array<number>(b.length + 1)
  for (let j = 0; j <= b.length; j++) prev[j] = j
  for (let i = 1; i <= a.length; i++) {
    curr[0] = i
    for (let j = 1; j <= b.length; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1
      curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
    }
    ;[prev, curr] = [curr, prev]
  }
  return prev[b.length]
}

/** Normalised Levenshtein SIMILARITY in [0,1] (1 = identical). 1 - distance/maxLen. */
export function levenshteinSimilarity(a: string, b: string): number {
  const max = Math.max(a.length, b.length)
  if (max === 0) return 1
  return 1 - levenshtein(a, b) / max
}

/** Collapse whitespace + lowercase for a fair text-vs-text similarity (formatting isn't parroting). */
export function normaliseForCompare(s: string): string {
  return s.replace(/\s+/g, ' ').trim().toLowerCase()
}

export const PARROT_SIMILARITY_THRESHOLD = 0.8
// SHORT-TEXT threshold: on a short comparison a few reworded words swing the ratio a lot, so two
// answers that are "the same point, reworded" can fall under 0.8 and defeat the repeated-prose stop.
// For short strings we relax to 0.7 so near-duplicates still register. (Long text keeps the stricter
// 0.8 — there's enough signal that 0.8 is already robust.)
const PARROT_SIMILARITY_THRESHOLD_SHORT = 0.7
const SHORT_COMPARE_LEN = 240
/** Length-aware similarity threshold: looser (0.7) for short strings, stricter (0.8) for long ones. */
export function similarityThresholdFor(a: string, b: string): number {
  return Math.min(a.length, b.length) <= SHORT_COMPARE_LEN
    ? PARROT_SIMILARITY_THRESHOLD_SHORT
    : PARROT_SIMILARITY_THRESHOLD
}

/**
 * Is this prose actually the BARE ARGUMENTS of a provide_answer call ({"answer": "..."} or
 * {"provide_answer": "..."}) that leaked as text instead of parsing as a tool call? A small-model
 * misfire the gate cascade treats differently from real prose (it's an ANSWER attempt, not a
 * no-tool turn). Only matches a small object (≤3 keys) so a big JSON document the model is
 * legitimately discussing never false-positives.
 */
export function looksLikeBareAnswerArgs(prose: string): boolean {
  const trimmed = prose.trim()
  if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false
  // Normalize the smart quotes the model emits so JSON.parse can read it.
  const normalized = trimmed.replace(/[“”]/g, '"').replace(/[‘’]/g, "'")
  let obj: unknown
  try {
    obj = JSON.parse(normalized)
  } catch {
    return false
  }
  if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false
  const keys = Object.keys(obj as Record<string, unknown>)
  // The provide_answer arg shape is {answer, sources?} — sometimes the model uses the tool name as
  // the key instead ({provide_answer: "..."}). Match either, and only when the object is small (a
  // real misfire), not a big JSON document the model is legitimately discussing.
  if (keys.length > 3) return false
  return keys.includes('answer') || keys.includes('provide_answer')
}

/**
 * Is this string a HARNESS/TOOL CONTROL message (a confirmation/rejection/status line), not real content?
 * Used by the hard-stop rescue: if the result it would surface as the answer is one of these control
 * strings (e.g. provide_answer's "Answer delivered with N source(s)."), surface a graceful fallback
 * instead — that text must never become the user-facing answer. Cheap exact-shape check, no context.
 */
export function looksLikeToolControlString(text: string): boolean {
  const t = text.trim()
  if (!t) return false
  return (
    /answer delivered with \d+ source/i.test(t) ||
    /none of the cited sources are real documentation pages/i.test(t) ||
    /^no tool named\b/i.test(t) ||
    // Tool ARGUMENT-VALIDATION errors (e.g. a stale-callId artifact_json_get: "The arguments supplied
    // to the tool failed input schema validation. 'callId' must be […]"). These are harness/tool error
    // strings, never an answer — surfacing one means the model parroted an ERROR. Catch the schema-
    // validation signature and the generic joi "must be" / "is not allowed" / "is required" shapes.
    /arguments supplied to the tool failed input schema validation/i.test(t) ||
    /failed (input )?schema validation/i.test(t) ||
    /\bmust be \[/i.test(t)
  )
}

/**
 * Is this tool-result body a DEGENERATE / EMPTY result — nothing the model (or the dup-stop rescue)
 * could answer FROM? Covers the observed failure: `artifact_json_get` with a wrong JSONPath (the 2B
 * guessed `$..content` when the field is `excerpt`) returns `(empty list)`, the model re-issues the
 * IDENTICAL call, the dup-stop fires, and that `(empty list)` string would otherwise be surfaced as the
 * answer. Recognise these so the rescue falls back to real prose instead. Distinct from
 * {@link looksLikeToolControlString} (named harness control strings); this is the empty-shape class:
 * empty string, an empty JSON array/object, or a bracketed "no results" sentinel.
 */
export function looksLikeEmptyOrControlResult(text: string): boolean {
  const t = text.trim()
  if (!t) return true
  if (looksLikeToolControlString(t)) return true
  // Empty/near-empty JSON containers: [], {}, "[]", or whitespace-only contents.
  if (/^[[{]\s*[\]}]$/.test(t)) return true
  // Bracketed/parenthesised emptiness sentinels: (empty list), (no results), [empty], (none), null.
  if (/^[([]\s*(empty|no\b|none|nil|null)\b[^)\]]*[)\]]$/i.test(t)) return true
  if (/^(null|undefined|n\/a|none|no results?(\s+found)?)$/i.test(t)) return true
  return false
}

/**
 * PARROTING GUARD (deterministic, content-agnostic). Did the model regurgitate something it was SHOWN —
 * a prior message, a tool result, a directive — instead of answering in its own words? Rather than match
 * known control strings, compare the reply against how EACH message in the dispatch context window was
 * rendered to the model (Levenshtein similarity); a reply ≥ {@link PARROT_SIMILARITY_THRESHOLD} similar to
 * any single context entry is a parrot. This catches the observed leaks (the 2B echoing provide_answer's
 * "Answer delivered with N source(s)." confirmation, a rejection notice, a search-result blob, or an
 * injected nudge) without enumerating them. `contextStrings` is the already-rendered model-facing context.
 */
export function looksLikeParroting(reply: string, contextStrings: readonly string[]): boolean {
  const r = normaliseForCompare(reply)
  if (r.length < 12) return false // too short to judge; a terse real reply isn't parroting
  for (const ctxStr of contextStrings) {
    const c = normaliseForCompare(ctxStr)
    if (c.length < 12) continue
    // (1) WHOLE-STRING similarity: the reply mirrors the entire context entry (echo of a short
    // confirmation / nudge / equally-sized blob).
    if (levenshteinSimilarity(r, c) >= similarityThresholdFor(r, c)) return true
    // (2) CONTAINMENT: the reply is a substantial VERBATIM SLICE of a longer context entry — the
    // model QUOTED a chunk of a doc passage / tool result instead of answering in its own words.
    // Whole-string Levenshtein misses this (the entry is far longer than the reply), so test direct
    // substring containment for a reply long enough to be a real quote.
    if (r.length >= 40 && c.length > r.length && c.includes(r)) return true
  }
  return false
}

/**
 * Strip a FALSE-ABSTENTION preamble off otherwise-good prose. The observed 2B failure: it reads the
 * docs and writes a correct, detailed synthesis — but prefixes it with "I couldn't find … in the
 * documentation. However, …" or "I don't have … but here is …". The disclaimer is wrong (it DID find
 * it) and poisons an otherwise-deliverable answer. When such a lead-in is immediately followed by real
 * substance, drop the lead-in and keep the substance. If the WHOLE reply is the abstention (no
 * "however"/substance after it), leave it untouched — that's a genuine IDK to handle elsewhere.
 */
export function stripFalseAbstentionPreamble(text: string): string {
  const t = text.trim()
  if (!t) return t
  // Match a leading abstention clause that PIVOTS into real content via however/but/here/though.
  const pivot =
    /^\s*(?:i\s+(?:could\s?n['’]?t|can['’]?t|was\s+un(?:able|successful)|do\s?n['’]?t\s+(?:have|see|find)|did\s?n['’]?t\s+find)[^.!?\n]*[.!?])\s+(?:however|but|that said|though|still|here(?:'s| is| are)?|i can (?:still )?tell you|based on)\b[:,]?\s*/i
  const m = pivot.exec(t)
  if (!m) return t
  const rest = t.slice(m[0].length).trim()
  // Only strip when meaningful substance remains (don't turn an abstention into an empty string).
  return rest.length >= 40 ? rest : t
}

/**
 * Is this reply DEGENERATE — empty, a stray single token, or punctuation — i.e. not a real answer? The
 * observed paraphrase livelock interleaved real answers with a bare "0" generation; without this guard
 * that "0" falls through to the plain-prose accept branch and becomes the committed answer. We treat
 * anything with fewer than 2 content words (alphanumeric run >= 2 chars) and under ~12 chars as
 * degenerate, plus a lone number/punctuation. Deliberately strict so a terse but REAL reply
 * ("Yes." / "ADK is a kit.") is NOT degenerate.
 */
export function looksLikeDegenerateReply(text: string): boolean {
  const t = text.trim()
  if (!t) return true
  if (t.length < 12 && /^[\W\d_]*$/.test(t)) return true // only punctuation/digits, short
  const words = t.match(/[a-z][a-z0-9]*/gi) ?? []
  return words.length < 2 && t.length < 12
}

/**
 * Does this prose answer look TRUNCATED — cut off mid-thought by the output cap rather than finished? A
 * generative model that runs out of output budget stops mid-sentence ("…you are responsible for filling
 * in the"); a finished answer ends on terminal punctuation, a closed quote/paren/fence, or a list it
 * completed. We can't read a finish-reason (LiteRT exposes none), so we infer from the tail. Used by the
 * incomplete-answer gate: a truncated answer is rejected with a "be more succinct" nudge so the model
 * re-answers shorter and FINISHES, instead of committing a half-sentence. Deliberately CONSERVATIVE —
 * only fires on a clear mid-sentence cut, never on a short-but-complete reply — so it can't livelock a
 * legitimately brief answer (and the no-progress backstop bounds it regardless).
 */
export function looksTruncated(text: string): boolean {
  const t = text.trim()
  // Too short to judge / handled by the degenerate gate.
  if (t.length < 40) return false
  // An unclosed code fence is an unambiguous truncation (odd number of ``` markers).
  if ((t.match(/```/g) ?? []).length % 2 === 1) return true
  // Look at the final non-empty line — markdown table/list/heading lines legitimately lack end
  // punctuation, so judge the last line of actual PROSE.
  const lines = t.split('\n').map((l) => l.trim())
  let last = ''
  for (let i = lines.length - 1; i >= 0; i--) {
    if (lines[i]) {
      last = lines[i]
      break
    }
  }
  if (!last) return false
  // Strip trailing markdown emphasis/quote/paren/bracket so "…done.**" or "…done.)" reads as complete.
  const tail = last.replace(/[*_`"'’”)\]]+$/u, '').trimEnd()
  if (!tail) return false
  // A complete sentence/clause ends in terminal punctuation (incl. a colon introducing a list the
  // model then rendered above) — these are NOT truncation.
  if (/[.!?:;…]$/u.test(tail)) return false
  // A bare markdown structural line (heading, list bullet, table row, blockquote) with nothing after it
  // is the model still mid-structure — but on its own that's ambiguous; require it to also END on a word
  // char (mid-sentence) to call it truncated.
  // Ends on a letter/number/comma/hyphen → mid-sentence cut.
  return /[\p{L}\p{N},\-—]$/u.test(tail)
}

/**
 * Does this answer contain a HALLUCINATED citation tag? The 2B was never taught `<cite>` markup — a real
 * @nhtio/adk citation is delivered through provide_answer's `sources[]`, never inline XML. When a
 * conversational/doc answer sprouts a `<cite>…</cite>` (or a `[cite: …]` bracket some quants emit) it is
 * invented plumbing that must not ship. Used by the fake-citation gate to reject + re-nudge for a clean
 * re-answer. Conservative: only the `cite` family, so ordinary prose that merely says "cite" is untouched.
 */
export function looksLikeFakeCitation(text: string): boolean {
  return /<\/?cite\b[^>]*>/i.test(text) || /\[\s*cite\s*[:\]]/i.test(text)
}

/**
 * Does a NON-code segment of the answer contain an XML-shaped tag? THE RULE (user directive): the model has
 * no business emitting ANYTHING XML-shaped in a reply the user sees — the ONLY place a `<tag>` may legitimately
 * appear is inside a fenced code block or an inline code span (a documentation example). Everywhere else, an
 * angle-bracket tag is internal plumbing (a leaked `<thought_${nonce} …>` / `<peer_agent_output_…>` envelope,
 * a `<cite>`, a `<provide_answer>` wrapper) or a hallucination — and a tag-bearing answer is a FAILURE that
 * must be REGENERATED, not shipped or banked.
 *
 * This replaces the old enumerated-vocabulary detector, which could not keep up with invented tag names
 * (observed leaks: `<thought___nudge:final-check …>`, `<thought___read-artifact …>`). It is deliberately
 * blanket-but-anchored:
 *  - Code carve-out: fenced ``` / ~~~ blocks and inline `…` spans are masked first, so a real docs example
 *    like ```html\n<div>…</div>\n``` or an inline `<thought>` mention is untouched.
 *  - A real tag requires a LETTER right after `<` (opener) or `</` (closer), so prose comparisons — `a < b`,
 *    `x <= 3`, `2<3`, `< 512 tokens` — never trip it (they have a space/digit/`=`, not a letter).
 *  - A trailing UNCLOSED opener is also caught (leaks frequently run off the end mid-tag).
 */
export function answerHasXmlTag(text: string): boolean {
  if (!text) return false
  // Mask code regions (fenced blocks first, then inline spans) so tags inside them do not count.
  const masked = text
    .replace(/```[\s\S]*?```/g, ' ')
    .replace(/~~~[\s\S]*?~~~/g, ' ')
    .replace(/`[^`]*`/g, ' ')
  // A complete opener/closer whose tag name starts with a letter.
  if (/<\/?[A-Za-z][^>]*>/.test(masked)) return true
  // A trailing unclosed opener/closer running off the end of the reply.
  if (/<\/?[A-Za-z][^<>]*$/.test(masked.trimEnd())) return true
  return false
}

// Real tool names the model might mistakenly wrap its prose in as a pseudo-XML tag (writing
// "<provide_answer> …" instead of CALLING provide_answer). Anchored to this known set so a genuine
// angle-bracket in prose (a generic, an HTML sample) is never stripped.
const LEAKED_TOOL_TAG_RE =
  /<\/?\s*(?:provide_answer|search_docs_semantic|search_docs_keyword|artifact_head|artifact_cat|artifact_json_get|make_plan|call_a_tool|tool_catalog|get_current_time|calculate)\b[^>]*>/gi

/**
 * Strip a leaked tool-call TAG the model wrote around its prose (e.g. a leading "<provide_answer>" or a
 * matching "</provide_answer>"), keeping the answer text. The prose is a real answer; only the pseudo-XML
 * wrapper is junk. Conservative: only removes tags whose name is a KNOWN tool (LEAKED_TOOL_TAG_RE), so
 * ordinary prose that happens to contain angle brackets is untouched. Collapses any leftover double-space.
 */
export function stripLeakedToolTag(text: string): string {
  // NB: LEAKED_TOOL_TAG_RE is /g — do NOT .test() then .replace() (shared lastIndex skips matches).
  const stripped = text.replace(LEAKED_TOOL_TAG_RE, '')
  if (stripped === text) return text
  return stripped.replace(/[ \t]{2,}/g, ' ').trim()
}

// A DANGLING, non-bracketed tool-call fragment the 2B appends AFTER finishing a good prose answer — it
// starts to NARRATE the structured call as literal text instead of emitting a real one, then runs off the
// end. Two observed shapes, both AFTER a clean closing sentence:
//   "…the unique behavior of your application. provide_answer: {"   (the TOOL NAME + JSON opener)
//   "…the agentic workflow you need. Sources: {"                     (a provide_answer FIELD NAME + opener)
// LEAKED_TOOL_TAG_RE only matches angle-bracket tags, so these bare "name: {" tails slip past it.
//   • Tool-name arm: a known tool name followed by any call/JSON opener (:/(/{/[). Ordinary prose that
//     merely mentions a tool ("call provide_answer with…") lacks the immediately-following opener → safe.
//   • Field-name arm (answer|sources): REQUIRES a `{`/`[` JSON opener right after the colon — so a genuine
//     markdown "Sources:" citation list (colon then a newline + "- /path", NOT a brace) is never cut. The
//     caller (stripTrailingToolCallLeak) additionally only cuts when a substantial sentence-ending head
//     remains, so a fragment-only reply is left intact for the guards.
const TRAILING_TOOL_CALL_LEAK_RE =
  /(?:(?:provide_answer|search_docs_semantic|search_docs_keyword|artifact_head|artifact_cat|artifact_json_get|make_plan|call_a_tool|tool_catalog|get_current_time|calculate)\s*[:(={[]|(?:answer|sources)\s*[:=]\s*[[{])[\s\S]*$/i

/**
 * Strip a TRAILING dangling tool-call fragment (a bare `provide_answer: {…`) the model appended after an
 * otherwise-complete prose answer, keeping the leading prose. Safe because it only cuts a trailing run that
 * begins at a known tool name + a call/JSON opener and continues to end-of-string, and only when a
 * substantial own-words head (>= 60 chars) remains that ends at a sentence boundary — so a genuine complete
 * answer is preserved and a fragment-only reply is left for the guards. Mirrors the trailing-run carve-outs
 * (safe to string-cut a trailing dump; never a mid-sentence one).
 */
export function stripTrailingToolCallLeak(text: string): string {
  const t = text.trim()
  if (!t) return t
  const endsClean = (s: string) => /[.!?)"”’\]]$/.test(s)
  // (a) Named fragment: "…application. provide_answer: {" / "…you need. Sources: {".
  const m = TRAILING_TOOL_CALL_LEAK_RE.exec(t)
  if (m && m.index > 0) {
    const head = t.slice(0, m.index).trim()
    // Only cut when the head is a substantial answer that closes cleanly (ends in sentence punctuation) —
    // otherwise the "answer" was really just the tool-call narration and the guards should handle it.
    if (head.length >= 60 && endsClean(head)) return head
  }
  // (b) BARE dangling JSON opener: the 2B closes its answer cleanly and then emits just the start of the
  // structured call — "…synthesizing the final answer. {" (a lone "{" / "[", optionally with a scrap of
  // partial JSON after it, running to end-of-string). A complete answer never legitimately ends on a bare
  // opening brace, so cutting it back to the clean sentence is safe. Same substantial-clean-head guard.
  const bare = /[\s]+[{[][\s\S]{0,40}$/.exec(t)
  if (bare && bare.index > 0) {
    const head = t.slice(0, bare.index).trim()
    if (head.length >= 60 && endsClean(head)) return head
  }
  return t
}

/**
 * Strip a TRAILING VERBATIM-QUOTE block off otherwise-good own-words prose, keeping the synthesis. The
 * observed failure (context tap): the 2B wrote an excellent own-words answer, then APPENDED a big verbatim
 * doc quote ("This concept is described in the documentation… \"Think of ADK as an engine block…\" (source:
 * /assembly/byo-memory-5)"). The raw-doc/parroting guard correctly flagged the QUOTE but rejected the
 * WHOLE reply, discarding the good synthesis. Here we cut from the first quote-introduction marker (a lead
 * like "as described in the documentation:" / "the docs say:" / a lone opening quote that begins a long
 * verbatim run) to the end, returning the leading own-words prose. Only strips when a SUBSTANTIAL
 * own-words head remains (>= 60 chars) AND the tail actually looks like a quote — else returns the input
 * unchanged (let the guards handle it).
 */
export function stripTrailingQuote(text: string): string {
  const t = text.trim()
  if (!t) return t
  // Quote-introduction markers: a sentence that hands off into a verbatim block.
  const introMatch =
    /\b(this\s+(is|concept\s+is)\s+described|as\s+(described|stated|written|noted)\b|the\s+(docs?|documentation)\s+(say|states?|describes?|reads?)|according\s+to\s+the\s+(docs?|documentation)|quoting|verbatim)\b[^.!?\n]*[:.]/i.exec(
      t
    )
  let cut = -1
  if (introMatch) {
    cut = introMatch.index
  } else {
    // A lone opening double-quote that starts a long (> 120 char) verbatim run near the end.
    const q = t.indexOf('"')
    if (q >= 60 && t.length - q > 120) cut = q
  }
  if (cut < 0) return t
  const head = t.slice(0, cut).trim()
  // Keep the head only if it's a substantial own-words answer that ISN'T itself raw doc source.
  return head.length >= 60 && !looksLikeRawDocSource(head) ? head : t
}

/**
 * Is this reply the model ACKNOWLEDGING a control directive instead of answering? The observed failure
 * (caught by the context tap): after a gate bounced its answer, the 2B treated the `[SYSTEM DIRECTIVE]`
 * nudge as the user talking and replied "I understand the directive. I will ensure that for all future
 * answers about @nhtio/adk, I will synthesise… and use provide_answer…" — a meta-conversation with the
 * gate, which then got committed as the answer. A directive-acknowledgment is NEVER an answer: catch it
 * so it's re-looped, never surfaced. Matches the compliance-speak openers + future-promise phrasing.
 */
export function looksLikeDirectiveAcknowledgment(text: string): boolean {
  const t = text.trim()
  if (!t) return false
  // Compliance-speak opener (the reply STARTS by agreeing to the instruction). An optional adverb may sit
  // between "I" and the verb ("I completely understand", "I sincerely apologize"), and the reply may open
  // with a pleasantry before the compliance ("Thank you for that feedback; I understand…", "Thanks for
  // pushing me on this! I'll make sure…") — so allow a short lead-in before the compliance opener too.
  const compliance =
    /(i\s+(\w+\s+){0,2}(understand|apologi[sz]e|will\s+(ensure|make\s+sure|now|synthesi[sz]e|rephrase|comply|provide|keep|stick)|['’]ll\s+(ensure|make\s+sure|keep|stick|try|aim))|understood\b|got\s+it\b|sure[,.]?\s+i\s+will\b|my\s+(sincere\s+)?apologies\b)/i
  // A reply that OPENS with the compliance-speak (allowing a brief pleasantry/meta lead-in of up to ~80
  // chars first) is an acknowledgment, not an answer.
  const lead = t.slice(0, 120)
  if (compliance.test(lead)) return true
  // Meta-reference to the GATE MACHINERY itself — a real answer to a user question never talks about the
  // "system directive/message", copying text "from the documentation", answering "in my own words", or
  // "original phrasing"; those only appear when the model is conversing WITH the nudge instead of the user.
  if (
    /\b(system\s+(directive|message|instruction)|copying\s+(text|from\s+the\s+documentation)|in\s+my\s+own\s+words|original\s+phrasing|stop\s+copying)\b/i.test(
      t
    )
  ) {
    return true
  }
  // Forward-looking promise about how it WILL answer from now on (talking ABOUT answering, not answering).
  return (
    /\b(for\s+all\s+future\s+(answers|responses)|from\s+now\s+on|going\s+forward|moving\s+forward|from\s+this\s+point\s+forward)\b/i.test(
      t
    ) && /\bi\s*(['’]ll|\s+will)\b/i.test(t)
  )
}

/**
 * Strip a LEADING field-name label off an otherwise-real answer. The observed failure (stress T1#3): the 2B
 * wrote a complete, good answer but prefixed it with the bare argument label — "answer: To dynamically
 * manage the context window…". Unlike a leaked XML tag (which is plumbing → regenerate), this is a benign
 * stray label safe to normalise off. Drops a single leading `answer:` / `provide_answer:` / `sources:`
 * (colon or equals) when SUBSTANTIAL prose follows (>= 40 chars), so a real answer that merely mentions
 * "the answer:" mid-sentence is never touched (anchored to the very start only).
 */
export function stripLeadingFieldLabel(text: string): string {
  const t = text.trim()
  if (!t) return t
  const m = /^(?:answer|provide_answer|sources)\s*[:=]\s+/i.exec(t)
  if (!m) return t
  const rest = t.slice(m[0].length).trim()
  return rest.length >= 40 ? rest : t
}

/**
 * Strip a LEADING directive-acknowledgment PREAMBLE off an otherwise-real answer, keeping the answer. The
 * observed failure (A#2): the 2B, having seen a formatting correction, opened with "I sincerely apologize
 * for that. I will make sure my responses are clean and easy to read for you." and THEN gave a complete,
 * correct answer. {@link looksLikeDirectiveAcknowledgment} only catches a reply that IS wholly an ack, so a
 * good answer prefixed with one sailed through. Here we drop one or two leading apology/compliance sentences
 * ("I apologize…", "I('ll| will) make sure…", "I understand…", "My apologies…", "Sure, I will…") when
 * SUBSTANTIAL real content follows (>= 60 chars) — so the user sees the answer, not the meta-chatter.
 * Conservative: only removes sentences that clearly match the ack shape, and only from the very start.
 */
export function stripLeadingDirectiveAck(text: string): string {
  let t = text.trim()
  if (!t) return t
  // A leading sentence that is compliance/apology meta-chatter (not answer content).
  const ackSentence =
    /^(?:i\s+(?:sincerely\s+)?(?:apologi[sz]e|understand|will\s+(?:ensure|make\s+sure|now|try|aim|strive)|['’]?ll\s+(?:ensure|make\s+sure|try))|my\s+(?:sincere\s+)?apologies|understood|got\s+it|sure[,.]?\s+i\s+will|of\s+course[,.]?\s+i)\b[^.!?\n]*[.!?]+\s*/i
  // Peel up to TWO such sentences (the model often writes "I apologize. I will make sure …").
  for (let i = 0; i < 2; i++) {
    const m = ackSentence.exec(t)
    if (!m) break
    const rest = t.slice(m[0].length).trim()
    // Only strip if a real answer remains; otherwise leave it for the wholesale-ack guard to reject.
    if (rest.length < 60) break
    t = rest
  }
  return t
}

/**
 * Does this text look like RAW DOCUMENTATION SOURCE — a verbatim passage the model READ, not an answer
 * it WROTE? The observed failure: the dup-stop rescue surfaced an `artifact_json_get` body verbatim, so
 * the committed "answer" was a raw doc excerpt full of authoring markup ({@link …}, ::: containers, //
 * code comments, markdown heading hashes, TSDoc tags). A direct quote of the docs is never an own-words
 * answer — detect it so the rescue refuses to surface it and the prose path can nudge "in your own words".
 */
export function looksLikeRawDocSource(text: string): boolean {
  const t = text.trim()
  if (!t) return false
  return (
    /\{@link\b/.test(t) || // TSDoc link tags
    /^:::|\n:::/.test(t) || // VitePress ::: containers
    /(^|\n)\s*\/\/ /.test(t) || // // code comments
    /(^|\n)#{1,6}\s/.test(t) || // markdown headings
    /(^|\n)```/.test(t) // fenced code blocks
  )
}

````

#### agent\_citations.ts

````ts
import { isKnownDocPath } from './agent_keyword_search'
import { contentTokens, looksLikeSpooledArtifact } from './agent_adk'
import { CAPACITY_MIN_CONTENT_WORDS, CAPACITY_RAG_HIT_COUNT } from './agent_turn_policy'
import type { TurnPlan } from './agent_tools'

/** Narrowing/scoping intent: the user is explicitly asking for LESS than everything — the highlights, the
 *  starting points, the essentials. This is the exact opposite of an exhaustive ask and is the very thing a
 *  capacity decline TELLS the user to do, so a request carrying it must NEVER be flipped to capacity_scoped on
 *  RAG breadth alone (e.g. "what are the important parts I need to know upfront?"). Explicit-exhaustive
 *  phrasing still overrides this — if they literally typed "every single … briefly", the exhaustive ask wins. */
function hasNarrowingIntent(text: string): boolean {
  return /\b(upfront|up front|important parts?|key (parts?|points?|takeaways?|things?)|main (parts?|points?|things?|ones?)|the essentials?|the basics?|to (start|begin)|starting points?|get(ting)? started|just the|only the|briefly|in (short|brief|summary)|tl;?dr|high[- ]level|at a glance|gist|overview of the)\b/i.test(
    text.trim()
  )
}

/** Explicit-exhaustive phrasing: the user is asking for EVERYTHING, completely. One half of the
 *  capacity_scoped trigger (the other is RAG breadth). Conservative — a normal broad question ("what are
 *  the important parts") has none of these markers. */
function hasExhaustivePhrasing(text: string): boolean {
  const t = text.trim()
  return (
    /\b(exhaustive|every single|each and every|leave nothing out|comprehensive (overview|reference|list) of (all|every)|all of (the|them)|the (full|complete|entire) (list|reference|catalog))\b/i.test(
      t
    ) ||
    (/\bevery\b/i.test(t) && /\b(all|complete|entire|whole|everything)\b/i.test(t))
  )
}

/**
 * Is this request too broad to render in FULL within the small on-device model's output budget → it should
 * be `capacity_scoped` (honest decline + cite where the full answer lives), not `doc_cited` (which would
 * truncate). Two independent signals, EITHER of which fires it:
 *   1. Explicit-exhaustive PHRASING ("every single…", "be exhaustive", "leave nothing out").
 *   2. RAG BREADTH — many pre-retrieved passages are strongly on-topic ({@link CAPACITY_RAG_HIT_COUNT}+
 *      hits at/above {@link CAPACITY_RAG_SCORE_FLOOR}), i.e. the topic genuinely spans a lot of the docs.
 * The breadth signal is the deterministic, evidence-based half — it catches broad requests that don't use
 * exhaustive wording, and it can't be argued with the way a prompt can.
 *
 * SUBSTANCE GUARD on the breadth arm: in a doc-heavy corpus the pre-retrieval returns many hits for almost
 * ANY input — even "hey, how are you?" clears the floor on ≥6 chunks — which would wrongly flip a greeting
 * to capacity_scoped. So the RAG-breadth signal only counts when the request itself has enough content words
 * to plausibly BE a doc query (≥{@link CAPACITY_MIN_CONTENT_WORDS}). A short greeting/small-talk has too few
 * and is never over-scope on breadth alone. (Explicit-exhaustive PHRASING still fires regardless — if the
 * user literally typed "be exhaustive", they meant it.)
 */
export function requestIsOverScope(text: string, highConfidenceHits: number): boolean {
  if (hasExhaustivePhrasing(text)) return true
  // NARROWING GUARD on the breadth arm: in a doc-heavy corpus almost any substantive on-topic question
  // clears the hit floor, but breadth in the CORPUS is not breadth in the REQUEST. If the user is explicitly
  // scoping DOWN ("the important parts upfront", "just the essentials", "briefly"), that is exactly the
  // narrowing a capacity decline would ask for — so we must answer it, not decline it. Only explicit-
  // exhaustive PHRASING (handled above) can override this.
  if (hasNarrowingIntent(text)) return false
  const substantive = contentTokens(text).size >= CAPACITY_MIN_CONTENT_WORDS
  return substantive && highConfidenceHits >= CAPACITY_RAG_HIT_COUNT
}

// Matches the 2B's self-cited TAIL: a trailing "Sources:" / "Source:" heading followed by one or more
// path-ish lines (markdown bullets or bare paths). Anchored to the END so it only fires when the model
// finished the answer and appended its own citations — not a mid-body mention of the word "source".
const TRAILING_SOURCES_RE =
  /\n+\s*(?:\*{0,2}\s*)?sources?\s*:?\s*\*{0,2}\s*\n(?:[\s>*-]*\/?[\w./#-]+[^\n]*\n?)+\s*$/i

/** True when the prose ends with the model's OWN "Sources:" list (a self-cited, finished doc answer). */
export function hasTrailingSourcesList(text: string): boolean {
  return TRAILING_SOURCES_RE.test(text)
}

/** Drop the trailing "Sources:" list so the structured SOURCES chip renders them instead of duplicate prose. */
export function stripTrailingSourcesList(text: string): string {
  return text.replace(TRAILING_SOURCES_RE, '').trimEnd()
}

/**
 * Extract the doc paths the model NAMED in its trailing Sources: list and keep only the ones that are
 * REAL @nhtio/adk pages (validated via isKnownDocPath — the exact check the provide_answer tool runs).
 * Lets a self-cited completion keep the model's OWN chosen citations when they're real, rather than
 * replacing them wholesale with the pre-fetched retrievable set. Deduped, capped at 6 (chip sanity).
 */
export async function validatedNamedSources(
  text: string
): Promise<Array<{ path: string; title: string }>> {
  const m = TRAILING_SOURCES_RE.exec(text)
  if (!m) return []
  const seen = new Set<string>()
  const candidates: string[] = []
  for (const line of m[0].split('\n')) {
    // A doc path on this line: an ABSOLUTE /segment/style token whose leading slash sits at a boundary
    // (start of line, or after bullet/quote/space markup) — so an internal slash inside a bare word is
    // NOT mistaken for a path root. Real @nhtio/adk doc paths are always absolute (e.g. /assembly/byo-llm).
    const pm = /(?:^|[\s>*•-])(\/[\w][\w./#-]*)/.exec(line)
    if (!pm) continue
    // Drop any #anchor — isKnownDocPath keys on the page path.
    const path = pm[1].replace(/#.*$/, '')
    if (path.length < 2 || seen.has(path)) continue
    seen.add(path)
    candidates.push(path)
  }
  const checked = await Promise.all(
    candidates.slice(0, 12).map(async (p) => ({ p, ok: await isKnownDocPath(p) }))
  )
  return checked
    .filter((c) => c.ok)
    .slice(0, 6)
    .map((c) => ({ path: c.p, title: c.p }))
}

// A trailing run of NAKED doc links — bare "/segment/path" tokens at the very END of the answer, WITHOUT a
// "Sources:" heading (that case is TRAILING_SOURCES_RE). The 2B often ends a doc/capacity answer with the
// pages it used dangling as plain relative URLs ("… see /assembly/byo-llm and /assembly/assembly.") — which
// render as functionally-dead text, not clickable citations. This matches the tail as: a boundary, then one
// or more absolute paths separated only by connective punctuation/words (comma, "and", "&", "or", bullets,
// whitespace, "see"/"at"/"in"/"page"/"section"/parens/colons), with only trailing punctuation after. It is
// deliberately conservative — it fires ONLY when the tail is essentially JUST links, so a path used inside a
// real sentence ("the /assembly page explains X") is NOT stripped (there's prose after it).
const TRAILING_NAKED_LINKS_RE =
  /(?:[\s.,;:()"'*>-]|\b(?:see|at|in|on|the|page|pages|section|sections|and|or|documentation|docs|here)\b)*(?:\/[\w-]+(?:\/[\w-]+)*[#\w-]*[\s.,;:()"'*>and&or/-]*)+\s*$/i

/**
 * DETERMINISTIC last resort when the inline-path specialist can't cleanly remove a path (a 2B sometimes
 * can't — observed on a trailing "…ownership: /assembly/byo-llm-2 and /assembly/byo-llm-14." run). A path
 * dangling at the very END of the answer is a pure citation run (the real cites are in the SOURCES chips),
 * so it is SAFE to cut deterministically — unlike a mid-sentence path, which is why we don't string-strip
 * those. We drop the trailing run plus the connective lead-in that introduced it ("… ownership:" / "… see"),
 * tidy the seam, and return the result ONLY if it's substantial and no inline path survives; otherwise the
 * ORIGINAL is returned untouched (a redundant path beats a mangled answer — the caller's contract).
 */
export function trailingRunFallback(text: string): string {
  const hit = extractTrailingNakedLinks(text)
  if (!hit) return text
  let body = hit.body
    // Drop a dangling connective lead-in the run left behind ("… detailed in the section on X:" → "… X.").
    .replace(/\s*[([]?\s*\b(?:see|at|in|on|under|via|and|or)\b\s*$/i, '')
    .replace(/[ \t]+([.!?,;:])/g, '$1')
    .replace(/[ \t]{2,}/g, ' ')
    .replace(/[:,;]\s*$/, '') // a colon/comma that introduced the (now-removed) links
    .trim()
  // Ensure it ends on terminal punctuation (the run took the final "." with it).
  if (body && !/[.!?]$/.test(body)) body += '.'
  if (body.length < 40) return text // too little left — keep the original
  if (INLINE_DOC_PATH_RE.test(body)) return text // still has an inline path — don't ship half-clean
  return body
}

/**
 * If the answer ENDS in one or more naked doc links (no "Sources:" heading), pull the absolute paths out and
 * return the body with that trailing link-run stripped. Only fires when the matched tail is at least ~half
 * links by character weight AND leaves a substantial body behind — so we never amputate a real sentence that
 * merely mentions a path. Returns null when there's no qualifying trailing link-run.
 */
function extractTrailingNakedLinks(text: string): { paths: string[]; body: string } | null {
  const trimmed = text.trimEnd()
  // Already handled by the "Sources:" path — don't double-fire.
  if (TRAILING_SOURCES_RE.test(trimmed)) return null
  const m = TRAILING_NAKED_LINKS_RE.exec(trimmed)
  if (!m || m.index <= 0) return null
  const tail = trimmed.slice(m.index)
  const paths = [...tail.matchAll(/\/[\w-]+(?:\/[\w-]+)*/g)].map((p) => p[0])
  if (paths.length === 0) return null
  // Guard: the tail must be MOSTLY links (not a long sentence that happens to end near a path), and stripping
  // it must leave a real answer behind (>= 40 chars) — else this isn't a trailing citation run.
  const linkChars = paths.join('').length
  if (linkChars < tail.trim().length * 0.5) return null
  const body = trimmed.slice(0, m.index).trimEnd()
  if (body.length < 40) return null
  return { paths, body }
}

/**
 * Like {@link validatedNamedSources} but for a trailing NAKED link-run: validate each path via isKnownDocPath
 * and return the real ones as citation chips + the body with the link-run stripped. Null when nothing
 * qualifies. Deterministic — no model cooperation, no classifier.
 */
export async function validatedTrailingNakedSources(text: string): Promise<{
  sources: Array<{ path: string; title: string }>
  body: string
} | null> {
  const hit = extractTrailingNakedLinks(text)
  if (!hit) return null
  const seen = new Set<string>()
  const uniq = hit.paths
    .map((p) => p.replace(/#.*$/, ''))
    .filter((p) => p.length >= 2 && !seen.has(p) && (seen.add(p), true))
  const checked = await Promise.all(
    uniq.slice(0, 12).map(async (p) => ({ p, ok: await isKnownDocPath(p) }))
  )
  const sources = checked
    .filter((c) => c.ok)
    .slice(0, 6)
    .map((c) => ({ path: c.p, title: c.p }))
  if (sources.length === 0) return null
  return { sources, body: hit.body }
}

// An INLINE naked doc-path sitting INSIDE a sentence — a bare relative URL like `/assembly/byo-llm` the model
// wrote into its prose ("… the storage callbacks at /assembly/batteries-llm-27." or a QUOTED "/assembly/byo-
// llm-3") rather than letting the SOURCES chips carry the citation. The slash root sits at a word boundary so
// an internal slash inside a bare token isn't mistaken for one; the boundary class includes whitespace,
// brackets, bullets, and QUOTE characters (straight ' " and curly ‘’ “”) because the 2B often wraps a cited
// path in quotes, which previously hid it from the gate. The path has ≥1 segment and starts with a letter.
export const INLINE_DOC_PATH_RE = /(?:^|[\s([>*•\-'"‘’“”])(\/[a-z][\w-]*(?:\/[\w-]+)*)(?:#[\w-]+)?/i

/**
 * Does this prose body contain a naked doc-path the model should NOT have written — a bare relative URL like
 * `/assembly/byo-llm` inside a sentence, OR a single fabricated path dangling at the end of a real sentence
 * ("… the callbacks at /assembly/batteries-llm-27.")? A relative URL in prose is a functionally-dead link,
 * and a small model regularly INVENTS plausible-but-fake ones. The gate uses this to REJECT + re-loop so the
 * model rewrites without inline paths — the real citations ride the SOURCES chips.
 *
 * The one thing we must NOT flag is a legitimate TRAILING CITATION RUN of REAL pages, because the citation-
 * chip validators ({@link validatedTrailingNakedSources} / the "Sources:" branch) turn those into clickable
 * chips. So: peel a trailing run, and exclude it ONLY when every path in it is a KNOWN real doc page. A
 * trailing run whose paths are fabricated is NOT a real citation — it's the very inline-path problem — so it
 * stays in scope and trips the gate. Async because that validation is async; awaited in the commit branches.
 */
export async function hasInlineDocPath(text: string): Promise<boolean> {
  if (!text || !/\/[a-z]/i.test(text)) return false
  let head = text.trimEnd()
  // ONLY exclude a structured "Sources:"-HEADED block: that renders as a distinct citation list, not inline
  // prose, and is legitimately not part of the answer body. EVERY OTHER bare /path in the body — whether
  // trailing ("… ownership: /a and /b.") or mid-sentence ("(see /assembly)"), real OR fabricated — is a dead
  // inline link that must be cleaned: the real citations always ride the SOURCES chips (from cap.sources), so
  // an inline path is never the only citation. (An earlier "peel a trailing run of real paths" carve-out
  // wrongly treated "…: /a and /b." as a legit dump and let real paths ship in the prose — removed.)
  if (TRAILING_SOURCES_RE.test(head)) {
    const named = await validatedNamedSources(head)
    const m = TRAILING_SOURCES_RE.exec(head)
    if (named.length > 0 && m && m.index > 0) head = head.slice(0, m.index)
  }
  return INLINE_DOC_PATH_RE.test(head)
}

// The relevance/naive/compact turn-selection algorithms (contentTokens/argText/relevanceToQuery/
// HistoryTurn/groupHistoryIntoTurns/selectRelevantTurns/selectNaiveTurns) now live in the shipped
// @nhtio/adk/batteries/context/thrift battery — consumed via the agent_adk.ts shim import at the top of
// this file (see getSelectedTurns() above). `contentTokens` below (used by requestIsOverScope,
// findGroundingRetrievable, hydrateRetrievables) resolves to that same shim-sourced import.

/**
 * Is this doc-prose GROUNDED in one of the pre-fetched retrievables already in context (the Ask-ADK
 * baseline RAG)? If so, return that retrievable's citable source so the answer can be committed AS A
 * CITED answer — grounded answers must still carry a citation, but the citation can come from the
 * retrievable that grounded it rather than forcing the model to re-run a search. Match = content-word
 * overlap (Jaccard-ish: shared tokens / prose tokens) above a threshold; returns the best match's
 * source + title. The user's rule: "pre-fetched RAG answers can be GROUNDED but they have to be CITED."
 */
export function findGroundingRetrievable(
  prose: string,
  retrievables: ReadonlyArray<{
    content?: { toString(): string }
    source?: string
  }>
): { path: string; title: string } | null {
  const proseTokens = contentTokens(prose)
  if (proseTokens.size < 5) return null
  // Score every retrievable by share of the prose's content-words it contains, keyed by source PATH
  // (a doc can appear as several chunked retrievables — fold them to one source so we judge the DOC,
  // not the chunk).
  const bySource = new Map<string, { title: string; shared: Set<string> }>()
  for (const r of retrievables) {
    const src = r.source
    if (!src) continue
    const body = r.content?.toString?.() ?? ''
    const rt = contentTokens(body.slice(0, 2000))
    if (rt.size === 0) continue
    const entry = bySource.get(src) ?? {
      // First content line of the retrievable is the page title (fetchRetrievablesCallback: `title\ncontent`).
      title: body.split('\n', 1)[0]?.trim() || src,
      shared: new Set<string>(),
    }
    for (const tok of proseTokens) if (rt.has(tok)) entry.shared.add(tok)
    bySource.set(src, entry)
  }
  const ranked = [...bySource.entries()]
    .map(([path, e]) => ({
      path,
      title: e.title,
      score: e.shared.size / proseTokens.size,
    }))
    .sort((a, b) => b.score - a.score)
  const top = ranked[0]
  if (!top) return null
  const runnerUp = ranked[1]?.score ?? 0
  // CONFIDENT which-doc determination (the user's "100% confidence, be nice to the E2B" bar): only
  // auto-cite when ONE source clearly dominates. Require BOTH (a) strong overlap — the answer's content
  // words are largely present in that doc — AND (b) a clear margin over the runner-up, so we are sure
  // WHICH doc it came from rather than guessing between several similar passages. When it's ambiguous
  // we do NOT fabricate a citation; the normal needs-citation path (and its backstops) handle it.
  const STRONG_OVERLAP = 0.5
  const DOMINANCE_MARGIN = 0.2
  return top.score >= STRONG_OVERLAP && top.score - runnerUp >= DOMINANCE_MARGIN
    ? { path: top.path, title: top.title }
    : null
}

/**
 * ALL distinct sources from this turn's pre-fetched retrievables — the exact docs the agent was
 * grounded in. Used by the force-commit backstop so an answer ABOUT @nhtio/adk is NEVER committed
 * uncited: we attach EVERY retrievable the model had available (deduped by path, highest-RAG-score
 * first), rather than guessing which single one it drew from. The model does not HAVE to cite (we
 * supply these as a safety net), but the prompt pushes it to cite itself; this just guarantees a doc
 * answer always lands with real, validated sources. Distinct from {@link findGroundingRetrievable}
 * (which fires only when ONE source clearly dominates, for the confident single-source auto-cite).
 */
/**
 * Is a captured provide_answer GROUNDED in the pages already in this turn's context? True when at least one
 * of the answer's cited source paths matches a path in the turn's retrievables / prefetched RAG set. A
 * well-formed provide_answer is already citation-checked (its paths passed isKnownDocPath at capture), so this
 * distinguishes a REAL-and-grounded answer (cite a page we actually gave the model) from a REAL-but-guessed one
 * (a valid path the model never saw) — WITHOUT re-running the citation check. Paths are compared normalised
 * (trailing slash / .html stripped) so /the-loop/llm-dispatch and /the-loop/llm-dispatch.html match.
 */
export function capturedIsGroundedInContext(
  captured: { sources?: ReadonlyArray<{ path?: string }> },
  contextSources: ReadonlyArray<{ path: string }>
): boolean {
  const norm = (p: string): string => p.replace(/(?:index)?\.html?$/i, '').replace(/\/+$/, '')
  const inContext = new Set(contextSources.map((s) => norm(s.path)))
  if (inContext.size === 0) return false
  return (captured.sources ?? []).some(
    (s) => s.path !== null && s.path !== undefined && inContext.has(norm(s.path))
  )
}

export function allRetrievableSources(
  retrievables: ReadonlyArray<{
    content?: { toString(): string }
    source?: string
    score?: number
  }>
): Array<{ path: string; title: string }> {
  const titleOf = (r: { content?: { toString(): string } }): string =>
    (r.content?.toString?.() ?? '').split('\n', 1)[0]?.trim() || ''
  const byPath = new Map<string, { title: string; score: number }>()
  for (const r of retrievables) {
    const src = r.source
    if (!src) continue
    const existing = byPath.get(src)
    const score = r.score ?? 0
    if (!existing || score > existing.score) {
      byPath.set(src, { title: titleOf(r) || src, score })
    }
  }
  return [...byPath.entries()]
    .sort((a, b) => b[1].score - a[1].score)
    .map(([path, e]) => ({ path, title: e.title }))
}

/**
 * Heuristic: does a CONVERSATIONAL reply look like raw JSON / structured data instead of prose? Used by
 * the prose-required gate — on a conversational-plan turn the answer must be ordinary text, but a 2B
 * sometimes dumps a JSON object/array (e.g. the tool catalog it browsed, or `{"answer": …}` args). We
 * flag a reply that STARTS like JSON (`{`/`[`) or is dominated by JSON punctuation, so the gate can bounce
 * it back toward prose. Deliberately conservative: a sentence that merely mentions `{}` in passing
 * (short, ends with normal punctuation) is NOT flagged.
 */
export function looksLikeJsonNotProse(text: string): boolean {
  const t = text.trim()
  if (!t) return false
  // Starts with an object/array opener (optionally a leading code fence) → structured, not prose.
  if (/^(```\w*\s*)?[[{]/.test(t)) return true
  // A run of `","` / `":"` / `"name":` pairs is catalog/schema JSON, not conversation.
  const jsonPairs = (t.match(/["”][a-z_]+["”]\s*:/gi) ?? []).length
  if (jsonPairs >= 2) return true
  return false
}

/**
 * Heuristic: does this prose reply look like an ABSTENTION ("I don't know", "I can't find that",
 * "I don't have access to…", "the tool failed")? Used by the false-abstention gate: an abstention is
 * only legitimate when the turn produced no usable tool result. When a tool DID return something this
 * turn, an abstention is very likely false (the observed 2B failure — it had the answer and gave up),
 * so the harness rejects it and folds the real result forward. Deliberately lenient: a false positive
 * just triggers one corrective re-loop (cheap); a false negative lets a bad abstention through.
 */
/**
 * Assemble the turn's USABLE tool results — the real, live-read bodies (NOT the handle directions the
 * model may have seen) of completed, non-error tool calls that actually carry content. Excludes the
 * meta navigation tools (tool_catalog is just a directory; navigate/memory writes are side-effects,
 * not answers). Returns a `tool → result` block for the false-abstention nudge, or null when nothing
 * usable was produced (so an abstention on that turn is honest and must be allowed through).
 */
export async function collectUnreadResultHandles(
  calls: Array<{
    id?: string
    tool?: string
    isError?: boolean
    results?: unknown
    args?: unknown
  }>
): Promise<Array<{ tool: string; callId: string }> | null> {
  // The artifact-read tools take a `callId` arg naming the handle they open. Gather every callId the
  // model has ALREADY read this turn, so a handle that's been browsed is no longer "unread".
  const readCallIds = new Set<string>()
  for (const c of calls) {
    if (!c.tool || !c.tool.startsWith('artifact_')) continue
    const cid = (c.args as { callId?: unknown } | undefined)?.callId
    if (typeof cid === 'string') readCallIds.add(cid)
  }
  // Side-effect tools never carry a readable answer body — exclude them. NOTE: tool_catalog is
  // deliberately INCLUDED — its result is a browsable artifact the model must read via artifact_json_*,
  // and the observed failure is exactly the model REQUESTING the catalog then fabricating its contents
  // instead of reading it.
  //
  // `provide_answer` is EXCLUDED (critical). It delivers the answer via the onAnswer side-channel and
  // returns only a short confirmation STRING ("Answer delivered with N source(s)."). The executor spools
  // that string to an artifact handle like any tool result — so without this exclusion the answer tool's
  // own confirmation is flagged as an "unread handle", the gate nudges "read it then provide_answer", the
  // model answers again → spools a FRESH confirmation handle → unread again. Answering is the act that
  // manufactures the handle that rejects the next answer: an unbounded loop with no exit (proven on the
  // Node/Ollama wire — T1#0: provide_answer at g3 spooled callId a6, read a6 → g5 answer spooled a7, read
  // a7 → g8 answer spooled a8 …, ~8 rounds until the accumulated reads overflowed the window). The answer
  // tool's return is terminal delivery, never a body to read back.
  const SKIP = new Set(['navigate_to_page', 'store_memory', 'update_memory', 'provide_answer'])
  const out: Array<{ tool: string; callId: string }> = []
  for (const c of calls) {
    if (!c.id || !c.tool || SKIP.has(c.tool) || c.isError) continue
    if (c.tool.startsWith('artifact_')) continue // the read tools themselves aren't "unread"
    if (readCallIds.has(c.id)) continue // already browsed via an artifact_* call
    // Only a SPOOLED-ARTIFACT result is an "unread handle" — the model saw a handle, not the body, and
    // must open it via the artifact_* tools. We peek byteLength ONLY to confirm the handle has content;
    // we deliberately do NOT read the body — surfacing it would inline the result, defeating the
    // handle/thrift pattern. The model reads it.
    if (!looksLikeSpooledArtifact(c.results)) continue
    let bytes = 0
    try {
      bytes = await (c.results as { byteLength: () => Promise<number> }).byteLength()
    } catch {
      /* best-effort */
    }
    if (bytes <= 0) continue
    out.push({ tool: c.tool, callId: c.id })
  }
  return out.length > 0 ? out : null
}

/**
 * When the dup-stop hard-stop rescues a wedged turn, pick WHICH tool call's result to surface as the
 * answer — keyed on the committed PLAN (intent), not on which call the model happened to loop on.
 *
 * @remarks
 * Only a `tool_computed` plan surfaces a RAW TOOL BODY as its answer (a value a tool must produce). Every
 * other answer kind — doc_cited / conversational / rhetorical / abstention — is delivered as own-words
 * prose (or a cited answer), so it must fall through to the retained-prose / graceful-fallback ladder, and
 * this returns `undefined` for them. For a tool_computed plan we prefer the MOST-RECENT NON-ERRORED call
 * whose tool the plan committed to (`toolsToUse`): that is the call whose result answers the question. If
 * the planned tool never succeeded this loop, we return `undefined` rather than surface an UNRELATED tool's
 * body — surfacing an off-plan result (e.g. a stale, relevance-seeded prior-turn `get_current_time`) as the
 * answer to a math question is exactly the cross-turn stale-artifact bleed this guards against.
 *
 * `calls` is append-ordered (the turn's `ctx.turnToolCalls`), so a reverse scan yields the most recent
 * match. Note the plan-match axis is correct whether the stale call originated this turn or a prior one —
 * intent, not recency, decides.
 */
export function selectAnswerBearingCall(
  calls: ReadonlyArray<{ tool?: string; results?: unknown; isError?: boolean }>,
  plan: TurnPlan | null
): { tool?: string; results?: unknown; isError?: boolean } | undefined {
  if (plan?.answerKind !== 'tool_computed') return undefined
  const planned = new Set(plan.toolsToUse)
  if (planned.size === 0) return undefined
  for (let i = calls.length - 1; i >= 0; i--) {
    const c = calls[i]
    if (c.tool && planned.has(c.tool) && !c.isError) return c
  }
  return undefined
}

````

#### agent\_artifact\_handles.ts

```ts
import { renderArtifactHandleBody, looksLikeSpooledArtifact } from './agent_adk'

// Produce a BOUNDED preview of a tool result (Tokenizable | SpooledArtifact | Media | string) to drive
// the human-facing chip in the UI. For a `SpooledArtifact` it reads at most `head(20)` — it NEVER calls
// `asString()` (materializing the full body defeats the spool). The full artifact is persisted by
// encoding the ToolCall as a handle (see storeToolCallCallback).
export async function readToolResultPreview(results: unknown): Promise<string | undefined> {
  if (results === null || results === undefined) return undefined
  try {
    if (typeof results === 'string') return results
    const r = results as {
      head?: (n: number) => Promise<string[]>
      toString?: () => string
    }
    if (typeof r.head === 'function') {
      const lines = await r.head(20)
      return lines.length === 20
        ? lines.join('\n') + '\n[...truncated for preview]'
        : lines.join('\n')
    }
    if (typeof r.toString === 'function') return String(r)
  } catch {
    /* ignore */
  }
  return undefined
}

// The order we want the forged artifact_* readers PRESENTED in the handle note, for THIS agent. A 2B
// reaches for the first tool listed; the library lists `artifact_json_type` first on a JSON artifact —
// a metadata-only reader that returns just "json" and teaches the model nothing, so it flails and then
// concludes it "couldn't find" the answer. We do NOT change the library default (every other consumer
// keeps it) and we do NOT inline the body (that blows the 2B's context window). We use the battery's
// now-wired `helpers.renderArtifactHandleBody` override seam to REORDER the printed reader list so a
// CONTENT reader leads: for search results that means `artifact_json_get` / `artifact_json_keys` (pull
// the excerpts) and the base `artifact_head` / `artifact_cat` (which also return the records) ahead of
// the metadata-only `artifact_json_type` / `artifact_json_length`. Pure presentation reorder — same
// tools, same callId, same metadata; only the line order the model reads changes.
// #region reader_priority
export const ARTIFACT_READER_PRIORITY = [
  'artifact_json_get',
  'artifact_json_keys',
  'artifact_head',
  'artifact_cat',
  'artifact_json_filter',
  'artifact_json_pluck',
  'artifact_json_slice',
  'artifact_grep',
  'artifact_tail',
  'artifact_line_count',
  'artifact_byte_length',
  'artifact_estimate_tokens',
  'artifact_json_length',
  'artifact_json_type',
]
// #endregion reader_priority

/**
 * This agent's artifact-handle renderer: the library default, but with the forged-reader list reordered
 * so a CONTENT-returning reader is listed first (see {@link ARTIFACT_READER_PRIORITY}). Wired into the
 * adapter via `helpers.renderArtifactHandleBody` AND used by the dialog chip's
 * {@link readModelFacingPreview} so the chip shows the EXACT note the model saw.
 *
 * @remarks
 * Implementation note: the library renderer reads the tool list off the artifact's
 * `constructor.toolMethods` (a frozen static array we must not mutate). So we render the default body,
 * then reorder ONLY its "- toolName — description" reader lines in place — the metadata header and the
 * trailing persistence note are untouched. This keeps the library as the single source of truth for the
 * note's FORMAT; we change only the order. Unknown tool names sort last (stable), so a future reader the
 * priority list doesn't mention still appears, just after the ranked ones.
 */
export function renderArtifactHandleBodyJsonFirst(input: {
  callId: string
  artifact: unknown
  byteLength: number
  lineCount: number
  estimatedTokens?: number
  encoding?: string
}): string {
  const base = renderArtifactHandleBody(input)
  const lines = base.split('\n')
  // The reader lines are the contiguous run of "- <name>[ — desc]" bullets after the
  // "call one of the following tools" preamble. Capture that block, reorder, splice back.
  const firstReader = lines.findIndex((l) => /^- artifact_/.test(l))
  if (firstReader === -1) return base
  let lastReader = firstReader
  while (lastReader + 1 < lines.length && /^- artifact_/.test(lines[lastReader + 1])) lastReader++
  const readerLines = lines.slice(firstReader, lastReader + 1)
  const rankOf = (line: string): number => {
    const m = /^- (artifact_[A-Za-z0-9_]+)/.exec(line)
    const name = m ? m[1] : ''
    const idx = ARTIFACT_READER_PRIORITY.indexOf(name)
    return idx === -1 ? ARTIFACT_READER_PRIORITY.length : idx
  }
  const reordered = readerLines
    .map((line, i) => ({ line, i, rank: rankOf(line) }))
    .sort((a, b) => a.rank - b.rank || a.i - b.i)
    .map((e) => e.line)
  return [...lines.slice(0, firstReader), ...reordered, ...lines.slice(lastReader + 1)].join('\n')
}

/**
 * The preview of a tool result AS THE MODEL SAW IT — for the dialog chip. The whole handle/thrift
 * point is that a non-inlined SpooledArtifact result reaches the model as a HANDLE (metadata + "read
 * me with the artifact_* tools"), NOT its body. So the chip must show that handle note, not the body
 * the model never received — otherwise the showcase would misrepresent what the model worked from.
 *
 * CRUCIAL: this does NOT approximate the handle text — it calls the BATTERY'S OWN
 * `renderArtifactHandleBody`, the exact function the executor used to build the tool_response the
 * model saw. Same input (artifact + callId + byte/line counts) → byte-identical output. The detection
 * uses the battery's own `looksLikeSpooledArtifact`. A result is handle-rendered when it's a
 * SpooledArtifact AND the ToolCall was NOT inline (mirrors the battery's render gate); ArtifactTool
 * and plain results are inlined, so their body preview IS what the model saw.
 */
// #region model_facing_preview
export async function readModelFacingPreview(tc: {
  id?: string
  tool?: string
  inline?: boolean
  fromArtifactTool?: boolean
  results?: unknown
}): Promise<string | undefined> {
  const r = tc.results
  // Handle-rendered: spooled body the model did NOT get inline (matches the battery's render gate).
  // looksLikeSpooledArtifact is the battery's own structural check (cross-realm-safe).
  if (looksLikeSpooledArtifact(r) && tc.inline === false && !tc.fromArtifactTool) {
    let byteLength = 0
    let lineCount = 0
    try {
      byteLength = await (r as { byteLength: () => Promise<number> }).byteLength()
    } catch {
      /* best-effort metadata */
    }
    try {
      lineCount = await (r as { lineCount: () => Promise<number> }).lineCount()
    } catch {
      /* best-effort metadata */
    }
    // The EXACT text the executor put in front of the model — same renderer (the json-first reorder
    // this agent passes to the adapter via helpers.renderArtifactHandleBody), same fields.
    return renderArtifactHandleBodyJsonFirst({
      callId: tc.id ?? '',
      artifact: r,
      byteLength,
      lineCount,
    })
  }
  return readToolResultPreview(r)
}
// #endregion model_facing_preview

/** Map a tool name to a human-readable "working" status for the activity indicator. */
export function activityForTool(tool: string): string {
  switch (tool) {
    case 'search_docs_semantic':
    case 'search_docs_keyword':
      return 'Searching the docs…'
    case 'navigate_to_page':
      return 'Asking to navigate…'
    case 'store_memory':
    case 'update_memory':
      return 'Updating memory…'
    case 'tool_catalog':
      return 'Browsing available tools…'
    case 'call_a_tool':
      return 'Calling a tool…'
    default:
      return `Running ${tool}…`
  }
}

```

#### agent\_tools.ts

````ts
// The flagship agent's REAL tool registry — no fixtures. Four groups:
//   1. Utility tools from the bundle (calculate / format_*).
//   2. Two doc-search tools the MODEL chooses between:
//        - search_docs_semantic  → Orama vector search (+ optional HyDE), page-boosted
//        - search_docs_keyword   → the SAME MiniSearch the human searchbox uses (quality gate)
//   3. navigate_to_page → opens a TurnGate (permission), internal-only, then routes.
//   4. Catalog capstone: a big HIDDEN registry surfaced as a browsable SpooledJsonArtifact
//      via tool_catalog, invoked through call_a_tool — 1000-tools-as-2-schemas, in miniature.
//
// Numeric ids use validator.number() (coerces "4512"→4512). Results are returned as JSON
// strings wrapped in SpooledJsonArtifact so the model can browse them with the auto-forged
// json_* tools.

import { searchSemantic } from './agent_rag'
import { searchKeyword, isKnownDocPath, knownDocPaths } from './agent_keyword_search'
import {
  Tool,
  ToolRegistry,
  SpooledJsonArtifact,
  looksLikeSpooledArtifact,
  validator,
  batteriesTools,
  isError,
  type DispatchContext,
} from './agent_adk'

/** A citation the model attaches to an answer — must point at a real documentation page. */
export interface AnswerSource {
  /** Internal doc path, e.g. "/the-loop/llm-dispatch". */
  path: string
  /** Human label for the chip (page/section title). */
  title?: string
}

/** The structured answer the agent produces — captured by the harness, rendered by the dialog. */
export interface CapturedAnswer {
  kind: 'answer' | 'idk'
  text: string
  sources: AnswerSource[]
}

/**
 * How the agent intends to answer THIS request — declared up front by the planner and validated at
 * turn close. The book-end contract: the open commits to a kind + the exact tools it will use; the
 * close refuses to finish until the answer actually satisfies that commitment.
 */
export type PlanAnswerKind =
  | 'tool_computed'
  | 'doc_cited'
  | 'conversational'
  | 'rhetorical'
  | 'capacity_scoped'
  | 'abstention'

/**
 * How much of the topic the answer should attempt, chosen by the planner AGAINST the real output-token
 * budget. The worker's answer is hard-capped by the max-output ceiling, so a broad question planned as an
 * exhaustive list gets truncated mid-sentence. The planner instead commits a scope up front: a `brief`
 * answer prioritizes the few most important points so it FINISHES within the budget, where a `thorough`
 * one is reserved for a narrow question that fits comfortably. Guidance only (rendered into the
 * plan-thought) — never a hard gate, which would livelock a 2B.
 */
export type PlanAnswerScope = 'brief' | 'standard' | 'thorough'

/** The structured plan the planner emits via `make_plan` (validated before the main loop runs). */
export interface TurnPlan {
  /** The kind of answer this request needs. Drives the close-gate's conformance check. */
  answerKind: PlanAnswerKind
  /** How much of the topic to attempt within the output budget. Drives the plan-thought's length target. */
  answerScope: PlanAnswerScope
  /** Exact catalog tool names the plan commits to calling (e.g. ["calculate"]). Empty for pure prose. */
  toolsToUse: string[]
  /** Short ordered steps the worker should follow. Rendered into the plan-thought. */
  steps: string[]
}

// NOTE on results: handlers return a JSON STRING and set `artifactConstructor: SpooledJsonArtifact`.
// The LLM adapter spools the string and wraps it via that constructor, so the model can browse
// the result with the auto-forged json_* tools. We never construct the artifact by hand.

/** Dependencies the tools close over (route awareness + the navigation gate + answer sink). */
export interface AgentToolDeps {
  /** Current in-site path, for page-boosting search + the system prompt. */
  currentPath: () => string
  /** Guarded internal navigation (validates + routes). Returns ok/reason. */
  navigateInternal: (path: string) => Promise<{ ok: boolean; resolved?: string; reason?: string }>
  /** Called when an answer tool fires — the harness captures the structured answer + ends the turn. */
  onAnswer: (answer: CapturedAnswer) => void
  /**
   * The turn's REAL, validated, never-shed doc pages (the pre-fetched RAG sources). Used to GROUND a
   * `provide_answer` that carries a good answer but omitted (or mis-cited) its `sources` — rather than
   * hard-erroring the whole answer back to a small model that can't reliably re-supply them. Returns an
   * empty array when the turn genuinely retrieved nothing citable (then the strict error path still
   * applies). These are only ever REAL retrieved pages — grounding never invents a source.
   */
  groundSources: () => AnswerSource[]
}

/**
 * The single ALWAYS-VISIBLE answer tool: `provide_answer`, for a CITED answer about @nhtio/adk.
 *
 * Deliberately the ONLY answer tool. There is no `say_i_dont_know` TOOL — a prominent one-click
 * "abstain" button is exactly the low-friction escape a small model over-reaches for (observed: a 2B
 * that had a successful tool result still clicked it and confabulated "the tool failed"). Abstention
 * is instead a PROSE outcome the output gate accepts (an honest "I don't know" needs no tool and no
 * citation), and a false-abstention gate in the harness rejects an abstention that contradicts a real
 * tool result this turn. Cited claims still route through `provide_answer`; sources are VALIDATED
 * against the real doc index, so a hallucinated path is rejected and the model is told to fix it.
 */
function answerTools(deps: AgentToolDeps): Tool[] {
  const provideAnswer = new Tool({
    name: 'provide_answer',
    description:
      'Deliver a final answer that is ABOUT @nhtio/adk (its concepts, APIs, behavior, how-to). CALL THIS AS A REAL TOOL — do not print its arguments as text; writing "<call:provide_answer…", a ```json block, or the answer as chat prose does NOT deliver anything and will be discarded. Pass your "answer" (your full written answer, in markdown). You do NOT need to supply sources: if you omit "sources", the harness automatically attaches the documentation pages your search retrieved. Optionally pass "sources" (a list of {path} objects, each a real @nhtio/adk doc page, e.g. [{"path":"/the-loop/llm-dispatch"}]) only to pin specific pages. Read your search result (artifact_read) BEFORE calling this so the citation is right the first time. Do NOT pass answer_kind / answer_scope / tools_to_use / steps (those belong to make_plan). Do NOT use this tool for general-knowledge questions (the date, a calculation, world facts) — answer those in plain prose. If the documentation genuinely does not cover an @nhtio/adk question, do not call this tool — reply in plain prose that you do not know.',
    // Schema is intentionally PERMISSIVE (answer/sources optional, unknown keys allowed). A `.required()`
    // schema makes a malformed call fail validation BEFORE the handler — a dead-end the 2B can't recover
    // from. The observed failure: after make_plan grew an `answer_scope` arg, the 2B started calling
    // provide_answer with the PLANNER'S shape (`{answer_kind, answer_scope, tools_to_use, steps}`) instead
    // of `{answer, sources}` — the call hard-rejected, so it gave up on the tool and committed the answer
    // as prose with inline "(Source: …)" (no SOURCES chip, no path validation). Accepting the call lets the
    // handler return a GUIDED correction showing the real two-field shape (mirrors call_a_tool's recovery).
    inputSchema: validator
      .object({
        answer: validator
          .string()
          .optional()
          .description('The full answer to the user, in markdown. Concise and concrete.'),
        sources: validator
          .array()
          .items(
            validator.object({
              path: validator
                .string()
                .required()
                .description('Internal doc path you used, e.g. "/the-loop/tools".'),
              title: validator.string().optional().description('Page/section title for the chip.'),
            })
          )
          .optional()
          .description(
            'OPTIONAL. Real @nhtio/adk doc page(s) backing the answer. Omit it and the harness attaches the pages your search retrieved — you do not need to hand-pick paths.'
          ),
      })
      .unknown(true),
    trusted: true,
    handler: async (args) => {
      const a = args as {
        answer?: unknown
        sources?: unknown
        // The make_plan keys the 2B copies here by mistake (detected for a targeted correction).
        answer_kind?: unknown
        tools_to_use?: unknown
        steps?: unknown
      }
      const answer = typeof a.answer === 'string' ? a.answer.trim() : ''
      const sources = Array.isArray(a.sources)
        ? (a.sources as Array<{ path: string; title?: string }>)
        : []
      // GUIDED CORRECTION for the planner-shape confusion: the model passed make_plan's args
      // (answer_kind / tools_to_use / steps) instead of provide_answer's. Show it the real two fields so
      // it re-issues correctly, rather than dead-ending and falling back to inline-cited prose.
      const usedPlanShape =
        a.answer_kind !== undefined || a.tools_to_use !== undefined || a.steps !== undefined
      // GROUND-DON'T-BOUNCE. A small model routinely writes a good ANSWER but forgets (or mis-shapes) the
      // `sources` field. Hard-erroring that discards a correct answer and sends a 2B into a churn it can't
      // escape — re-plan / re-search / apologise / grab a stale artifact / commit an "I will now read the
      // artifact…" intent-narration deflection (observed T2#4). If there IS a real answer but no sources,
      // ground it from the turn's REAL retrieved pages (deps.groundSources — never invented) and deliver it,
      // instead of bouncing. Only take the strict error path when there is genuinely nothing to work with:
      // no answer at all, or an answer we cannot ground because the turn retrieved nothing citable.
      if (answer && sources.length === 0) {
        const grounded = deps.groundSources()
        if (grounded.length > 0) {
          deps.onAnswer({
            kind: 'answer',
            text: answer,
            sources: grounded.map((s) => ({ path: s.path, title: s.title })),
          })
          return `Answer delivered, grounded with ${grounded.length} source(s) from your search.`
        }
      }
      // We reach here only when there is nothing to deliver: no "answer" at all, OR an answer with no usable
      // sources AND nothing to ground it from (the turn retrieved no citable pages). `sources` is OPTIONAL
      // (the harness grounds from search when omitted), so the real correction is "write the answer."
      // NB: the "provide_answer needs exactly two arguments" prefix is matched by the dispatchOutput seam
      // (agent_runtime.ts sawSourcelessAnswer) — keep it stable.
      if (!answer || sources.length === 0) {
        const planNote = usedPlanShape ? "Those are the make_plan arguments, not this tool's. " : ''
        return (
          `provide_answer needs exactly two arguments: "answer" (your full answer to the user, in ` +
          `markdown) — and OPTIONALLY "sources". ${planNote}` +
          (answer
            ? `You wrote an answer but I could not find documentation pages to attach — search the docs ` +
              `(search_docs_semantic) so real pages are on hand, then call provide_answer with your answer again.`
            : `You provided no "answer". Put your full written answer in "answer" and call provide_answer ` +
              `again; you can omit "sources" — the harness attaches the pages your search retrieved.`)
        )
      }
      // Validate every cited source is a REAL doc page. Reject hallucinated paths so the model
      // must correct them (the citation guarantee is only as good as this check).
      const checked = await Promise.all(
        sources.map(async (s) => ({ s, ok: await isKnownDocPath(s.path) }))
      )
      const valid = checked.filter((c) => c.ok).map((c) => c.s)
      const invalid = checked.filter((c) => !c.ok).map((c) => c.s.path)
      if (valid.length === 0) {
        // Same ground-don't-bounce rule for ALL-hallucinated sources: the model wrote a real answer but
        // every path it cited is invalid (a guess). Rather than bounce a good answer to a 2B that guessed,
        // ground it from the turn's REAL retrieved pages when we have them; only error when we don't.
        const grounded = deps.groundSources()
        if (grounded.length > 0) {
          deps.onAnswer({
            kind: 'answer',
            text: answer,
            sources: grounded.map((s) => ({ path: s.path, title: s.title })),
          })
          return `Answer delivered, grounded with ${grounded.length} real source(s) from your search (the path(s) you cited were not real pages).`
        }
        return `None of the cited sources are real documentation pages: ${invalid.join(', ') || '(none provided)'}. Search the docs (search_docs_semantic / search_docs_keyword) to find real pages, then call provide_answer again with at least one valid source path. If the docs genuinely do not cover this, do not call provide_answer — reply in plain prose that you do not know.`
      }
      deps.onAnswer({
        kind: 'answer',
        text: answer,
        sources: valid.map((s) => ({ path: s.path, title: s.title })),
      })
      const dropped = invalid.length ? ` (ignored ${invalid.length} invalid source(s))` : ''
      return `Answer delivered with ${valid.length} source(s)${dropped}.`
    },
  })

  return [provideAnswer]
}

/**
 * The PLANNER tool. Used ONLY inside the harness's planner dispatch (not the main registry): the
 * planner is shown the request + the real tool catalog (names + descriptions) and MUST emit exactly
 * one `make_plan` call committing to how it will answer. Its structured schema is the book-end
 * contract — the close gate later refuses to finish until the answer satisfies this plan. Because the
 * planner is handed the real catalog and its `tools_to_use` are validated against it, it cannot
 * commit to a fabricated tool (the failure where a 2B invents `dateAddTool` and gives up).
 */
export function makePlanTool(onPlan: (plan: TurnPlan) => void): Tool {
  return new Tool({
    name: 'make_plan',
    description:
      'Plan how you will answer the request. Call this exactly once. Declare the kind of answer ' +
      'needed and, if it needs tools, the EXACT tool names (from the catalog you were shown) you will ' +
      'call. Do not invent tool names.',
    inputSchema: validator.object({
      answer_kind: validator
        .string()
        .valid(
          'tool_computed',
          'doc_cited',
          'conversational',
          'rhetorical',
          'capacity_scoped',
          'abstention'
        )
        .required()
        .description(
          'tool_computed = the answer is a value a tool must produce (math, date, lookup); ' +
            'doc_cited = a factual answer about @nhtio/adk that needs documentation sources; ' +
            'conversational = greeting/small-talk/thanks/clarifying; ' +
            'rhetorical = reacting to or challenging the PRIOR answer (a rhetorical question, a leading ' +
            'statement, an invited opinion) — engage and reframe in your own words, no tool, no docs; ' +
            'capacity_scoped = an EXHAUSTIVE/complete request ("everything", "every single", "be ' +
            'exhaustive", "comprehensive overview of all") too large to fully write out in the output ' +
            'budget — say so honestly, then search and cite the pages that DO cover it in full (MUST ' +
            'include search_docs_semantic in tools_to_use); ' +
            'abstention = genuinely cannot be answered.'
        ),
      // NOT a strict .valid() enum: the model sometimes echoes the answer_kind token into answer_scope
      // (observed: answer_scope:"capacity_scoped"). A strict enum would REJECT THE WHOLE make_plan call over
      // that one slip — dropping a correctly-classified plan to null and wedging the turn. So we accept any
      // string here and COERCE an out-of-range value to a sensible default in the handler; the important
      // field (answer_kind) still lands. The allowed values are stated for the model in the description.
      answer_scope: validator
        .string()
        .default('standard')
        .description(
          'How much of the topic to attempt, given the output-length budget you were told. One of exactly: ' +
            '"brief" = a broad/open-ended request (e.g. "what are the important parts", "tell me about X") ' +
            'where an exhaustive answer would not fit — prioritize the few MOST important points so the ' +
            'answer finishes within the budget; "standard" = a normal focused question; "thorough" = a ' +
            'narrow, specific question whose full answer fits comfortably. When unsure for a broad ' +
            'question, choose "brief". Do NOT put an answer_kind value here — this field is ONLY ' +
            'brief/standard/thorough.'
        ),
      tools_to_use: validator
        .array()
        .items(validator.string())
        .default([])
        .description(
          'Exact tool names you will call (from the catalog shown), e.g. ["calculate"]. Empty for a ' +
            'conversational or pure-prose answer.'
        ),
      steps: validator
        .array()
        .items(validator.string())
        .default([])
        .description('Short ordered steps you will follow to answer.'),
    }),
    trusted: true,
    handler: async (args) => {
      // The schema keys are snake_case (the model-facing arg names); read them via bracket access to
      // keep our locals camelCase (lint) without renaming the wire contract.
      const a = args as {
        answer_kind: PlanAnswerKind
        answer_scope?: string
        tools_to_use?: string[]
        steps?: string[]
      }
      // COERCE answer_scope: the schema now accepts any string (so a slip like echoing the answer_kind into
      // this field can't reject the whole plan). Map an out-of-range value to a sensible default — a
      // capacity_scoped answer is inherently short, so default it to "brief"; everything else to "standard".
      const SCOPES: readonly PlanAnswerScope[] = ['brief', 'standard', 'thorough']
      const rawScope = a.answer_scope
      const answerScope: PlanAnswerScope = SCOPES.includes(rawScope as PlanAnswerScope)
        ? (rawScope as PlanAnswerScope)
        : a.answer_kind === 'capacity_scoped'
          ? 'brief'
          : 'standard'
      onPlan({
        answerKind: a.answer_kind,
        answerScope,
        toolsToUse: Array.isArray(a.tools_to_use) ? a.tools_to_use : [],
        steps: Array.isArray(a.steps) ? a.steps : [],
      })
      return 'Plan recorded.'
    },
  })
}

/**
 * Every ready-made Tool the bundled battery suites ship — memory CRUD
 * (store_memory/list_memories/update_memory/delete_memory), standing-instruction CRUD,
 * retrievable CRUD, and the deterministic compute categories (math, datetime, encoding,
 * formatting, parsing, statistics, text, geo, color, units, …). The memory / standing /
 * retrievable tools delegate to ctx.storeMemory / refreshStandingInstructions / etc. — which
 * the harness wires to the SQLite facade — so registering them gives the agent a REAL,
 * persistent memory loop with zero bespoke glue. scrapper/searxng export factories (not Tool
 * instances) so Tool.isTool() filters them out; this is also realm-safe.
 */
function batteryToolInstances(): Tool[] {
  const out: Tool[] = []
  for (const value of Object.values(batteriesTools as Record<string, unknown>)) {
    if (Tool.isTool(value)) out.push(value as Tool)
    else if (Array.isArray(value)) for (const v of value) if (Tool.isTool(v)) out.push(v as Tool)
  }
  // De-dupe by name (categories re-export, and the *Tools tuples overlap singletons).
  const seen = new Set<string>()
  return out.filter((t) => (seen.has(t.name) ? false : (seen.add(t.name), true)))
}

/** The two search tools (model picks). Both return a SpooledJsonArtifact of hits. */
function searchTools(deps: AgentToolDeps): Tool[] {
  const semantic = new Tool({
    name: 'search_docs_semantic',
    description:
      'Semantic (meaning-based) search over the @nhtio/adk documentation. Best for conceptual questions where exact words may differ. Returns the most relevant doc passages. The result is a JSON array of hit objects, each with EXACTLY these fields: "title" (page/section title), "page" (the doc path to cite, e.g. "/assembly/byo-llm"), "anchor" (section id), and "excerpt" (the passage text — THIS is the content you answer from). To read it after it comes back as a handle, call artifact_json_get with path "$[*].excerpt" to pull every passage (or artifact_head to see the whole array). Do NOT use a field named "content" — the text field is "excerpt". SEARCH ONCE: one call returns what you need — read the handle, then answer via provide_answer. Do NOT search again or re-run the same query; and once a read has returned the passages, do not re-read the same handle — you already have the content.',
    inputSchema: validator.object({
      query: validator.string().required().description('What to search for, in natural language.'),
    }),
    trusted: true,
    artifactConstructor: () => SpooledJsonArtifact,
    handler: async (args) => {
      const { query } = args as { query: string }
      // TEST-ONLY over-cap trigger (dev/preview only — the whole block tree-shakes out of the released
      // build via __ADK_WIRETAP__, and is inert unless localStorage['adk:bigread'] is set). Uncaps the
      // excerpt and bumps topK so a whole-array read ($[*].excerpt / artifact_head) DETERMINISTICALLY
      // exceeds the result-too-large cap, exercising the loop's retract path against a REAL search read
      // (no synthetic tool). A narrower $[0].excerpt still fits, so it also proves the clear path.
      let bigRead = false
      if (__ADK_WIRETAP__) {
        try {
          bigRead = localStorage.getItem('adk:bigread') === '1'
        } catch {
          /* no localStorage (SSR) — stays off */
        }
      }
      const topK = bigRead ? 16 : 6
      const hits = await searchSemantic(query, { topK, currentPath: deps.currentPath() })
      return JSON.stringify(
        hits.map((h) => ({
          title: h.title,
          page: h.pageUrl,
          anchor: h.anchor,
          excerpt: bigRead ? h.content : h.content.slice(0, 500),
        }))
      )
    },
  })
  const keyword = new Tool({
    name: 'search_docs_keyword',
    description:
      'Keyword search over the docs — the SAME index this site\'s search box uses. Best when you know specific terms, API names, or exact phrases. Returns matching pages. The result is a JSON array of hit objects, each with EXACTLY these fields: "title" (page title), "page" (the doc path to cite, e.g. "/the-loop/tools"), and "section" (the heading trail). Read it with artifact_json_get path "$[*].title" / "$[*].page" or artifact_head for the whole array. There is no "excerpt"/"content" field here — keyword hits carry titles and paths, so prefer search_docs_semantic when you need passage text to answer from. SEARCH ONCE: read the handle, then answer via provide_answer — do not re-run the search or re-read a handle you already read.',
    inputSchema: validator.object({
      query: validator.string().required().description('Keywords or an exact phrase to find.'),
    }),
    trusted: true,
    artifactConstructor: () => SpooledJsonArtifact,
    handler: async (args) => {
      const { query } = args as { query: string }
      const hits = await searchKeyword(query, 6)
      return JSON.stringify(
        hits.map((h) => ({ title: h.title, page: h.path, section: h.titles.join(' › ') }))
      )
    },
  })
  return [semantic, keyword]
}

/**
 * Stash key under which `release_artifact` records the set of this-turn tool-call ids the model has declared
 * it is done with. The flagship's dispatchInput build reads this set and tags those results `thisTurn: false`
 * so the subtractive pass may shed them (their full bodies no longer need to sit in the window). A plain
 * string[] is stored (stash values round-trip as JSON); the reader dedupes into a Set.
 */
export const RELEASED_ARTIFACTS_STASH_KEY = 'flagshipReleasedArtifacts'

/**
 * The callIds of this-turn tool results that are queryable artifacts — i.e. results the model produced this
 * turn (not from an artifact tool itself) whose body is a SpooledArtifact. This is the valid target set for
 * `release_artifact` and the visibility gate for the tool (shown only when there is something to release).
 * Mirrors the filter `SpooledArtifact.forgeTools` uses to build the reader callId enum.
 */
function thisTurnArtifactCallIds(ctx: DispatchContext): string[] {
  const out: string[] = []
  for (const tc of ctx.turnToolCalls) {
    const t = tc as { id?: string; fromArtifactTool?: boolean; results?: unknown }
    if (t.fromArtifactTool) continue
    const r = t.results
    const items = Array.isArray(r) ? r : [r]
    if (items.some((item) => looksLikeSpooledArtifact(item)) && typeof t.id === 'string') {
      out.push(t.id)
    }
  }
  return out
}

/**
 * `release_artifact` — the model's explicit "I am done with this result" signal. After reading a spooled
 * result (via artifact_head/artifact_json_get/…) and using what it needs, the model calls this with the
 * result's callId; the flagship then lets the subtractive pass shed that result's full body from the window
 * on later dispatches (see agent_runtime.ts: released ids are tagged thisTurn:false). This is the principled
 * companion to the N-cap backstop — the model frees exactly what it no longer needs, instead of the pass
 * guessing. Purely a budget optimisation: never load-bearing (the answer path does not depend on it), so it
 * sheds early under budget pressure and is simply absent when there is nothing to release.
 */
function releaseArtifactTool(): Tool {
  return new Tool({
    name: 'release_artifact',
    description:
      'Declare that you are DONE with a spooled tool result you read earlier this turn, so its content can be dropped from the context window to make room. Pass the callId of the result (the same callId you used with artifact_head / artifact_json_get). Only call this AFTER you have read and used what you needed from it. It frees space; it does not affect your answer.',
    inputSchema: validator.object({
      callId: validator
        .string()
        .required()
        .description('The callId of the this-turn result you are finished with.'),
    }),
    handler: async (args, ctx: DispatchContext) => {
      const { callId } = args as { callId: string }
      const valid = thisTurnArtifactCallIds(ctx)
      if (!valid.includes(callId)) {
        // Guided recovery (mirrors call_a_tool's arg-schema recovery): name the real releasable callIds.
        return valid.length > 0
          ? `No releasable artifact with callId "${callId}". Releasable this-turn callIds: ${valid.join(', ')}.`
          : `There are no spooled artifacts to release this turn.`
      }
      const prior = ctx.stash.get(RELEASED_ARTIFACTS_STASH_KEY, []) as string[]
      const next = Array.from(new Set([...prior, callId]))
      ctx.stash.set(RELEASED_ARTIFACTS_STASH_KEY, next)
      return `Released ${callId}. Its content can now be dropped from the context window.`
    },
  })
}

/** The navigate tool — permission-gated, internal-only. */
// How long the navigate-permission gate waits for a human before auto-declining. An unattended user (or a
// headless run) never clicks Allow/Deny; without a timeout the ctx.waitFor hangs until the turn's own deadline
// kills it, and the model gets NO feedback (observed: a browser corpus turn stuck on "Thinking…" to the 420s
// cap). The timeout self-rejects the gate (E_TURN_GATE_TIMEOUT) → the handler returns the decline as tool
// feedback so the model continues. The dialog shows this as a live countdown next to Allow/Deny.
const NAV_GATE_TIMEOUT_MS = 60_000

/**
 * True when a gate rejection is the E_TURN_GATE_TIMEOUT self-reject (vs an explicit Deny or an abort).
 * Matched by cross-realm-safe `code`/`name` (survives the precompiled-bundle boundary — this docs-theme file
 * cannot `isInstanceOf`-import ADK source), mirroring how agent_runtime classifies its typed errors.
 */
function isTurnGateTimeout(err: unknown): boolean {
  const code = (err as { code?: unknown; name?: unknown } | null | undefined)?.code
  const name = (err as { name?: unknown } | null | undefined)?.name
  return code === 'E_TURN_GATE_TIMEOUT' || name === 'E_TURN_GATE_TIMEOUT'
}

/**
 * Rank real doc paths by closeness to a (likely-hallucinated) path the model tried — the path analog of
 * {@link suggestToolNames}. A 2B invents plausible-but-nonexistent routes (observed: `/middleware/state-
 * management` when the real page is under `/the-loop/…`); rather than dead-end, return the nearest REAL pages
 * so the model can retry with a valid one. Scores: shared trailing segment (the leaf a guess usually gets
 * right), then shared leading run, then any segment overlap. Deduped, capped at `limit`.
 */
function suggestDocPaths(known: ReadonlySet<string>, attempted: string, limit = 5): string[] {
  const norm = (p: string): string =>
    p
      .toLowerCase()
      .replace(/[#?].*$/, '')
      .replace(/\/+$/, '')
  const target = norm(attempted)
  if (!target) return []
  const targetSegs = target.split('/').filter(Boolean)
  const targetLeaf = targetSegs[targetSegs.length - 1] ?? ''
  const scored: Array<{ path: string; score: number }> = []
  for (const raw of known) {
    const p = norm(raw)
    if (!p || p === target) continue
    const segs = p.split('/').filter(Boolean)
    const leaf = segs[segs.length - 1] ?? ''
    let score = 0
    if (targetLeaf && leaf === targetLeaf)
      score += 4 // same leaf slug — the strongest signal
    else if (targetLeaf && (leaf.includes(targetLeaf) || targetLeaf.includes(leaf))) score += 2
    // any shared non-leaf segment (same section)
    if (segs.some((s) => targetSegs.includes(s))) score += 1
    // shared leading run of ≥4 chars
    let i = 0
    while (i < p.length && i < target.length && p[i] === target[i]) i++
    if (i >= 4) score += 1
    if (score > 0) scored.push({ path: raw, score })
  }
  scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
  return scored.slice(0, limit).map((s) => s.path)
}

function navigateTool(deps: AgentToolDeps): Tool {
  return new Tool({
    name: 'navigate_to_page',
    description:
      'Open a documentation page in the USER\'S browser — a navigation action that moves the person to another page, not a way for you to read one. It asks the user for permission and returns nothing you can read; it does NOT fetch or return page content. To READ the docs, use search_docs_semantic / search_docs_keyword and the artifact_* readers. Pass ONLY a path you actually saw in a search result (each hit\'s "page" field is a real doc path, e.g. "/the-loop/tools") — do NOT invent or guess a path; an unknown path is rejected. Only call this after you have already answered, to offer the user a relevant page to visit next.',
    inputSchema: validator.object({
      path: validator.string().required().description('Internal site path starting with "/".'),
      reason: validator.string().optional().description('Why this page is relevant.'),
    }),
    trusted: true,
    handler: async (args, ctx: DispatchContext) => {
      const { path, reason } = args as { path: string; reason?: string }
      // GROUND the path BEFORE opening the gate: a 2B invents plausible routes (observed:
      // `/middleware/state-management`, which does not exist), and navigateInternal validates only SHAPE, so
      // `useRouter().go()` silently no-ops on a phantom page (the user clicks Allow and nothing happens). Reject
      // an unknown path with the nearest REAL pages as feedback — never prompt the user to approve a navigation
      // to nowhere. Same isKnownDocPath check provide_answer uses on citations.
      if (!(await isKnownDocPath(path))) {
        const suggestions = suggestDocPaths(await knownDocPaths(), path)
        return suggestions.length > 0
          ? `No page at "${path}". Did you mean: ${suggestions.join(', ')}? Pass a path from your search results (each hit's "page" field is a real doc path), not an invented one.`
          : `No page at "${path}". Search the docs (search_docs_semantic / search_docs_keyword) and pass a real "page" path from a hit — do not invent a path.`
      }
      // Open a TurnGate the dialog renders as an allow/deny prompt WITH a countdown. It self-rejects after
      // NAV_GATE_TIMEOUT_MS so an unanswered prompt (unattended / headless user) becomes a decline the model
      // can act on, instead of hanging the turn.
      let allowed = false
      let timedOut = false
      try {
        const res = await ctx.waitFor<{ allow: boolean }>({
          id: `gate-nav-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
          createdAt: new Date().toISOString(),
          reason: reason ? `Navigate to ${path} — ${reason}` : `Navigate you to ${path}?`,
          // expiresAt rides the payload (the public channel to the turnGateOpen listener; the gate's own
          // `timeout` is private) so the dialog can render a live countdown to the auto-decline.
          payload: { kind: 'navigate', path, expiresAt: Date.now() + NAV_GATE_TIMEOUT_MS },
          schema: validator.object({ allow: validator.boolean().required() }),
          timeout: NAV_GATE_TIMEOUT_MS,
        })
        allowed = !!res?.allow
      } catch (err) {
        // A gate TIMEOUT (E_TURN_GATE_TIMEOUT) rejects here; treat any rejection as a decline, but distinguish
        // the timeout so the feedback tells the model WHY (no response) vs an explicit user Deny.
        allowed = false
        timedOut = isTurnGateTimeout(err)
      }
      // Each outcome returns ACTIONABLE feedback — a decline is still feedback the model works from.
      if (timedOut)
        return `Navigation to ${path} timed out (no response). Continue without navigating — answer from your search results.`
      if (!allowed)
        return `You declined navigation to ${path}. Continue — answer from what you've already gathered.`
      const nav = await deps.navigateInternal(path)
      return nav.ok
        ? `Navigated the user to ${nav.resolved}.`
        : `Could not navigate to ${path}: ${nav.reason}.`
    },
  })
}

/**
 * The catalog capstone. A big set of tools is HIDDEN from the model's tool list; the model
 * discovers them via `tool_catalog` (a browsable SpooledJsonArtifact) and invokes the chosen
 * one through `call_a_tool` — two visible schemas instead of N. Built entirely from shipped
 * primitives (ToolRegistry.get → executor).
 */
/**
 * Normalise a tool name for fuzzy matching: lowercase, strip every non-alphanumeric (so `_`/`-`/spaces
 * vanish), and drop a trailing "tool" suffix. This collapses the small model's common miss — camelCase
 * + a "Tool" suffix (`getCurrentTimeTool`) — onto the real snake_case name (`get_current_time`), which
 * normalise both to `getcurrenttime`. Used to suggest the tool the model almost certainly meant.
 */
function normaliseToolName(name: string): string {
  return name
    .toLowerCase()
    .replace(/[^a-z0-9]/g, '')
    .replace(/tool$/, '')
}

/**
 * Rank known tool names by closeness to a (likely-hallucinated) name the model tried. Exact
 * normalised matches first, then containment either way, capped at `limit`. Drives the corrective
 * message `call_a_tool` returns when the name is not found — so the model gets a concrete next step
 * (the real name) instead of a dead end.
 */
function suggestToolNames(registry: ToolRegistry, attempted: string, limit = 5): string[] {
  const target = normaliseToolName(attempted)
  if (!target) return []
  const scored: Array<{ name: string; score: number }> = []
  for (const t of registry.all()) {
    const n = normaliseToolName(t.name)
    let score = 0
    if (n === target) score = 3
    else if (n.includes(target) || target.includes(n)) score = 2
    else {
      // share a leading run of ≥4 chars (e.g. "getcurrent…")
      let i = 0
      while (i < n.length && i < target.length && n[i] === target[i]) i++
      if (i >= 4) score = 1
    }
    if (score > 0) scored.push({ name: t.name, score })
  }
  scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
  return scored.slice(0, limit).map((s) => s.name)
}

function catalogCapstone(registry: ToolRegistry): Tool[] {
  const catalog = new Tool({
    name: 'tool_catalog',
    description:
      'List every available tool by name + description, as a browsable JSON document. Use this to discover tools before calling one via call_a_tool.',
    inputSchema: validator.object({}),
    trusted: true,
    artifactConstructor: () => SpooledJsonArtifact,
    handler: async () => {
      const all = registry.all().map((t) => ({ name: t.name, description: t.description }))
      return JSON.stringify(all)
    },
  })
  const callATool = new Tool({
    name: 'call_a_tool',
    description:
      'Invoke any tool from the catalog by name, passing its arguments. Use tool_catalog first to find the tool name and its arguments.',
    inputSchema: validator.object({
      name: validator.string().required().description('The tool name from the catalog.'),
      args: validator.object({}).unknown(true).default({}).description('Arguments for that tool.'),
    }),
    trusted: true,
    handler: async (args, ctx: DispatchContext) => {
      const { name, args: toolArgs } = args as { name: string; args?: unknown }
      const target = registry.get(name)
      if (!target) {
        // Don't dead-end the model on a wrong name (it loves camelCase + a "Tool" suffix, e.g.
        // `getCurrentTimeTool` for `get_current_time`). Suggest the closest real names, and if none
        // are close, point it back at the catalog — a concrete next step, not a flat failure.
        const suggestions = suggestToolNames(registry, name)
        if (suggestions.length > 0) {
          return `No tool named "${name}". Did you mean: ${suggestions.join(', ')}? Tool names are exact (lower_snake_case, no "Tool" suffix). Retry call_a_tool with one of those exact names.`
        }
        return `No tool named "${name}". Call tool_catalog to list the exact available tool names, then retry call_a_tool with a name from that list.`
      }
      // Same validation + artifact handling + events as a direct call. The executor THROWS when the
      // supplied args fail the target's input schema — the model's #2 miss after a wrong name is a
      // wrong ARG SHAPE (it guessed `{a, b}` for calculate, which wants `{expression}`), because it
      // called the tool without reading its catalog entry. Don't dead-end on the generic "handler
      // threw" string: catch it and return the target's REAL schema so the model can retry with the
      // right args — exactly mirroring the did-you-mean recovery for wrong names.
      let result: unknown
      try {
        result = await target.executor(ctx)(toolArgs ?? {})
      } catch (err) {
        const desc = target.describe()
        const schemaJson = JSON.stringify(desc.inputSchema)
        const reason = isError(err) ? err.message : String(err)
        return `The arguments you passed to "${name}" were rejected (${reason}). That tool's REQUIRED arguments are:\n${schemaJson}\nRetry call_a_tool with name "${name}" and args matching that schema exactly. Do not guess the argument names — use the ones in the schema above.`
      }
      if (typeof result === 'string') return result
      // Duck-typed binary check (cross-realm-safe; avoids bare `instanceof Uint8Array`).
      if (ArrayBuffer.isView(result as ArrayBufferView)) {
        return new TextDecoder().decode(result as Uint8Array)
      }
      return JSON.stringify(result)
    },
  })
  return [catalog, callATool]
}

/**
 * Build the full registry. `visibleToolNames` is the turn's shortlist; everything else is
 * hidden but still callable (via call_a_tool / direct get) — that's the thrift lever.
 */
export function buildToolRegistry(deps: AgentToolDeps): {
  registry: ToolRegistry
  /** Tools that should stay visible by default (the rest hide). */
  defaultVisible: string[]
} {
  const answer = answerTools(deps) // ALWAYS-VISIBLE: the only way to respond
  const battery = batteryToolInstances() // memory/standing/retrievable CRUD + compute tools
  const search = searchTools(deps)
  const nav = navigateTool(deps)
  const release = releaseArtifactTool() // model's "done with this result" → lets the pass shed its body
  const registry = new ToolRegistry([...answer, ...battery, ...search, nav, release])
  const capstone = catalogCapstone(registry)
  for (const t of capstone) registry.register(t)
  // Visible by default: the ONE answer tool (provide_answer — for cited ADK answers; abstention and
  // general-knowledge answers are plain prose, NOT a tool), the two doc-search tools, navigate,
  // memory-write (so the agent proactively remembers), and the catalog capstone. Everything else —
  // the rest of the memory CRUD, standing-instruction + retrievable CRUD, and all the deterministic
  // compute tools — stays HIDDEN but callable via tool_catalog → call_a_tool (the thrift lever).
  const defaultVisible = [
    'provide_answer',
    'search_docs_semantic',
    'search_docs_keyword',
    'navigate_to_page',
    'store_memory',
    'tool_catalog',
    'call_a_tool',
  ]
  return { registry, defaultVisible }
}

````

#### agent\_system\_prompt.ts

````ts
// The flagship agent's system prompt — built PER TURN so it can carry the page the user is
// currently viewing (dynamic system message).
//
// SCOPE: this is ONLY the application-level layer the transformers.js battery does NOT
// provide — persona, page-awareness, and behavioral policy. It deliberately does NOT enumerate
// tools, standing instructions, memories, or retrievables: the battery renders all of those
// from the dispatch context (apply_chat_template gets the native `tools`; the bucketOrder
// renders standingInstructions / memories / retrievables into the system turn). Listing tools
// here would duplicate what the model already receives through the proper channel.
//
// PER-INSTRUCTION GRADED SHEDDING (the 4096/1024 thrift redesign): the prompt is no longer a flat
// lines[] — it is an ordered list of INSTRUCTIONS, each with a DENSE form (1-line non-negotiable
// imperative) and a VERBOSE form (the full explanation + examples + rationale). buildSystemPrompt
// takes the turn's input budget and:
//   1. starts with the ALL-DENSE floor (every instruction's dense line) — the unsheddable minimum,
//   2. then, in `priority` order (lowest number first), UPGRADES each instruction dense→verbose while
//      the running token estimate still fits the budget.
// Result: a large window renders byte-identical to the historical all-verbose prompt; a 3072-token
// input budget renders the ~all-dense floor; in between it degrades one instruction at a time, keeping
// the verbose form of the highest-priority (lowest-number) instructions longest. This lets the agent
// fit its behavioral policy into a tight edge window without dropping any non-negotiable rule.

import { Tokenizable } from './agent_adk'

export interface SystemPromptInput {
  /** Current in-site path, e.g. "/the-loop/tools". */
  currentPath: string
  /** Whether the current turn carries an image attachment. */
  hasImage: boolean
}

/**
 * One behavioral instruction with two renderings. `dense` is ALWAYS emitted (the floor); `verbose`
 * replaces it when budget allows. `priority` orders the dense→verbose upgrade (lower = upgraded first,
 * i.e. keeps its full explanation longest under budget pressure).
 */
interface GradedInstruction {
  priority: number
  dense: string
  verbose: string
}

// The behavioral instructions, authored dense + verbose. Priority reflects how much a small on-device
// model benefits from the FULL explanation: the read-then-answer / deliver-as-tool-call / cite rules
// (the ones whose absence caused the loops this scaffolding fixes) keep their verbose form longest.
const INSTRUCTIONS: readonly GradedInstruction[] = [
  {
    // Prefer tools over guesses.
    priority: 4,
    dense:
      '- PREFER TOOLS OVER GUESSES. Not certain? search (search_docs_semantic/keyword) before answering.',
    verbose:
      '- PREFER TOOLS OVER GUESSES. Never answer from memory or assumption. If you are not certain,\n' +
      '  search the documentation (search_docs_semantic / search_docs_keyword) before responding.\n' +
      '- You have relevant documentation pre-loaded for this turn (shown with their page paths); if it is\n' +
      "  not enough to answer the reader's intent, search for more before answering.",
  },
  {
    // Cite what you used.
    priority: 2,
    dense:
      '- CITE. An @nhtio/adk answer goes through provide_answer with ≥1 real doc page path (from the passages you used). Non-@nhtio/adk replies are plain prose, no citation.',
    verbose:
      '- CITE WHAT YOU USED. An answer ABOUT @nhtio/adk must go through provide_answer with at least one\n' +
      '  real documentation page path as a source. You already have page paths: every pre-loaded passage\n' +
      '  and every search result carries its own page path — answer in your own words, then cite the\n' +
      '  path(s) of the passages you actually drew from. Do not answer an @nhtio/adk question as bare prose\n' +
      '  without citing; that is not accepted. (Only non-@nhtio/adk replies — greetings, general facts —\n' +
      '  are plain prose with no citation.)',
  },
  {
    // Read tool results before acting.
    priority: 1,
    dense:
      '- READ TOOL RESULTS BEFORE ACTING. A tool that returned a value SUCCEEDED — answer from it; do not re-run it or say you failed.',
    verbose:
      '- ALWAYS READ YOUR TOOL RESULTS BEFORE ACTING. After any tool runs, read what it returned and use\n' +
      '  it to answer. A tool that returned a value SUCCEEDED — do not say it failed, and do not say you\n' +
      '  do not know, when the answer is sitting in the result you just received. Acting before reading\n' +
      '  the result is the most common mistake — do not make it.',
  },
  {
    // Read artifact handles, then answer.
    priority: 1,
    dense:
      "- READ THE HANDLE ONCE, THEN ANSWER. A big result is a HANDLE (callId): read it once with artifact_json_get ('$[*].excerpt') or artifact_head, then answer + cite. Do NOT re-read it; the content IS your material.",
    verbose:
      '- READING ARTIFACT HANDLES, THEN ANSWERING. A large result (e.g. search results) comes back as a\n' +
      '  HANDLE: a note saying "this artifact was not inlined" plus a callId and the artifact_* tools to read\n' +
      '  it with. Call ONE of them with the given callId to read it — artifact_head shows you the first\n' +
      "  records, and for a JSON result artifact_json_get with a JSONPath (e.g. '$[*].excerpt') pulls\n" +
      '  specific fields. EITHER returns real, usable content. CRUCIAL: once a read returns the records, that\n' +
      '  content IS your material — do NOT read the same artifact again. READ ONCE, then ANSWER from what you\n' +
      '  got: synthesise a reply from the excerpts and cite the page paths they carry. The failure to avoid is\n' +
      '  reading the same artifact over and over (you already have it) or reading it and then saying "I\n' +
      '  couldn\'t find it" — the answer is in the records you just received.',
  },
  {
    // Answer only from tool-returned info.
    priority: 5,
    dense:
      '- ANSWER FROM TOOL RESULTS, NOT YOUR OWN GUESS. A tool value OVERRIDES your recollection — quote it exactly; never restate a different number.',
    verbose:
      '- ANSWER ONLY FROM THE INFORMATION RETURNED BY TOOLS. When a tool has produced a value, THAT value\n' +
      '  is the answer — quote it exactly. It OVERRIDES anything you think you already know. If your own\n' +
      '  recollection disagrees with what a tool returned (a number, a date, a fact), you are wrong and the\n' +
      '  tool is right: use the tool result, never your own guess. Do NOT compute, recall, or estimate a\n' +
      '  value yourself when a tool gave you one — restating a different number than the tool returned is\n' +
      '  the single worst mistake you can make here.',
  },
  {
    // Do not hallucinate.
    priority: 3,
    dense:
      '- DO NOT HALLUCINATE. Never invent an answer, path, or API. If it is genuinely not in the docs after searching, say so honestly in prose — an honest "I don\'t know" is valid.',
    verbose:
      '- DO NOT HALLUCINATE. Never invent an answer, a doc path, or an API. Only state what the\n' +
      '  documentation or your tool results actually support. If — after searching and reading your\n' +
      '  results — the answer genuinely is not there, say so honestly in plain prose ("I don\'t know" /\n' +
      '  "I couldn\'t find that in the docs"). An honest I-don\'t-know is a valid answer; a guess is not.',
  },
  {
    // Using tools you cannot see (call_a_tool how-to). KEEP-TOOL guidance: must survive as long as
    // tool_catalog/call_a_tool are visible, so the model knows how to reach shed tools.
    priority: 6,
    dense:
      '- HIDDEN TOOLS: only a few tools are shown; many more exist (date/math/format/text/units). To use one: call tool_catalog to list exact names, then call_a_tool with the EXACT lower_snake_case name (e.g. "get_current_time"). Never invent a name.',
    verbose:
      'Using tools you cannot see:\n' +
      '- Only a few tools are listed for you directly. MANY MORE exist (date/time, math, formatting,\n' +
      '  text, units, and more) but are hidden to save space. To use one, FIRST call tool_catalog to\n' +
      '  list every real tool name + description, THEN call call_a_tool with the EXACT name from that\n' +
      '  list and its arguments.\n' +
      '- NEVER guess or invent a tool name. Tool names are exact and lower_snake_case with NO "Tool"\n' +
      '  suffix (e.g. the time tool is "get_current_time", NOT "getCurrentTimeTool"). If a call_a_tool\n' +
      '  result says the name was not found, read its suggestions and retry with an exact name — do not\n' +
      '  give up and do not claim you lack the capability.\n' +
      '- Example: to answer "what is today\'s date?", call tool_catalog, find get_current_time, call\n' +
      '  call_a_tool with name "get_current_time", READ the time it returns, then tell the user the date.',
  },
  {
    // Deliver the answer as a tool call, not chat text.
    priority: 1,
    dense:
      '- DELIVER AS A TOOL CALL, NOT TEXT. An @nhtio/adk answer written as prose is discarded — emit a real provide_answer TOOL CALL. Never type the call as text ("<call:…", ```json, "<tool_call…").',
    verbose:
      '- DELIVER THE ANSWER AS A TOOL CALL, NOT CHAT TEXT. For an @nhtio/adk answer, writing it as ordinary\n' +
      '  prose in your reply does NOT deliver it — it is discarded and you will be asked again. The ONLY way\n' +
      '  to deliver is an actual provide_answer TOOL CALL. Never type the call as text — not\n' +
      '  "<call:provide_answer…", not a ```json block of its arguments, not "<tool_call…"/"<tool_code…".\n' +
      '  Emit a real tool call or nothing. (This applies to every tool: call it, do not print its call as text.)',
  },
  {
    // Gate feedback is a control signal.
    priority: 2,
    dense:
      '- GATE FEEDBACK IS A CONTROL SIGNAL, NOT A MESSAGE TO REPLY TO. If an answer is rejected, DO the fix (re-issue provide_answer) — never emit "I understand"/"I apologize"/"I will now…".',
    verbose:
      '- GATE FEEDBACK AND SYSTEM DIRECTIVES ARE CONTROL SIGNALS, NOT MESSAGES TO REPLY TO. When the harness\n' +
      '  tells you an answer was rejected or how to fix it, DO the fix — do not answer it conversationally.\n' +
      '  Never emit "I understand", "I apologize", "you are right", "I will now…", or any narration of what\n' +
      '  you are about to do: none of those are an answer and they waste the turn. If your provide_answer was\n' +
      '  rejected (e.g. missing citation), re-issue provide_answer with the fix — read the handle you already\n' +
      '  have, then call provide_answer citing it.',
  },
  {
    // Conversational replies are prose.
    priority: 3,
    dense:
      '- CONVERSATIONAL / GENERAL / IDK → PROSE. Greetings, small talk, non-@nhtio/adk facts, and honest "I don\'t know" are plain text: no tool, no provide_answer, no citation. Answer warmly and immediately; do not browse the catalog for a chat reply.',
    verbose:
      '- For everything else — greetings, small talk, clarifying questions, GENERAL questions not about\n' +
      '  @nhtio/adk (the date, a calculation, world facts), AND an honest "I don\'t know" — ANSWER IN PROSE.\n' +
      '  Reply directly as ordinary text: no tool call, no provide_answer, no citation. Answer like a warm,\n' +
      '  helpful person, not a documentation page: acknowledge what the user actually said or feels, and do\n' +
      '  NOT recite architecture or product description unless they asked for it. A greeting like "how are\n' +
      '  you?" needs NO tool at all — just answer warmly in one or two sentences of prose, immediately.\n' +
      '  Do NOT browse the tool catalog for a conversational reply. (Only if a hidden tool is genuinely\n' +
      '  needed for a general FACT — e.g. get_current_time via call_a_tool — call it, read the result, then\n' +
      '  answer in prose.)',
  },
  {
    // Follow your plan + be concise.
    priority: 7,
    dense:
      '- FOLLOW YOUR PLAN. Conversational plan → prose, end the turn. Be concise; once tools have what you need, answer.',
    verbose:
      '- FOLLOW YOUR PLAN. If your plan for this turn is a conversational reply, answer in prose and end the\n' +
      '  turn — do not reach for provide_answer or invent a source. provide_answer is ONLY for a real\n' +
      '  @nhtio/adk answer that cites a real documentation page.\n' +
      '- Be concise and concrete. Once your tools have what you need, answer.',
  },
]

// Cheap token estimate for the budget-fit decision (chars/4). The battery's exact per-encoding count
// governs the real guard; here we only need a monotonic estimate to order the dense→verbose upgrades.
const estTokens = (s: string): number => Math.ceil(s.length / 4)

/**
 * Build the per-turn system prompt as a Tokenizable, graded to fit `inputBudget` tokens.
 *
 * @param input - page/image context.
 * @param inputBudget - the turn's input token budget (window − output reserve). When omitted or large,
 *   every instruction renders verbose (historical behaviour). When tight, instructions stay dense; the
 *   highest-priority ones upgrade to verbose first while the running estimate still fits.
 */
export function buildSystemPrompt(input: SystemPromptInput, inputBudget?: number): Tokenizable {
  const header = [
    'You are the @nhtio/adk documentation assistant, running entirely on-device in the',
    "reader's browser. Help the reader understand and use @nhtio/adk.",
  ].join('\n')
  const pageLine = `- The reader is currently viewing: ${input.currentPath}. Prefer information relevant to this page when appropriate.`
  const imageLine = input.hasImage
    ? '- The reader attached an image this turn. Use its visual content to inform your answer.'
    : null

  // Framing that is always present (persona + page context + optional image note).
  const framingTokens =
    estTokens(header) + estTokens(pageLine) + (imageLine ? estTokens(imageLine) : 0)

  // Decide, per instruction, dense vs verbose. Default (no budget / generous budget) = all verbose.
  // The order of `INSTRUCTIONS` is preserved in the OUTPUT; `priority` only governs which upgrade to
  // verbose first under pressure.
  const useVerbose = new Set<number>()
  if (inputBudget === undefined) {
    INSTRUCTIONS.forEach((_, i) => useVerbose.add(i))
  } else {
    // Start all-dense; running total = framing + every dense line.
    let running = framingTokens + INSTRUCTIONS.reduce((sum, ins) => sum + estTokens(ins.dense), 0)
    // The prompt is only ONE bucket competing for the input budget — the tool declarations (~2.4k for a
    // read-phase dispatch before shedding) and the newest-turn timeline also live here. So the prompt's
    // verbose-upgrade ceiling is the budget MINUS a realistic reserve for those, not a fraction of the
    // whole budget. Reserve ~2400 (tools) + ~500 (newest turn) ≈ 2900; the prompt may upgrade to verbose
    // only within whatever remains. At a generous window the reserve is a rounding error and the prompt
    // goes fully verbose; at 3072 input the reserve leaves ~170 for upgrades → the prompt stays ~all-dense
    // (the tools bucket is what the tool-shed order trims, not the prompt). Never below the dense floor.
    const TOOLS_TIMELINE_RESERVE = 2900
    const promptCeiling = Math.max(running, inputBudget - TOOLS_TIMELINE_RESERVE)
    // Upgrade dense→verbose in priority order (lowest number first) while each upgrade still fits.
    const order = INSTRUCTIONS.map((ins, i) => ({ i, ins })).sort(
      (a, b) => a.ins.priority - b.ins.priority
    )
    for (const { i, ins } of order) {
      const delta = estTokens(ins.verbose) - estTokens(ins.dense)
      if (running + delta <= promptCeiling) {
        useVerbose.add(i)
        running += delta
      }
    }
  }

  const body = INSTRUCTIONS.map((ins, i) => (useVerbose.has(i) ? ins.verbose : ins.dense))
  const lines = [header, '', ...body, pageLine, ...(imageLine ? [imageLine] : [])]
  return new Tokenizable(lines.join('\n'))
}

````

#### agent\_rag.ts

```ts
// Retrieval for the flagship agent — dogfoods the ADK Orama vector battery, seeded AT BOOT
// (no build-time vector precompute). The build emits chunk TEXT (ask-adk-index.json); we
// embed on-device, upsert into Orama, and cache the vectors in SQLite so a reload rebuilds
// the in-memory Orama index without re-embedding. Exposes semantic search (with an optional
// HyDE pass) used by the `search_docs_semantic` tool.

import { OramaVectorStore } from './agent_adk'
import { embed, embedMany } from './agent_embedder'
import { _getAgentDb as getAgentDb } from './agent_swarm_store'

const COLLECTION = 'docs'
const DIMS = 384 // Xenova/all-MiniLM-L6-v2
// Cap the boot-seed corpus: embedding runs on-device, one chunk at a time, so the whole
// docs corpus would be minutes of work. A few hundred of the most useful chunks is plenty
// for a demo and keeps first-search latency bounded. (The keyword tool covers the full site.)
const MAX_SEED_CHUNKS = 200

interface SeedChunk {
  id: string
  pageUrl: string
  anchor: string
  title: string
  headingPath: string[]
  content: string
}

export interface DocHit {
  id: string
  title: string
  pageUrl: string
  anchor: string
  content: string
  score: number
}

// The Orama store is in-memory (per tab) — fine; the durable corpus is SQLite. We rebuild
// the store from SQLite on boot, or seed it from the build index on first run.
let store: ReturnType<InstanceType<typeof OramaVectorStore>['asCallable']> | null = null
let ensurePromise: Promise<void> | null = null

async function freshStore(): Promise<typeof store> {
  const s = new OramaVectorStore({})
  await s.connect()
  const callable = s.asCallable()
  await callable.schema.createCollectionIfNotExists(COLLECTION, (c) =>
    c.vector({ dimensions: DIMS })
  )
  return callable
}

/** Optional progress reporter for the (one-time) on-device seed embedding. */
export type RagSeedProgress = (done: number, total: number) => void
let seedProgress: RagSeedProgress | undefined
export function onRagSeedProgress(cb: RagSeedProgress | undefined): void {
  seedProgress = cb
}

/** Boot the RAG index: rebuild from SQLite if seeded, else seed from the build index.
 *  LAZY — call this only when semantic search is actually needed (not on model load). */
export function ensureRagIndex(): Promise<void> {
  if (!ensurePromise) {
    ensurePromise = (async () => {
      store = await freshStore()
      const cached = await loadCachedChunks()
      if (cached.length > 0) {
        await upsertChunks(cached)
        return
      }
      // First run: fetch build-emitted chunks (capped), embed on-device, seed Orama + cache.
      const fetched = await fetchSeedChunks()
      const seeds = fetched.slice(0, MAX_SEED_CHUNKS)
      if (seeds.length === 0) return
      const withVecs: CachedChunk[] = []
      for (let i = 0; i < seeds.length; i++) {
        seedProgress?.(i, seeds.length)
        const vecs = await embedMany([seeds[i].content])
        withVecs.push({ chunk: seeds[i], vector: vecs[0] })
      }
      seedProgress?.(seeds.length, seeds.length)
      await upsertChunks(withVecs)
      await persistChunks(withVecs)
    })()
  }
  return ensurePromise
}

async function fetchSeedChunks(): Promise<SeedChunk[]> {
  try {
    const base = (import.meta as { env?: { BASE_URL?: string } }).env?.BASE_URL ?? '/'
    const resp = await fetch(`${base}ask-adk-index.json`)
    if (!resp.ok) return []
    const raw = (await resp.json()) as SeedChunk[]
    return raw
  } catch {
    return []
  }
}

interface CachedChunk {
  chunk: SeedChunk
  vector: number[]
}

async function loadCachedChunks(): Promise<CachedChunk[]> {
  try {
    const rows = await getAgentDb()
      .selectFrom('rag_chunks')
      .select(['id', 'doc_path', 'title', 'content', 'embedding', 'metadata_json'])
      .execute()
    const out: CachedChunk[] = []
    for (const r of rows) {
      if (!r.embedding) continue
      const meta = r.metadata_json
        ? (JSON.parse(r.metadata_json) as { anchor?: string; headingPath?: string[] })
        : {}
      out.push({
        chunk: {
          id: r.id,
          pageUrl: r.doc_path,
          anchor: meta.anchor ?? '',
          title: r.title ?? '',
          headingPath: meta.headingPath ?? [],
          content: r.content,
        },
        vector: blobToVec(r.embedding as Uint8Array),
      })
    }
    return out
  } catch {
    return []
  }
}

async function persistChunks(items: CachedChunk[]): Promise<void> {
  const db = getAgentDb()
  for (const { chunk, vector } of items) {
    await db
      .insertInto('rag_chunks')
      .values({
        id: chunk.id,
        doc_path: chunk.pageUrl,
        title: chunk.title,
        content: chunk.content,
        model_content: null,
        embedding: vecToBlob(vector),
        metadata_json: JSON.stringify({ anchor: chunk.anchor, headingPath: chunk.headingPath }),
      })
      .onConflict((oc) => oc.column('id').doNothing())
      .execute()
  }
}

async function upsertChunks(items: CachedChunk[]): Promise<void> {
  if (!store) return
  await store(COLLECTION).upsert(
    items.map(({ chunk, vector }) => ({
      id: chunk.id,
      vector,
      metadata: {
        title: chunk.title,
        pageUrl: chunk.pageUrl,
        anchor: chunk.anchor,
        content: chunk.content,
      },
    }))
  )
}

/**
 * Semantic search over the docs corpus. `currentPath` (optional) boosts hits from the page
 * the user is currently viewing. `hyde` (optional) is a hypothetical-answer string to embed
 * instead of the raw query (the HyDE technique — better recall for terse queries).
 */
export async function searchSemantic(
  query: string,
  opts: { topK?: number; currentPath?: string; hyde?: string } = {}
): Promise<DocHit[]> {
  await ensureRagIndex()
  if (!store) return []
  const topK = opts.topK ?? 6
  const vector = await embed(opts.hyde && opts.hyde.trim() ? opts.hyde : query)
  // Over-fetch, then apply the page-boost re-rank locally.
  const matches = await store(COLLECTION)
    .nearVector(vector)
    .select('id', 'metadata', 'score')
    .limit(topK * 2)
  const hits: DocHit[] = matches.map((m) => {
    const meta = (m.metadata ?? {}) as {
      title?: string
      pageUrl?: string
      anchor?: string
      content?: string
    }
    const base = m.score ?? 0
    const onPage = opts.currentPath && meta.pageUrl && samePage(meta.pageUrl, opts.currentPath)
    return {
      id: m.id ?? '',
      title: meta.title ?? '',
      pageUrl: meta.pageUrl ?? '',
      anchor: meta.anchor ?? '',
      content: meta.content ?? '',
      // Page-boost: nudge same-page chunks up without drowning genuinely better matches.
      score: onPage ? base + 0.15 : base,
    }
  })
  hits.sort((a, b) => b.score - a.score)
  return hits.slice(0, topK)
}

function samePage(pageUrl: string, currentPath: string): boolean {
  const norm = (s: string): string =>
    s
      .replace(/[#?].*$/, '')
      .replace(/(?:index)?\.html?$/, '')
      .replace(/\/$/, '')
  return norm(pageUrl) === norm(currentPath)
}

// Float32 <-> BLOB round-trip (must match the store_facade helpers).
function vecToBlob(v: number[]): Uint8Array {
  return new Uint8Array(new Float32Array(v).buffer)
}
function blobToVec(b: Uint8Array): number[] {
  return Array.from(new Float32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4)))
}

```

#### agent\_keyword\_search.ts

```ts
// The human-parity search tool: the EXACT same local search the VitePress searchbox uses.
//
// VitePress's built-in `local` provider builds a MiniSearch index, exposed via the
// `@localSearchIndex` virtual module (a per-locale map of lazy loaders). We load the same
// index with the same MiniSearch config as VPLocalSearchBox.vue, so `search_docs_keyword`
// returns what a human would see in the searchbox — a real quality gate, not a parallel
// index. (@orama/plugin-vitepress is intentionally disabled in this repo — config.mts.)
//
// Client-only + lazy: the virtual module and minisearch load on first use.

export interface KeywordHit {
  id: string
  title: string
  titles: string[]
  /** Route path (id is the page url + anchor in VitePress's index). */
  path: string
}

// MiniSearch load options copied verbatim from VitePress VPLocalSearchBox.vue so results
// match the human searchbox exactly.
const MINISEARCH_OPTIONS = {
  fields: ['title', 'titles', 'text'],
  storeFields: ['title', 'titles'],
  searchOptions: {
    fuzzy: 0.2,
    prefix: true,
    boost: { title: 4, text: 2, titles: 1 },
  },
}

let indexPromise: Promise<unknown> | null = null

/**
 * NODE-ONLY test seam. Inject a pre-built MiniSearch index (loaded from VitePress's emitted
 * `@localSearchIndex` chunk via MiniSearch.loadJSON with MINISEARCH_OPTIONS) so keyword search works in the
 * portable Node harness — the browser path (window + the @localSearchIndex virtual module) is untouched. The
 * injected instance must expose `search(q)`; it satisfies BOTH searchKeyword and knownDocPaths (they share
 * loadIndex). See tests/agent/_harness/node_keyword_index.ts.
 */
export function _setKeywordIndex(mini: { search: (q: string) => unknown[] }): void {
  indexPromise = Promise.resolve(mini)
}

async function loadIndex(): Promise<unknown> {
  // If a Node harness pre-injected an index, use it (the guard/virtual-module import below is browser-only).
  if (indexPromise) return indexPromise
  if (typeof window === 'undefined') throw new Error('keyword search is client-only')
  if (!indexPromise) {
    indexPromise = (async () => {
      // @localSearchIndex default is { [locale]: () => Promise<{ default: rawJSON }> }.
      const mod = (await import('@localSearchIndex')) as {
        default: Record<string, () => Promise<{ default: string }>>
      }
      const loaders = mod.default
      // 'root' is the default locale key; fall back to the first available.
      const localeKey = loaders.root ? 'root' : Object.keys(loaders)[0]
      const loaded = await loaders[localeKey]()
      const raw = loaded?.default
      const { default: MiniSearch } = (await import('minisearch')) as {
        default: {
          loadJSON: (
            json: string,
            opts: typeof MINISEARCH_OPTIONS
          ) => {
            search: (q: string) => Array<{ id: string; title?: string; titles?: string[] }>
          }
        }
      }
      return MiniSearch.loadJSON(raw, MINISEARCH_OPTIONS)
    })()
  }
  return indexPromise
}

/** Run the human-parity keyword search. Returns the same hits the searchbox would show. */
export async function searchKeyword(query: string, topK = 6): Promise<KeywordHit[]> {
  const mini = (await loadIndex()) as {
    search: (q: string) => Array<{ id: string; title?: string; titles?: string[] }>
  }
  const results = mini.search(query).slice(0, topK)
  return results.map((r) => ({
    id: r.id,
    title: r.title ?? '',
    titles: r.titles ?? [],
    // VitePress ids are "<path>#<anchor>"; the path is everything before the hash.
    path: r.id.split('#')[0],
  }))
}

// The set of every real doc route in the site (path, no anchor), derived from the same
// MiniSearch index. Used to VALIDATE citations: a source the model cites must be a real
// documentation page, not a hallucinated path. Cached after first build.
let knownPathsPromise: Promise<Set<string>> | null = null

function normalizeDocPath(p: string): string {
  // Strip anchor/query, trailing slash, and a trailing .html so "/the-loop/", "/the-loop",
  // and "/the-loop.html#x" all compare equal.
  return (
    p
      .replace(/[#?].*$/, '')
      .replace(/\.html?$/, '')
      .replace(/\/+$/, '') || '/'
  )
}

export async function knownDocPaths(): Promise<Set<string>> {
  if (!knownPathsPromise) {
    knownPathsPromise = (async () => {
      const mini = (await loadIndex()) as {
        // MiniSearch exposes the stored docs via documentIds + the internal store; the public
        // path is to search broadly, but the index also carries every doc id. We read the ids.
        documentCount?: number
        _documentIds?: Map<number, string> | Record<number, string>
      }
      const out = new Set<string>()
      // MiniSearch keeps doc ids in _documentIds (a Map in current versions, a plain object in
      // older ones). Duck-type via `.values` rather than `instanceof Map` (realm-safe).
      const ids = mini._documentIds as
        | { values?: () => Iterable<string> }
        | Record<number, string>
        | undefined
      if (ids && typeof (ids as { values?: unknown }).values === 'function') {
        for (const v of (ids as { values: () => Iterable<string> }).values()) {
          out.add(normalizeDocPath(String(v)))
        }
      } else if (ids && typeof ids === 'object') {
        for (const v of Object.values(ids as Record<number, string>)) {
          out.add(normalizeDocPath(String(v)))
        }
      }
      return out
    })()
  }
  return knownPathsPromise
}

/** True when `path` resolves to a real documentation page (anchor/format-insensitive). */
export async function isKnownDocPath(path: string): Promise<boolean> {
  if (!path || typeof path !== 'string') return false
  const paths = await knownDocPaths()
  return paths.has(normalizeDocPath(path))
}

```

#### agent\_subtractive\_pass.ts

```ts
// The subtractive pass — the whole Token Thrift thesis, in code, for the flagship agent.
//
// A context window is NOT a chat history. It is just what you send for ONE dispatch. So:
// hold a large WORKING set (messages, memories, retrievables, thoughts, an image, tools),
// then SUBTRACT it down to the high-signal slice that fits the active model's window —
// focus, don't accumulate. See memory chat_history_vs_context_window.
//
// THIS MODULE IS NOW A THIN POLICY WRAPPER. The algorithm itself — the whole subtractive
// pass, `resolveBudget`, `stripPriorTurnThoughts` — is CONSUMED from the shipped
// `@nhtio/adk/batteries/context/thrift` battery (via the agent_adk.ts shim: the battery is
// loaded from the precompiled REPL bundle, never imported as source — see agent_adk.ts's own
// header comment for why). What remains HERE is the showcase's own POLICY, not algorithm:
// - TOOL_SHED_ORDER + the shedRank() built from it (the flagship's domain knowledge of which
//   ~90 named tools are cheap-to-lose "gather" tools vs. load-bearing "delivery"/"reader" tools
//   — the battery has no opinion on tool names, by design, and exposes `shedRank` as an
//   injectable seam for exactly this).
// - ENCODING = 'gemma' (the battery defaults to 'cl100k_base'; the flagship's engine IS Gemma).
// - the ephemeral-message / summary-message id predicates (`__eph-` / `__compact-summary`) —
//   passed explicitly even though they match the battery's own defaults, for self-documentation
//   at this call site (a future maintainer should not have to open the battery source to learn
//   what id convention this agent actually uses).
//
// The public surface below (`subtractToFit`, `resolveBudget`, `stripPriorTurnThoughts`,
// `WorkingSet`, `ThriftTrace`, `BucketTrace`, `WorkingImage`, `WorkingToolCall`, `RenderToolsFn`)
// keeps EXACTLY the same names/signatures the flagship's agent_runtime.ts call site and the
// tests/unit/agent/*.cross.spec.ts specs already depend on — the OLD 9-positional-argument
// `subtractToFit(ws, contextWindow, relevantToolNames, outputReserve?, keepThoughtIds?,
// renderTools?, protectThoughtIds?, renderCtx?, protectedToolNames?)` form is preserved here and
// adapted internally into the battery's real `(ws, contextWindow, relevantToolNames, options)`
// signature, so callers/tests need ZERO changes.

import * as adk from './agent_adk'
import { Tokenizable } from './agent_adk'
import type { Message, Memory, Retrievable, Thought, ToolRegistry, Tool } from './agent_adk'
import type {
  ThriftWorkingSetType,
  ThriftSubtractToFitOptionsType,
  ThriftTraceResult,
} from './agent_adk'

/**
 * Renders a set of tools into the EXACT prompt-text block the battery sends to the model (LiteRT's
 * `renderToolsAsPromptText`, injected by the app). With `toolDelivery:'prompt'` the model receives each
 * tool's full name + description + JSON-Schema parameters wrapped in a `<tool_definitions>` block — ~100–
 * 300 tokens/tool, NOT the ~15 a `name: description` proxy implies. When provided, the pass measures the
 * real rendered block; when omitted it falls back to the proxy (keeps this module standalone/testable).
 */
export type RenderToolsFn = (tools: ReadonlyArray<Tool>) => string

/**
 * The token encoding the pass measures against. The flagship engine is Gemma (LiteRT-LM), so we count
 * with the first-class `gemma` encoding (256k SentencePiece via @lenml/tokenizer-gemini) — NOT a cl100k
 * proxy. This matters for the budget math: the battery's overflow guard and this pass must agree on the
 * unit, and Gemma chat framing (`<start_of_turn>` etc.) counts as single ids under gemma, not the ~5-way
 * char split a tiktoken proxy would produce. The shipped battery defaults to `'cl100k_base'` (it has no
 * opinion on which model a caller runs) — this showcase always passes `encoding: ENCODING` explicitly to
 * the battery so the budget math agrees with what the LiteRT-LM battery's own overflow guard counts.
 */
export const ENCODING = 'gemma' as const

/** A line in the "what got cut" panel — one bucket's before/after token weight. */
export interface BucketTrace {
  bucket: string
  beforeTokens: number
  afterTokens: number
  beforeCount: number
  afterCount: number
  note?: string
}

/** The full record of one subtractive pass — drives the demo's instrumentation panel. */
export interface ThriftTrace {
  contextWindow: number
  reserve: number
  budget: number
  totalBefore: number
  totalAfter: number
  fits: boolean
  refused: boolean
  buckets: BucketTrace[]
}

/** An image attachment in the working set — measured as a flat token cost (a token hog). */
export interface WorkingImage {
  /** Human label for the trace panel. */
  label: string
  /** Approximate token cost of including this image in the dispatch. */
  tokenCost: number
  /** Whether it survived the pass (set by subtractToFit). */
  kept?: boolean
}

/**
 * A prior-turn tool call in the working set. The battery renders each tool RESULT (the model-facing
 * text — a spooled-artifact handle note, or an inlined body) into the dispatched prompt, so its token
 * weight must be measured and shed like any other bucket. The token cost is PRE-COMPUTED by the caller
 * (via the battery's own result renderer) so the pass stays synchronous; `ref` is the opaque
 * `ToolCall` the caller reconciles against `ctx.turnToolCalls` after the shed.
 */
export interface WorkingToolCall {
  /** Opaque handle back to the caller's ToolCall (used to reconcile the ctx Set after shedding). */
  ref: unknown
  /** Token cost of this call's model-facing rendered result, measured by the caller's battery renderer. */
  tokenCost: number
  /** Chronological order key — oldest tool results shed first (stale prior-turn search blobs / handles). */
  createdAtMs: number
  /**
   * True when this tool call was produced during the CURRENT turn (the model just made it). Such results
   * are the turn's active working set — NEVER budget-shed them: evicting a result the model just fetched
   * makes it immediately re-request the same call (the observed 25× identical search loop). Only PRIOR-turn
   * results (falsy `thisTurn`) are eligible for shedding — the model already acted on those. Their token
   * weight still counts toward the budget (honest measurement); they are simply protected from eviction.
   */
  thisTurn?: boolean
}

/** The mutable working set a dispatch starts from — the "everything reasonable" set. */
export interface WorkingSet {
  systemPrompt: Tokenizable
  /**
   * Durable directives the agent honours every turn (the battery renders these into the prompt and its
   * overflow guard counts them). They are a FIXED cost like the system prompt — never shed — so the pass
   * only ADDS them to the running total, never trims them. Omit (or empty) when the caller uses none (the
   * flagship agent does); a consumer that DOES set standing instructions must pass them here or thrift
   * would undercount vs. the guard. Each entry is measured via its own `toString()`.
   */
  standingInstructions?: Tokenizable[]
  messages: Message[]
  memories: Memory[]
  retrievables: Retrievable[]
  thoughts: Thought[]
  tools: ToolRegistry
  /**
   * Prior-turn tool calls whose RENDERED RESULTS the battery puts in the prompt. Omit (or empty) when the
   * caller doesn't measure them — the pass then treats tool-result weight as 0 (its historical behavior).
   */
  toolCalls?: WorkingToolCall[]
  /** Optional image attachment (multimodal turns). The single biggest token hog. */
  image?: WorkingImage
}

// #region budget
// Tool-shed priority (LAST-RESORT bucket). Tools are the model's ability to ACT, so they shed only after
// every cheaper bucket (image, prior-turn results, RAG, memories, messages, stale thoughts) is exhausted
// and the dispatch STILL won't fit. Order = shed order (first listed sheds first).
//
// KEY POLICY (per the user): at a tight window the RIGHT move is to shed the ACTING tools — including
// `provide_answer` — and let the model answer in PROSE from the prefetched RAG that is ALREADY in context
// (baseline retrieval runs before any tool call). Shedding the answer tool frees its schema tokens for MORE
// RAG (higher-quality grounded prose) AND removes the citation pressure the model can't satisfy or render
// at that budget. The prose auto-capture path (dispatchOutput) commits the answer — no answer tool needed.
// So `provide_answer` is NOT kept-to-the-end; it sheds alongside the gather tools it can no longer cite
// from. The ONE thing to preserve is the ability to DELIVER, and prose auto-capture is that path.
//
// When `provide_answer` is shed, the loop drops all citation instructions/nudges (see agent_runtime.ts,
// keyed on `provide_answer` not being visible) so we never prompt a citation the window can't hold.
// Sentinel placeholder in TOOL_SHED_ORDER marking where UNLISTED tools (routed compute/battery tools, custom
// downstream tools) rank. Never a real tool name; shedRank uses its index as the fallback rank.
const COMPUTE_TIER_MARKER = ' compute-tier'
// TOTAL shed ranking: EVERY tool that can be visible gets a tier, shed-first → shed-last. A turn's visible
// set is defaultVisible (provide_answer, search_docs_*, navigate_to_page, store_memory, tool_catalog,
// call_a_tool) + the planner's routedTools (any of the ~80 battery tools — calculate, date_*, stats_*, …) +
// the forged artifact_* readers. Tools listed here take their explicit index; any UNLISTED tool (a routed
// battery/compute tool, or a downstream consumer's custom tool) falls into the COMPUTE tier via shedRank's
// fallback, so the ranking is total and robust to tools it has never seen. Protection (protectedToolNames)
// sits ABOVE this: a planned-but-uncalled tool is excluded from the shed queue entirely, so a tool_computed
// turn's routed `calculate` is unsheddable until called even though the compute tier would shed it early.
const TOOL_SHED_ORDER: readonly string[] = [
  // Tier 1 — exotic artifact readers (highest volume, lowest utility for basic doc Q&A)
  'artifact_json_type',
  'artifact_json_keys',
  'artifact_json_length',
  'artifact_json_filter',
  'artifact_json_slice',
  'artifact_json_pluck',
  'artifact_byte_length',
  'artifact_line_count',
  'artifact_estimate_tokens',
  'artifact_tail',
  'artifact_grep',
  'artifact_cat',
  // release_artifact — a pure budget luxury (frees a read result's body). Sheds among the first: if the
  // window is tight enough to shed tools at all, the model should spend its schema budget on reading/answering,
  // not on the "I'm done" bookkeeping tool. The N-cap backstop sheds old bodies without it.
  'release_artifact',
  // Tier 2 — routed compute/battery tools: NOT listed by name (there are ~80 and consumers add their own).
  // shedRank maps any unlisted non-core tool here, between the exotic readers and the browse/meta tools.
  COMPUTE_TIER_MARKER,
  // Tier 3 — heavy browse / meta / action tools
  'tool_catalog', // biggest browse schema, least essential once RAG is present
  'call_a_tool',
  'navigate_to_page',
  // Tier 4 — memory / CRUD
  'store_memory',
  'update_memory',
  // Tier 5 — gather tools
  'search_docs_keyword',
  'search_docs_semantic',
  // Tier 6 — delivery
  'provide_answer', // sheds with the gather tools → prose auto-capture commits, citations dissolve
  // Tier 7 — core readers (kept LAST — a handle the model already has is its cheapest path to real grounding)
  'artifact_json_get',
  'artifact_head',
]

/** Ranks a tool by last-resort shed priority (step 9), built from {@link TOOL_SHED_ORDER}. A tool listed
 *  there takes its explicit index. Any UNLISTED tool — a routed compute/battery tool (calculate, date_*,
 *  …) or a downstream consumer's custom tool — falls into the COMPUTE tier (the marker's index), so it
 *  sheds AFTER the exotic artifact readers but BEFORE the browse/gather/deliver/core tools. An unlisted
 *  `artifact_*` reader (a subclass's bespoke reader not known by name) is treated as core-ish and kept
 *  near the end. This is the flagship's own domain knowledge — the battery's own default `shedRank`
 *  treats every tool equally, having no opinion on tool names. */
const shedRank = (name: string): number => {
  const idx = TOOL_SHED_ORDER.indexOf(name)
  if (idx >= 0) return idx
  const computeTierRank = TOOL_SHED_ORDER.indexOf(COMPUTE_TIER_MARKER)
  // Unlisted artifact_* reader → keep near the end (after everything listed), like the core readers.
  if (name.startsWith('artifact_')) return TOOL_SHED_ORDER.length
  // Every other unlisted tool → the compute tier (shed early, after the exotic readers).
  return computeTierRank
}
// #endregion budget

/**
 * Resolve the per-dispatch INPUT token budget from the ACTIVE model's window: the window minus the room
 * held back for the model's own output. The same call works for a 4K model and a 1M model. Delegates to
 * the shipped battery's `resolveBudget` — the algorithm itself lives in
 * `@nhtio/adk/batteries/context/thrift`, this is a thin forwarding wrapper so callers of this module
 * (and the flagship's own `#budget`/etc. call sites, if any) don't need a second import.
 *
 * @param contextWindow - the active model's context window (input + output share it).
 * @param outputReserve - the EXACT number of tokens to hold back for output — pass the generation
 *   `maxTokens` the model is configured with. When omitted, falls back to the battery's calibrated
 *   default reserve fraction (0.35 of the window — the flagship's own calibrated value) of the window
 *   (a guess, for callers that don't know the cap). Clamped so the reserve never exceeds the window.
 */
export const resolveBudget = (contextWindow: number, outputReserve?: number): number =>
  adk.resolveBudget(contextWindow, outputReserve)

/**
 * Gemma model card §3 — "No Thinking Content in History": thoughts from previous model
 * turns MUST NOT be re-added before the next user turn. Enforced HERE in the pipeline, not
 * with a battery flag. Also pure thrift: prior-turn reasoning is the highest-volume,
 * lowest-reuse content there is. Delegates to the shipped battery's `stripPriorTurnThoughts`.
 *
 * `keepIds` is an allow-list of thought ids to PRESERVE — used for the planner's synthetic
 * THIS-TURN plan thought, which is fresh guidance generated for the current request (NOT prior-turn
 * CoT, and NOT subject to §3): it must survive into the worker's prompt so the worker follows the
 * plan. Everything else is still dropped.
 */
export const stripPriorTurnThoughts = (
  ws: Pick<WorkingSet, 'thoughts'>,
  keepIds?: ReadonlySet<string>
): { dropped: number; tokens: number } =>
  adk.stripPriorTurnThoughts(
    ws as unknown as Pick<ThriftWorkingSetType, 'thoughts'>,
    {
      estimateTokens: (v, enc, ctx) => Tokenizable.estimateTokens(v, enc, ctx as never),
      encoding: ENCODING,
    },
    keepIds
  )

// #region shed
/**
 * The subtractive pass. Start wide; measure every bucket; then shed lowest-signal first
 * until the dispatch fits the active window's budget — the image (the single biggest hog)
 * goes first when it doesn't fit, then tools the turn doesn't need, the tail of the
 * retrieval ranking, low-value memories, the oldest conversation turns. Each cut is by
 * EVIDENCE (a measured bucket), never a guess. If even the floor (system prompt + newest
 * turn) won't fit, REFUSE — a bounded refusal beats a truncated, incoherent dispatch.
 *
 * THE ALGORITHM ITSELF now lives in `@nhtio/adk/batteries/context/thrift`'s `subtractToFit` (loaded via
 * the agent_adk.ts shim). This wrapper preserves the flagship's OLD 9-positional-argument call
 * signature (unchanged since before the battery extraction — every existing call site and cross-spec
 * test depends on it) and adapts positionals → the battery's real options-object form internally,
 * binding the showcase's own policy: `TOOL_SHED_ORDER`-derived `shedRank`, `ENCODING = 'gemma'`, and the
 * `__eph-`/`__compact-summary` id predicates (passed explicitly even though they match the battery's own
 * defaults, for self-documentation at this call site).
 */
export const subtractToFit = (
  ws: WorkingSet,
  contextWindow: number,
  relevantToolNames: string[],
  outputReserve?: number,
  /** Thought ids to PRESERVE through the prior-turn strip (the planner's this-turn plan thought). */
  keepThoughtIds?: ReadonlySet<string>,
  /**
   * The battery's tool-declaration renderer. When supplied, the tools bucket is measured against the
   * REAL rendered `<tool_definitions>` block (full JSON-Schema per tool) instead of a `name: description`
   * proxy — the load-bearing fix for the context-overflow crash, where the proxy undercounted the tool
   * block by ~10× and let a doc turn blow the engine cap. Omit to fall back to the proxy.
   */
  renderTools?: RenderToolsFn,
  /**
   * Thought ids that must NEVER be shed for budget even when over — the this-turn scaffolding the worker
   * needs to answer at all (e.g. the planner's plan thought + the citation reinforcement). Everything else
   * in the surviving keep-set (the per-iteration nudge thoughts, prior synthetic guidance) is sheddable
   * oldest-first when the dispatch still doesn't fit. Omit to make every surviving thought sheddable.
   * This is a SUBSET of `keepThoughtIds`: keepThoughtIds decides what survives the Gemma §3 prior-turn
   * strip; protectThoughtIds decides what additionally survives the budget shed.
   */
  protectThoughtIds?: ReadonlySet<string>,
  /**
   * The live dispatch context, so a DYNAMIC (evaluatable) thought's token count reflects the string it
   * will resolve to for THIS dispatch — `t.content.estimateTokens(ENCODING, renderCtx)` counts exactly
   * what the battery's `.render(renderCtx)` will assemble. Without it a dynamic thought would be counted
   * as its no-ctx fallback, and the budget could disagree with the shipped prompt. Optional; static
   * content counts identically with or without it.
   */
  renderCtx?: unknown,
  /**
   * Tool names the CURRENT PLAN committed to that have NOT yet been called this turn. These are
   * UNSHEDDABLE by the last-resort tool-shed: the plan-thought tells the model to call them, so removing
   * them from the rendered `<tool_definitions>` block leaves the model instructed to call a tool it cannot
   * see — the proven tool_computed failure (planner chose get_current_time, thrift shed it, the 2B could
   * only see provide_answer and abstained "I don't have the date"). The protection is bounded: once the
   * tool has been called (its result is in context) it drops off this set and sheds normally, so it is
   * never a permanent floor. Omit to make every visible tool sheddable (prior behaviour).
   */
  protectedToolNames?: ReadonlySet<string>
): ThriftTrace => {
  const options: ThriftSubtractToFitOptionsType = {
    estimateTokens: (v, enc, ctx) => Tokenizable.estimateTokens(v, enc, ctx as never),
    encoding: ENCODING,
    outputReserve,
    keepThoughtIds,
    renderTools: renderTools as unknown as ThriftSubtractToFitOptionsType['renderTools'],
    protectThoughtIds,
    renderCtx,
    protectedToolNames,
    shedRank,
    // Passed explicitly even though these match the battery's own defaults — self-documentation of the
    // flagship's id conventions at this call site (see the module header comment).
    isEphemeralMessage: (m) => m.id.startsWith('__eph-'),
    isSummaryMessage: (m) => m.id === '__compact-summary',
  }
  const trace: ThriftTraceResult = adk.subtractToFit(
    ws as unknown as ThriftWorkingSetType,
    contextWindow,
    relevantToolNames,
    options
  )
  return trace
}
// #endregion shed

```

#### agent\_harness\_types.ts

```ts
import type { ModelChoice } from './agent_models'
import type { DispatchExecutorFn } from './agent_adk'
import type { ThriftTrace } from './agent_subtractive_pass'

export type AgentPhase = 'idle' | 'loading' | 'generating' | 'complete' | 'error'

export interface AgentEvents {
  onPhase?: (p: AgentPhase) => void
  onTrace?: (t: ThriftTrace) => void
  /** Cold-load progress. `pct` is 0..100 when the provider reports it, or `null` when the load is
   *  INDETERMINATE (LiteRT's load telemetry is provider-opaque) — the dialog renders a spinner then. */
  onLoadProgress?: (p: { file: string; pct: number | null }) => void
  /** Streamed answer delta (the human-facing assistant message). */
  onDelta?: (text: string) => void
  /**
   * Human-readable status of what the agent is doing RIGHT NOW — drives the
   * "working / thinking / processing" indicator so the user never wonders whether the
   * model is alive. e.g. "Loading model…", "Thinking…", "Searching the docs…",
   * "Running get_order…", "Writing the answer…".
   */
  onActivity?: (status: string) => void
  /** A turn finished; the dialog should refresh history from the store. */
  onTurnComplete?: () => void
  /**
   * The probed WebGPU device budget, forwarded once the model is ready (from the battery's `ready`
   * lifecycle report). The dialog surfaces this next to the context-window slider so the user can
   * relate their chosen window to the device's per-allocation ceiling — observability, never a cap.
   * `maxBufferMB` is `maxBufferBytes / 2**20`; absent on non-WebGPU runtimes.
   */
  onGpuBudget?: (b: { maxBufferMB: number; available: boolean }) => void
  /**
   * A HARNESS GATE fired this turn — one of the rails that keep the small model honest, surfaced so
   * the showcase can SEE the scaffolding work (not just the model). Emitted when the loop rejects or
   * redirects a generation: a bad tool name corrected, a bare-args misfire bounced, a false
   * abstention vetoed, a premature give-up pushed back to the catalog, an uncited doc answer sent
   * back for sources. `detail` is a short human label for the rail that engaged.
   */
  onGate?: (g: { gate: AgentGateKind; detail: string }) => void
  /**
   * A unit of agent REASONING surfaced to the UI — so the showcase shows not just the answer but how the
   * agent got there. Four kinds: `'reasoning'` is the model's own chain-of-thought (only when the
   * generate-CoT toggle is on); `'plan'` is the synthetic this-turn plan the planner book-end injects;
   * `'nudge'` is a corrective course-correction thought a gate injected; `'checker'` is a gate ANNOUNCING
   * the criterion it is about to evaluate the generation against ("I must evaluate if this …") — so the
   * showcase shows the rails *deciding*, not just firing. `id` groups streamed deltas (the UI replaces by
   * id, so re-emitted sticky nudges + streamed CoT are idempotent); `text` is the full accumulated content
   * for that id. Turn-scoped and NOT persisted into the MODEL context next turn — attached in-memory to
   * the resulting assistant message (mirrors {@link AgentEvents.onGate}); gone on reload, consistent with
   * the chat-history (persisted) ≠ context-window (per-dispatch) invariant. Plan/nudge/checker thoughts
   * are emitted regardless of the toggle, so the UI shows them unconditionally.
   */
  onThought?: (t: { id: string; kind: AgentThoughtKind; text: string; isComplete: boolean }) => void
  /**
   * The worker-hosted LiteRT engine lost its WebGPU device (driver TDR / GPU-process crash). The engine is
   * dead and no turn can complete on it; the harness has ALREADY taken the recoverable action per the
   * escalation ladder — `action:'respawn'` means the worker was terminated + a fresh one is loading (the
   * caller should surface a transient "recovering" state), `action:'reload'` means a repeat loss inside the
   * crash window forces a full `window.location.reload()` (Chrome blocks new GPU adapters after 2 crashes in
   * ~2 min; only a reload clears it). Conversation history lives in SQLite, so a reload is lossless — the
   * dialog should show a banner and reload. `reason` is the raw device-lost reason for display/telemetry.
   */
  onDeviceLost?: (e: { action: 'respawn' | 'reload'; reason: string }) => void
}

/** The kinds of reasoning the dialog can surface (see {@link AgentEvents.onThought}). */
export type AgentThoughtKind = 'reasoning' | 'plan' | 'nudge' | 'checker'

/** The harness rails the dialog can surface (see {@link AgentEvents.onGate}). */
export type AgentGateKind =
  | 'bad-tool-name'
  | 'bare-args'
  | 'false-abstention'
  | 'check-catalog'
  | 'needs-citation'
  | 'force-answer'
  | 'unverified-claim'
  | 'plan-violation'
  | 'unread-handle'
  | 'duplicate-call'
  | 'prose-required'
  | 'parroting'
  | 'incomplete-answer'
  | 'fake-citation'
  | 'leaked-envelope'
  // An artifact read produced a result too large to fit the window (see RESULT_HEADROOM_FACTOR). The
  // over-cap read is retracted and the model re-steered to a NARROWER query — tool-requiring, so it clears
  // any live prose gate. Enforced as an invariant (every over-cap read is rejected), not a bounded nudge.
  | 'result-too-large'
  // Not a model-behavior gate — the context-overflow observability event. Surfaced through onGate (the
  // same instrumentation stream the real gates use) so a turn that blew the engine cap appears in the
  // "what happened" panel as a first-class event, not a swallowed string.
  | 'context-overflow'
  // Not a model-behavior gate — the token-estimation-degraded observability event (the runner's `warning`
  // bus). A tokenizer threw mid-run and the estimate fell back to a char-based guesstimate; surfaced here
  // so the degrade is visible rather than silent, without failing the turn.
  | 'token-estimation-degraded'
  // Not a model-behavior gate — the navigate-permission TurnGate opened by navigate_to_page. Surfaced here
  // so a turn that asked to move the user shows up in the "what happened" panel; in a headless harness it's
  // also the record that the gate was auto-acked (see #autoGateVerdict) rather than answered by a human.
  | 'navigate'

// Gate CLASSES for the mutual-exclusion rule. A 2B wedges when its context holds two opposite instructions
// at once. Nudges are STICKY (they persist until their gate clears), and multiple can be active, so a
// "call search_docs_semantic now" correction and a "reply in plain prose, do NOT call a tool" correction
// could co-exist in the same assembled directive — telling the model to call a tool AND call nothing. These
// two sets are mutually exclusive: emitting a gate from one class clears any active gate from the other (see
// emitGate), so at most one CLASS is ever active. Same-class coexistence (e.g. "search" + "read the handle")
// is fine — those compose. Gates in NEITHER set (duplicate-call/force-answer hard-stops, the observability
// kinds) never cross-clear.
const TOOL_REQUIRING_GATES: ReadonlySet<AgentGateKind> = new Set<AgentGateKind>([
  'needs-citation', // call search / provide_answer
  'unread-handle', // call artifact_* to read the handle
  'check-catalog', // call tool_catalog
  'plan-violation', // call the planned-but-uncalled tool
  'bad-tool-name', // re-call the tool with a real name (also reused for bad-args)
  'bare-args', // re-issue the tool call properly
  'unverified-claim', // compute the value via a tool first
  'result-too-large', // re-read the SAME handle with a narrower query
])
const PROSE_ONLY_GATES: ReadonlySet<AgentGateKind> = new Set<AgentGateKind>([
  'incomplete-answer', // re-answer shorter, in prose
  'prose-required', // answer in plain prose, no tool
  'parroting', // answer in your OWN words
  'false-abstention', // don't give up — answer from the result you have
  'fake-citation', // drop the invented markup; plain prose
  'leaked-envelope', // re-answer clean, no XML plumbing
])

export interface RunInput {
  text: string
  image?: { bytes: Uint8Array; mimeType: string }
  /**
   * Id of a user message the caller has ALREADY persisted (so the human sees their own message
   * the instant they hit send, not after the turn). When present, run() reuses this id and skips
   * its own user-message storeMessage — avoiding a duplicate row. When absent, run() persists the
   * user message itself (the standalone / programmatic path).
   */
  userMessageId?: string
}

/** The outcome of one turn. `oom`/`contextOverflow` classify the failure so the dialog picks the right
 *  banner (capacity vs. too-long) and Retry affordance. */
export interface RunResult {
  answer: string
  trace: ThriftTrace | null
  refused: boolean
  error?: string
  /** True when `error` is a WebGPU out-of-memory — the dialog shows the capacity banner + Retry. */
  oom?: boolean
  /** True when `error` is a context overflow (prompt exceeded the engine's token cap) — the dialog
   *  shows the "conversation got too long for this window — reduce it and Retry" banner. */
  contextOverflow?: boolean
  /** True when the subtractive pass could NOT fit the user's context-window budget even after shedding
   *  everything sheddable (only the unsheddable floor — system + tools + reserve + this-turn results —
   *  remains, and it still exceeds the budget). The WALL: rather than silently dispatch an over-budget
   *  prompt (which the old code did — the window was a soft target), the turn refuses and the dialog
   *  shows a specific "your window is too small for this turn — raise it or simplify" banner with the
   *  real math. Distinct from `contextOverflow` (engine cap, downstream) so `run()` does NOT retry it:
   *  the pass already shed everything, so a retry at the same window would refuse identically. */
  windowTooSmall?: boolean
  /** True when the turn was abandoned because the model repeatedly surfaced internal plumbing (an
   *  XML-shaped trust-tier fence) in its answer — a signal that THIS turn's context is poisoned. `run()`
   *  treats it like `contextOverflow`: shrink the window a step so the subtractive pass sheds the
   *  accumulated cruft, then re-run — escalating until the model answers clean or a fallback ships. Never
   *  surfaced to the user (internal recovery signal). */
  contextPoisoned?: boolean
}

/** The minimal adapter surface the harness actually drives: an `executor()` (required) and an optional
 *  `preload()`/`dispose()` (LiteRt's cold-load; Ollama has none). Lets a Node harness inject a different
 *  ADK LLM battery (e.g. OllamaAdapter) without the harness depending on LiteRt concretely. */
export interface HarnessAdapter {
  executor(overrides?: unknown): DispatchExecutorFn | Promise<DispatchExecutorFn>
  preload?(overrides?: unknown): Promise<unknown>
  dispose?(): Promise<void>
  /**
   * Render a tool set into the EXACT text this adapter puts on the wire, so the subtractive pass measures the
   * tools bucket in the SAME representation the adapter's own overflow guard counts. Formats differ by
   * transport — LiteRT/tjs deliver tools as a compact prompt-text block (~100 tok/tool); Ollama/OpenAI/WebLLM
   * deliver JSON tool schemas (~180 tok/tool for the same tools). If the pass measured prompt-text while the
   * active battery sent JSON, it would UNDER-count and stop shedding early, then the battery guard would
   * overflow — a measurement mismatch. Supply the matching renderer here and the pass and guard agree.
   * Omit → the flagship falls back to its LiteRT prompt-text renderer (correct for the built-in LiteRT path).
   */
  measureToolsAsText?(tools: ReadonlyArray<unknown>): string
  /**
   * Render a prior-turn tool RESULT into the EXACT model-facing text this adapter puts in the prompt, so the
   * pass measures the tool-result (toolCalls) bucket identically to the adapter's overflow guard. The guard
   * wraps the raw result body in a role/trust envelope (e.g. Ollama's renderOllamaToolCallResult adds
   * tool_name framing + the trusted/untrusted content wrapper) — ~100–200 tokens the bare handle-body
   * preview omits. Supply the adapter's own result renderer here and the buckets agree. Omit → the flagship
   * falls back to its handle-body preview (correct for the built-in LiteRT path, whose guard uses that body).
   */
  measureToolResultAsText?(tc: unknown): Promise<string> | string
}

/** Build the LLM adapter for a model. Injected via the constructor's `opts.adapterFactory`; defaults to
 *  the built-in LiteRt factory (browser path — byte-identical to before). `hooks` carries the wiretap/
 *  progress/GPU callbacks the built-in factory wires; a Node factory may ignore them. */
export type AdapterFactory = (
  model: ModelChoice,
  opts: {
    maxTokens: number
    enableThinking: boolean
    contextWindowMax: number
    // The user's DECLARED context window (the thrift window). THE WALL: the overflow guard is
    // contextWindow − maxTokens, full stop — not the engine cap. A prompt that doesn't fit the declared
    // window overflows honestly rather than silently running up to the engine limit.
    contextWindow: number
  }
) => HarnessAdapter

export { TOOL_REQUIRING_GATES, PROSE_ONLY_GATES }

```

#### agent\_errors.ts

```ts
import { isError } from './agent_adk'

/**
 * True when a thrown/nacked value is a WebGPU out-of-VRAM signal. Reads the TYPED error the battery
 * now surfaces (`E_LLM_GPU_OUT_OF_MEMORY`, identified by its cross-realm-safe `code`/`name` so it
 * survives the precompiled-bundle boundary) first, falling back to a message-signature match for any
 * raw error that slipped through untyped. The OOM is a faithful capacity signal (the device can't
 * allocate the buffers this turn needs), so it must surface to the user (capacity banner + Retry)
 * rather than be swallowed. This replaces the former message-only regex — surface, don't guess.
 */
function isGpuOomError(err: unknown): boolean {
  const code = (err as { code?: unknown; name?: unknown } | null | undefined)?.code
  const name = (err as { name?: unknown } | null | undefined)?.name
  if (code === 'E_LLM_GPU_OUT_OF_MEMORY' || name === 'E_LLM_GPU_OUT_OF_MEMORY') return true
  const message = isError(err) ? err.message : String(err)
  return /failed to allocate memory for buffer mapping|operation does not support unaligned accesses|out of memory|RequestDeviceFailed|Failed to create|mapAsync|memory access out of bounds|cannot enlarge memory|abort\(oom\)/i.test(
    message
  )
}

/**
 * True when a thrown/nacked value is a CONTEXT-OVERFLOW signal — the dispatch's prompt exceeded the
 * engine's fixed token cap. Reads the TYPED error the LiteRT battery now surfaces
 * (`E_LITERT_LM_CONTEXT_OVERFLOW`, matched by its cross-realm-safe `code`/`name` so it survives the
 * precompiled-bundle boundary) from BOTH paths that converge on it — the pre-dispatch guard AND the
 * engine's own raw "too long" throw translated by `toLiteRtGenerationError`. Belt-and-suspenders: also
 * matches the raw engine message in case an untyped error slips through. Distinct from GPU OOM — this is
 * a LENGTH error (the prompt is too long), not a capacity error (the device is out of VRAM); the
 * recovery is different (shed context / shrink the window, not recycle the GPU).
 */
function isContextOverflowError(err: unknown): boolean {
  const code = (err as { code?: unknown; name?: unknown } | null | undefined)?.code
  const name = (err as { name?: unknown } | null | undefined)?.name
  if (code === 'E_LITERT_LM_CONTEXT_OVERFLOW' || name === 'E_LITERT_LM_CONTEXT_OVERFLOW')
    return true
  const message = isError(err) ? err.message : String(err)
  return /input token ids are too long|exceeding the maximum number of tokens/i.test(message)
}

/**
 * True when a thrown/nacked value is the {@link makeWindowTooSmallError} WALL signal — matched by its
 * cross-realm-safe `code`/`name` (survives the precompiled-bundle boundary). Kept distinct from
 * {@link isContextOverflowError} so `run()` can refuse to retry it (the pass already shed everything;
 * a same-window retry would refuse identically) and the dialog can show the specific budget-math banner.
 */
function isWindowTooSmallError(err: unknown): boolean {
  const code = (err as { code?: unknown; name?: unknown } | null | undefined)?.code
  const name = (err as { name?: unknown } | null | undefined)?.name
  return code === 'E_ADK_WINDOW_TOO_SMALL' || name === 'E_ADK_WINDOW_TOO_SMALL'
}

// ADK wraps the underlying failure in a GENERIC container before it reaches the app's error seams:
// a thrown executor becomes `E_LLM_EXECUTION_EXECUTOR_ERROR`, a turn-output-pipeline throw becomes
// `E_OUTPUT_PIPELINE_ERROR`, an un-run output pipeline becomes `E_PIPELINE_SHORT_CIRCUITED` — each
// carrying the real error in `.cause`. Reading only the container's `.message` yields the useless
// "The LLM execution executor callback threw an error." string AND misclassifies OOM / context-overflow
// (whose signatures live on the CAUSE, not the wrapper). Walk the `.cause` chain to the deepest link so
// classification and the surfaced message reflect what actually failed. Cycle-guarded + depth-bounded.
const WRAPPER_ERROR_CODES = new Set([
  'E_LLM_EXECUTION_EXECUTOR_ERROR',
  'E_OUTPUT_PIPELINE_ERROR',
  'E_PIPELINE_SHORT_CIRCUITED',
  'E_DISPATCH_PIPELINE_ERROR',
])
function unwrapToRootCause(err: unknown): unknown {
  let cur = err
  const seen = new Set<unknown>()
  for (let depth = 0; depth < 8 && cur && !seen.has(cur); depth++) {
    seen.add(cur)
    const x = cur as { code?: unknown; name?: unknown; cause?: unknown }
    const isWrapper =
      WRAPPER_ERROR_CODES.has(x?.code as string) || WRAPPER_ERROR_CODES.has(x?.name as string)
    // Only unwrap KNOWN generic containers; stop at the first meaningful (non-wrapper) error, or when
    // there's no deeper cause to follow.
    if (!isWrapper || x?.cause === undefined || x?.cause === null) return cur
    cur = x.cause
  }
  return cur
}

export { isGpuOomError, isContextOverflowError, isWindowTooSmallError, unwrapToRootCause }

```

#### agent\_record\_mappers.ts

```ts
import { Message, Memory, ToolCall, Tokenizable, decode } from './agent_adk'
import type { ConversationMessage, MemoryRecord, ToolCallRecord } from './agent_store_facade'

// ── Record → ADK primitive mappers ───────────────────────────────────────────

// Map a stored ConversationMessage → an ADK Message (model plane prefers model_content).
function convToMessage(m: ConversationMessage): Message {
  return new Message({
    id: m.id,
    role: m.role,
    content: m.modelContent ?? m.content,
    createdAt: m.createdAt,
    updatedAt: m.createdAt,
  })
}

// Map a stored MemoryRecord → an ADK Memory (model plane prefers the token-dense text).
function memRecordToMemory(m: MemoryRecord): Memory {
  return new Memory({
    id: m.id,
    content: m.modelText ?? m.text,
    confidence: 1, // the agent explicitly chose to record it
    importance: m.importance,
    createdAt: m.createdAt,
    updatedAt: m.createdAt,
  })
}

// Map a stored ToolCallRecord → an ADK ToolCall for replay. PREFERS the `@nhtio/encoder` snapshot:
// decoding rehydrates the original ToolCall with its SpooledArtifact result as a LIVE, lazy handle
// rebound to the same OPFS file (zero bytes in memory at rest) — so the forged `artifact_*` tools can
// query a prior catalog/search result across turns. Falls back to a Tokenizable(resultText) only when
// no snapshot was stored (older rows, or an un-encodable result).
function toolRecordToToolCall(t: ToolCallRecord): ToolCall {
  if (t.encoded) {
    try {
      return decode(t.encoded) as unknown as ToolCall
    } catch {
      /* corrupt/unsupported snapshot → fall through to the text replay below */
    }
  }
  return new ToolCall({
    id: t.id,
    tool: t.tool,
    args: t.args,
    checksum: t.checksum,
    isComplete: true,
    isError: t.isError,
    results: new Tokenizable(t.resultText ?? ''),
    createdAt: t.createdAt,
    updatedAt: t.createdAt,
    completedAt: t.createdAt,
  })
}

export { convToMessage, memRecordToMemory, toolRecordToToolCall }

```

#### agent\_models.ts

```ts
// The selectable model roster. ONLY models proven strong enough for the real tool loop are listed
// (validated in the transformers.js model matrix — see tests/_fixtures/model_matrix.ts). Each entry
// carries capability flags so the UI can gate media upload on `multimodal` and the harness can pin
// the right per-family parser + thinking behaviour.
//
// Engine is LiteRT-LM (Google `.litertlm` on WebGPU). The browser runtime runs Gemma ONLY — exactly two
// builds (`gemma-4-E2B-it-web` / `gemma-4-E4B-it-web`); non-Gemma `.litertlm` files load but fail
// generation. So the picker is the two Gemma sizes. (We switched off transformers.js/ONNX-web because
// its wasm32 4GB heap OOMs a multi-step agent turn regardless of window size — LiteRT avoids that heap.)
export type ModelChoice = 'gemma-e2b' | 'gemma-e4b'

export interface ModelConfig {
  /** Short label for the picker. */
  label: string
  /** One-line description (size / strengths) for the picker. */
  description: string
  /** The hosted `.litertlm` model URL (LiteRT-web Gemma build). */
  model: string
  /** Tool-call parser family (matrix-proven per model). */
  toolCallParser: string
  /** Reasoning parser family, or 'none'. Kept ACTIVE for thinking-capable models even when thinking
   *  is toggled off — a reasoning model still emits thought blocks at runtime (Gemma's `<|channel>
   *  thought…`, DeepSeek's `<think>`), and the parser is what routes them to the thoughts channel
   *  instead of leaking them into the visible answer. */
  reasoningParser: string
  /** Whether this model has a reasoning/thinking channel at all. Drives the UI's "show thinking"
   *  toggle (only offered for capable models). Thinking is OFF by default (it burns the tool loop),
   *  but the user can switch it on — seeing CoT in a web-loaded model is part of the showcase. */
  supportsThinking: boolean
}

// The two LiteRT-web Gemma builds, hosted by litert-community on the HF hub.
const LITERT_E2B_URL =
  'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it-web.litertlm'
const LITERT_E4B_URL =
  'https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.litertlm'

const MODELS: Record<ModelChoice, ModelConfig> = {
  'gemma-e2b': {
    label: 'Gemma 4 E2B',
    description: '~2B · on-device via LiteRT · tools · fastest',
    model: LITERT_E2B_URL,
    toolCallParser: 'auto',
    // gemma_channel stays active whether or not thinking is toggled on: with thinking OFF it catches
    // any `<|channel>thought…` block the model still emits at runtime (so it doesn't leak into the
    // answer); with thinking ON it routes the CoT to the thoughts channel for display.
    reasoningParser: 'gemma_channel',
    supportsThinking: true,
  },
  'gemma-e4b': {
    label: 'Gemma 4 E4B',
    description: '~4B · on-device via LiteRT · tools · stronger, heavier',
    model: LITERT_E4B_URL,
    toolCallParser: 'auto',
    reasoningParser: 'gemma_channel',
    supportsThinking: true,
  },
}

/** The default model: Gemma E2B (the lighter, faster cold-load). */
export const DEFAULT_MODEL: ModelChoice = 'gemma-e2b'

/** Public registry view for the picker (id + label + description). LiteRT-web is text-only, so there
 *  is no multimodal flag — every entry is a text model. */
export function listModels(): Array<{
  id: ModelChoice
  label: string
  description: string
}> {
  return (Object.entries(MODELS) as Array<[ModelChoice, ModelConfig]>).map(([id, c]) => ({
    id,
    label: c.label,
    description: c.description,
  }))
}

/** Whether a given model has a reasoning/thinking channel (drives the "show thinking" toggle). */
export function modelSupportsThinking(model: ModelChoice): boolean {
  return MODELS[model]?.supportsThinking ?? false
}

export { LITERT_E2B_URL, LITERT_E4B_URL, MODELS }

```

### The deployment glue

#### litert\_lm\_worker.ts

```ts
// The LiteRT-LM engine, isolated in a DISPOSABLE Web Worker.
//
// WHY a worker (the device-loss fix): long Gemma-E4B sessions crash at a browser-level WebGPU device
// loss (driver TDR). On the MAIN THREAD our recovery `recycle() = dispose()+preload()` cannot recover —
// `dispose()` only nulls JS refs (it does NOT scrub the native WebGPU device / WASM heap), so it reuses a
// dead GPUAdapter → "A valid external Instance reference no longer exists" use-after-free cascade; every
// later turn dies. Hosting the engine in a Worker turns "recycle" into `worker.terminate()` + respawn — a
// TOTAL teardown that destroys the whole GPU context + WASM heap + every dead ref in one OS-level op, and
// the fresh worker calls requestAdapter() from a clean context.
//
// CRITICAL — CLASSIC worker, NOT a module worker. LiteRT's Emscripten glue (litertlm_wasm_internal.js via
// @litertjs/wasm-utils createWasmLib) calls importScripts(), which is ILLEGAL in {type:'module'} workers.
// So this file is compiled to an IIFE bundle (litert-lm-worker.vite.config.mts, formats:['iife']) and
// spawned with `new Worker(url)` WITHOUT {type:'module'} — see litert_lm_worker_proxy.ts. This diverges
// from the WebLLM worker (which is a module worker). Proven in bin/_probe (Step-0 GO gate).
//
// The 4 wasm assets (litertlm_wasm_internal.{js,wasm}, litertlm_wasm_compat_internal.{js,wasm}) are
// resolved by the glue via `new URL(".", self.location).href` — RELATIVE to this worker's own URL — so the
// build copies them into docs/public/repl/ next to litert-lm-worker.js (see package.json build script).
//
// This file imports ONLY the LiteRT peer. No @nhtio/adk source, no Vue, nothing SSR-evaluated (mirrors
// agent_sqlite_worker.ts). It hand-rolls a typed request/response envelope with a PERSISTENT correlation
// map (generation is streaming/high-frequency, not one-shot RPC), mirroring the SQLite worker's protocol.

import { Engine, loadLiteRtLm } from '@litert-lm/core'
import type { Conversation, EngineSettings, ConversationConfig, Message } from '@litert-lm/core'

// ── Envelope protocol (host ⇄ worker) ────────────────────────────────────────────────────────────

/** A request posted from the host to the worker. Correlated by `id`. */
export type LitertWorkerRequest =
  | { id: string; op: 'init'; engineSettings: EngineSettings }
  | { id: string; op: 'createConversation'; convId: string; config?: ConversationConfig }
  | { id: string; op: 'sendStreaming'; convId: string; streamId: string; messages: unknown }
  | { id: string; op: 'send'; convId: string; messages: unknown }
  | { id: string; op: 'cancel'; convId: string }
  | { id: string; op: 'delete' }
  // DEV/TEST-ONLY: destroy the sentinel device to simulate a WebGPU device loss (drives the
  // respawn→reload escalation ladder in a smoke test). Guarded by the host (only sent under wiretap).
  | { id: string; op: '__forceDeviceLost' }

/** A correlated reply to a {@link LitertWorkerRequest} (`ok` or `error`, keyed by the request `id`). */
export interface LitertWorkerReply {
  id: string
  kind: 'ok' | 'error'
  /** For `kind:'ok'` on a non-streaming `send`: the final message. Otherwise absent. */
  message?: Message
  /** For `kind:'error'`: the original error message as a STRING (never a thrown Error object — mirrors
   *  the SQLite worker convention; the host reconstructs an Error so downstream classification works). */
  error?: string
}

/** An UNSOLICITED event posted from the worker (streaming chunks, stream lifecycle, device loss). */
export type LitertWorkerEvent =
  | { kind: 'ev:delta'; streamId: string; content: Message['content'] }
  | { kind: 'ev:streamEnd'; streamId: string }
  | { kind: 'ev:streamError'; streamId: string; error: string }
  // Init/load progress forwarded from the engine (LiteRT 0.13.1 reports none, but the seam is here).
  | { kind: 'ev:progress'; report: unknown }
  // The sentinel device was lost (GPU-process crash / TDR / destroy). The whole engine is dead; the host
  // escalates (respawn worker, or page-reload on repeated loss inside the crash window).
  | { kind: 'ev:deviceLost'; reason: string }

type OutboundMessage = LitertWorkerReply | LitertWorkerEvent

// ── Worker state ──────────────────────────────────────────────────────────────────────────────────

let engine: Engine | undefined
const conversations = new Map<string, Conversation>()
// Bounded-lifetime growth: the adapter creates a fresh conversation per dispatch and never signals
// "done", so conversations accumulate for the LIFETIME of this worker. That is bounded by design — the
// whole point of the migration is periodic TOTAL teardown (worker.terminate() on recycle / device loss),
// which drops this map with the worker. We do NOT auto-delete (the engine interface allows conversation
// reuse; guessing would be unsound).

// Minimal structural view of the WebGPU bits we read — @webgpu/types is not installed, and navigator.gpu
// is `unknown` in this project's lib set, so we type it locally (mirrors chat_common/gpu_budget.ts).
interface GpuDeviceLike {
  readonly lost: Promise<{ reason?: string; message?: string }>
  destroy?: () => void
}
interface GpuAdapterLike {
  requestDevice: () => Promise<GpuDeviceLike>
}
interface NavigatorGpuLike {
  gpu?: { requestAdapter: (opts?: unknown) => Promise<GpuAdapterLike | null | undefined> }
}

// The sentinel device: a SEPARATE GPUDevice acquired purely as a canary. When the GPU process crashes
// (TDR) every device sharing that process — including this one — has its `.lost` promise resolve, so it is
// a reliable signal for a process-level loss even while the engine's own device is opaque inside the wasm.
// destroy() also resolves `.lost` (reason "destroyed"), which is how __forceDeviceLost simulates it.
let sentinelDevice: GpuDeviceLike | undefined
let deviceLostPosted = false

const post = (msg: OutboundMessage): void => {
  ;(self as unknown as { postMessage: (m: unknown) => void }).postMessage(msg)
}

async function armSentinelDevice(): Promise<void> {
  if (sentinelDevice) return
  const nav =
    typeof navigator !== 'undefined' ? (navigator as unknown as NavigatorGpuLike) : undefined
  const gpu = nav?.gpu
  if (!gpu) return
  try {
    const adapter = await gpu.requestAdapter()
    if (!adapter) return
    const device = await adapter.requestDevice()
    sentinelDevice = device
    void device.lost.then((info) => {
      if (deviceLostPosted) return
      deviceLostPosted = true
      post({
        kind: 'ev:deviceLost',
        reason: info?.reason ?? info?.message ?? 'device lost',
      })
    })
  } catch {
    // A sentinel is best-effort observability — never block engine boot on it.
  }
}

// Load the LiteRT-LM wasm/glue once, from THIS worker's own directory (self-hosted, no CDN). `new URL('.',
// self.location.href)` is the directory containing litert-lm-worker.js — where the build placed the 4
// assets. Passing a DIRECTORY lets LiteRT feature-detect relaxedSimd and pick the internal vs compat build.
let litertLmLoaded = false
async function ensureLiteRtLmLoaded(): Promise<void> {
  if (litertLmLoaded) return
  const loc = (self as unknown as { location?: { href?: string } }).location
  const wasmDir = new URL('.', loc?.href ?? '/').href
  try {
    await loadLiteRtLm(wasmDir)
  } catch (err) {
    // loadLiteRtLm throws if a global load is already in flight/done. On a fresh worker that shouldn't
    // happen, but never let a double-load race abort init — the engine reuses the existing global instance.
    if (!/already loading|already loaded/i.test(errText(err))) throw err
  }
  litertLmLoaded = true
}

/** Best-effort error → string (never leak a thrown Error object across postMessage). */
const errText = (err: unknown): string =>
  // eslint-disable-next-line adk/prefer-is-error -- standalone classic Web Worker; it must NOT import @nhtio/adk source (the bundle/guards never load in this isolated IIFE worker context — same carve-out as agent_sqlite_worker.ts).
  err instanceof Error ? err.message : typeof err === 'string' ? err : String(err)

// ── Message handling ────────────────────────────────────────────────────────────────────────────

async function handle(req: LitertWorkerRequest): Promise<void> {
  const reply = (r: Omit<LitertWorkerReply, 'id'>): void => post({ id: req.id, ...r })

  switch (req.op) {
    case 'init': {
      try {
        // Arm the sentinel canary alongside the real engine boot (both acquire from the same GPU).
        await armSentinelDevice()
        // SELF-HOSTED WASM (hard requirement: no CDN, no hardcoded host). `Engine.create` alone would
        // default to `getOrLoadGlobalLiteRtLm()` → the jsdelivr CDN (LiteRtLm.DEFAULT_WASM_PATH). Instead
        // load the 4 wasm/glue assets from a directory RELATIVE to THIS worker's own URL — the build copied
        // them next to litert-lm-worker.js (see litert-lm-worker.vite.config.mts). `new URL('.', ...)`
        // resolves to the worker's directory, so it works under a '/' base OR a Pages subpath, and the
        // Emscripten glue's own relative .wasm fetch lands in the same directory. Idempotent-guarded:
        // loadLiteRtLm throws if already loaded, which is fine on a fresh worker but we swallow it defensively.
        await ensureLiteRtLmLoaded()
        engine = await Engine.create(req.engineSettings)
        reply({ kind: 'ok' })
      } catch (err) {
        reply({ kind: 'error', error: errText(err) })
      }
      return
    }
    case 'createConversation': {
      try {
        if (!engine) throw new Error('LiteRT worker: engine not initialised')
        const conv = await engine.createConversation(req.config)
        conversations.set(req.convId, conv)
        reply({ kind: 'ok' })
      } catch (err) {
        reply({ kind: 'error', error: errText(err) })
      }
      return
    }
    case 'sendStreaming': {
      // Streaming is fire-and-forward: the `ok` reply confirms the stream STARTED (or an error if the
      // synchronous sendMessageStreaming call threw); chunks/end/error then flow as ev:* events keyed by
      // streamId. This keeps the id-correlated promise map one-shot while the stream is persistent.
      const conv = conversations.get(req.convId)
      if (!conv) {
        reply({ kind: 'error', error: `LiteRT worker: unknown conversation ${req.convId}` })
        return
      }
      let readable: ReadableStream<Message>
      try {
        readable = conv.sendMessageStreaming(req.messages as never)
        reply({ kind: 'ok' })
      } catch (err) {
        // A synchronous throw from sendMessageStreaming (e.g. context overflow) — surface it as a stream
        // error so the host's synthesized stream rejects with the ORIGINAL message string, preserving the
        // adapter's E_LITERT_LM_CONTEXT_OVERFLOW / GPU-OOM classification.
        reply({ kind: 'ok' })
        post({ kind: 'ev:streamError', streamId: req.streamId, error: errText(err) })
        return
      }
      void (async () => {
        try {
          const reader = readable.getReader()
          while (true) {
            const { value, done } = await reader.read()
            if (done) break
            if (!value) continue
            post({ kind: 'ev:delta', streamId: req.streamId, content: value.content })
          }
          post({ kind: 'ev:streamEnd', streamId: req.streamId })
        } catch (err) {
          post({ kind: 'ev:streamError', streamId: req.streamId, error: errText(err) })
        }
      })()
      return
    }
    case 'send': {
      try {
        const conv = conversations.get(req.convId)
        if (!conv) throw new Error(`LiteRT worker: unknown conversation ${req.convId}`)
        const message = await conv.sendMessage(req.messages as never)
        reply({ kind: 'ok', message })
      } catch (err) {
        reply({ kind: 'error', error: errText(err) })
      }
      return
    }
    case 'cancel': {
      try {
        conversations.get(req.convId)?.cancel()
      } catch {
        // cancel is best-effort
      }
      reply({ kind: 'ok' })
      return
    }
    case 'delete': {
      try {
        await engine?.delete()
      } catch {
        // teardown must not throw
      }
      engine = undefined
      conversations.clear()
      reply({ kind: 'ok' })
      return
    }
    case '__forceDeviceLost': {
      // DEV/TEST: destroy the sentinel device → its `.lost` resolves (reason "destroyed") → the same
      // ev:deviceLost path the real TDR takes. Proves the respawn→reload escalation ladder end-to-end.
      try {
        sentinelDevice?.destroy?.()
      } catch {
        /* ignore */
      }
      reply({ kind: 'ok' })
      return
    }
  }
}

self.addEventListener('message', (ev: MessageEvent<LitertWorkerRequest>) => {
  const req = ev.data
  if (!req || typeof req.op !== 'string') return
  void handle(req)
})

```

#### litert\_lm\_worker\_proxy.ts

```ts
// #region proxy_header
// Host-side proxy for the LiteRT-LM engine running in a disposable Web Worker.
//
// This is the injected `createEngine` factory (LiteRtLmAdapterOptions.createEngine seam). It returns an
// object that STRUCTURALLY implements the interface the adapter calls — `engine.createConversation(config)`
// → conversation with `sendMessageStreaming()`/`sendMessage()`/`cancel()`, and `engine.delete()` — but
// forwards every call over postMessage to the real engine in litert_lm_worker.ts. ZERO changes to the
// battery: the adapter body is untouched; it just drives this proxy instead of a same-thread Engine.
//
// The recycle-as-total-teardown property falls out for free: adapter.dispose() awaits engine.delete()
// (which here posts `delete` AND terminates the worker), and adapter.preload() calls this factory again
// (which spawns a FRESH worker). So the existing `recycle() = dispose()+preload()` becomes
// `worker.terminate()` + respawn with no adapter change — the whole point of the migration.
//
// Envelope: id-correlated one-shot promises for init/createConversation/send/cancel/delete (mirrors
// agent_sqlite_worker.ts), PLUS a PERSISTENT streamId-keyed map for streaming generation (which is
// high-frequency, not one-shot). Errors cross as STRINGS and are reconstructed into Error objects here so
// the adapter's toLiteRtGenerationError classification (E_LITERT_LM_CONTEXT_OVERFLOW / GPU-OOM) still works
// on the message signature.
// #endregion proxy_header

import type { LitertWorkerRequest, LitertWorkerReply, LitertWorkerEvent } from './litert_lm_worker'
import type {
  CreateLiteRtLmEngine,
  LiteRtLmEngine,
  LiteRtLmConversation,
  LiteRtMessage,
  LiteRtEngineSettings,
  LiteRtConversationConfig,
} from '@nhtio/adk/batteries/llm/litert_lm'

// Distributive Omit: `Omit<Union, K>` collapses a discriminated union to its COMMON keys, which would
// erase each op's own fields. Distributing over the union preserves each variant's shape minus `id`.
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never
/** A worker request without its `id` (minted by {@link WorkerEngineProxy.#call}). */
type WorkerRequestBody = DistributiveOmit<LitertWorkerRequest, 'id'>

/** Hooks the host wires once; they survive every respawn (the factory closes over them). */
export interface WorkerEngineHooks {
  /** Fired when the worker's sentinel WebGPU device is lost (TDR / GPU-process crash / forced destroy).
   *  The engine is dead; the host escalates (respawn worker, or page-reload on repeated loss). */
  onDeviceLost?: (info: { reason: string }) => void
  /** Forward the worker's engine load-progress events (LiteRT 0.13.1 emits none; forward-looking seam). */
  onProgress?: (report: unknown) => void
}

let workerSeq = 0
const nextId = (): string => `w${(workerSeq += 1)}`

// #region spawn_worker
/** Construct the classic Worker for the self-hosted LiteRT engine bundle. Client-only.
 *
 * CLASSIC worker (no {type:'module'}): the LiteRT Emscripten glue calls importScripts(), illegal in a
 * module worker. RELATIVE URL only (import.meta.env.BASE_URL + window.location.origin) — no hard-coded
 * host — so it resolves under both a local '/' base and a GitLab Pages subpath. Mirrors webllm_worker_url.ts
 * but WITHOUT the module flag. */
function spawnWorker(): Worker {
  if (typeof window === 'undefined') {
    throw new Error('LiteRT worker is client-only (no SSR)')
  }
  const url = new URL(`${import.meta.env.BASE_URL}repl/litert-lm-worker.js`, window.location.origin)
    .href
  // NB: NO { type: 'module' } — see the module-doc above.
  return new Worker(url)
}
// #endregion spawn_worker

interface PendingCall {
  resolve: (reply: LitertWorkerReply) => void
  reject: (err: Error) => void
}

interface StreamSink {
  push: (content: LiteRtMessage['content']) => void
  end: () => void
  error: (message: string) => void
}

/**
 * A live worker-backed engine proxy. One instance owns exactly one Worker; `delete()` tears it down. A
 * fresh instance (new worker) is minted per `createEngine` call, so `dispose()+preload()` = respawn.
 */
class WorkerEngineProxy {
  readonly #worker: Worker
  readonly #hooks: WorkerEngineHooks
  readonly #pending = new Map<string, PendingCall>()
  readonly #streams = new Map<string, StreamSink>()
  #terminated = false
  #deviceLost = false

  constructor(hooks: WorkerEngineHooks) {
    this.#hooks = hooks
    this.#worker = spawnWorker()
    this.#worker.addEventListener('message', this.#onMessage)
    this.#worker.addEventListener('error', this.#onError)
  }

  #onMessage = (ev: MessageEvent<LitertWorkerReply | LitertWorkerEvent>): void => {
    const msg = ev.data
    if (!msg || typeof (msg as { kind?: unknown }).kind !== 'string') return
    // Correlated one-shot replies carry an `id`; unsolicited events do not.
    if ('id' in msg && typeof (msg as LitertWorkerReply).id === 'string') {
      const reply = msg as LitertWorkerReply
      const pending = this.#pending.get(reply.id)
      if (!pending) return
      this.#pending.delete(reply.id)
      if (reply.kind === 'error') {
        pending.reject(new Error(reply.error ?? 'LiteRT worker error'))
      } else {
        pending.resolve(reply)
      }
      return
    }
    const event = msg as LitertWorkerEvent
    switch (event.kind) {
      case 'ev:delta': {
        this.#streams.get(event.streamId)?.push(event.content)
        return
      }
      case 'ev:streamEnd': {
        const sink = this.#streams.get(event.streamId)
        this.#streams.delete(event.streamId)
        sink?.end()
        return
      }
      case 'ev:streamError': {
        const sink = this.#streams.get(event.streamId)
        this.#streams.delete(event.streamId)
        sink?.error(event.error)
        return
      }
      case 'ev:progress': {
        this.#hooks.onProgress?.(event.report)
        return
      }
      // #region device_lost
      case 'ev:deviceLost': {
        if (this.#deviceLost) return
        this.#deviceLost = true
        // Fail every in-flight call + stream — the engine is dead; nothing will complete.
        const lostErr = `WebGPU device lost: ${event.reason}`
        for (const [, p] of this.#pending) p.reject(new Error(lostErr))
        this.#pending.clear()
        for (const [, s] of this.#streams) s.error(lostErr)
        this.#streams.clear()
        this.#hooks.onDeviceLost?.({ reason: event.reason })
        return
      }
      // #endregion device_lost
    }
  }

  #onError = (ev: ErrorEvent): void => {
    // A worker-level uncaught error / spawn failure. Fail everything in flight with the message.
    const message = ev.message || 'LiteRT worker crashed'
    for (const [, p] of this.#pending) p.reject(new Error(message))
    this.#pending.clear()
    for (const [, s] of this.#streams) s.error(message)
    this.#streams.clear()
  }

  #call(req: WorkerRequestBody): Promise<LitertWorkerReply> {
    if (this.#terminated) {
      return Promise.reject(new Error('LiteRT worker has been terminated'))
    }
    const id = nextId()
    return new Promise<LitertWorkerReply>((resolve, reject) => {
      this.#pending.set(id, { resolve, reject })
      this.#worker.postMessage({ id, ...req } as LitertWorkerRequest)
    })
  }

  async init(engineSettings: LiteRtEngineSettings): Promise<void> {
    await this.#call({ op: 'init', engineSettings })
  }

  /** DEV/TEST ONLY: simulate a device loss to exercise the escalation ladder. */
  async forceDeviceLost(): Promise<void> {
    await this.#call({ op: '__forceDeviceLost' })
  }

  // ── Engine interface the adapter drives ──────────────────────────────────────────────────────

  async createConversation(config?: LiteRtConversationConfig): Promise<LiteRtLmConversation> {
    const convId = nextId()
    await this.#call({ op: 'createConversation', convId, config })
    return this.#conversation(convId) as unknown as LiteRtLmConversation
  }

  async delete(): Promise<void> {
    // Post `delete` (engine.delete() in the worker) then TOTALLY tear down the worker — the OS-level op
    // that scrubs the WebGPU device + WASM heap + every dead ref (the recovery a main-thread dispose can't
    // do). Best-effort: if the worker is already wedged, terminate regardless.
    try {
      await this.#call({ op: 'delete' })
    } catch {
      /* worker may be dead already; terminate regardless */
    }
    this.#teardown()
  }

  #teardown(): void {
    if (this.#terminated) return
    this.#terminated = true
    this.#worker.removeEventListener('message', this.#onMessage)
    this.#worker.removeEventListener('error', this.#onError)
    try {
      this.#worker.terminate()
    } catch {
      /* ignore */
    }
  }

  #conversation(convId: string): {
    sendMessageStreaming: (messages: unknown) => ReadableStream<LiteRtMessage>
    sendMessage: (messages: unknown) => Promise<LiteRtMessage>
    cancel: () => void
  } {
    const proxy = this
    return {
      // SYNC (matches the real Conversation.sendMessageStreaming): return a real ReadableStream
      // immediately, fed by streamId-keyed ev:delta events. The adapter's stream-reading loop is
      // unchanged. A worker-side synchronous throw (e.g. context overflow) arrives as an ev:streamError
      // carrying the ORIGINAL message, which errors this stream — so toLiteRtGenerationError classifies it.
      sendMessageStreaming(messages: unknown): ReadableStream<LiteRtMessage> {
        const streamId = nextId()
        return new ReadableStream<LiteRtMessage>({
          start(controller): void {
            proxy.#streams.set(streamId, {
              push: (content) => {
                try {
                  controller.enqueue({ content } as LiteRtMessage)
                } catch {
                  /* controller may be closed if the reader cancelled */
                }
              },
              end: () => {
                try {
                  controller.close()
                } catch {
                  /* already closed */
                }
              },
              error: (message) => {
                try {
                  controller.error(new Error(message))
                } catch {
                  /* already errored/closed */
                }
              },
            })
            // Kick off the worker-side stream. A rejected `ok` (worker unreachable) errors the stream.
            proxy.#call({ op: 'sendStreaming', convId, streamId, messages }).catch((err: Error) => {
              const sink = proxy.#streams.get(streamId)
              proxy.#streams.delete(streamId)
              sink?.error(err.message)
            })
          },
          cancel(): void {
            proxy.#streams.delete(streamId)
            // Best-effort: tell the worker to stop generating.
            void proxy.#call({ op: 'cancel', convId }).catch(() => undefined)
          },
        })
      },
      async sendMessage(messages: unknown): Promise<LiteRtMessage> {
        const reply = await proxy.#call({ op: 'send', convId, messages })
        return (reply.message ?? { content: '' }) as LiteRtMessage
      },
      cancel(): void {
        void proxy.#call({ op: 'cancel', convId }).catch(() => undefined)
      },
    }
  }
}

/**
 * Build the injectable `createEngine` factory that runs the LiteRT engine in a disposable Worker.
 *
 * @param hooks - Device-loss + progress callbacks, wired ONCE by the host and reused across every respawn.
 * @returns A [`CreateLiteRtLmEngine`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/litert_lm/types/type-aliases/CreateLiteRtLmEngine) the adapter calls on each (re)load. Each call spawns a fresh
 *   worker, boots the engine inside it, and returns a proxy engine; `engine.delete()` terminates the worker.
 */
export function createWorkerEngine(hooks: WorkerEngineHooks = {}): CreateLiteRtLmEngine {
  return async ({ engineSettings, onInitProgress }) => {
    const proxy = new WorkerEngineProxy({
      onDeviceLost: hooks.onDeviceLost,
      onProgress: (report) => {
        hooks.onProgress?.(report)
        onInitProgress?.(report)
      },
    })
    await proxy.init(engineSettings as LiteRtEngineSettings)
    return proxy as unknown as LiteRtLmEngine
  }
}

/** Narrow a resolved engine to the worker proxy for dev/test hooks (forceDeviceLost). Returns undefined
 *  when the engine is not a worker proxy. Duck-typed (not `instanceof`) — the resolved engine crosses the
 *  battery boundary as an opaque `LiteRtLmEngine`, and this is a same-module private class; a structural
 *  check on the dev-only `forceDeviceLost` method is both sufficient and realm-safe. */
export function asWorkerEngineProxy(
  engine: unknown
): { forceDeviceLost: () => Promise<void> } | undefined {
  return (
    // eslint-disable-next-line adk/prefer-is-object -- this module must not import @nhtio/adk source (agent/* consume ADK only via the runtime-populated agent_adk.ts holders, undefined at module-eval); a bare typeof check keeps this dev-only narrow dependency-free.
    engine !== null &&
      typeof engine === 'object' &&
      typeof (engine as { forceDeviceLost?: unknown }).forceDeviceLost === 'function'
      ? (engine as { forceDeviceLost: () => Promise<void> })
      : undefined
  )
}

```

#### gpu\_loss\_policy.ts

```ts
// GPU device-loss escalation policy for the worker-hosted LiteRT engine.
//
// When the worker's sentinel WebGPU device is lost (driver TDR / GPU-process crash), the engine is dead
// and no in-flight or future turn can complete on that worker. Recovery is a LADDER:
//
//   1st loss in the window  → RESPAWN the worker (worker.terminate() + a fresh worker/engine). A single
//                             transient TDR is recoverable this way — a clean GPU context from scratch.
//   2nd loss in the window  → full PAGE RELOAD. Chrome blocks new GPU adapters after 2 GPU-process crashes
//                             in ~2 min ("device lost" persists), so only a reload clears it. Conversation
//                             history lives in SQLite, so a reload is LOSSLESS — the user resumes the thread.
//
// The window is a sliding 120s: two losses spaced far apart are each treated as a first (transient) loss,
// not an escalation, so an occasional TDR over a multi-hour session never forces a reload.
//
// This module is pure policy (no DOM beyond the injected reload fn) so it is unit-testable. The host
// (agent_runtime.ts) owns the respawn action and passes reload; the dialog surfaces the reload with a banner.

/** The escalation the policy decided for a single device-loss event. */
export type GpuLossAction = 'respawn' | 'reload'

/** How long two losses must fall within to count as an escalation (Chrome's ~2-min GPU-crash window). */
export const GPU_LOSS_WINDOW_MS = 120_000

export interface GpuLossPolicyOptions {
  /** Sliding window; a loss older than this no longer counts toward escalation. Default 120_000. */
  windowMs?: number
  /** Injected clock (for tests). Default `Date.now`. */
  now?: () => number
}

/**
 * Sliding-window device-loss escalation decider.
 *
 * @remarks
 * Call {@link GpuLossPolicy.record} once per `ev:deviceLost`. It returns `'respawn'` for the first loss in
 * the window and `'reload'` for a second (or later) loss still inside the window. Prunes losses older than
 * the window so the counter reflects only the recent burst.
 */
export class GpuLossPolicy {
  readonly #windowMs: number
  readonly #now: () => number
  #timestamps: number[] = []

  constructor(options: GpuLossPolicyOptions = {}) {
    this.#windowMs = options.windowMs ?? GPU_LOSS_WINDOW_MS
    this.#now = options.now ?? Date.now
  }

  /**
   * Record a device-loss event and return the escalation action.
   *
   * @returns `'respawn'` for the first loss in the current window, `'reload'` for a repeat inside it.
   */
  record(): GpuLossAction {
    const t = this.#now()
    // Drop losses that have aged out of the window.
    this.#timestamps = this.#timestamps.filter((ts) => t - ts < this.#windowMs)
    this.#timestamps.push(t)
    return this.#timestamps.length >= 2 ? 'reload' : 'respawn'
  }

  /** Number of losses currently inside the window (observability/tests). */
  get recentCount(): number {
    const t = this.#now()
    return this.#timestamps.filter((ts) => t - ts < this.#windowMs).length
  }

  /** Clear the loss history (e.g. after a clean recovery + a settled session). */
  reset(): void {
    this.#timestamps = []
  }
}

```

#### agent\_litert\_adapter.ts

```ts
import { recordWire } from './agent_wiretap'
import { renderArtifactHandleBodyJsonFirst } from './agent_artifact_handles'
import {
  LiteRtLmAdapter,
  OpfsSpoolReader,
  OpfsSpoolStore,
  SPOOL_READER_TAG_OPFS,
  defaultRenderTrustedContent,
  defaultRenderUntrustedContent,
  registerAdkEncodables,
  registerSpoolReaderResolver,
  renderLiteRtToolResult,
} from './agent_adk'
import type { ModelConfig } from './agent_models'
import type { SpoolStore } from '@nhtio/adk/common'
import type { AgentEvents, HarnessAdapter } from './agent_harness_types'
import type { LiteRtLmAdapterOptions } from '@nhtio/adk/batteries/llm/litert_lm'

// One-time `@nhtio/encoder` wiring so a persisted ToolCall round-trips with its SpooledArtifact result
// as a LIVE handle. registerAdkEncodables() teaches the encoder every ADK class; the OPFS resolver
// re-binds a `spool:opfs` locator { name } to a fresh OpfsSpoolReader against the OPFS root — so decode
// yields a lazy, zero-at-rest artifact pointing at the same durable bytes (no asString(), no copy).
//
// The resolver type is SYNCHRONOUS — `(locator) => SpoolReader`. The flydrive battery handles this by
// closing the resolver over a `const disk = new Disk(...)` resolved up front; we do the SAME for OPFS:
// resolve the OPFS root ONCE here (it is stable for the session), capture it in a const, and the sync
// resolver builds the reader from the already-live root. The only deferred I/O is `getFileHandle(name)`
// on the reader's first byte-touch (which only happens when the model actually queries the artifact).
// This is why ensureEncoderWiring is async and awaited before the first decode.
let encoderWiringPromise: Promise<void> | null = null
export function ensureEncoderWiring(): Promise<void> {
  if (!encoderWiringPromise) {
    encoderWiringPromise = (async () => {
      registerAdkEncodables()
      // OPFS is browser-only. In the Node harness (no navigator.storage) there is no OPFS root and no
      // persisted-artifact rehydration (fresh in-memory store per run), so skip the spool-reader resolver
      // entirely rather than crash on getDirectory(). The browser path is unchanged.
      if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return
      const root = await navigator.storage.getDirectory()
      registerSpoolReaderResolver(SPOOL_READER_TAG_OPFS, (locator) => {
        const { name } = locator as { name: string }
        // Lazy handle over the PRE-RESOLVED root (mirrors FlydriveSpoolReader(disk, key)): no per-call
        // getDirectory(); getFile() only opens the file when the reader first reads.
        const handle = {
          kind: 'file' as const,
          name,
          getFile: async () => {
            const fileHandle = await root.getFileHandle(name)
            return fileHandle.getFile()
          },
          createWritable: () => {
            throw new Error('rehydrated OPFS artifact handles are read-only')
          },
        }
        return new OpfsSpoolReader(handle as never, { name } as never)
      })
    })()
  }
  return encoderWiringPromise
}

export function makeDefaultSpoolStore(): SpoolStore {
  return new OpfsSpoolStore({ keyPrefix: 'agent-spool-' }) as unknown as SpoolStore
}

export function makeLiteRtAdapter(
  modelConfig: ModelConfig,
  engineConfig: LiteRtLmAdapterOptions,
  events: AgentEvents,
  spoolStoreFactory: () => SpoolStore
): HarnessAdapter {
  const adapter = new LiteRtLmAdapter({
    // The hosted `.litertlm` URL (LiteRT-web Gemma build). LiteRT loads it via Engine.create —
    // no device/dtype (the runtime owns GPU placement; there is no ONNX wasm32 heap to pin around).
    model: modelConfig.model,
    // DISPOSABLE-WORKER ENGINE (the WebGPU device-loss fix). Inject a createEngine factory that runs the
    // LiteRT engine inside a disposable Web Worker instead of Engine.create() on the main thread. Long
    // E4B sessions crash at a browser-level WebGPU device loss (driver TDR); on the main thread our
    // recovery `recycle()=dispose()+preload()` reuses the DEAD GPUAdapter (dispose only nulls JS refs) →
    // "A valid external Instance reference no longer exists" cascade, every later turn dies. Hosting the
    // engine in a worker turns dispose() into worker.terminate() (total teardown of the GPU context +
    // WASM heap) and preload() into a fresh worker spawn — so the EXISTING recycle path becomes
    // terminate+respawn with ZERO battery changes. The onDeviceLost hook drives the escalation ladder
    // (respawn → page reload). See litert_lm_worker_proxy.ts + gpu_loss_policy.ts. NB: no-op on SSR /
    // non-browser (the factory is client-only), matching every other browser-path option here.
    createEngine: engineConfig.createEngine,
    stream: true,
    // Override the artifact-handle renderer so the forged reader list leads with a CONTENT reader
    // (artifact_json_get/keys, artifact_head) instead of the library's metadata-only
    // artifact_json_type — a 2B grabs the first listed tool. Presentation-only (same tools, same
    // body); NOT inlining. See renderArtifactHandleBodyJsonFirst / ARTIFACT_READER_PRIORITY.
    helpers: {
      renderArtifactHandleBody: renderArtifactHandleBodyJsonFirst,
    },
    // NOTE: no forgeToolsFilter. Artifact readers are now forged into ctx.tools by the CORE before the
    // subtractive pass runs, so the pass sees the full forged set and sheds the exotic readers by budget
    // rank (TOOL_SHED_ORDER) when the window is tight — no battery-side pre-narrowing needed.
    // Thinking is user-controlled (default OFF — it burns the tool loop). The reasoning parser
    // (cfg.reasoningParser) stays active regardless so a model that thinks anyway has its CoT
    // routed to the thoughts channel rather than leaking into the answer.
    enableThinking: engineConfig.enableThinking,
    // autoAck OFF — the harness owns termination (dispatchOutput). Why not autoAck: a plain
    // prose reply is allowed for CONVERSATION, but a prose reply that is actually a
    // documentation ANSWER must be cited. autoAck would end the turn on prose before we can
    // tell the two apart; instead a classifier guard (1-token "is this an ADK answer? 0/1")
    // inspects prose and, if it's an uncited doc answer, re-loops the model toward
    // provide_answer. Cited answers (provide_answer) and honest IDK ack via `captured`.
    autoAck: false,
    toolCallParser: modelConfig.toolCallParser as never,
    reasoningParser: modelConfig.reasoningParser as never,
    // Tool definitions go in the PROMPT, not the native `preface.tools` slot — the gemma-4
    // `.litertlm` chat template throws `template:80` on native tools. The 'auto' tool-call parser
    // already covers Gemma's runtime `call:NAME{…}` / pythonic format.
    toolDelivery: 'prompt',
    // Sampling — top-p (nucleus) at Gemma's recommended temp/top_p. The LiteRT-web Gemma `.litertlm`
    // build REJECTS top_k > 1 at runtime ("Top-K value N must be <= 1"), so we pin topK:1 and let
    // top_p + temperature drive diversity. (Greedy alone produces degenerate output on a 2B; nucleus
    // sampling at temp 1.0 keeps it coherent.)
    sampler: 'top-p',
    temperature: 1.0,
    topP: 0.95,
    topK: 1,
    // Output ceiling — user-controlled (the max-output slider), clamped by the dialog to
    // [enough-to-call-a-tool, contextWindow/4]. A doc assistant needs room for a full cited
    // answer, but a too-large reserve starves RAG/history and inflates KV-cache VRAM.
    maxTokens: engineConfig.maxTokens,
    // LiteRT's engine context cap (maxNumTokens) is FIXED at load and the runtime hard-rejects a
    // prompt that exceeds it (`Input token ids are too long … >= N`) — NOT an OOM, a length error.
    // It must cover the WHOLE prompt (subtractive-pass input window + the model's own output), so we
    // size it to the agent's MAX input window + max output, set once here at construction. The
    // per-turn window slider stays <= this; we never raise it per-turn (the engine can't grow after
    // load). See the LiteRT maxNumTokens note.
    maxNumTokens: engineConfig.maxNumTokens,
    // ── Arm the battery's pre-dispatch overflow guard (surface, don't impose) ──
    // Count with the engine's OWN tokenizer (Gemma, 256k SentencePiece) — NOT a cl100k proxy — so the
    // guard's tally matches what the engine actually measures. GUARD = THRIFT WINDOW (Option A): the
    // guard's contextWindow is the turn's ACTUAL input budget (#contextWindow − output reserve), NOT
    // the engine cap. This is the CONSTRUCTOR fallback; every real dispatch overrides it per-call with
    // the current #contextWindow (which the retry ladder mutates) — see the worker/planner executor
    // notes. Sizing the guard to the engine cap (the old code, a summarizer's "backstop" drift from the
    // intent) left a silent overshoot zone between the 8192 thrift target and the 11776 engine guard
    // where over-budget turns rode un-caught until they tipped the engine limit. maxNumTokens above is
    // the separate, real engine hard-limit.
    tokenEncoding: 'gemma',
    contextWindow: engineConfig.contextWindow,
    // Spool tool output to OPFS (durable artifact bytes survive reload) instead of the
    // per-dispatch in-memory default. NOTE: OpfsSpoolStore's keyPrefix is a FILENAME prefix,
    // NOT a subdirectory — it becomes `keyPrefix + callId` as a single OPFS file name, and a
    // '/' is ILLEGAL in an OPFS name (getFileHandle throws "Name is not allowed"). A trailing
    // slash here silently turned EVERY tool result into that error string, which the model then
    // read back and tried to "answer" — derailing every turn. Use a slash-free prefix.
    spoolStore: spoolStoreFactory() as never,
    // LiteRT-web is TEXT-ONLY (no image/audio input) — there is no multimodal config.
    // LiteRT's load progress is provider-opaque (no per-file %). We can't drive a percentage bar,
    // so we surface an INDETERMINATE load signal (pct: null) — the dialog renders a spinner. The
    // coarse loading/compiling/ready lifecycle phases still drive the phase indicator.
    onInitProgress: () => {
      events.onLoadProgress?.({ file: '', pct: null })
    },
    // Forward the WebGPU device budget the battery probes on `ready` — the observability half of
    // "surface, don't impose": the dialog shows the user their device's per-allocation ceiling so
    // they can pick a context window with eyes open, rather than the battery silently capping it.
    onReady: (report: { gpuBudget?: { maxBufferBytes: number; available: boolean } }) => {
      const b = report.gpuBudget
      if (b) {
        events.onGpuBudget?.({
          maxBufferMB: Math.round(b.maxBufferBytes / (1024 * 1024)),
          available: b.available,
        })
      }
    },
    // Raw-generation tap (battery observability seam). Dogfoods the new `onRawGeneration` hook: it
    // records what the model literally emitted vs. what the parser extracted, so a future "why did
    // it abstain / why is there raw markup?" is one console line away instead of a temporary adapter
    // patch. A small-model tool call in an unparsed shape shows up here as a non-empty `rawText`
    // with empty `toolCalls` and the call text still sitting in `cleanedText` — the exact signature
    // that turned out to be the real cause of the "abstention" bug.
    onRawGeneration: (o: {
      rawText: string
      cleanedText: string
      toolCalls: ReadonlyArray<{ name: string }>
      streamId?: string
    }) => {
      const ring = ((
        globalThis as unknown as { __agentRawGenerations?: unknown[] }
      ).__agentRawGenerations ??= [])
      ring.push({
        raw: o.rawText,
        clean: o.cleanedText,
        tools: o.toolCalls.map((c) => c.name),
      })
      if (ring.length > 20) ring.shift()
      // Persistent raw-wire tap (FROM the model) — dev/preview only; tree-shaken from the deployed
      // build. Stores the LITERAL model output so we read ground truth, not the markdown-rendered DOM.
      if (__ADK_WIRETAP__) {
        recordWire({
          dir: 'from',
          battery: 'litert_lm',
          streamId: o.streamId ?? '',
          kind: 'generation',
          payload: o.rawText,
        })
      }
    },
    // Prompt-assembled tap (TO the model) — the exact prompt bytes going in, dev/preview only.
    onPromptAssembled: (o: {
      battery: string
      kind: string
      streamId: string
      preface?: unknown
      messages: unknown
      tools?: unknown
    }) => {
      if (__ADK_WIRETAP__) {
        recordWire({
          dir: 'to',
          battery: o.battery,
          streamId: o.streamId,
          kind: o.kind,
          payload: {
            preface: o.preface,
            messages: o.messages,
            tools: o.tools,
          },
        })
      }
    },
  })
  // MEASURE TOOL RESULTS AS THE LiteRT OVERFLOW GUARD DOES. The guard tallies each rendered tool result
  // via renderLiteRtToolResult → the trust-ENVELOPED body (<untrusted_content_<nonce> …>body</…>), which
  // adds ~200 tok/result over the bare handle body the subtractive pass measured by default. Left
  // unmeasured, the pass reported "fits" (bare) while the guard overflowed (enveloped) — the browser
  // deep-forge-turn parity gap (proven on the wire: toolResults pass=373 vs guard=570). Attach the same
  // measureToolResultAsText the Ollama harness uses so the pass's toolCalls bucket EQUALS the guard's
  // timeline contribution. `tool` undefined: the envelope framing derives from the toolCall + results
  // (artifact-vs-plain body from the result's own shape), not the tool definition. warn is a no-op —
  // measurement must never throw. Returns the enveloped `.content` string (the async render is awaited
  // by the pass's workingToolCalls build).
  const litertAdapter = adapter as unknown as {
    measureToolResultAsText?: (tc: unknown) => Promise<string>
  }
  litertAdapter.measureToolResultAsText = async (tc: unknown): Promise<string> => {
    const item = await renderLiteRtToolResult({
      toolCall: tc as never,
      results: (tc as { results?: unknown }).results as never,
      tool: undefined,
      renderUntrustedContent: defaultRenderUntrustedContent,
      renderTrustedContent: defaultRenderTrustedContent,
      unsupportedMediaPolicy: 'synthetic-description',
      warn: () => undefined,
    })
    return (
      (item as { tool_response?: { response?: { content?: string } } }).tool_response?.response
        ?.content ?? ''
    )
  }
  return adapter
}

```

#### agent\_persistence\_callbacks.ts

```ts
import { KEEP_RECENT_TURNS, THOUGHT_REPLAY_LIMIT } from './agent_turn_policy'
import { convToMessage, memRecordToMemory, toolRecordToToolCall } from './agent_record_mappers'
import {
  selectRelevantTurns,
  selectNaiveTurns,
  groupHistoryIntoTurns,
  type ThriftHistoryTurnType,
} from './agent_adk'
import {
  encode,
  inMemoryMediaReader,
  Memory,
  Retrievable,
  Thought,
  Tokenizable,
  type TurnRunnerConfig,
} from './agent_adk'
import {
  fetchMessages,
  listMemories,
  storeMemory,
  mutateMemory,
  deleteMemory,
  listStandingInstructions,
  storeStandingInstruction,
  deleteStandingInstructionByText,
  listToolCalls,
  storeToolCall,
  storeMedia,
  listThoughtsForConversation,
  type ConversationMessage,
  type ToolCallRecord,
} from './agent_store_facade'
import type { TurnState } from './agent_turn_state'

type StoreMediaBytesCallback = TurnRunnerConfig['storeMediaBytesCallback']

export interface TurnCallbacks {
  noop2: (_a: unknown, _b: unknown) => Promise<void>
  storeMediaBytesCallback: StoreMediaBytesCallback
  getSelectedTurns: () => Promise<Array<ThriftHistoryTurnType<ConversationMessage, ToolCallRecord>>>
  fetchMessagesCallback: TurnRunnerConfig['fetchMessagesCallback']
  fetchMemoriesCallback: TurnRunnerConfig['fetchMemoriesCallback']
  fetchToolCallsCallback: TurnRunnerConfig['fetchToolCallsCallback']
  fetchThoughtsCallback: TurnRunnerConfig['fetchThoughtsCallback']
  fetchRetrievablesCallback: TurnRunnerConfig['fetchRetrievablesCallback']
  refreshStandingInstructionsCallback: TurnRunnerConfig['refreshStandingInstructionsCallback']
  storeMemoryCallback: TurnRunnerConfig['storeMemoryCallback']
  mutateMemoryCallback: TurnRunnerConfig['mutateMemoryCallback']
  deleteMemoryCallback: TurnRunnerConfig['deleteMemoryCallback']
  storeStandingInstructionCallback: TurnRunnerConfig['storeStandingInstructionCallback']
  deleteStandingInstructionCallback: TurnRunnerConfig['deleteStandingInstructionCallback']
  storeMessageCallback: TurnRunnerConfig['storeMessageCallback']
  storeToolCallCallback: TurnRunnerConfig['storeToolCallCallback']
}

export function makeTurnCallbacks(deps: {
  convId: string
  state: TurnState
  contextWindow: number
  maxTokens: number
  contextStrategy: 'thrift' | 'compact' | 'naive'
  inputText: string
  ensureEncoderWiring: () => Promise<void>
  assembleCompactedTurns: (
    turns: ReadonlyArray<ThriftHistoryTurnType<ConversationMessage, ToolCallRecord>>
  ) => Promise<Array<ThriftHistoryTurnType<ConversationMessage, ToolCallRecord>>>
  getPrefetchedHits: () => Promise<
    Array<{ id?: string; title: string; content: string; pageUrl: string; score: number }>
  >
  readToolResultPreview: (results: unknown) => Promise<string | undefined>
  readModelFacingPreview: (tc: {
    results?: unknown
    inline?: boolean
    fromArtifactTool?: boolean
  }) => Promise<string | undefined>
}): TurnCallbacks {
  const {
    convId,
    state,
    contextWindow,
    maxTokens,
    contextStrategy,
    inputText,
    ensureEncoderWiring,
    assembleCompactedTurns,
    getPrefetchedHits,
    readToolResultPreview,
    readModelFacingPreview,
  } = deps

  // TurnRunnerConfig validates callback ARITY (fetch/refresh = 1, store/mutate/delete = 2,
  // bytes = 3), so the unused stubs must declare the right number of params — not arity-0.
  const noop2 = async (_a: unknown, _b: unknown): Promise<void> => undefined

  // storeMediaBytes: TOOL-GENERATED media (e.g. a TTS clip, a rendered chart). Persist the
  // bytes durably and return a re-openable MediaReader handle over the SAME bytes (the
  // contract: each stream() yields a fresh drainable stream). User-uploaded media is persisted
  // separately in run() above.
  const storeMediaBytesCallback = (async (
    _c: unknown,
    id: string,
    bytes: unknown
  ): Promise<ReturnType<typeof inMemoryMediaReader>> => {
    // Realm-safe binary check (avoids bare `instanceof Uint8Array`); ArrayBuffer.isView is
    // true for any typed-array/DataView view, which is what the bytes conduit yields.
    const u8 = ArrayBuffer.isView(bytes as ArrayBufferView)
      ? new Uint8Array(
          (bytes as ArrayBufferView).buffer,
          (bytes as ArrayBufferView).byteOffset,
          (bytes as ArrayBufferView).byteLength
        )
      : new Uint8Array(bytes as ArrayBufferLike)
    await storeMedia({
      id,
      conversationId: convId,
      kind: 'image',
      mimeType: 'application/octet-stream',
      origin: 'tool-generated',
      bytes: u8,
    })
    return inMemoryMediaReader(u8)
  }) as unknown as StoreMediaBytesCallback

  // CONTEXT ASSEMBLY BY RELEVANCE OVER ALL HISTORY (the continuity mechanism). Instead of replaying a
  // recent-N window of prior messages + tool calls, walk the ENTIRE conversation, group it into TURNS,
  // and select the turns RELEVANT to the current question (always keeping the most recent few for
  // coreference) — so a turn from far back can resurface when it matters now. fetchMessages and
  // fetchToolCalls both draw from this ONE selection, computed lazily once per dispatch and memoised so
  // the two callbacks stay consistent. Relevance is judged per TURN (its Q&A vs the query); every
  // primitive in a chosen turn — messages AND tool calls / artifact handles — inherits that turn's
  // relevance. (Artifact relevance MUST be inherited from the turn, not measured by inspecting the
  // artifact body — reading it would blow up context, defeating the spool/handle pattern.) Prior-turn
  // artifact handles are thus filtered ONLY when their turn is irrelevant, never dropped by default.
  const getSelectedTurns = (): Promise<
    Array<ThriftHistoryTurnType<ConversationMessage, ToolCallRecord>>
  > => {
    if (!state.selectedTurnsPromise) {
      state.selectedTurnsPromise = (async () => {
        // Decoding a persisted SpooledArtifact handle needs the encoder + OPFS reader resolver wired.
        await ensureEncoderWiring()
        // No limit → the WHOLE history (both fetches return chronological order).
        const [msgRows, tcRows] = await Promise.all([fetchMessages(convId), listToolCalls(convId)])
        const turns = groupHistoryIntoTurns<ConversationMessage, ToolCallRecord>(msgRows, tcRows)
        // HEAD-TO-HEAD strategy branch (confirmation-bias check; default 'thrift'). Each arm is a
        // middleware over the turn set: (turns) → turns-to-replay. thrift = relevance-select all history;
        // naive = FIFO-drop by recency to a budget; compact = keep recent verbatim + a running same-model
        // summary of everything older. The per-dispatch subtractive pass runs downstream for ALL arms.
        // History budget ≈ window − output reserve − a nominal tools/RAG allowance (the room prior turns
        // realistically get). The subtractive pass still trims the assembled prompt to the hard wall.
        // Shared by naive (FIFO drop-line) and thrift (window-pressure denominator for the scaled floor).
        const historyBudget = Math.max(512, contextWindow - maxTokens - 3000)
        const estimateTokens = (v: string, enc: string, ctx?: unknown): number =>
          Tokenizable.estimateTokens(v, enc as never, ctx as never)
        if (contextStrategy === 'naive') {
          return selectNaiveTurns(turns, historyBudget, { estimateTokens, encoding: 'gemma' })
        }
        if (contextStrategy === 'compact') {
          return assembleCompactedTurns(turns)
        }
        return selectRelevantTurns(turns, inputText, {
          keepRecent: KEEP_RECENT_TURNS,
          historyBudget,
          estimateTokens,
          encoding: 'gemma',
        })
      })()
    }
    return state.selectedTurnsPromise
  }

  // fetchMessages: the messages of the relevance-selected turns (model plane prefers model_content).
  // The just-persisted user turn is the most-recent turn, always kept.
  const fetchMessagesCallback = async (_ctx: unknown) => {
    const turns = await getSelectedTurns()
    return turns.flatMap((t) => t.messages).map(convToMessage)
  }
  // fetchMemories: every durable memory the agent has authored (via the memory battery tool).
  const fetchMemoriesCallback = async (_ctx: unknown): Promise<Memory[]> => {
    const rows = await listMemories()
    return rows.map(memRecordToMemory)
  }
  // fetchToolCalls: the tool calls of the relevance-selected turns (so a replayed artifact handle is one
  // whose ORIGINATING TURN is relevant now). Errored calls are still dropped — a failed call (e.g. a
  // stale-callId artifact_json_get) carries an error string, never a usable result, and replaying it is
  // exactly how the 2B grabbed a stale callId and parroted the error.
  const fetchToolCallsCallback = async (_ctx: unknown) => {
    const turns = await getSelectedTurns()
    return turns
      .flatMap((t) => t.toolCalls)
      .filter((tc) => !tc.isError)
      .map(toolRecordToToolCall)
  }
  // fetchThoughts: replay PRIOR-TURN reasoning into the model context — but ONLY the SYNTHETIC kinds
  // (plan + gate course-corrections). The model's OWN chain-of-thought (kind 'reasoning') is filtered
  // OUT: Gemma model card §3 forbids prior-turn thinking in history, and re-feeding a 2B its own past
  // CoT is pure noise. The synthetic plan/nudge thoughts are harness-authored THIS-product guidance, so
  // replaying the prior turn's reasoning trail is safe and helps continuity. (The UI shows all kinds;
  // the MODEL only gets the synthetic ones — same content/model split as everywhere else.)
  const fetchThoughtsCallback = async (_ctx: unknown): Promise<Thought[]> => {
    const rows = await listThoughtsForConversation(convId)
    const now = new Date().toISOString()
    return rows
      .filter((t) => t.kind !== 'reasoning' && t.kind !== 'checker')
      .filter((t) => typeof t.text === 'string' && t.text.trim().length > 0)
      .slice(-THOUGHT_REPLAY_LIMIT)
      .map(
        (t) =>
          new Thought({
            id: t.id,
            content: t.text,
            createdAt: t.createdAt || now,
            updatedAt: t.createdAt || now,
          })
      )
  }
  // fetchRetrievables: PRE-FETCH RAG from the user's intent — baseline grounding the model
  // gets WITHOUT having to ask (the Ask-ADK move). The search_docs_* tools are the escape
  // hatch for when this baseline is insufficient to satisfy the intent.
  const fetchRetrievablesCallback = async (_ctx: unknown): Promise<Retrievable[]> => {
    try {
      // Reuse the SAME memoized pre-retrieval the planner already ran (no double search).
      const hits = await getPrefetchedHits()
      const now = new Date().toISOString()
      return hits.map(
        (h) =>
          new Retrievable({
            // The id becomes the retrieval fence's NONCE (tag name). It must be unguessable + NON-path-shaped:
            // a path-shaped fallback like `rag-<pageUrl>#<anchor>` gets copied by the 2B as a citation and
            // rejected by isKnownDocPath → re-cite loop. Page provenance is carried by `source` below.
            id: h.id || crypto.randomUUID(),
            content: `${h.title}\n${h.content}`,
            trustTier: 'first-party', // our own documentation
            source: h.pageUrl,
            score: Math.max(0, Math.min(1, h.score)),
            createdAt: now,
            updatedAt: now,
          })
      )
    } catch {
      return []
    }
  }
  // refreshStandingInstructions: durable directives the agent honors every turn.
  const refreshStandingInstructionsCallback = async (_ctx: unknown): Promise<string[]> => {
    const rows = await listStandingInstructions()
    return rows.map((s) => s.modelText ?? s.text)
  }

  // Memory persistence → SQLite facade (the memory battery tools call ctx.store/mutate/delete).
  const storeMemoryCallback = async (_c: unknown, m: Memory): Promise<void> => {
    await storeMemory({
      id: m.id,
      text: m.content.toString(),
      importance: m.importance,
    })
  }
  const mutateMemoryCallback = async (_c: unknown, m: Memory): Promise<void> => {
    await mutateMemory({
      id: m.id,
      text: m.content.toString(),
      importance: m.importance,
    })
  }
  const deleteMemoryCallback = async (_c: unknown, id: string): Promise<void> => {
    await deleteMemory(id)
  }
  // Standing-instruction persistence → SQLite facade (battery tools key on content string).
  const storeStandingInstructionCallback = async (_c: unknown, v: unknown): Promise<void> => {
    const text =
      typeof v === 'string' ? v : String((v as { toString?: () => string })?.toString?.() ?? '')
    if (text.trim()) await storeStandingInstruction(text)
  }
  const deleteStandingInstructionCallback = async (_c: unknown, v: unknown): Promise<void> => {
    const text =
      typeof v === 'string' ? v : String((v as { toString?: () => string })?.toString?.() ?? '')
    if (text.trim()) await deleteStandingInstructionByText(text)
  }
  // Message persistence: the adapter calls this for each assistant PROSE message. We capture
  // the latest as the prose answer but do NOT persist here — the turn's final answer (prose, or
  // a captured cited answer) is persisted once after the loop, so intermediate iterations and
  // superseded prose never reach the human plane.
  const storeMessageCallback = async (
    _c: unknown,
    v: { role?: string; content?: { toString(): string } }
  ): Promise<void> => {
    if (v.role !== 'assistant') return
    const content = v.content?.toString?.() ?? ''
    if (content.trim()) state.proseAnswer = content
  }
  // Tool-call persistence: the adapter calls this once a tool completes — store it so it
  // replays into later turns. Best-effort result text for the replay envelope.
  const storeToolCallCallback = async (_c: unknown, tc: unknown): Promise<void> => {
    const t = tc as {
      id?: string
      tool?: string
      checksum?: string
      args?: unknown
      isError?: boolean
      results?: unknown
      inline?: boolean
      fromArtifactTool?: boolean
    }
    if (!t.checksum || !t.tool) return
    // Persist the ToolCall as an `@nhtio/encoder` snapshot: a SpooledArtifact result is captured as its
    // OPFS reader HANDLE (the locator), NOT its bytes, so the body is never materialised here and the
    // rehydrated artifact stays lazy + queryable across turns. `resultText` is a BOUNDED preview for
    // the human-facing chip only (a head() slice — never asString() on a large artifact).
    let encoded: string | undefined
    try {
      encoded = encode(tc as never)
    } catch {
      /* un-encodable result (e.g. a non-describable reader) → fall back to resultText replay */
    }
    await storeToolCall(convId, {
      id: t.id,
      tool: t.tool,
      args: (t.args as Record<string, unknown>) ?? {},
      checksum: t.checksum,
      isError: !!t.isError,
      // content/model_content dichotomy, tool-call edition. resultText = the FULL body (human /
      // replay view); modelResultText = what the MODEL actually saw — for a non-inlined
      // SpooledArtifact that's the HANDLE note, not the body it never received. The chip renders the
      // model-facing one so the showcase is faithful to the context the model worked from.
      resultText: await readToolResultPreview(t.results),
      modelResultText: await readModelFacingPreview(t),
      encoded,
    })
  }

  return {
    noop2,
    storeMediaBytesCallback,
    getSelectedTurns,
    fetchMessagesCallback,
    fetchMemoriesCallback,
    fetchToolCallsCallback,
    fetchThoughtsCallback,
    fetchRetrievablesCallback,
    refreshStandingInstructionsCallback,
    storeMemoryCallback,
    mutateMemoryCallback,
    deleteMemoryCallback,
    storeStandingInstructionCallback,
    deleteStandingInstructionCallback,
    storeMessageCallback,
    storeToolCallCallback,
  }
}

```

#### agent\_store\_facade.ts

```ts
// The CHAT-HISTORY facade over the Kysely/SQLite store. Same surface as Ask ADK's
// conversation store (so the harness's 25 ADK storage callbacks port across), but backed
// by typed SQL and carrying the dual representation: `content` (human/chat) +
// `modelContent` (token-dense, model-facing). The dialog renders `content`; the working-set
// fetch prefers `modelContent`. See memory chat_history_vs_context_window.

import { _getAgentDb as getDb, _isoNow as isoNow, _uid as uid } from './agent_swarm_store'

export interface ConversationMeta {
  id: string
  title: string
  createdAt: string
  updatedAt: string
  deletedAt?: string
}

export interface PersistedReference {
  id: string
  pageUrl: string
  anchor: string
  title: string
  headingPath: string[]
  excerpt: string
}

export interface PersistedActivityAttempt {
  attempt: number
  steps: unknown[]
  verdict?: { passed: boolean; valid: number; invalid: number; reason?: string }
}

export interface ConversationMessage {
  id: string
  role: 'user' | 'assistant'
  /** The human/chat representation — what the dialog renders. */
  content: string
  /** The token-dense, model-facing representation — preferred by the working-set fetch. */
  modelContent?: string
  createdAt: string
  references?: PersistedReference[]
  attempts?: PersistedActivityAttempt[]
}

export interface MemoryRecord {
  id: string
  text: string
  modelText?: string
  importance: number
  createdAt: string
  embedding?: number[]
}

export interface StandingInstructionRecord {
  id: string
  text: string
  modelText?: string
  createdAt: string
}

export interface ToolCallRecord {
  id: string
  conversationId: string
  tool: string
  /** Arguments as a plain object (parsed from JSON). */
  args: Record<string, unknown>
  checksum: string
  isError: boolean
  /**
   * The tool's FULL result body (the head() preview) — the human/replay view. Same content/model
   * dichotomy as messages: this is the verbose "what the tool produced" side.
   */
  resultText?: string
  /**
   * The result AS THE MODEL SAW IT. For a non-inlined SpooledArtifact this is the HANDLE note (the
   * model never got the body — it queried via artifact_* tools), so the chip shows the model's real
   * context, not content it didn't receive. Falls back to resultText when the two are identical
   * (inlined results). The model-plane analogue of message.modelContent.
   */
  modelResultText?: string
  /**
   * `@nhtio/encoder` snapshot of the whole ToolCall. A SpooledArtifact result is captured as its reader
   * HANDLE (the OPFS locator), not its bytes, so decoding rehydrates a lazy, zero-at-rest artifact the
   * forged `artifact_*` tools can query across turns. Preferred over `resultText` for replay.
   */
  encoded?: string
  createdAt: string
}

export interface ThoughtRecord {
  id: string
  conversationId: string
  /** The assistant message this turn's reasoning attaches to (set when the turn's message is persisted). */
  messageId?: string
  /** 'reasoning' (model CoT) | 'plan' (synthetic plan) | 'nudge' (gate course-correction). */
  kind: string
  text: string
  createdAt: string
}

export interface MediaRecord {
  id: string
  conversationId?: string
  /** The chat message this media is attached to (user upload) — null for tool-generated. */
  messageId?: string
  kind: string
  mimeType: string
  filename?: string
  /** 'user-upload' or 'tool-generated' — where the bytes came from. */
  origin: 'user-upload' | 'tool-generated'
  bytes: Uint8Array
  createdAt: string
}

export const DEFAULT_CONVERSATION_ID = 'agent-default'

// ── Conversations ────────────────────────────────────────────────────────────

export async function listConversations(): Promise<ConversationMeta[]> {
  const rows = await getDb()
    .selectFrom('conversations')
    .selectAll()
    .where('deleted_at', 'is', null)
    .orderBy('updated_at', 'desc')
    .execute()
  return rows.map(rowToConvMeta)
}

export async function createConversation(title: string): Promise<ConversationMeta> {
  const meta: ConversationMeta = {
    id: uid('conv'),
    title,
    createdAt: isoNow(),
    updatedAt: isoNow(),
  }
  await getDb()
    .insertInto('conversations')
    .values({
      id: meta.id,
      title,
      created_at: meta.createdAt,
      updated_at: meta.updatedAt,
      deleted_at: null,
    })
    .execute()
  return meta
}

export async function deleteConversation(convId: string): Promise<void> {
  const now = isoNow()
  await getDb()
    .updateTable('conversations')
    .set({ deleted_at: now, updated_at: now })
    .where('id', '=', convId)
    .execute()
  await getDb().deleteFrom('messages').where('conversation_id', '=', convId).execute()
}

export async function renameConversation(convId: string, title: string): Promise<void> {
  await getDb()
    .updateTable('conversations')
    .set({ title, updated_at: isoNow() })
    .where('id', '=', convId)
    .execute()
}

export async function ensureDefaultConversation(): Promise<string> {
  const existing = await getDb()
    .selectFrom('conversations')
    .select('id')
    .where('id', '=', DEFAULT_CONVERSATION_ID)
    .where('deleted_at', 'is', null)
    .executeTakeFirst()
  if (!existing) {
    const now = isoNow()
    // Upsert: a prior soft-delete row may exist.
    await getDb()
      .insertInto('conversations')
      .values({
        id: DEFAULT_CONVERSATION_ID,
        title: 'Agent',
        created_at: now,
        updated_at: now,
        deleted_at: null,
      })
      .onConflict((oc) =>
        oc.column('id').doUpdateSet({ deleted_at: null, updated_at: now, title: 'Agent' })
      )
      .execute()
  }
  return DEFAULT_CONVERSATION_ID
}

export async function clearAllData(): Promise<void> {
  const db = getDb()
  await db.deleteFrom('messages').execute()
  await db.deleteFrom('memories').execute()
  await db.deleteFrom('standing_instructions').execute()
  await db.deleteFrom('tool_calls').execute()
  await db.deleteFrom('thoughts').execute()
  await db.deleteFrom('media').execute()
  await db.deleteFrom('conversations').execute()
  await ensureDefaultConversation()
}

// ── Messages ───────────────────────────────────────────────────────────────

export async function fetchMessages(
  convId: string,
  limit?: number
): Promise<ConversationMessage[]> {
  let q = getDb()
    .selectFrom('messages')
    .selectAll()
    .where('conversation_id', '=', convId)
    .orderBy('created_at', 'asc')
  if (typeof limit === 'number') {
    // newest `limit` in chronological order
    const recent = await getDb()
      .selectFrom('messages')
      .selectAll()
      .where('conversation_id', '=', convId)
      .orderBy('created_at', 'desc')
      .limit(limit)
      .execute()
    return recent.reverse().map(rowToMessage)
  }
  const rows = await q.execute()
  return rows.map(rowToMessage)
}

export async function storeMessage(
  convId: string,
  msg: Omit<ConversationMessage, 'id' | 'createdAt'> & Partial<Pick<ConversationMessage, 'id'>>
): Promise<ConversationMessage> {
  const id = msg.id ?? uid('msg')
  const createdAt = isoNow()
  await getDb()
    .insertInto('messages')
    .values({
      id,
      conversation_id: convId,
      role: msg.role,
      content: msg.content,
      model_content: msg.modelContent ?? null,
      created_at: createdAt,
      references_json: msg.references ? JSON.stringify(msg.references) : null,
      attempts_json: msg.attempts ? JSON.stringify(msg.attempts) : null,
    })
    .execute()
  await getDb()
    .updateTable('conversations')
    .set({ updated_at: createdAt })
    .where('id', '=', convId)
    .execute()
  return {
    id,
    role: msg.role,
    content: msg.content,
    modelContent: msg.modelContent,
    createdAt,
    references: msg.references,
    attempts: msg.attempts,
  }
}

export async function mutateMessage(
  _convId: string,
  msgId: string,
  patch: Partial<Pick<ConversationMessage, 'content' | 'modelContent'>>
): Promise<void> {
  const set: Record<string, unknown> = {}
  if (patch.content !== undefined) set.content = patch.content
  if (patch.modelContent !== undefined) set.model_content = patch.modelContent
  if (Object.keys(set).length === 0) return
  await getDb().updateTable('messages').set(set).where('id', '=', msgId).execute()
}

export async function deleteMessage(_convId: string, msgId: string): Promise<void> {
  await getDb().deleteFrom('messages').where('id', '=', msgId).execute()
}

// ── Memories ───────────────────────────────────────────────────────────────

export async function listMemories(): Promise<MemoryRecord[]> {
  const rows = await getDb().selectFrom('memories').selectAll().execute()
  return rows.map(rowToMemory)
}

export async function storeMemory(
  record: Omit<MemoryRecord, 'id' | 'createdAt'> & Partial<Pick<MemoryRecord, 'id'>>
): Promise<MemoryRecord> {
  const id = record.id ?? uid('mem')
  const createdAt = isoNow()
  await getDb()
    .insertInto('memories')
    .values({
      id,
      content: record.text,
      model_content: record.modelText ?? null,
      importance: record.importance ?? null,
      created_at: createdAt,
      embedding: record.embedding ? f32ToBlob(record.embedding) : null,
    })
    .execute()
  return { ...record, id, createdAt }
}

export async function mutateMemory(
  record: Pick<MemoryRecord, 'id'> & Partial<Omit<MemoryRecord, 'id' | 'createdAt'>>
): Promise<void> {
  const set: Record<string, unknown> = {}
  if (record.text !== undefined) set.content = record.text
  if (record.modelText !== undefined) set.model_content = record.modelText
  if (record.importance !== undefined) set.importance = record.importance
  if (record.embedding !== undefined) set.embedding = f32ToBlob(record.embedding)
  if (Object.keys(set).length === 0) return
  await getDb().updateTable('memories').set(set).where('id', '=', record.id).execute()
}

export async function deleteMemory(id: string): Promise<void> {
  await getDb().deleteFrom('memories').where('id', '=', id).execute()
}

// ── Standing Instructions ──────────────────────────────────────────────────

export async function listStandingInstructions(): Promise<StandingInstructionRecord[]> {
  const rows = await getDb().selectFrom('standing_instructions').selectAll().execute()
  return rows.map(rowToStanding)
}

export async function storeStandingInstruction(
  text: string,
  modelText?: string
): Promise<StandingInstructionRecord> {
  const rec: StandingInstructionRecord = { id: uid('si'), text, modelText, createdAt: isoNow() }
  await getDb()
    .insertInto('standing_instructions')
    .values({
      id: rec.id,
      content: text,
      model_content: modelText ?? null,
      created_at: rec.createdAt,
    })
    .execute()
  return rec
}

export async function deleteStandingInstruction(id: string): Promise<void> {
  await getDb().deleteFrom('standing_instructions').where('id', '=', id).execute()
}

/** Delete by exact content — the standing-instruction battery tools key on content, not id. */
export async function deleteStandingInstructionByText(text: string): Promise<void> {
  await getDb().deleteFrom('standing_instructions').where('content', '=', text).execute()
}

// ── Tool calls (persisted across turns; recent ones replayed into new turns) ──

export async function listToolCalls(convId: string, limit?: number): Promise<ToolCallRecord[]> {
  if (typeof limit === 'number') {
    const recent = await getDb()
      .selectFrom('tool_calls')
      .selectAll()
      .where('conversation_id', '=', convId)
      .orderBy('created_at', 'desc')
      .limit(limit)
      .execute()
    return recent.reverse().map(rowToToolCall)
  }
  const rows = await getDb()
    .selectFrom('tool_calls')
    .selectAll()
    .where('conversation_id', '=', convId)
    .orderBy('created_at', 'asc')
    .execute()
  return rows.map(rowToToolCall)
}

export async function storeToolCall(
  convId: string,
  rec: Omit<ToolCallRecord, 'id' | 'conversationId' | 'createdAt'> &
    Partial<Pick<ToolCallRecord, 'id'>>
): Promise<ToolCallRecord> {
  const id = rec.id ?? uid('tc')
  const createdAt = isoNow()
  await getDb()
    .insertInto('tool_calls')
    .values({
      id,
      conversation_id: convId,
      tool: rec.tool,
      args_json: JSON.stringify(rec.args ?? {}),
      checksum: rec.checksum,
      is_error: rec.isError ? 1 : 0,
      result_text: rec.resultText ?? null,
      model_result_text: rec.modelResultText ?? null,
      encoded: rec.encoded ?? null,
      created_at: createdAt,
    })
    .onConflict((oc) => oc.column('id').doNothing())
    .execute()
  return { ...rec, id, conversationId: convId, createdAt }
}

// ── Thoughts (agent reasoning — durable, survive reload, part of turn history) ─

/** Persist one reasoning entry. `messageId` ties it to the turn's assistant message (set at turn end). */
export async function storeThought(
  convId: string,
  rec: Omit<ThoughtRecord, 'id' | 'conversationId' | 'createdAt'> &
    Partial<Pick<ThoughtRecord, 'id' | 'createdAt'>>
): Promise<ThoughtRecord> {
  const id = rec.id ?? uid('th')
  const createdAt = rec.createdAt ?? isoNow()
  await getDb()
    .insertInto('thoughts')
    .values({
      id,
      conversation_id: convId,
      message_id: rec.messageId ?? null,
      kind: rec.kind,
      text: rec.text,
      created_at: createdAt,
    })
    .onConflict((oc) => oc.column('id').doNothing())
    .execute()
  return {
    id,
    conversationId: convId,
    messageId: rec.messageId,
    kind: rec.kind,
    text: rec.text,
    createdAt,
  }
}

/** Every thought in a conversation, oldest-first — for rehydrating the Thinking panel on reload. */
export async function listThoughtsForConversation(convId: string): Promise<ThoughtRecord[]> {
  const rows = await getDb()
    .selectFrom('thoughts')
    .selectAll()
    .where('conversation_id', '=', convId)
    .orderBy('created_at', 'asc')
    .execute()
  return rows.map((r) => ({
    id: r.id,
    conversationId: r.conversation_id,
    ...(r.message_id ? { messageId: r.message_id } : {}),
    kind: r.kind,
    text: r.text,
    createdAt: r.created_at,
  }))
}

// ── Media (user uploads + tool-generated bytes — durable, survive reload) ─────

export async function storeMedia(
  rec: Omit<MediaRecord, 'id' | 'createdAt'> & Partial<Pick<MediaRecord, 'id'>>
): Promise<MediaRecord> {
  const id = rec.id ?? uid('media')
  const createdAt = isoNow()
  await getDb()
    .insertInto('media')
    .values({
      id,
      conversation_id: rec.conversationId ?? null,
      message_id: rec.messageId ?? null,
      kind: rec.kind,
      mime_type: rec.mimeType,
      filename: rec.filename ?? null,
      origin: rec.origin,
      bytes: rec.bytes,
      created_at: createdAt,
    })
    .onConflict((oc) => oc.column('id').doNothing())
    .execute()
  return { ...rec, id, createdAt }
}

/** Metadata only (no bytes) for every media in a conversation — for rendering history chips. */
export async function listMediaForConversation(
  convId: string
): Promise<Array<Omit<MediaRecord, 'bytes'>>> {
  const rows = await getDb()
    .selectFrom('media')
    .select([
      'id',
      'conversation_id',
      'message_id',
      'kind',
      'mime_type',
      'filename',
      'origin',
      'created_at',
    ])
    .where('conversation_id', '=', convId)
    .orderBy('created_at', 'asc')
    .execute()
  return rows.map((r) => ({
    id: r.id,
    ...(r.conversation_id ? { conversationId: r.conversation_id } : {}),
    ...(r.message_id ? { messageId: r.message_id } : {}),
    kind: r.kind,
    mimeType: r.mime_type,
    ...(r.filename ? { filename: r.filename } : {}),
    origin: r.origin as MediaRecord['origin'],
    createdAt: r.created_at,
  }))
}

/** Fetch one media's raw bytes by id (for rendering / re-decoding). */
export async function getMediaBytes(id: string): Promise<Uint8Array | null> {
  const row = await getDb()
    .selectFrom('media')
    .select(['bytes'])
    .where('id', '=', id)
    .executeTakeFirst()
  return row ? (row.bytes as Uint8Array) : null
}

// ── Key/value (small durable UI state — e.g. the last context-utilization trace) ─────

export async function kvSet(key: string, value: string): Promise<void> {
  await getDb()
    .insertInto('kv')
    .values({ k: key, v: value })
    .onConflict((oc) => oc.column('k').doUpdateSet({ v: value }))
    .execute()
}

export async function kvGet(key: string): Promise<string | null> {
  const row = await getDb().selectFrom('kv').select(['v']).where('k', '=', key).executeTakeFirst()
  return row ? row.v : null
}

// ── row mappers ──────────────────────────────────────────────────────────────

type ConvRow = {
  id: string
  title: string
  created_at: string
  updated_at: string
  deleted_at: string | null
}
function rowToConvMeta(r: ConvRow): ConversationMeta {
  return {
    id: r.id,
    title: r.title,
    createdAt: r.created_at,
    updatedAt: r.updated_at,
    ...(r.deleted_at ? { deletedAt: r.deleted_at } : {}),
  }
}

type MsgRow = {
  id: string
  role: string
  content: string
  model_content: string | null
  created_at: string
  references_json: string | null
  attempts_json: string | null
}
function rowToMessage(r: MsgRow): ConversationMessage {
  return {
    id: r.id,
    role: r.role === 'assistant' ? 'assistant' : 'user',
    content: r.content,
    ...(r.model_content ? { modelContent: r.model_content } : {}),
    createdAt: r.created_at,
    ...(r.references_json
      ? { references: JSON.parse(r.references_json) as PersistedReference[] }
      : {}),
    ...(r.attempts_json
      ? { attempts: JSON.parse(r.attempts_json) as PersistedActivityAttempt[] }
      : {}),
  }
}

type MemRow = {
  id: string
  content: string
  model_content: string | null
  importance: number | null
  created_at: string
  embedding: Uint8Array | null
}
function rowToMemory(r: MemRow): MemoryRecord {
  return {
    id: r.id,
    text: r.content,
    ...(r.model_content ? { modelText: r.model_content } : {}),
    importance: r.importance ?? 0,
    createdAt: r.created_at,
    ...(r.embedding ? { embedding: blobToF32(r.embedding) } : {}),
  }
}

type StandingRow = { id: string; content: string; model_content: string | null; created_at: string }
function rowToStanding(r: StandingRow): StandingInstructionRecord {
  return {
    id: r.id,
    text: r.content,
    ...(r.model_content ? { modelText: r.model_content } : {}),
    createdAt: r.created_at,
  }
}

type ToolCallRow = {
  id: string
  conversation_id: string
  tool: string
  args_json: string
  checksum: string
  is_error: number
  result_text: string | null
  model_result_text: string | null
  encoded: string | null
  created_at: string
}
function rowToToolCall(r: ToolCallRow): ToolCallRecord {
  let args: Record<string, unknown> = {}
  try {
    args = JSON.parse(r.args_json) as Record<string, unknown>
  } catch {
    /* keep {} on malformed json */
  }
  return {
    id: r.id,
    conversationId: r.conversation_id,
    tool: r.tool,
    args,
    checksum: r.checksum,
    isError: r.is_error !== 0,
    ...(r.result_text ? { resultText: r.result_text } : {}),
    ...(r.model_result_text ? { modelResultText: r.model_result_text } : {}),
    ...(r.encoded ? { encoded: r.encoded } : {}),
    createdAt: r.created_at,
  }
}

// Embedding <-> BLOB helpers (Float32 round-trip).
function f32ToBlob(v: number[]): Uint8Array {
  return new Uint8Array(new Float32Array(v).buffer)
}
function blobToF32(b: Uint8Array): number[] {
  return Array.from(new Float32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4)))
}

```

#### agent\_swarm\_store.ts

```ts
// The flagship agent's durable, cross-tab store — the CHAT-HISTORY plane.
//
// Architecture (see plan + memory chat_history_vs_context_window):
//   - SQLite (opfs-sahpool) lives in ONE Web Worker, single connection.
//   - @nhtio/swarm elects ONE leader tab. The leader OWNS the worker and answers
//     `db:exec` RPCs. Follower tabs send `swarm.request('db:exec', sql, params)` to the
//     leader. On leader-tab close, swarm re-elects and the new leader spawns the worker.
//   - Kysely (agent_kysely_dialect) is built on the routed exec helper, so callers write
//     typed queries without caring whether they're leader or follower.
//
// This module is the HUMAN-facing record: every write here is a real conversation turn /
// memory / instruction. The model-facing context window is built elsewhere (the harness)
// and never flows through storeMessage. `content` is the chat version; `model_content` is
// the token-dense version the working-set fetch prefers.
//
// SSR: nothing here runs at module scope. `initAgentStore()` is called from onMounted.

import { Swarm, setPSK } from '@nhtio/swarm'
import { createAgentDb, type AgentDatabase, type AgentExecFn } from './agent_kysely_dialect'
import type { Kysely } from 'kysely'
import type { SqliteWorkerRequest, SqliteWorkerResponse } from './agent_sqlite_worker'

// A fixed namespace PSK — swarm requires one; it only isolates this demo's BroadcastChannel
// from any other swarm network on the same origin. Not a secret (client-side demo).
const SWARM_PSK = 'nhtio-adk-flagship-agent-demo-psk-v1'
// Swarm event used to route a SQL exec to the leader tab.
const DB_EXEC = 'db:exec'

type SwarmEvents = {
  // request('db:exec', sql, parameters) → rows
  [DB_EXEC]: [sql: string, parameters: readonly unknown[]]
}

let swarm: Swarm<SwarmEvents> | null = null
let worker: Worker | null = null
let db: Kysely<AgentDatabase> | null = null
let initPromise: Promise<void> | null = null

// ── SQLite worker ownership (leader only) ────────────────────────────────────

/** Spawn + init the worker (leader tab only). Idempotent. */
async function ensureWorker(): Promise<void> {
  if (worker) return
  const w = new Worker(new URL('./agent_sqlite_worker.ts', import.meta.url), { type: 'module' })
  worker = w
  await workerCall(w, { id: rpcId(), op: 'init' })
}

function teardownWorker(): void {
  if (worker) {
    worker.terminate()
    worker = null
  }
}

/**
 * Resolve once leadership has settled enough to query safely. A lone tab becomes leader
 * within ~1 election cycle; we resolve immediately when `leader` flips true. For a
 * follower (another tab is already leader) we resolve after a short grace window — by then
 * a leader exists and `routedExec` can RPC to it. Bounded so init never hangs.
 */
function waitForLeadershipSettled(maxWaitMs = 3000): Promise<void> {
  return new Promise((resolve) => {
    if (!swarm) return resolve()
    if (swarm.leader) return resolve()
    let done = false
    const finish = (): void => {
      if (done) return
      done = true
      resolve()
    }
    // Fire as soon as we win leadership.
    swarm.onLeadershipChange((isLeader) => {
      if (isLeader) finish()
    })
    // Otherwise, a follower: give the election a grace window, then proceed (the leader
    // tab will answer db:exec RPCs). Poll so we settle as early as possible.
    const start = Date.now()
    const tick = (): void => {
      if (done) return
      if (swarm?.leader || Date.now() - start >= maxWaitMs) return finish()
      setTimeout(tick, 120)
    }
    setTimeout(tick, 120)
  })
}

let rpcSeq = 0
function rpcId(): string {
  rpcSeq += 1
  return `r${rpcSeq}`
}

/** One round-trip to the SQLite worker. */
function workerCall(w: Worker, req: SqliteWorkerRequest): Promise<Record<string, unknown>[]> {
  return new Promise((resolve, reject) => {
    const onMsg = (ev: MessageEvent<SqliteWorkerResponse>): void => {
      if (ev.data.id !== req.id) return
      w.removeEventListener('message', onMsg)
      if (ev.data.ok) resolve(ev.data.rows ?? [])
      else reject(new Error(ev.data.error ?? 'SQLite worker error'))
    }
    w.addEventListener('message', onMsg)
    w.postMessage(req)
  })
}

// ── The routed exec helper Kysely is built on ────────────────────────────────
// Leader: run on the owned worker. Follower: RPC to the leader via swarm.

async function routedExec(
  sql: string,
  parameters: readonly unknown[]
): Promise<Record<string, unknown>[]> {
  if (!swarm) throw new Error('agent store not initialised')
  if (swarm.leader) {
    await ensureWorker()
    return workerCall(worker!, { id: rpcId(), op: 'exec', sql, parameters })
  }
  // Follower → ask the leader. swarm.request resolves with the leader handler's return.
  return swarm.request<Record<string, unknown>[]>(DB_EXEC, sql, parameters)
}

// ── Lifecycle ────────────────────────────────────────────────────────────────

/** Client-only bootstrap. Idempotent + memoised. Call from onMounted. */
export function initAgentStore(): Promise<void> {
  if (typeof window === 'undefined') {
    return Promise.reject(new Error('agent store is client-only (no SSR)'))
  }
  if (!initPromise) {
    initPromise = (async () => {
      setPSK(SWARM_PSK)
      swarm = Swarm.instance<SwarmEvents>(SWARM_PSK)
      // Leader answers db:exec for followers by running it on the owned worker.
      swarm.onRequest(DB_EXEC, async (sql, parameters) => {
        await ensureWorker()
        return workerCall(worker!, { id: rpcId(), op: 'exec', sql, parameters })
      })
      // Own/disown the worker as leadership changes.
      swarm.onLeadershipChange((isLeader) => {
        if (isLeader) void ensureWorker()
        else teardownWorker()
      })
      // Leadership is elected asynchronously (Raft, ~500ms poll). Wait for it to settle
      // before the first query: a lone tab MUST become leader (and own the worker) rather
      // than RPC into the void and hit RequestTimeoutError. We resolve as soon as either
      // (a) we become leader, or (b) a leader exists elsewhere to RPC to.
      await waitForLeadershipSettled()
      if (swarm.leader) await ensureWorker()
      db = createAgentDb(routedExec)
    })()
  }
  return initPromise
}

export function disposeAgentStore(): void {
  teardownWorker()
  db = null
  swarm = null
  initPromise = null
}

/**
 * NODE-ONLY test seam. Bind the module's Kysely `db` to a caller-supplied exec fn, bypassing the
 * browser stack entirely (no Swarm, no Web Worker, no OPFS) — so the whole chat-history facade runs
 * unchanged against a `node:sqlite` backend in the portable Node harness (see tests/agent/_harness/*). The
 * browser path (initAgentStore) is untouched. NOT called anywhere in the app; only the Node harness/tests
 * invoke it. Idempotent-ish: overwrites `db` with a fresh dialect over the given exec.
 */
export function _initAgentStoreWithExec(exec: AgentExecFn): void {
  db = createAgentDb(exec)
}

function getDb(): Kysely<AgentDatabase> {
  if (!db) throw new Error('agent store not initialised — call initAgentStore() first')
  return db
}

function isoNow(): string {
  return new Date().toISOString()
}
function uid(prefix: string): string {
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}

// Facade (conversations / messages / memories / standing instructions / rag) follows in
// the same file — see agent_swarm_store.facade section appended below.
export { getDb as _getAgentDb, isoNow as _isoNow, uid as _uid, routedExec as _routedExec }

```

#### agent\_sqlite\_worker.ts

```ts
// The single SQLite connection for the flagship agent, isolated in a Web Worker.
//
// WHY a worker: the official @sqlite.org/sqlite-wasm OPFS VFSes only work in a Worker
// (the OPFS sync-access-handle APIs are unavailable on the main thread). We use the
// `opfs-sahpool` VFS specifically because it needs NO COOP/COEP headers (this docs site
// is NOT cross-origin-isolated) — at the cost of a single connection, which is exactly
// why exactly ONE context (the swarm leader tab) owns this worker and other tabs RPC to
// it. See agent_swarm_store.ts.
//
// Vite compiles this to its own chunk via `new Worker(new URL('./agent_sqlite_worker.ts',
// import.meta.url), {type:'module'})` — the same pattern the docs already ship for the
// WebLLM worker. It imports ONLY the sqlite peer; no @nhtio/adk source, no Vue, nothing
// SSR-evaluated.

import { default as sqlite3InitModule } from '@sqlite.org/sqlite-wasm'

/** Request envelope posted to the worker from the (leader-tab) store. */
export interface SqliteWorkerRequest {
  id: string
  op: 'init' | 'exec'
  sql?: string
  /** Positional bind parameters for `exec`. */
  parameters?: readonly unknown[]
}

/** Response envelope posted back to the store. */
export interface SqliteWorkerResponse {
  id: string
  ok: boolean
  rows?: Record<string, unknown>[]
  error?: string
}

// The opfs-sahpool DB handle, created once on `init`.
let db: { exec: (opts: unknown) => unknown; close?: () => void } | null = null
let initPromise: Promise<void> | null = null

// The agent's schema. Idempotent — safe to run on every init.
//
// DUAL REPRESENTATION (core invariant — see memory chat_history_vs_context_window):
// every primitive stores BOTH `content` (the verbose CHAT/human version rendered in the
// dialog) AND `model_content` (the token-dense MODEL version the context window uses,
// preserving facts + intent). The working-set fetch path reads `model_content` (falling
// back to `content` when no distilled version exists); the UI renders `content`. Chat
// history and the context window are different planes — this makes that structural, not
// incidental. memories/standing_instructions/rag_chunks cache an embedding BLOB so a
// reload rebuilds the in-memory Orama index without re-embedding.
const SCHEMA = `
CREATE TABLE IF NOT EXISTS conversations (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS messages (
  id TEXT PRIMARY KEY,
  conversation_id TEXT NOT NULL,
  role TEXT NOT NULL,
  content TEXT NOT NULL,
  model_content TEXT,
  created_at TEXT NOT NULL,
  references_json TEXT,
  attempts_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
CREATE TABLE IF NOT EXISTS memories (
  id TEXT PRIMARY KEY,
  content TEXT NOT NULL,
  model_content TEXT,
  importance REAL,
  created_at TEXT NOT NULL,
  embedding BLOB
);
CREATE TABLE IF NOT EXISTS standing_instructions (
  id TEXT PRIMARY KEY,
  content TEXT NOT NULL,
  model_content TEXT,
  created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tool_calls (
  id TEXT PRIMARY KEY,
  conversation_id TEXT NOT NULL,
  tool TEXT NOT NULL,
  args_json TEXT NOT NULL,
  checksum TEXT NOT NULL,
  is_error INTEGER NOT NULL DEFAULT 0,
  result_text TEXT,
  -- The result AS THE MODEL SAW IT (content/model_content dichotomy, tool-call edition). For a
  -- non-inlined SpooledArtifact this is the HANDLE note, not the body — so the chip shows the model's
  -- real context. result_text holds the full human/replay body.
  model_result_text TEXT,
  -- @nhtio/encoder snapshot of the whole ToolCall. A SpooledArtifact result is captured as its reader
  -- HANDLE (the OPFS locator), NOT its bytes, so a rehydrated artifact is lazy + zero-at-rest and the
  -- forged artifact_* tools can query it across turns. result_text stays for the human-facing chip only.
  encoded TEXT,
  created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_toolcalls_conv ON tool_calls(conversation_id, created_at);
-- Agent reasoning, persisted per turn: the synthetic plan, gate course-corrections, and (when enabled)
-- the model's own chain-of-thought. Attached to the assistant message of the turn (message_id) so the
-- Thinking panel rehydrates on reload — part of the recorded turn history, not just a live UI signal.
CREATE TABLE IF NOT EXISTS thoughts (
  id TEXT PRIMARY KEY,
  conversation_id TEXT NOT NULL,
  message_id TEXT,
  kind TEXT NOT NULL,
  text TEXT NOT NULL,
  created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_thoughts_conv ON thoughts(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_thoughts_msg ON thoughts(message_id);
CREATE TABLE IF NOT EXISTS media (
  id TEXT PRIMARY KEY,
  conversation_id TEXT,
  message_id TEXT,
  kind TEXT NOT NULL,
  mime_type TEXT NOT NULL,
  filename TEXT,
  origin TEXT NOT NULL,
  bytes BLOB NOT NULL,
  created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_media_conv ON media(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_media_msg ON media(message_id);
CREATE TABLE IF NOT EXISTS rag_chunks (
  id TEXT PRIMARY KEY,
  doc_path TEXT NOT NULL,
  title TEXT,
  content TEXT NOT NULL,
  model_content TEXT,
  embedding BLOB,
  metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_rag_doc ON rag_chunks(doc_path);
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL);
`

/**
 * Idempotently add `column` to `table` if a pre-existing OPFS DB lacks it. Reads the live column set
 * via `PRAGMA table_info` (SQLite has no `ADD COLUMN IF NOT EXISTS`) and only issues the ALTER when the
 * column is genuinely absent. Additive — never drops or renames — so a DB from an older schema gains the
 * column without losing rows. Safe to call on every init.
 */
function ensureColumn(table: string, column: string, type: string): void {
  const cols = db!.exec({
    sql: `PRAGMA table_info(${table})`,
    rowMode: 'object',
    returnValue: 'resultRows',
  }) as Array<{ name?: string }> | undefined
  const present = (cols ?? []).some((c) => c.name === column)
  if (!present) {
    db!.exec({ sql: `ALTER TABLE ${table} ADD COLUMN ${column} ${type}` })
  }
}

async function ensureDb(): Promise<void> {
  if (db) return
  if (!initPromise) {
    initPromise = (async () => {
      const sqlite3 = await sqlite3InitModule()
      // opfs-sahpool: no COOP/COEP required; single connection; durable in OPFS.
      const poolUtil = await (
        sqlite3 as unknown as {
          installOpfsSAHPoolVfs: (o: { name?: string }) => Promise<{
            OpfsSAHPoolDb: new (filename: string) => typeof db & object
          }>
        }
      ).installOpfsSAHPoolVfs({ name: 'nhtio-agent' })
      db = new poolUtil.OpfsSAHPoolDb('/agent.sqlite3') as unknown as typeof db
      // Run the schema (idempotent).
      db!.exec({ sql: SCHEMA })
      // Additive migrations for DBs created by an EARLIER schema. `CREATE TABLE IF NOT EXISTS` is a
      // no-op when the table already exists in OPFS from a prior session, so a column added to the
      // CREATE later (e.g. tool_calls.encoded — the @nhtio/encoder snapshot) is NEVER applied to that
      // persisted table. The first INSERT naming the missing column then fails with
      // "table tool_calls has no column named encoded", the executor throws, and the whole turn dies
      // with E_LLM_EXECUTION_EXECUTOR_ERROR — on every fresh page load, because the DB outlives the
      // realm. SQLite has no ADD COLUMN IF NOT EXISTS, so we guard each add by inspecting the live
      // column set. Additive only (no drops/renames) — existing chat history is preserved.
      ensureColumn('tool_calls', 'encoded', 'TEXT')
      ensureColumn('tool_calls', 'model_result_text', 'TEXT')
    })()
  }
  await initPromise
}

/** Run one SQL statement (or batch) and return rows as plain objects. */
function execRows(sql: string, parameters: readonly unknown[]): Record<string, unknown>[] {
  if (!db) throw new Error('SQLite worker: db not initialised')
  const rows = db.exec({
    sql,
    bind: parameters.length > 0 ? parameters : undefined,
    rowMode: 'object',
    returnValue: 'resultRows',
  }) as Record<string, unknown>[] | undefined
  return rows ?? []
}

self.addEventListener('message', (ev: MessageEvent<SqliteWorkerRequest>) => {
  const req = ev.data
  void (async (): Promise<void> => {
    const reply = (r: Omit<SqliteWorkerResponse, 'id'>): void =>
      self.postMessage({ id: req.id, ...r } satisfies SqliteWorkerResponse)
    try {
      await ensureDb()
      if (req.op === 'init') {
        reply({ ok: true, rows: [] })
        return
      }
      const rows = execRows(req.sql ?? '', req.parameters ?? [])
      reply({ ok: true, rows })
    } catch (err) {
      // eslint-disable-next-line adk/prefer-is-error -- this is a standalone Web Worker; it must NOT import @nhtio/adk source (the bundle/guards never load in this isolated worker context).
      reply({ ok: false, error: err instanceof Error ? err.message : String(err) })
    }
  })()
})

```

#### agent\_kysely\_dialect.ts

```ts
// A custom Kysely dialect whose connection is an async RPC: it hands a compiled
// {sql, parameters} to an injected `execFn` and gets rows back. That `execFn` is the
// store's exec helper — on the swarm LEADER tab it calls the SQLite worker directly; on
// a follower tab it `swarm.request('db:exec', …)`s the leader. The dialect neither knows
// nor cares which; it only needs "SQL string + params → rows".
//
// We reuse Kysely's stock SQLite SQL generation (SqliteAdapter / SqliteQueryCompiler /
// SqliteIntrospector) — only the driver/connection transport is custom.

import {
  Kysely,
  SqliteAdapter,
  SqliteIntrospector,
  SqliteQueryCompiler,
  CompiledQuery,
  type Dialect,
  type Driver,
  type DatabaseConnection,
  type QueryResult,
  type DatabaseIntrospector,
  type QueryCompiler,
  type DialectAdapter,
} from 'kysely'

/** The transport the dialect is built on: compile → rows. */
export type AgentExecFn = (
  sql: string,
  parameters: readonly unknown[]
) => Promise<Record<string, unknown>[]>

/** Typed schema for the agent DB (mirrors the worker DDL; dual content columns). */
export interface AgentDatabase {
  conversations: {
    id: string
    title: string
    created_at: string
    updated_at: string
    deleted_at: string | null
  }
  messages: {
    id: string
    conversation_id: string
    role: string
    content: string
    model_content: string | null
    created_at: string
    references_json: string | null
    attempts_json: string | null
  }
  memories: {
    id: string
    content: string
    model_content: string | null
    importance: number | null
    created_at: string
    embedding: Uint8Array | null
  }
  standing_instructions: {
    id: string
    content: string
    model_content: string | null
    created_at: string
  }
  tool_calls: {
    id: string
    conversation_id: string
    tool: string
    args_json: string
    checksum: string
    is_error: number
    result_text: string | null
    /** The result as the MODEL saw it (handle note for non-inlined artifacts). content/model_content for tool calls. */
    model_result_text: string | null
    /** @nhtio/encoder snapshot of the ToolCall (artifact persisted as a handle, not bytes). */
    encoded: string | null
    created_at: string
  }
  thoughts: {
    id: string
    conversation_id: string
    /** The assistant message this turn's reasoning attaches to (null until the turn's message exists). */
    message_id: string | null
    /** 'reasoning' (model CoT) | 'plan' (synthetic plan) | 'nudge' (gate course-correction). */
    kind: string
    text: string
    created_at: string
  }
  media: {
    id: string
    conversation_id: string | null
    message_id: string | null
    kind: string
    mime_type: string
    filename: string | null
    origin: string
    bytes: Uint8Array
    created_at: string
  }
  rag_chunks: {
    id: string
    doc_path: string
    title: string | null
    content: string
    model_content: string | null
    embedding: Uint8Array | null
    metadata_json: string | null
  }
  kv: { k: string; v: string }
}

class AgentConnection implements DatabaseConnection {
  readonly #exec: AgentExecFn
  constructor(exec: AgentExecFn) {
    this.#exec = exec
  }

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const rows = (await this.#exec(compiled.sql, compiled.parameters)) as R[]
    return { rows }
  }

  // Streaming is not supported over the RPC transport; the agent never streams SQL.
  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('agent Kysely dialect does not support streamQuery')
  }
}

class AgentDriver implements Driver {
  readonly #exec: AgentExecFn
  #connection: AgentConnection | undefined
  constructor(exec: AgentExecFn) {
    this.#exec = exec
  }
  async init(): Promise<void> {
    this.#connection = new AgentConnection(this.#exec)
  }
  async acquireConnection(): Promise<DatabaseConnection> {
    if (!this.#connection) this.#connection = new AgentConnection(this.#exec)
    return this.#connection
  }
  // Single logical connection (one opfs-sahpool handle). Transactions run as raw SQL
  // through the same exec; Kysely's BEGIN/COMMIT/ROLLBACK compile to statements the
  // worker runs in order.
  async beginTransaction(conn: DatabaseConnection): Promise<void> {
    await conn.executeQuery(CompiledQuery.raw('begin'))
  }
  async commitTransaction(conn: DatabaseConnection): Promise<void> {
    await conn.executeQuery(CompiledQuery.raw('commit'))
  }
  async rollbackTransaction(conn: DatabaseConnection): Promise<void> {
    await conn.executeQuery(CompiledQuery.raw('rollback'))
  }
  async releaseConnection(): Promise<void> {
    /* single shared connection — nothing to release */
  }
  async destroy(): Promise<void> {
    this.#connection = undefined
  }
}

class AgentDialect implements Dialect {
  readonly #exec: AgentExecFn
  constructor(exec: AgentExecFn) {
    this.#exec = exec
  }
  createDriver(): Driver {
    return new AgentDriver(this.#exec)
  }
  createQueryCompiler(): QueryCompiler {
    return new SqliteQueryCompiler()
  }
  createAdapter(): DialectAdapter {
    return new SqliteAdapter()
  }
  createIntrospector(db: Kysely<unknown>): DatabaseIntrospector {
    return new SqliteIntrospector(db)
  }
}

/** Build a typed Kysely instance over the agent's RPC transport. */
export function createAgentDb(exec: AgentExecFn): Kysely<AgentDatabase> {
  return new Kysely<AgentDatabase>({ dialect: new AgentDialect(exec) })
}

```

#### agent\_embedder.ts

```ts
// On-device embedder for the flagship agent's RAG — the same model + config the build-time
// chunker uses, so query vectors are comparable with the seeded corpus. Mirrors the Ask ADK
// embedder pattern (the proven in-prod browser path).

let extractorPromise: Promise<
  (input: string, opts?: unknown) => Promise<{ data: Float32Array }>
> | null = null

async function loadExtractor(): Promise<
  (input: string, opts?: unknown) => Promise<{ data: Float32Array }>
> {
  const transformers = await import('@huggingface/transformers')
  // Force remote model resolution (the dev server's local origin returns index.html).
  ;(
    transformers as unknown as { env: { allowLocalModels: boolean; useBrowserCache: boolean } }
  ).env.allowLocalModels = false
  ;(
    transformers as unknown as { env: { allowLocalModels: boolean; useBrowserCache: boolean } }
  ).env.useBrowserCache = true
  // q8 matches the build chunker (bin/build_ask_adk_index.ts) so vectors are comparable.
  return transformers.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', {
    dtype: 'q8',
  }) as unknown as Promise<(input: string, opts?: unknown) => Promise<{ data: Float32Array }>>
}

export function preloadEmbedder(): Promise<unknown> {
  if (!extractorPromise) extractorPromise = loadExtractor()
  return extractorPromise
}

/** Embed one text → 384-dim unit-normalised vector. */
export async function embed(text: string): Promise<number[]> {
  const extractor = (await preloadEmbedder()) as (
    input: string,
    opts?: unknown
  ) => Promise<{ data: Float32Array }>
  const out = await extractor(text, { pooling: 'mean', normalize: true })
  return Array.from(out.data)
}

/** Embed many texts sequentially (browser-friendly; the seed corpus is small). */
export async function embedMany(texts: string[]): Promise<number[][]> {
  const out: number[][] = []
  for (const t of texts) out.push(await embed(t))
  return out
}

```

#### agent\_state.ts

```ts
// Module-level singletons for the flagship agent.
//
// WHY module-level (not component refs): the widget lives in the persistent VitePress
// layout slot and stays mounted across SPA navigation. Keeping the harness, the loaded
// model, the open gate, the pending-interrupt queue, and the panel state HERE means the
// agent survives navigation AND panel hide/show WITHOUT reloading the 2GB model. A
// component remount just re-reads these.
//
// Client-only; everything is lazily populated.

import { shallowRef, ref, type Ref, type ShallowRef } from 'vue'
import type { AgentHarness } from './agent_runtime'

// The single harness instance (owns the loaded model). Null until first load.
export const harnessRef: ShallowRef<AgentHarness | null> = shallowRef(null)

// Panel UI state (livechat-style): closed bubble vs open panel; minimized.
export const panelOpen: Ref<boolean> = ref(false)
export const modelLoaded: Ref<boolean> = ref(false)

// The currently-open permission gate (navigate_to_page), surfaced to the dialog. Null when
// none is pending. The dialog renders an allow/deny prompt bound to this and calls resolve.
export interface PendingGate {
  id: string
  reason: string
  payload: unknown
  resolve: (value: { allow: boolean }) => void
  reject: (err: Error) => void
  /** Epoch ms when this gate auto-declines (from the gate's timeout). The dialog renders a live countdown
   *  to it next to Allow/Deny so the user sees how long they have before it auto-declines. Undefined when
   *  the gate has no timeout (waits indefinitely). */
  expiresAt?: number
}
export const pendingGate: ShallowRef<PendingGate | null> = shallowRef(null)

// ── Seamless interruption: messages typed while a dispatch is running ─────────
// The dialog persists each user message immediately (chat history) AND pushes its content
// here. The harness's per-iteration input pipeline drains this into ctx.turnMessages so the
// model sees it on the NEXT dispatch — no "busy" gate. createdAt stamps the real send time
// so the interjection slots chronologically (interleaved with in-progress tool activity).
export interface PendingUserMessage {
  id: string
  content: string
  createdAt: string
}
const pendingUserMessages: PendingUserMessage[] = []

export function enqueueUserMessage(m: PendingUserMessage): void {
  pendingUserMessages.push(m)
}

/** Drain everything queued so far (called by the harness each iteration). */
export function drainUserMessages(): PendingUserMessage[] {
  if (pendingUserMessages.length === 0) return []
  return pendingUserMessages.splice(0, pendingUserMessages.length)
}

export function hasPendingUserMessages(): boolean {
  return pendingUserMessages.length > 0
}

```

#### agent\_route.ts

```ts
// Client-only VitePress route helpers for the flagship agent: the current page path
// (for the dynamic system message + RAG page-boost) and a guarded internal navigation.
//
// Imported only inside client code (the dialog / harness); VitePress's useRoute/useRouter
// are composables that must be called in a component setup or after the router exists.

import { useRoute, useRouter, withBase } from 'vitepress'

/** Current in-site path (e.g. "/the-loop/tools"). Reactive via the VitePress route. */
export function currentPath(): string {
  try {
    return useRoute().path
  } catch {
    return typeof window !== 'undefined' ? window.location.pathname : '/'
  }
}

/**
 * Validate that `path` is an INTERNAL site route and navigate to it. Rejects absolute
 * URLs, protocol-relative `//host`, and anything off-origin BEFORE navigating — the
 * navigate tool must never be a redirect vector. Returns the resolved path on success.
 */
export async function navigateInternal(
  path: string
): Promise<{ ok: boolean; resolved?: string; reason?: string }> {
  const raw = (path ?? '').trim()
  if (!raw) return { ok: false, reason: 'empty path' }
  // Reject anything that could escape the origin.
  if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return { ok: false, reason: 'absolute URL not allowed' }
  if (raw.startsWith('//')) return { ok: false, reason: 'protocol-relative URL not allowed' }
  if (!raw.startsWith('/')) return { ok: false, reason: 'path must start with "/"' }
  // Normalise + base-prefix so it stays inside the deployed site.
  const clean = raw.replace(/[?#].*$/, '')
  const target = withBase(clean)
  try {
    await useRouter().go(target)
    return { ok: true, resolved: target }
  } catch (err) {
    // eslint-disable-next-line adk/prefer-is-error -- this docs-theme file cannot import @nhtio/adk source (iOS WebKit stack-overflow rule); the ADK guards load only via the precompiled bundle accessor, which this leaf route helper deliberately avoids.
    return { ok: false, reason: err instanceof Error ? err.message : String(err) }
  }
}

```
