LoopController: escalate on step count, repeated calls, or wall-clock timeout
nwhitehouse added a 138-line controller that wraps the tool-dispatch callback in `runLLMStream` and escalates when the agent loop runs too long, repeats the same call three times, or passes 60 seconds of wall-clock time.
lib/loopController.ts tracks three independent triggers, first-wins: MAX_STEPS_EXCEEDED (total tool dispatches in the turn, default 12, env OLAVA_MAX_STEPS), REPEATED_TOOL_CALL (same name and arguments called three times in a row, env OLAVA_MAX_REPEATED_CALLS), and WALL_CLOCK_EXCEEDED (60 seconds since the turn started, env OLAVA_WALL_CLOCK_MS). Steps are counted per individual dispatch, not per batch, so a single iteration with four tool calls counts as four steps.
On escalation, the controller appends an escalationNote to every tool result in the current batch - a plain-text instruction asking the model to stop calling tools and synthesize the best answer from what it already has. The model still receives the data it just fetched. It also emits a loop.escalated row to the feat-015 audit log. The chatTools.ts integration wraps the existing runTools callback.
The three defaults feel calibrated for a lightweight legal assistant: 1-3 tool calls is typical, 12 is generous, and the 3-repeat trigger specifically targets the "model retrying a failing tool" failure mode. All three are overridable per-deploy via env vars without a code change.
tests/loopController.test.ts (72 LOC) covers each trigger, the negative case where args differ, currentStep accounting, and escalation note formatting. All 16 backend tests pass. The class is independent of the chat code, which is why it's testable at this level.
This is stacked on feat-015 (uses recordEvent). The chatId parameter it depends on was introduced in feat-017.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?