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



132
133
134
135
136
# File 'lib/ask/agent/loop.rb', line 132

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
# 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

  # 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
    user_results = tool_executor.execute_parallel(
      user_tool_calls, tools, hooks, event_emitter, ToolAbortController.new
    ) do |tool_call_id, result|
      tc = user_tool_calls[tool_call_id]
      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

  # 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| r[:message].to_s.truncate(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

  # 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