Class: Insika::LoopDetector
- Inherits:
-
Object
- Object
- Insika::LoopDetector
- 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
-
.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).
Instance Method Summary collapse
-
#initialize(chat:, limit:, emit:) ⇒ LoopDetector
constructor
chat: the turn's chat — must answer #add_message (the boundary append).
-
#message_ended(message) ⇒ Object
RubyLLM after_message.
-
#tool_call(name, arguments) ⇒ Object
From ChatBuilder's before_tool_call.
-
#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).
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 () role = field(, :role).to_s return open_batch() 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 |