girb 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "auto_continue"
3
4
  require_relative "conversation_history"
4
5
  require_relative "providers/base"
6
+ require_relative "debug_context_builder"
7
+ require_relative "debug_prompt_builder"
5
8
 
6
9
  module Girb
7
10
  class AiClient
@@ -11,23 +14,100 @@ module Girb
11
14
  @provider = Girb.configuration.provider!
12
15
  end
13
16
 
14
- def ask(question, context, binding: nil, line_no: nil)
17
+ def ask(question, context, binding: nil, line_no: nil, irb_context: nil, debug_mode: false)
15
18
  @current_binding = binding
16
19
  @current_line_no = line_no
20
+ @irb_context = irb_context
21
+ @debug_mode = debug_mode
17
22
  @reasoning_log = []
18
23
 
19
- prompt_builder = PromptBuilder.new(question, context)
24
+ prompt_builder = create_prompt_builder(question, context)
20
25
  @system_prompt = prompt_builder.system_prompt
21
26
  user_message = prompt_builder.user_message
22
27
 
23
28
  ConversationHistory.add_user_message(user_message)
24
29
 
25
30
  tools = build_tools
26
- process_with_tools(tools)
31
+
32
+ # In debug mode, auto-continue is handled by DebugIntegration, not here
33
+ if @debug_mode
34
+ process_with_tools(tools)
35
+ else
36
+ auto_continue_count = 0
37
+ original_int_handler = setup_interrupt_handler
38
+ @debug_command_queued = false
39
+
40
+ begin
41
+ loop do
42
+ # Check for interrupt at start of loop
43
+ if Girb::AutoContinue.interrupted?
44
+ Girb::AutoContinue.clear_interrupt!
45
+ handle_irb_interrupted
46
+ break
47
+ end
48
+
49
+ process_with_tools(tools)
50
+
51
+ # If a debug command was queued, exit immediately
52
+ # The command needs to be executed by IRB first, then DebugIntegration handles auto-continue
53
+ if @debug_command_queued
54
+ break
55
+ end
56
+
57
+ # Check for interrupt after API call (Ctrl+C during request)
58
+ if Girb::AutoContinue.interrupted?
59
+ Girb::AutoContinue.clear_interrupt!
60
+ handle_irb_interrupted
61
+ break
62
+ end
63
+
64
+ break unless Girb::AutoContinue.active?
65
+
66
+ auto_continue_count += 1
67
+ if auto_continue_count >= Girb::AutoContinue::MAX_ITERATIONS
68
+ handle_irb_limit_reached
69
+ break
70
+ end
71
+
72
+ Girb::AutoContinue.reset!
73
+
74
+ # Rebuild context with current binding state
75
+ new_context = create_context_builder(@current_binding, @irb_context).build
76
+ continuation = "(auto-continue: Your previous action has been completed. " \
77
+ "Here is the updated context. Continue your investigation.)"
78
+ continuation_builder = create_prompt_builder(continuation, new_context)
79
+ ConversationHistory.add_user_message(continuation_builder.user_message)
80
+ end
81
+ ensure
82
+ restore_interrupt_handler(original_int_handler)
83
+ # Only reset AutoContinue if no debug command was queued
84
+ # (it will be transferred to DebugIntegration in IrbDebugHook)
85
+ unless @debug_command_queued
86
+ Girb::AutoContinue.reset!
87
+ end
88
+ Girb::AutoContinue.clear_interrupt!
89
+ end
90
+ end
27
91
  end
28
92
 
29
93
  private
30
94
 
95
+ def create_prompt_builder(question, context)
96
+ if @debug_mode
97
+ DebugPromptBuilder.new(question, context)
98
+ else
99
+ PromptBuilder.new(question, context)
100
+ end
101
+ end
102
+
103
+ def create_context_builder(binding, irb_context)
104
+ if @debug_mode
105
+ DebugContextBuilder.new(binding)
106
+ else
107
+ ContextBuilder.new(binding, irb_context)
108
+ end
109
+ end
110
+
31
111
  def build_tools
32
112
  Tools.available_tools.map do |tool_class|
33
113
  {
@@ -43,6 +123,12 @@ module Girb
43
123
  accumulated_text = []
44
124
 
45
125
  loop do
126
+ # Check for interrupt at start of each iteration
127
+ if check_interrupted?
128
+ puts "\n[girb] Interrupted by user (Ctrl+C)"
129
+ break
130
+ end
131
+
46
132
  iterations += 1
47
133
  if iterations > MAX_TOOL_ITERATIONS
48
134
  puts "\n[girb] Tool iteration limit reached"
@@ -50,12 +136,29 @@ module Girb
50
136
  end
51
137
 
52
138
  messages = ConversationHistory.to_normalized
53
- response = @provider.chat(
54
- messages: messages,
55
- system_prompt: @system_prompt,
56
- tools: tools,
57
- binding: @current_binding
58
- )
139
+ begin
140
+ response = @provider.chat(
141
+ messages: messages,
142
+ system_prompt: @system_prompt,
143
+ tools: tools,
144
+ binding: @current_binding
145
+ )
146
+ rescue Interrupt => e
147
+ puts "\n[girb] Interrupted by user (Ctrl+C)"
148
+ Girb::AutoContinue.interrupt! unless @debug_mode
149
+ Girb::DebugIntegration.interrupt! if @debug_mode && defined?(Girb::DebugIntegration)
150
+ break
151
+ rescue Exception => e
152
+ # IRB::Abort and similar exceptions
153
+ if e.class.name.include?("Abort") || e.class.name.include?("Interrupt")
154
+ puts "\n[girb] Interrupted by user (Ctrl+C)"
155
+ Girb::AutoContinue.interrupt! unless @debug_mode
156
+ Girb::DebugIntegration.interrupt! if @debug_mode && defined?(Girb::DebugIntegration)
157
+ break
158
+ else
159
+ raise
160
+ end
161
+ end
59
162
 
60
163
  if Girb.configuration.debug
61
164
  puts "[girb] function_calls: #{response.function_calls.inspect}"
@@ -79,27 +182,49 @@ module Girb
79
182
  accumulated_text << response.text
80
183
  end
81
184
 
82
- function_call = response.function_calls.first
83
- tool_name = function_call[:name]
84
- tool_args = function_call[:args] || {}
185
+ debug_command_called = false
85
186
 
86
- if Girb.configuration.debug
87
- puts "[girb] Tool: #{tool_name}(#{tool_args.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')})"
88
- end
187
+ response.function_calls.each do |function_call|
188
+ tool_name = function_call[:name]
189
+ tool_args = function_call[:args] || {}
190
+ tool_id = function_call[:id]
191
+
192
+ if Girb.configuration.debug
193
+ puts "[girb] Tool: #{tool_name}(#{tool_args.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')})"
194
+ end
195
+
196
+ result = execute_tool(tool_name, tool_args)
89
197
 
90
- result = execute_tool(tool_name, tool_args)
198
+ @reasoning_log << {
199
+ tool: tool_name,
200
+ args: tool_args,
201
+ result: result
202
+ }
91
203
 
92
- @reasoning_log << {
93
- tool: tool_name,
94
- args: tool_args,
95
- result: result
96
- }
204
+ ConversationHistory.add_tool_call(tool_name, tool_args, result, id: tool_id)
97
205
 
98
- # Record tool call and result in conversation history
99
- ConversationHistory.add_tool_call(tool_name, tool_args, result)
206
+ if Girb.configuration.debug && result.is_a?(Hash) && result[:error]
207
+ puts "[girb] Tool error: #{result[:error]}"
208
+ end
100
209
 
101
- if Girb.configuration.debug && result.is_a?(Hash) && result[:error]
102
- puts "[girb] Tool error: #{result[:error]}"
210
+ # If run_debug_command was called, we need to exit the tool loop
211
+ # so the debugger/IRB can execute the pending commands
212
+ if tool_name == "run_debug_command"
213
+ debug_command_called = true
214
+ # In IRB mode, mark that we've queued a debug command
215
+ # This will prevent the auto-continue loop from continuing
216
+ @debug_command_queued = true unless @debug_mode
217
+ end
218
+ end
219
+
220
+ # Exit tool loop if debug command was called - let debugger take over
221
+ if debug_command_called
222
+ # Save accumulated text and pending tool calls as assistant message
223
+ text = accumulated_text.any? ? accumulated_text.join("\n") : ""
224
+ ConversationHistory.add_assistant_message(text)
225
+ record_ai_response(text) unless text.empty?
226
+ puts text unless text.empty?
227
+ break
103
228
  end
104
229
  else
105
230
  # Text response
@@ -140,10 +265,13 @@ module Girb
140
265
  end
141
266
 
142
267
  def record_ai_response(response)
143
- return unless @current_line_no
144
-
145
- reasoning = @reasoning_log.empty? ? nil : format_reasoning
146
- SessionHistory.record_ai_response(@current_line_no, response, reasoning)
268
+ if @debug_mode
269
+ require_relative "debug_session_history"
270
+ DebugSessionHistory.record_ai_response(response)
271
+ elsif @current_line_no
272
+ reasoning = @reasoning_log.empty? ? nil : format_reasoning
273
+ SessionHistory.record_ai_response(@current_line_no, response, reasoning)
274
+ end
147
275
  end
148
276
 
149
277
  def format_reasoning
@@ -154,5 +282,57 @@ module Girb
154
282
  "Tool: #{log[:tool]}(#{args_str})\nResult: #{result_str}"
155
283
  end.join("\n\n")
156
284
  end
285
+
286
+ def setup_interrupt_handler
287
+ trap("INT") do
288
+ Girb::AutoContinue.interrupt!
289
+ end
290
+ end
291
+
292
+ def check_interrupted?
293
+ if @debug_mode
294
+ defined?(Girb::DebugIntegration) && Girb::DebugIntegration.interrupted?
295
+ else
296
+ Girb::AutoContinue.interrupted?
297
+ end
298
+ end
299
+
300
+ def restore_interrupt_handler(original_handler)
301
+ if original_handler
302
+ trap("INT", original_handler)
303
+ else
304
+ trap("INT", "DEFAULT")
305
+ end
306
+ end
307
+
308
+ def handle_irb_interrupted
309
+ return unless @current_binding
310
+
311
+ new_context = create_context_builder(@current_binding, @irb_context).build
312
+ interrupt_message = "(System: User interrupted with Ctrl+C. " \
313
+ "Briefly summarize your progress so far. " \
314
+ "Tell the user where you stopped and how to continue if needed.)"
315
+ continuation_builder = create_prompt_builder(interrupt_message, new_context)
316
+ ConversationHistory.add_user_message(continuation_builder.user_message)
317
+ process_with_tools(build_tools)
318
+ rescue StandardError => e
319
+ puts "[girb] Error summarizing: #{e.message}" if Girb.configuration.debug
320
+ end
321
+
322
+ def handle_irb_limit_reached
323
+ puts "\n[girb] Auto-continue limit reached (#{Girb::AutoContinue::MAX_ITERATIONS})"
324
+ return unless @current_binding
325
+
326
+ new_context = create_context_builder(@current_binding, @irb_context).build
327
+ limit_message = "(System: Auto-continue limit (#{Girb::AutoContinue::MAX_ITERATIONS}) reached. " \
328
+ "Summarize your progress so far and tell the user what was accomplished. " \
329
+ "If the task is not complete, explain what remains and instruct the user " \
330
+ "to continue with a follow-up request.)"
331
+ continuation_builder = create_prompt_builder(limit_message, new_context)
332
+ ConversationHistory.add_user_message(continuation_builder.user_message)
333
+ process_with_tools(build_tools)
334
+ rescue StandardError => e
335
+ puts "[girb] Error summarizing: #{e.message}" if Girb.configuration.debug
336
+ end
157
337
  end
158
338
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ module AutoContinue
5
+ MAX_ITERATIONS = 20
6
+
7
+ class << self
8
+ def active?
9
+ @active || false
10
+ end
11
+
12
+ def request!
13
+ @active = true
14
+ end
15
+
16
+ def reset!
17
+ @active = false
18
+ end
19
+
20
+ def interrupted?
21
+ @interrupted || false
22
+ end
23
+
24
+ def interrupt!
25
+ @interrupted = true
26
+ end
27
+
28
+ def clear_interrupt!
29
+ @interrupted = false
30
+ end
31
+ end
32
+ end
33
+ end
@@ -22,8 +22,8 @@ module Girb
22
22
  instance.add_assistant_message(content)
23
23
  end
24
24
 
25
- def add_tool_call(tool_name, args, result)
26
- instance.add_tool_call(tool_name, args, result)
25
+ def add_tool_call(tool_name, args, result, id: nil)
26
+ instance.add_tool_call(tool_name, args, result, id: id)
27
27
  end
28
28
 
29
29
  def to_contents
@@ -72,8 +72,9 @@ module Girb
72
72
  end
73
73
  end
74
74
 
75
- def add_tool_call(tool_name, args, result)
75
+ def add_tool_call(tool_name, args, result, id: nil)
76
76
  @pending_tool_calls << {
77
+ id: id || "call_#{SecureRandom.hex(12)}",
77
78
  name: tool_name,
78
79
  args: args,
79
80
  result: result
@@ -105,15 +106,15 @@ module Girb
105
106
 
106
107
  # Add tool calls and results if present
107
108
  msg.tool_calls&.each do |tc|
108
- result << { role: :tool_call, name: tc[:name], args: tc[:args] }
109
- result << { role: :tool_result, name: tc[:name], result: tc[:result] }
109
+ result << { role: :tool_call, id: tc[:id], name: tc[:name], args: tc[:args] }
110
+ result << { role: :tool_result, id: tc[:id], name: tc[:name], result: tc[:result] }
110
111
  end
111
112
  end
112
113
 
113
114
  # Add pending tool calls
114
115
  @pending_tool_calls.each do |tc|
115
- result << { role: :tool_call, name: tc[:name], args: tc[:args] }
116
- result << { role: :tool_result, name: tc[:name], result: tc[:result] }
116
+ result << { role: :tool_call, id: tc[:id], name: tc[:name], args: tc[:args] }
117
+ result << { role: :tool_result, id: tc[:id], name: tc[:name], result: tc[:result] }
117
118
  end
118
119
 
119
120
  result
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "debug_session_history"
4
+
5
+ module Girb
6
+ class DebugContextBuilder
7
+ MAX_INSPECT_LENGTH = 500
8
+
9
+ def initialize(binding, thread_client: nil)
10
+ @binding = binding
11
+ @thread_client = thread_client
12
+ end
13
+
14
+ def build
15
+ {
16
+ source_location: capture_source_location,
17
+ local_variables: capture_locals,
18
+ instance_variables: capture_instance_variables,
19
+ self_info: capture_self,
20
+ backtrace: capture_backtrace,
21
+ breakpoint_info: capture_breakpoint_info,
22
+ session_history: capture_session_history
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def capture_source_location
29
+ loc = @binding.source_location
30
+ return nil unless loc
31
+
32
+ file, line = loc
33
+ {
34
+ file: file,
35
+ line: line
36
+ }
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def capture_locals
42
+ @binding.local_variables.to_h do |var|
43
+ value = @binding.local_variable_get(var)
44
+ [var, safe_inspect(value)]
45
+ end
46
+ end
47
+
48
+ def capture_instance_variables
49
+ obj = @binding.receiver
50
+ obj.instance_variables.to_h do |var|
51
+ value = obj.instance_variable_get(var)
52
+ [var, safe_inspect(value)]
53
+ end
54
+ rescue StandardError
55
+ {}
56
+ end
57
+
58
+ def capture_self
59
+ obj = @binding.receiver
60
+ {
61
+ class: obj.class.name,
62
+ inspect: safe_inspect(obj),
63
+ methods: obj.methods(false).first(20)
64
+ }
65
+ end
66
+
67
+ def capture_backtrace
68
+ return nil unless @thread_client
69
+
70
+ @thread_client.current_frame&.location&.to_s
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ def capture_breakpoint_info
76
+ return nil unless defined?(DEBUGGER__) && DEBUGGER__.respond_to?(:breakpoints)
77
+
78
+ DEBUGGER__.breakpoints.map do |bp|
79
+ { type: bp.class.name, location: bp.to_s }
80
+ end
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ def capture_session_history
86
+ DebugSessionHistory.format_history(20)
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
91
+ def safe_inspect(obj, max_length: MAX_INSPECT_LENGTH)
92
+ if defined?(ActiveRecord::Base) && obj.is_a?(ActiveRecord::Base)
93
+ return inspect_active_record(obj)
94
+ end
95
+
96
+ result = obj.inspect
97
+ result.length > max_length ? "#{result[0, max_length]}..." : result
98
+ rescue StandardError => e
99
+ "#<#{obj.class} (inspect failed: #{e.message})>"
100
+ end
101
+
102
+ def inspect_active_record(obj)
103
+ {
104
+ class: obj.class.name,
105
+ id: obj.try(:id),
106
+ attributes: obj.attributes,
107
+ new_record: obj.new_record?,
108
+ changed: obj.changed?,
109
+ errors: obj.errors.full_messages
110
+ }
111
+ end
112
+ end
113
+ end