Class: Ask::Agent::Loop

Inherits:
Object
  • Object
show all
Defined in:
lib/ask/agent/loop.rb

Constant Summary collapse

LOOP_DETECTION_WINDOW =
3

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(max_turns: 25, max_consecutive_tool_turns: 6) ⇒ Loop

Returns a new instance of Loop.



11
12
13
14
15
16
17
18
# File 'lib/ask/agent/loop.rb', line 11

def initialize(max_turns: 25, max_consecutive_tool_turns: 6)
  @max_turns = max_turns
  @turn_count = 0
  @recent_results = []
  @loop_detected = false
  @consecutive_tool_turns = 0
@max_consecutive_tool_turns = max_consecutive_tool_turns
end

Instance Attribute Details

#last_costObject (readonly)

Returns the value of attribute last_cost.



9
10
11
# File 'lib/ask/agent/loop.rb', line 9

def last_cost
  @last_cost
end

#last_input_tokensObject (readonly)

Returns the value of attribute last_input_tokens.



9
10
11
# File 'lib/ask/agent/loop.rb', line 9

def last_input_tokens
  @last_input_tokens
end

#last_output_tokensObject (readonly)

Returns the value of attribute last_output_tokens.



9
10
11
# File 'lib/ask/agent/loop.rb', line 9

def last_output_tokens
  @last_output_tokens
end

#turn_countObject (readonly)

Returns the value of attribute turn_count.



9
10
11
# File 'lib/ask/agent/loop.rb', line 9

def turn_count
  @turn_count
end

Instance Method Details

#reset!Object



169
170
171
172
173
# File 'lib/ask/agent/loop.rb', line 169

def reset!
  @turn_count = 0
  @recent_results = []
  @loop_detected = false
end

#run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil) ⇒ Object

Raises:



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/ask/agent/loop.rb', line 20

def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil)
  raise MaxTurnsExceeded if @turn_count >= @max_turns

  event_emitter.emit(Events::TurnStart.new)

  response = chat.ask(message) do |chunk|
    if chunk.content.to_s.strip.length > 0
      event_emitter.emit(Events::TextDelta.new(content: chunk.content))
    end

    if chunk.respond_to?(:thinking) && chunk.thinking.to_s.strip.length > 0
      event_emitter.emit(Events::ThinkingDelta.new(content: chunk.thinking))
    end

    if chunk.tool_call?
      calls = chunk.tool_calls
      if calls.respond_to?(:each)
        calls.each do |id, tc|
          event_emitter.emit(Events::ToolCallDelta.new(
            name: tc.name, arguments: tc.arguments, id: tc.id
          ))
        end
      end
    end
  end

  @last_input_tokens = response.input_tokens
  @last_output_tokens = response.output_tokens
  @last_cost = response.cost

  event_emitter.emit(Events::MessageEnd.new(tool_calls: response.tool_call?))
  @turn_count += 1

  # Barge-in abort: stop as soon as the in-flight LLM call ends — no
  # tool execution, no follow-up turns. The reply so far is returned
  # (the caller has already moved on).
  if aborted?(event_emitter)
    @consecutive_tool_turns = 0
    return response.content.to_s
  end

  # Check if there are any tool calls (user-executed or provider-executed)
  has_tool_calls = response.tool_call? || (response.tool_results&.any? == true)

  unless has_tool_calls
    @consecutive_tool_turns = 0
    return response.content.to_s
  end

  @consecutive_tool_turns += 1

  provider_results = response.tool_results || {}
  all_tool_results = []

  # Add provider-executed tool results directly to conversation
  provider_results.each do |id, result|
    chat.add_message(role: :tool, content: result[:message].to_s, tool_call_id: id)
    all_tool_results << {
      tool_name: result[:tool_name] || id,
      message: result[:message].to_s,
      status: result[:status] || "success",
      provider_executed: true
    }
  end

  # Determine which tool calls still need local execution
  user_tool_calls = response.tool_calls.reject { |id, _| provider_results.key?(id) }

  if user_tool_calls.any?
    # Execute user tool calls locally
    # Respect the session's parallel_tools setting: parallel tools run
    # in threads (with the caller's thread-local context inherited);
    # sequential tools run in the caller thread so per-request context
    # (e.g. Rails CurrentAttributes) is visible without any copying.
    # Pending (async) tool results skip the chat message — it is added
    # when the background work completes.
    user_results = tool_executor.execute(
      user_tool_calls, tools, hooks: hooks, event_emitter: event_emitter,
      result_callback: lambda do |tool_call_id, result|
        tc = user_tool_calls[tool_call_id]
        next unless tc
        next if result[:status] == "pending"

        chat.add_message(role: :tool, content: result[:message].to_s, tool_call_id: tool_call_id) if tc
      end
    )
    all_tool_results.concat(user_results)
  end

  # Async tools: hand the turn back with the interim reply. The
  # session registers the pending calls; completions arrive later via
  # Session#complete_pending_tool.
  pending_calls = all_tool_results.select { |r| r[:status] == "pending" }
  unless pending_calls.empty?
    pending_calls.each do |pending_result|
      if event_emitter.respond_to?(:register_pending_tool)
        event_emitter.register_pending_tool(
          pending_result[:tool_call_id], pending_result
        )
      end
    end
    @consecutive_tool_turns = 0
    return response.content.to_s
  end

  # Check loop detection
  if loop_detected?(all_tool_results)
    raise LoopDetected, all_tool_results.last[:tool_name]
  end

  if @consecutive_tool_turns >= @max_consecutive_tool_turns
    summary = all_tool_results.map { |r| truncate(r[:message], 80) }.first(2).join("; ")
    return "Based on my investigation: #{summary}"
  end

  event_emitter.emit(Events::TurnEnd.new(
    tool_results: all_tool_results,
    turn_number: @turn_count,
    input_tokens: @last_input_tokens,
    output_tokens: @last_output_tokens,
    cost: @last_cost
  ))

  if compactor && compactor.should_compact?
    compactor.run(event_emitter: event_emitter)
  end

  # Persist after each turn so mid-session crashes don't lose progress
  persist&.call

  raise MaxTurnsExceeded if @turn_count >= @max_turns

  # Aborted while tools ran? Skip the follow-up LLM call.
  return response.content.to_s if aborted?(event_emitter)

  # Recursive call — LLM processes tool results
  run_turn(
    chat: chat,
    message: "",
    tools: tools,
    tool_executor: tool_executor,
    compactor: compactor,
    hooks: hooks,
    event_emitter: event_emitter,
    session_id: session_id,
    persist: persist
  )
end