Class: Insika::LoopDetector

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/loop_detector.rb

Overview

loop detection by (tool, args) hash, with a ONE-SHOT intervention.

max_tool_calls bounds how MANY tool calls a turn makes, not how useful they are: a model retrying the exact same call — same tool, identical arguments — after an empty or error result burns the whole budget doing something that was settled on the first repeat. This detector is the engine saying so, once.

The streak is CONSECUTIVE and turn-scoped, like the max_tool_calls counter it sits next to in ChatBuilder#wire_callbacks: a call revisited much later in a long turn is not the pathology being caught, and semantic ("nearly the same") matching is how a guard-rail starts eating legitimate retries.

Two invariants, both borrowed from SteerInjector, because the intervention is a user message appended mid-loop:

· Batch boundary only. The append happens after the LAST tool result of a batch closes — a user message between two tool results is rejected by Anthropic outright. Same arithmetic: an assistant message opens a batch of N, the Nth role: tool message closes it. · A halted batch receives nothing. With halt_when there is no next model step; a warning appended there would sit unanswered forever.

The repeated call itself STILL RUNS — fabricating a synthetic result would teach the model that tools lie (the failure refuses). The warning rides after the truth; only a repeat that arrives AFTER the warning was spent aborts, through the existing TimeoutError(stage: :tool_limit) path.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(chat:, limit:, emit:) ⇒ LoopDetector

chat: the turn's chat — must answer #add_message (the boundary append). limit: the streak that triggers the intervention (profile's max_tool_repeat). Values < 2 mean OFF: a "streak of 1" is every call, which is meaningless. emit: ->(type, data) — the Executor's emitter, bound to the task.



45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/insika/loop_detector.rb', line 45

def initialize(chat:, limit:, emit:)
  @chat = chat
  @limit = limit
  @emit = emit
  @last = nil          # fingerprint of the previous call (nil = none yet)
  @streak = 0
  @intervened = false  # the ONE warning of this turn has been delivered
  @pending = false     # detection fired; waiting for the batch boundary
  @expected = nil      # tool calls announced by the batch in flight
  @seen = 0
  @halted = false
end

Class Method Details

.intervention(name, streak) ⇒ Object

The one intervention text, verbatim — a fixed engine sentence, so a report can identify it without an origin stamp (chat messages carry none).



33
34
35
36
37
38
# File 'lib/insika/loop_detector.rb', line 33

def self.intervention(name, streak)
  "You have called `#{name}` with identical arguments #{streak} times in a row and " \
  "received the same result every time. Repeating it will not produce new information. " \
  "Do not call it again with the same arguments — answer with what you already have, " \
  "or change your approach."
end

Instance Method Details

#message_ended(message) ⇒ Object

RubyLLM after_message. An assistant message carrying tool calls OPENS a batch; the Nth tool result CLOSES it — the one boundary where appending is valid.



90
91
92
93
94
95
96
97
# File 'lib/insika/loop_detector.rb', line 90

def message_ended(message)
  role = field(message, :role).to_s
  return open_batch(message) if role == "assistant"
  return unless role == "tool" && @expected

  @seen += 1
  intervene! if @seen >= @expected
end

#tool_call(name, arguments) ⇒ Object

From ChatBuilder's before_tool_call. Raises BEFORE the call executes once the warning is spent — bounded spend is the point of aborting here.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/insika/loop_detector.rb', line 60

def tool_call(name, arguments)
  fingerprint = [name.to_s, canonical(arguments)]
  if fingerprint == @last
    @streak += 1
  else
    # A different call broke the run: the loop resolved itself, so a warning
    # armed earlier is moot — it must not fire later naming the WRONG call.
    @streak = 1
    @pending = false
  end
  @last = fingerprint
  return if @streak < @limit

  if @intervened
    raise Insika::TimeoutError.new(
      "tool loop detected (#{name} repeated with identical arguments after a warning)",
      stage: :tool_limit)
  end
  @pending = true
end

#tool_result(result) ⇒ Object

From ChatBuilder's after_tool_result, with the RAW result — the only place a Tool::Halt is still recognizable (SteerInjector's comment applies here).



83
84
85
# File 'lib/insika/loop_detector.rb', line 83

def tool_result(result)
  @halted = true if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
end