girb 0.4.2 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c8e1b470a14e892f8309060aeba93369a49d9447f5407f8f16ba114838c35ac
4
- data.tar.gz: ef985221519c5ac871b158f51304aff1eef55c9ff75a34a45118842102acef84
3
+ metadata.gz: d2de8ea7943a9f07d92d64077d4b00698d74173b77751461b96ef0f39f450f0f
4
+ data.tar.gz: dc43f1e3039aa9fb5869249c93b0e17a4e501a8b5e54e3e84f99996e8be94fd1
5
5
  SHA512:
6
- metadata.gz: 640764d0fad7f882743e3bf5fc973da26363e9730e65a17471c575d5e912c57b08545aaefca707dd1d87efac7fb5335d82e028718be9414c8623f29653484d44
7
- data.tar.gz: 14ec81191995278196e8d8f6de2f0653456488b1139b9d1418341f56b251e2288bba8d3a8a2640e58d358ae047460e0f35169dcac9bd8c574fa20e99e10b51d4
6
+ metadata.gz: 0bc812210be8ee6e03a02a28972d1f1d9a9837543d02d2f9888e5b487972cbedccd173831e289b74d2e0a389f4e7ced5ce64f270278b8e2b67a06f4851bb5f57
7
+ data.tar.gz: 208ef2e119d7dc5da244bd016fdd8584aa2456724a91368d38d4efb206aacb2c5bace91dc23c7aa5ec002387d1f1e5272c2d4187285e0eb00d40ec44b4ad60b2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-06-16
4
+
5
+ ### Added
6
+
7
+ - Provider-agnostic error classification: `Providers::Base::Response` now carries an
8
+ `error_kind` (`:malformed_tool_call`/`:rate_limit`/`:transient`/`:timeout`/`:fatal`) with
9
+ `#self_correctable?` and `#transport_retryable?` predicates. Providers may set it explicitly;
10
+ a conservative fallback classifier infers it from the error message otherwise.
11
+ - Restored self-correction for malformed function calls: when the provider reports a malformed
12
+ tool call, girb feeds corrective guidance back to the model and retries (bounded by
13
+ `MAX_SELF_CORRECTION`). Transport errors (rate limit, etc.) are not retried this way.
14
+ - `LanguageDetector`: lightweight, dependency-free heuristic that locks the response language to
15
+ the user's original question.
16
+ - GitHub Actions CI running the test suite on Ruby 3.2 / 3.3 / 3.4.
17
+
18
+ ### Changed
19
+
20
+ - When the per-turn tool-call limit (`MAX_TOOL_ITERATIONS`) is reached, girb now makes one final
21
+ request with no tools available so the model produces a natural-language answer, instead of ending
22
+ silently on raw tool output.
23
+ - The response-language directive is now derived from the original question and placed at the end
24
+ of the system prompt, and is propagated through auto-continue / interrupt / limit turns so English
25
+ system messages no longer flip the output language (root fix for the recurring "responds in
26
+ Japanese" / wrong-language bug).
27
+ - `Gemfile` no longer hard-codes local provider-gem paths; a fresh clone can `bundle install` and run
28
+ the test suite without sibling repos (`GIRB_LOCAL_PROVIDERS=1` re-enables the local paths).
29
+
30
+ ### Fixed
31
+
32
+ - Stop recording the assistant message twice on a non-empty text response (avoids conversation-history
33
+ duplication, redundant context, and language drift).
34
+ - Tool execution no longer crashes the loop and dumps a raw backtrace when evaluated code raises a
35
+ `ScriptError` (e.g. `require` of an unavailable gem → `LoadError`). `evaluate_code` and the tool
36
+ dispatcher now catch `ScriptError` in addition to `StandardError`, returning a structured error so the
37
+ AI can recover. `Interrupt`/`SignalException` still propagate, so Ctrl+C handling is unaffected.
38
+
39
+ ### Notes / follow-ups
40
+
41
+ - A user-defined `custom_prompt` is placed after the language directive, so an explicit user language
42
+ preference overrides automatic detection.
43
+ - The self-correction retry records an empty assistant turn plus a user-role feedback message to keep
44
+ provider alternation valid. Normalizing empty turns / consecutive-user handling per provider is left
45
+ to the provider adapter gems (a future provider-conformance test kit will cover this).
46
+
3
47
  ## [0.4.2] - 2026-02-13
4
48
 
5
49
  ### Added
data/README.md CHANGED
@@ -398,6 +398,23 @@ class MyProvider < Girb::Providers::Base
398
398
  end
399
399
  ```
400
400
 
401
+ ### Reporting errors
402
+
403
+ On failure, return a `Response` with an `error` message. Optionally set `error_kind` to one of
404
+ `:malformed_tool_call`, `:rate_limit`, `:transient`, `:timeout`, or `:fatal` so girb can react
405
+ appropriately — in particular, `:malformed_tool_call` lets girb feed the model corrective guidance and
406
+ retry automatically:
407
+
408
+ ```ruby
409
+ Girb::Providers::Base::Response.new(
410
+ error: "the provider rejected the tool call",
411
+ error_kind: :malformed_tool_call
412
+ )
413
+ ```
414
+
415
+ If `error_kind` is omitted, girb infers a conservative category from the error message (defaulting to
416
+ `:fatal`).
417
+
401
418
  ---
402
419
 
403
420
  ## Requirements
@@ -414,3 +431,19 @@ MIT License
414
431
  ## Contributing
415
432
 
416
433
  Bug reports and feature requests welcome at [GitHub Issues](https://github.com/rira100000000/girb/issues).
434
+
435
+ ### Development
436
+
437
+ ```bash
438
+ bundle install
439
+ bundle exec rspec
440
+ ```
441
+
442
+ The test suite does not require a provider gem. To run girb interactively against your local checkout
443
+ with a provider, opt in with `GIRB_LOCAL_PROVIDERS=1` (uses a local sibling checkout such as
444
+ `../girb-gemini` if present, otherwise the published gem):
445
+
446
+ ```bash
447
+ GIRB_LOCAL_PROVIDERS=1 bundle install
448
+ GIRB_LOCAL_PROVIDERS=1 bundle exec girb
449
+ ```
data/README_ja.md CHANGED
@@ -396,6 +396,22 @@ class MyProvider < Girb::Providers::Base
396
396
  end
397
397
  ```
398
398
 
399
+ ### エラーの通知
400
+
401
+ 失敗時は `error` メッセージを持つ `Response` を返します。任意で `error_kind` を
402
+ `:malformed_tool_call` / `:rate_limit` / `:transient` / `:timeout` / `:fatal` のいずれかに設定すると、
403
+ girb が適切に対応できます。特に `:malformed_tool_call` を返すと、girb がモデルに修正指示を与えて
404
+ 自動的にリトライします:
405
+
406
+ ```ruby
407
+ Girb::Providers::Base::Response.new(
408
+ error: "プロバイダーがツール呼び出しを拒否しました",
409
+ error_kind: :malformed_tool_call
410
+ )
411
+ ```
412
+
413
+ `error_kind` を省略した場合は、エラーメッセージから保守的に分類されます(既定は `:fatal`)。
414
+
399
415
  ---
400
416
 
401
417
  ## 動作要件
@@ -412,3 +428,19 @@ MIT License
412
428
  ## 貢献
413
429
 
414
430
  バグ報告や機能リクエストは [GitHub Issues](https://github.com/rira100000000/girb/issues) へお願いします。
431
+
432
+ ### 開発
433
+
434
+ ```bash
435
+ bundle install
436
+ bundle exec rspec
437
+ ```
438
+
439
+ テストの実行にプロバイダー gem は不要です。ローカルのチェックアウトをプロバイダー付きで対話的に動かす
440
+ 場合は `GIRB_LOCAL_PROVIDERS=1` を指定します(`../girb-gemini` などローカルの sibling があればそれを、
441
+ 無ければ公開 gem を使用):
442
+
443
+ ```bash
444
+ GIRB_LOCAL_PROVIDERS=1 bundle install
445
+ GIRB_LOCAL_PROVIDERS=1 bundle exec girb
446
+ ```
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "auto_continue"
4
+ require_relative "language_detector"
4
5
  require_relative "conversation_history"
5
6
  require_relative "session_persistence"
6
7
  require_relative "providers/base"
@@ -10,6 +11,8 @@ require_relative "debug_prompt_builder"
10
11
  module Girb
11
12
  class AiClient
12
13
  MAX_TOOL_ITERATIONS = 10
14
+ # Max provider-agnostic retries after a malformed tool call before giving up.
15
+ MAX_SELF_CORRECTION = 2
13
16
 
14
17
  def initialize
15
18
  @provider = Girb.configuration.provider!
@@ -21,6 +24,10 @@ module Girb
21
24
  @irb_context = irb_context
22
25
  @debug_mode = debug_mode
23
26
  @reasoning_log = []
27
+ @self_correction_count = 0
28
+ # Lock the response language to the original question once, so English
29
+ # auto-continue / interrupt / limit messages can't re-anchor it.
30
+ @response_language = Girb::LanguageDetector.detect(question)
24
31
 
25
32
  prompt_builder = create_prompt_builder(question, context)
26
33
  @system_prompt = prompt_builder.system_prompt
@@ -98,9 +105,9 @@ module Girb
98
105
 
99
106
  def create_prompt_builder(question, context)
100
107
  if @debug_mode
101
- DebugPromptBuilder.new(question, context)
108
+ DebugPromptBuilder.new(question, context, response_language: @response_language)
102
109
  else
103
- PromptBuilder.new(question, context)
110
+ PromptBuilder.new(question, context, response_language: @response_language)
104
111
  end
105
112
  end
106
113
 
@@ -136,6 +143,7 @@ module Girb
136
143
  iterations += 1
137
144
  if iterations > MAX_TOOL_ITERATIONS
138
145
  puts "\n[girb] Tool iteration limit reached"
146
+ emit_tool_limit_summary
139
147
  break
140
148
  end
141
149
 
@@ -196,6 +204,23 @@ module Girb
196
204
 
197
205
  if response.error && !response.function_call?
198
206
  puts "[girb] API Error: #{response.error}" if Girb.configuration.debug
207
+
208
+ # Provider-agnostic self-correction: when the model emits a tool call
209
+ # the provider can't parse, feed it corrective guidance and retry.
210
+ # Bounded by MAX_SELF_CORRECTION; the outer loop's MAX_TOOL_ITERATIONS
211
+ # is the absolute backstop. Only malformed tool calls are retried here
212
+ # (transport errors like rate limits are not the model's to fix).
213
+ if response.self_correctable? && @self_correction_count < MAX_SELF_CORRECTION
214
+ @self_correction_count += 1
215
+ if Girb.configuration.debug
216
+ puts "[girb] Self-correcting malformed tool call " \
217
+ "(attempt #{@self_correction_count}/#{MAX_SELF_CORRECTION})"
218
+ end
219
+ ConversationHistory.add_assistant_message("")
220
+ ConversationHistory.add_user_message(self_correction_feedback)
221
+ next
222
+ end
223
+
199
224
  ConversationHistory.add_assistant_message("")
200
225
  error_summary = response.error.to_s.split(":").first || "Unknown error"
201
226
  puts "[girb] Error: #{error_summary}"
@@ -262,7 +287,6 @@ module Girb
262
287
  puts "[girb] Warning: Empty response"
263
288
  end
264
289
  else
265
- ConversationHistory.add_assistant_message(full_text)
266
290
  puts full_text
267
291
  record_ai_response(full_text)
268
292
  end
@@ -271,6 +295,53 @@ module Girb
271
295
  end
272
296
  end
273
297
 
298
+ # When the per-turn tool-call limit is hit, ask the model once more — with NO
299
+ # tools available — so it must produce a final natural-language answer instead
300
+ # of ending silently on raw tool output. Mirrors handle_irb_limit_reached.
301
+ def emit_tool_limit_summary
302
+ # Flush any pending tool calls into an assistant turn first, so the summary
303
+ # request lands AFTER the tool results in the conversation, not before.
304
+ # Guarded so we don't append an empty assistant turn when nothing is pending.
305
+ ConversationHistory.add_assistant_message("") if ConversationHistory.pending_tool_calls?
306
+ ConversationHistory.add_user_message(
307
+ "(System: Tool-call limit reached. Do NOT call any more tools. " \
308
+ "Summarize what you found and give your best final answer now.)"
309
+ )
310
+
311
+ text = ""
312
+ begin
313
+ final = @provider.chat(
314
+ messages: ConversationHistory.to_normalized,
315
+ system_prompt: @system_prompt,
316
+ tools: [],
317
+ binding: @current_binding
318
+ )
319
+ text = final&.text.to_s
320
+ rescue ScriptError, StandardError => e
321
+ # Interrupt/SignalException are not caught here, so Ctrl+C still propagates.
322
+ puts "[girb] Error summarizing: #{e.message}" if Girb.configuration.debug
323
+ end
324
+
325
+ # Always close the turn with an assistant message — even on failure/empty —
326
+ # so the synthetic user request above is never left dangling (which would
327
+ # break alternation and leak the internal instruction into the next turn).
328
+ ConversationHistory.add_assistant_message(text)
329
+
330
+ if text.strip.empty?
331
+ puts "[girb] (No final summary was produced — see the tool output above for results.)"
332
+ else
333
+ puts text
334
+ record_ai_response(text)
335
+ end
336
+ end
337
+
338
+ def self_correction_feedback
339
+ "(System: Your previous tool call could not be parsed by the provider. " \
340
+ "This is a Ruby environment. Respond with a single well-formed tool call " \
341
+ "whose arguments are valid JSON — do not use Python syntax, and do not put " \
342
+ "prose where structured arguments are expected.)"
343
+ end
344
+
274
345
  def execute_tool(tool_name, args)
275
346
  tool_class = Tools.find_tool(tool_name)
276
347
 
@@ -286,7 +357,11 @@ module Girb
286
357
  else
287
358
  { error: "No binding available for tool execution" }
288
359
  end
289
- rescue StandardError => e
360
+ rescue ScriptError, StandardError => e
361
+ # ScriptError (LoadError/NotImplementedError) is not a StandardError; catch
362
+ # both so a tool can never crash the loop and dump a raw backtrace. Interrupt
363
+ # and SignalException are not StandardError/ScriptError, so Ctrl+C still
364
+ # propagates to the interrupt handling above.
290
365
  { error: "Tool execution failed: #{e.class} - #{e.message}" }
291
366
  end
292
367
 
@@ -45,6 +45,10 @@ module Girb
45
45
  def to_normalized
46
46
  instance.to_normalized
47
47
  end
48
+
49
+ def pending_tool_calls?
50
+ instance.pending_tool_calls?
51
+ end
48
52
  end
49
53
 
50
54
  attr_reader :messages
@@ -82,6 +86,10 @@ module Girb
82
86
  }.compact
83
87
  end
84
88
 
89
+ def pending_tool_calls?
90
+ @pending_tool_calls.any?
91
+ end
92
+
85
93
  def clear!
86
94
  @messages.clear
87
95
  @pending_tool_calls.clear
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "language_detector"
4
+
3
5
  module Girb
4
6
  class DebugPromptBuilder
5
7
  SYSTEM_PROMPT = <<~PROMPT
@@ -189,18 +191,21 @@ module Girb
189
191
  Non-navigation commands (break, info, bt) should come before navigation commands (step, next, continue).
190
192
  PROMPT
191
193
 
192
- def initialize(question, context)
194
+ def initialize(question, context, response_language: nil)
193
195
  @question = question
194
196
  @context = context
197
+ @response_language = response_language || LanguageDetector.detect(question)
195
198
  end
196
199
 
197
200
  def system_prompt
201
+ prompt = SYSTEM_PROMPT
202
+ # After the bulk of the prompt (so it isn't drowned out by English debug
203
+ # context), but before user-defined instructions so an explicit user
204
+ # language preference still wins.
205
+ prompt += "\n" + language_directive
198
206
  custom = Girb.configuration&.custom_prompt
199
- if custom && !custom.empty?
200
- "#{SYSTEM_PROMPT}\n\n## User-Defined Instructions\n#{custom}"
201
- else
202
- SYSTEM_PROMPT
203
- end
207
+ prompt += "\n\n## User-Defined Instructions\n#{custom}" if custom && !custom.empty?
208
+ prompt
204
209
  end
205
210
 
206
211
  def user_message
@@ -215,6 +220,30 @@ module Girb
215
220
 
216
221
  private
217
222
 
223
+ def language_directive
224
+ lang = @response_language
225
+ if lang == LanguageDetector::FALLBACK
226
+ <<~MSG
227
+ ## Response Language
228
+ Respond in #{lang}: detect it from the user's ORIGINAL question and match it.
229
+ This is NOT determined by code, tool output, logs, stack traces, system
230
+ messages, or prior assistant turns. Keep identifiers, code, errors, and
231
+ quoted output unchanged.
232
+ (If the user's instructions below specify a language, follow those instead.)
233
+ MSG
234
+ else
235
+ <<~MSG
236
+ ## Response Language
237
+ response_language for this conversation is: #{lang}.
238
+ It is derived ONLY from the user's original question — NOT from code, tool
239
+ output, logs, stack traces, system messages, or prior assistant turns.
240
+ Unless the user's instructions below say otherwise, your final answer
241
+ AND your one-line tool comments MUST be in #{lang}.
242
+ Keep identifiers, code, errors, and quoted output unchanged.
243
+ MSG
244
+ end
245
+ end
246
+
218
247
  def build_context_section
219
248
  <<~CONTEXT
220
249
  ### Source Location
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ # Lightweight, dependency-free heuristic for the response language.
5
+ #
6
+ # The goal is NOT accurate language identification — it is to stop the model
7
+ # from drifting to English when the user clearly wrote in Japanese (a recurring
8
+ # bug), without forcing a wrong language onto users of other languages.
9
+ #
10
+ # Returns one of:
11
+ # "Japanese" - high confidence (kana present)
12
+ # "English" - high confidence (Latin-only, no other scripts)
13
+ # "the user's language" - uncertain; fall back to "match the user" guidance
14
+ module LanguageDetector
15
+ FALLBACK = "the user's language"
16
+
17
+ # Common English function words. Latin-only text is only called "English"
18
+ # when one of these appears, so Spanish/French/German/romaji questions fall
19
+ # back to "match the user" instead of being forced into English.
20
+ # Note: single-letter "i" is intentionally excluded — with the /i flag it
21
+ # would match a Ruby loop variable `i` and misclassify code as English.
22
+ ENGLISH_MARKERS = /\b(?:the|is|are|was|were|what|why|how|does|do|did|this|that|these|those|
23
+ can|could|should|would|will|when|where|which|who|of|to|in|on|for|
24
+ with|and|or|not|please|explain|show|tell|my|it|its)\b/xi
25
+
26
+ module_function
27
+
28
+ def detect(text)
29
+ s = text.to_s
30
+ return FALLBACK if s.strip.empty?
31
+
32
+ # Kana is unique to Japanese — any occurrence is a strong signal.
33
+ return "Japanese" if s.match?(/[\p{Hiragana}\p{Katakana}]/)
34
+
35
+ # Han without kana is ambiguous (could be Chinese); stay cautious.
36
+ return FALLBACK if s.match?(/\p{Han}/)
37
+
38
+ # Any other non-Latin script (Hangul, Cyrillic, Arabic, ...) -> don't guess.
39
+ return FALLBACK if s.match?(/[^\p{Latin}\p{Common}\p{Inherited}]/)
40
+
41
+ # Latin-only: only commit to English when a clear English marker is present.
42
+ return "English" if s.match?(ENGLISH_MARKERS)
43
+
44
+ FALLBACK
45
+ end
46
+ end
47
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "language_detector"
4
+
3
5
  module Girb
4
6
  class PromptBuilder
5
7
  # Common prompt shared across all IRB modes
@@ -204,9 +206,13 @@ module Girb
204
206
  - For simple one-shot investigations
205
207
  PROMPT
206
208
 
207
- def initialize(question, context)
209
+ def initialize(question, context, response_language: nil)
208
210
  @question = question
209
211
  @context = context
212
+ # Derived from the ORIGINAL user question and propagated by AiClient, so
213
+ # English system/continuation messages never re-anchor the output language.
214
+ # Falls back to detecting from this question for standalone callers.
215
+ @response_language = response_language || LanguageDetector.detect(question)
210
216
  end
211
217
 
212
218
  # Legacy single prompt format (for backward compatibility)
@@ -225,12 +231,17 @@ module Girb
225
231
  def system_prompt
226
232
  prompt = COMMON_PROMPT + "\n" + mode_specific_prompt + "\n" + CONTINUE_ANALYSIS_PROMPT
227
233
 
234
+ # Language directive goes after the bulk of the prompt so it isn't drowned
235
+ # out, but BEFORE user-defined instructions so an explicit user language
236
+ # preference still wins.
237
+ prompt += "\n" + language_directive
238
+
228
239
  custom = Girb.configuration&.custom_prompt
229
240
  if custom && !custom.empty?
230
- prompt + "\n\n## User-Defined Instructions\n#{custom}"
231
- else
232
- prompt
241
+ prompt += "\n\n## User-Defined Instructions\n#{custom}"
233
242
  end
243
+
244
+ prompt
234
245
  end
235
246
 
236
247
  # User message (context + question)
@@ -246,6 +257,30 @@ module Girb
246
257
 
247
258
  private
248
259
 
260
+ def language_directive
261
+ lang = @response_language
262
+ if lang == LanguageDetector::FALLBACK
263
+ <<~MSG
264
+ ## Response Language
265
+ Respond in #{lang}: detect it from the user's ORIGINAL question and match it.
266
+ This is NOT determined by code, tool output, logs, stack traces, system
267
+ messages, or prior assistant turns. Keep identifiers, code, errors, and
268
+ quoted output unchanged.
269
+ (If the user's instructions below specify a language, follow those instead.)
270
+ MSG
271
+ else
272
+ <<~MSG
273
+ ## Response Language
274
+ response_language for this conversation is: #{lang}.
275
+ It is derived ONLY from the user's original question — NOT from code, tool
276
+ output, logs, stack traces, system messages, or prior assistant turns.
277
+ Unless the user's instructions below say otherwise, your final answer
278
+ AND your one-line tool comments MUST be in #{lang}.
279
+ Keep identifiers, code, errors, and quoted output unchanged.
280
+ MSG
281
+ end
282
+ end
283
+
249
284
  def mode_specific_prompt
250
285
  case detect_mode
251
286
  when :breakpoint
@@ -32,18 +32,70 @@ module Girb
32
32
 
33
33
  # Response object returned by chat method
34
34
  class Response
35
- attr_reader :text, :function_calls, :error, :raw_response
35
+ # Normalized error categories. Providers SHOULD set error_kind explicitly;
36
+ # when omitted, a conservative fallback classification is applied.
37
+ # :malformed_tool_call - the model emitted an invalid/unparseable tool call
38
+ # :rate_limit - provider rate limited the request
39
+ # :transient - temporary provider/network failure, safe to retry
40
+ # :timeout - request timed out
41
+ # :fatal - non-recoverable (auth, bad request, unknown)
42
+ ERROR_KINDS = %i[malformed_tool_call rate_limit transient timeout fatal].freeze
43
+
44
+ attr_reader :text, :function_calls, :error, :raw_response, :error_kind
45
+
46
+ def initialize(text: nil, function_calls: nil, error: nil, raw_response: nil, error_kind: nil)
47
+ if error_kind && !ERROR_KINDS.include?(error_kind)
48
+ raise ArgumentError, "unknown error_kind: #{error_kind.inspect} (expected one of #{ERROR_KINDS.inspect})"
49
+ end
36
50
 
37
- def initialize(text: nil, function_calls: nil, error: nil, raw_response: nil)
38
51
  @text = text
39
52
  @function_calls = function_calls || []
40
53
  @error = error
41
54
  @raw_response = raw_response
55
+ @error_kind = error_kind
56
+ @error_kind ||= classify_error(error) if error
42
57
  end
43
58
 
44
59
  def function_call?
45
60
  @function_calls.any?
46
61
  end
62
+
63
+ # The model can fix this itself by re-emitting a valid tool call.
64
+ # Drives provider-agnostic self-correction in the tool loop.
65
+ def self_correctable?
66
+ @error_kind == :malformed_tool_call
67
+ end
68
+
69
+ # The request can be retried at the transport layer without involving
70
+ # the model (no conversation-history change). Not wired into the loop
71
+ # yet; exposed for providers and a future transport-retry milestone.
72
+ def transport_retryable?
73
+ %i[rate_limit transient timeout].include?(@error_kind)
74
+ end
75
+
76
+ private
77
+
78
+ # Conservative fallback used only when the provider does not set
79
+ # error_kind. Defaults to :fatal and classifies only a small set of
80
+ # unambiguous, provider-reported patterns, so a generic error message is
81
+ # never mistaken for a malformed tool call.
82
+ def classify_error(err)
83
+ s = err.to_s.downcase
84
+ return :malformed_tool_call if s.include?("malformed_function_call") || s.include?("malformed function call")
85
+ if s.include?("rate limit") || s.include?("rate_limit") || s.match?(/\b429\b/) ||
86
+ s.include?("too many requests") || s.include?("resource_exhausted") || s.include?("quota")
87
+ return :rate_limit
88
+ end
89
+ if s.include?("timed out") || s.include?("timeout") || s.include?("deadline exceeded") ||
90
+ s.include?("execution expired") || s.include?("etimedout")
91
+ return :timeout
92
+ end
93
+ if s.match?(/\b503\b/) || s.include?("service unavailable") || s.include?("unavailable") ||
94
+ s.include?("econnreset") || s.include?("connection reset")
95
+ return :transient
96
+ end
97
+ :fatal
98
+ end
47
99
  end
48
100
 
49
101
  # Normalized tool definition format
@@ -29,18 +29,34 @@ module Girb
29
29
  def execute(binding, code:)
30
30
  captured_output = StringIO.new
31
31
  original_stdout = $stdout
32
- $stdout = captured_output
32
+ error_response = nil
33
33
 
34
+ # A single ensure guarantees $stdout is restored on every exit path —
35
+ # including exceptions we intentionally let propagate (Interrupt,
36
+ # SignalException), which the rescue clauses below do NOT catch.
34
37
  begin
38
+ $stdout = captured_output
35
39
  result = binding.eval(code)
40
+ rescue SyntaxError => e
41
+ error_response = { code: code, error: "Syntax error: #{e.message}", success: false }
42
+ rescue ScriptError, StandardError => e
43
+ # ScriptError (LoadError/NotImplementedError) is NOT a StandardError,
44
+ # so without it such errors would escape and crash the tool loop.
45
+ error_response = { code: code, error: "#{e.class}: #{e.message}", backtrace: e.backtrace&.first(5), success: false }
36
46
  ensure
37
47
  $stdout = original_stdout
38
48
  end
39
49
 
40
50
  stdout_str = captured_output.string
41
51
  # Also print captured output to the real console for user visibility
52
+ # (after $stdout is restored, so it reaches the real terminal).
42
53
  print stdout_str unless stdout_str.empty?
43
54
 
55
+ if error_response
56
+ error_response[:stdout] = stdout_str unless stdout_str.empty?
57
+ return error_response
58
+ end
59
+
44
60
  response = {
45
61
  code: code,
46
62
  result: safe_inspect(result),
@@ -49,15 +65,6 @@ module Girb
49
65
  }
50
66
  response[:stdout] = stdout_str unless stdout_str.empty?
51
67
  response
52
- rescue SyntaxError => e
53
- $stdout = original_stdout if $stdout != original_stdout
54
- { code: code, error: "Syntax error: #{e.message}", success: false }
55
- rescue StandardError => e
56
- $stdout = original_stdout if $stdout != original_stdout
57
- stdout_str = captured_output&.string
58
- response = { code: code, error: "#{e.class}: #{e.message}", backtrace: e.backtrace&.first(5), success: false }
59
- response[:stdout] = stdout_str if stdout_str && !stdout_str.empty?
60
- response
61
68
  end
62
69
  end
63
70
  end
data/lib/girb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Girb
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: girb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rira100000000
@@ -95,6 +95,7 @@ files:
95
95
  - lib/girb/exception_capture.rb
96
96
  - lib/girb/girbrc_loader.rb
97
97
  - lib/girb/irb_integration.rb
98
+ - lib/girb/language_detector.rb
98
99
  - lib/girb/prompt_builder.rb
99
100
  - lib/girb/providers/base.rb
100
101
  - lib/girb/railtie.rb
@@ -138,7 +139,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
139
  - !ruby/object:Gem::Version
139
140
  version: '0'
140
141
  requirements: []
141
- rubygems_version: 3.7.2
142
+ rubygems_version: 3.6.9
142
143
  specification_version: 4
143
144
  summary: AI-powered IRB assistant
144
145
  test_files: []