rubyn-code 0.5.1 → 0.8.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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -4
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +100 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  6. data/lib/rubyn_code/agent/llm_caller.rb +7 -3
  7. data/lib/rubyn_code/agent/loop.rb +106 -12
  8. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
  10. data/lib/rubyn_code/agent/tool_processor.rb +13 -1
  11. data/lib/rubyn_code/auth/oauth.rb +1 -1
  12. data/lib/rubyn_code/auth/token_store.rb +49 -4
  13. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  14. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  15. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  16. data/lib/rubyn_code/chisel/debt.rb +65 -0
  17. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  18. data/lib/rubyn_code/chisel.rb +127 -0
  19. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  20. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  21. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  23. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  24. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  25. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  26. data/lib/rubyn_code/cli/commands/context.rb +30 -1
  27. data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
  28. data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
  29. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  30. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  31. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  32. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  33. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  34. data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
  35. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  36. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  37. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  38. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  39. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  40. data/lib/rubyn_code/cli/first_run.rb +1 -1
  41. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  42. data/lib/rubyn_code/cli/mention_expander.rb +111 -0
  43. data/lib/rubyn_code/cli/renderer.rb +3 -2
  44. data/lib/rubyn_code/cli/repl.rb +68 -15
  45. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  46. data/lib/rubyn_code/cli/repl_setup.rb +17 -7
  47. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  48. data/lib/rubyn_code/cli/version_check.rb +10 -3
  49. data/lib/rubyn_code/config/defaults.rb +14 -1
  50. data/lib/rubyn_code/config/schema.json +9 -0
  51. data/lib/rubyn_code/config/settings.rb +19 -2
  52. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  53. data/lib/rubyn_code/context/manager.rb +29 -12
  54. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  55. data/lib/rubyn_code/debug.rb +11 -5
  56. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  57. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  58. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  59. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  60. data/lib/rubyn_code/hooks/response.rb +83 -0
  61. data/lib/rubyn_code/hooks/runner.rb +61 -3
  62. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  63. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  64. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  65. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  66. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  67. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  69. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  70. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  71. data/lib/rubyn_code/index/codebase_index.rb +103 -4
  72. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  73. data/lib/rubyn_code/learning/injector.rb +1 -2
  74. data/lib/rubyn_code/learning/porter.rb +129 -0
  75. data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
  76. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  77. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  78. data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
  79. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  80. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  81. data/lib/rubyn_code/llm/client.rb +16 -3
  82. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  83. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  84. data/lib/rubyn_code/llm/model_router.rb +4 -4
  85. data/lib/rubyn_code/mcp/client.rb +59 -0
  86. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  87. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  88. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  90. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  91. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  92. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  93. data/lib/rubyn_code/memory/search.rb +9 -5
  94. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  95. data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
  96. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  97. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  98. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  99. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  100. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  101. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  102. data/lib/rubyn_code/tasks/models.rb +0 -16
  103. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  104. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  105. data/lib/rubyn_code/teams/manager.rb +83 -5
  106. data/lib/rubyn_code/teams/teammate.rb +3 -14
  107. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  108. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  109. data/lib/rubyn_code/tools/bash.rb +3 -3
  110. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  111. data/lib/rubyn_code/tools/executor.rb +9 -4
  112. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  113. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  114. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  115. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  116. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  117. data/lib/rubyn_code/tools/web_search.rb +4 -1
  118. data/lib/rubyn_code/version.rb +1 -1
  119. data/lib/rubyn_code.rb +57 -9
  120. data/skills/rubyn_self_test.md +462 -14
  121. data/skills/self_test/chisel_smoke.rb +84 -0
  122. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  123. metadata +45 -6
  124. data/lib/rubyn_code/context/context_budget.rb +0 -183
  125. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  126. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  127. data/lib/rubyn_code/llm/streaming.rb +0 -10
  128. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
3
  require 'json'
5
4
  require 'securerandom'
6
5
  require_relative '../message_builder'
@@ -8,6 +7,7 @@ require_relative '../message_builder'
8
7
  module RubynCode
9
8
  module LLM
10
9
  module Adapters
10
+ # rubocop:disable Metrics/ClassLength -- auth helpers + streaming + finalize are all needed
11
11
  class Anthropic < Base
12
12
  include JsonParsing
13
13
  include PromptCaching
@@ -17,12 +17,33 @@ module RubynCode
17
17
  MAX_RETRIES = 3
18
18
  RETRY_DELAYS = [2, 5, 10].freeze
19
19
 
20
+ # Fable/Mythos/Opus 5 models opt into a server-side Opus 4.8 fallback
21
+ # by default: a declined request (safety-classifier refusal) is
22
+ # transparently re-served by the fallback model in the same call.
23
+ # No config toggle — hardcoded for these model families only.
24
+ FALLBACK_ELIGIBLE_MODELS = /\Aclaude-(fable|mythos|opus-5)/
25
+ FALLBACK_BETA = 'server-side-fallback-2026-06-01'
26
+
27
+ TASK_BUDGET_BETA = 'task-budgets-2026-03-13'
28
+ TASK_BUDGET_MIN_TOKENS = 20_000 # API minimum; below this, task_budget is omitted entirely
29
+ TASK_BUDGET_MODELS = /\Aclaude-(fable|mythos|sonnet-5|opus-5|opus-4-[78])/
30
+
20
31
  AVAILABLE_MODELS = %w[
21
- claude-sonnet-4-20250514
32
+ claude-fable-5
33
+ claude-opus-5
34
+ claude-opus-4-8
35
+ claude-opus-4-7
22
36
  claude-opus-4-6
23
- claude-haiku-4-20250506
37
+ claude-sonnet-5
38
+ claude-sonnet-4-6
39
+ claude-haiku-4-5
24
40
  ].freeze
25
41
 
42
+ # Models on the adaptive-thinking API surface (Claude 4.6+).
43
+ # budget_tokens is removed there and returns a 400; older models
44
+ # (Haiku 4.5, Sonnet/Opus 4.5 and earlier) still take enabled + budget.
45
+ ADAPTIVE_THINKING_MODELS = /\Aclaude-(fable|mythos|opus-5|opus-4-[678]|sonnet-5|sonnet-4-6)/
46
+
26
47
  def provider_name
27
48
  'anthropic'
28
49
  end
@@ -31,14 +52,16 @@ module RubynCode
31
52
  AVAILABLE_MODELS
32
53
  end
33
54
 
34
- def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil) # rubocop:disable Metrics/ParameterLists -- mirrors LLM adapter interface
55
+ def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, # rubocop:disable Metrics/ParameterLists -- mirrors LLM adapter interface
56
+ task_budget: nil, thinking: nil, effort: nil)
35
57
  ensure_valid_token!
36
58
  use_streaming = on_text && oauth_token?
37
59
 
38
60
  body = build_request_body(
39
61
  messages: messages, tools: tools, system: system,
40
62
  model: model, max_tokens: max_tokens,
41
- stream: use_streaming, task_budget: task_budget
63
+ stream: use_streaming, task_budget: task_budget,
64
+ thinking: thinking, effort: effort
42
65
  )
43
66
 
44
67
  return stream_request(body, on_text) if use_streaming
@@ -46,30 +69,73 @@ module RubynCode
46
69
  execute_with_retries(body, on_text)
47
70
  end
48
71
 
49
- private
72
+ # -- Auth helpers ----------------------------------------------
50
73
 
51
- def api_url
52
- API_URL
53
- end
74
+ # Validate the active auth path before sending the request.
75
+ # Raises Client::AuthExpiredError with a clear message instead
76
+ # of letting the HTTP request fail with a 401.
77
+ def ensure_valid_token!
78
+ return if token_valid? && access_token && !access_token.empty?
54
79
 
55
- # -- Auth ---------------------------------------------------------
80
+ return if !oauth_token? && api_key && !api_key.empty?
56
81
 
57
- def oauth_token?
58
- access_token.include?('sk-ant-oat')
82
+ raise Client::AuthExpiredError, 'No valid authentication configured'
59
83
  end
60
84
 
61
- def ensure_valid_token!
62
- return if Auth::TokenStore.valid?
85
+ # @return [Boolean] true when using the OAuth token (vs API key).
86
+ # A token from the keychain (or marked source: :keychain /
87
+ # :oauth) is treated as OAuth; a token from :env or
88
+ # :tokens_file is treated as a long-lived API key.
89
+ def oauth_token?
90
+ token = raw_access_token
91
+ return false unless token.is_a?(Hash)
63
92
 
64
- raise Client::AuthExpiredError,
65
- 'No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY.'
93
+ source = token[:source] || token['source']
94
+ [:keychain, 'keychain', :oauth].include?(source)
66
95
  end
67
96
 
97
+ # @return [String, nil] the access token in use.
98
+ # Accepts either a raw String token or a Hash like
99
+ # { access_token: "...", expires_at: ..., source: :keychain }.
100
+ # Memoized per-instance so that repeated calls during one
101
+ # request don't re-hit the token store.
68
102
  def access_token
69
- tokens = Auth::TokenStore.load
70
- raise Client::AuthExpiredError, 'No stored access token' unless tokens&.dig(:access_token)
103
+ return @access_token if defined?(@access_token)
104
+
105
+ token = raw_access_token
106
+ @access_token =
107
+ case token
108
+ when nil then nil
109
+ when String then token.empty? ? nil : token
110
+ when Hash then token[:access_token] || token['access_token']
111
+ end
112
+ end
113
+
114
+ # @return [String, nil] the API key (ANTHROPIC_API_KEY), or nil
115
+ def api_key
116
+ ENV.fetch('ANTHROPIC_API_KEY', nil)
117
+ end
118
+
119
+ # @return [Boolean] true if the token store says the active
120
+ # credential is still valid
121
+ def token_valid?
122
+ return true unless defined?(Auth::TokenStore)
123
+ return true unless Auth::TokenStore.respond_to?(:valid?)
124
+
125
+ Auth::TokenStore.valid?
126
+ end
127
+
128
+ def raw_access_token
129
+ return @raw_access_token if defined?(@raw_access_token)
130
+
131
+ @raw_access_token =
132
+ (Auth::TokenStore.load if defined?(Auth::TokenStore) && Auth::TokenStore.respond_to?(:load))
133
+ end
134
+
135
+ private
71
136
 
72
- tokens[:access_token]
137
+ def api_url
138
+ API_URL
73
139
  end
74
140
 
75
141
  # -- Request execution --------------------------------------------
@@ -93,7 +159,7 @@ module RubynCode
93
159
 
94
160
  def post_request(body)
95
161
  connection.post(api_url) do |req|
96
- apply_headers(req)
162
+ apply_headers(req, body)
97
163
  req.body = JSON.generate(body)
98
164
  end
99
165
  end
@@ -118,7 +184,7 @@ module RubynCode
118
184
  error_chunks = []
119
185
 
120
186
  response = streaming_connection.post(api_url) do |req|
121
- apply_headers(req)
187
+ apply_headers(req, body)
122
188
  req.body = JSON.generate(body)
123
189
  req.options.on_data = on_data_proc(streamer, error_chunks)
124
190
  end
@@ -167,6 +233,7 @@ module RubynCode
167
233
  end
168
234
 
169
235
  def build_faraday_connection
236
+ require 'faraday'
170
237
  Faraday.new do |f|
171
238
  f.options.timeout = 300
172
239
  f.options.open_timeout = 30
@@ -176,23 +243,35 @@ module RubynCode
176
243
 
177
244
  # -- Headers ------------------------------------------------------
178
245
 
179
- def apply_headers(req)
246
+ def apply_headers(req, body = {})
180
247
  req.headers['Content-Type'] = 'application/json'
181
248
  req.headers['anthropic-version'] = ANTHROPIC_VERSION
182
- oauth_token? ? apply_oauth_headers(req) : apply_api_key_headers(req)
249
+ oauth_token? ? apply_oauth_headers(req, body) : apply_api_key_headers(req, body)
183
250
  end
184
251
 
185
- def apply_oauth_headers(req)
252
+ def apply_oauth_headers(req, body)
186
253
  req.headers['Authorization'] = "Bearer #{access_token}"
187
- req.headers['anthropic-beta'] = 'oauth-2025-04-20'
254
+ # The subscription-auth beta must stay present, so feature betas append rather than replace.
255
+ betas = ['oauth-2025-04-20']
256
+ betas << TASK_BUDGET_BETA if task_budget_on_wire?(body)
257
+ betas << FALLBACK_BETA if body[:fallbacks]
258
+ req.headers['anthropic-beta'] = betas.join(',')
188
259
  req.headers['x-app'] = 'cli'
189
260
  req.headers['User-Agent'] = 'claude-code/2.1.79'
190
261
  req.headers['X-Claude-Code-Session-Id'] = session_id
191
262
  req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
192
263
  end
193
264
 
194
- def apply_api_key_headers(req)
265
+ def apply_api_key_headers(req, body)
195
266
  req.headers['x-api-key'] = access_token
267
+ betas = []
268
+ betas << TASK_BUDGET_BETA if task_budget_on_wire?(body)
269
+ betas << FALLBACK_BETA if body[:fallbacks]
270
+ req.headers['anthropic-beta'] = betas.join(',') unless betas.empty?
271
+ end
272
+
273
+ def task_budget_on_wire?(body)
274
+ !!body&.dig(:output_config, :task_budget)
196
275
  end
197
276
 
198
277
  def session_id
@@ -201,15 +280,70 @@ module RubynCode
201
280
 
202
281
  # -- Request body -------------------------------------------------
203
282
 
204
- def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, **_opts) # rubocop:disable Metrics/ParameterLists -- API request builder mirrors Claude API params
205
- body = { model: model, max_tokens: max_tokens }
283
+ def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, # rubocop:disable Metrics/ParameterLists -- API request builder mirrors Claude API params
284
+ thinking: nil, effort: nil, task_budget: nil, **_opts)
285
+ body = { model: model, max_tokens: ensure_max_tokens_for_thinking(max_tokens, thinking) }
286
+ apply_thinking(body, thinking)
287
+ apply_effort(body, effort)
288
+ apply_task_budget(body, task_budget, model)
206
289
  apply_system_blocks(body, system)
207
290
  apply_tool_cache(body, tools)
291
+ apply_fallbacks(body, model)
208
292
  body[:messages] = add_message_cache_breakpoint(messages)
209
293
  body[:stream] = true if stream
210
294
  body
211
295
  end
212
296
 
297
+ def apply_fallbacks(body, model)
298
+ return unless model.to_s.match?(FALLBACK_ELIGIBLE_MODELS)
299
+
300
+ body[:fallbacks] = [{ model: 'claude-opus-4-8' }]
301
+ end
302
+
303
+ # Advisory pacing signal only — Claude sees a running countdown and paces
304
+ # itself, but max_tokens remains the enforced hard cap per response.
305
+ def apply_task_budget(body, task_budget, model)
306
+ return unless task_budget.is_a?(Hash)
307
+ return unless model.to_s.match?(TASK_BUDGET_MODELS)
308
+
309
+ # `remaining` is what's left for the task, matching what the model should pace against.
310
+ total = task_budget[:remaining].to_i
311
+ return if total < TASK_BUDGET_MIN_TOKENS
312
+
313
+ (body[:output_config] ||= {})[:task_budget] = { type: 'tokens', total: total }
314
+ end
315
+
316
+ def apply_thinking(body, thinking)
317
+ return unless thinking.is_a?(Hash) && thinking[:budget_tokens].to_i.positive?
318
+
319
+ body[:thinking] =
320
+ if body[:model].to_s.match?(ADAPTIVE_THINKING_MODELS)
321
+ { type: 'adaptive' }
322
+ else
323
+ { type: 'enabled', budget_tokens: thinking[:budget_tokens].to_i }
324
+ end
325
+ end
326
+
327
+ # Merges into `output_config` (rather than assigning a whole hash) so
328
+ # this composes with other output_config keys, e.g. task_budget.
329
+ def apply_effort(body, effort)
330
+ return unless effort
331
+
332
+ (body[:output_config] ||= {})[:effort] = effort
333
+ end
334
+
335
+ # Anthropic requires max_tokens > budget_tokens. When thinking is on,
336
+ # raise max_tokens to budget + 1024 if not already large enough.
337
+ def ensure_max_tokens_for_thinking(max_tokens, thinking)
338
+ return max_tokens unless thinking.is_a?(Hash)
339
+
340
+ budget = thinking[:budget_tokens].to_i
341
+ return max_tokens if budget.zero?
342
+ return max_tokens if max_tokens >= budget + 1024
343
+
344
+ budget + 1024
345
+ end
346
+
213
347
  # -- Response parsing ---------------------------------------------
214
348
 
215
349
  def handle_api_response(response)
@@ -245,13 +379,17 @@ module RubynCode
245
379
  def build_api_response(body)
246
380
  content = parse_content_blocks(body['content'])
247
381
  usage = parse_usage(body['usage'])
248
- Response.new(id: body['id'], content: content, stop_reason: body['stop_reason'], usage: usage)
382
+ Response.new(
383
+ id: body['id'], content: content, stop_reason: body['stop_reason'],
384
+ stop_details: body['stop_details'], usage: usage
385
+ )
249
386
  end
250
387
 
251
388
  def parse_content_blocks(blocks)
252
389
  (blocks || []).filter_map do |block|
253
390
  case block['type']
254
391
  when 'text' then TextBlock.new(text: block['text'])
392
+ when 'thinking' then ThinkingBlock.new(text: block['thinking'] || block['text'])
255
393
  when 'tool_use'
256
394
  ToolUseBlock.new(id: block['id'], name: block['name'], input: block['input'])
257
395
  end
@@ -270,5 +408,6 @@ module RubynCode
270
408
  end
271
409
  end
272
410
  end
411
+ # rubocop:enable Metrics/ClassLength
273
412
  end
274
413
  end
@@ -35,8 +35,10 @@ module RubynCode
35
35
  @content_blocks = []
36
36
  @current_block_index = nil
37
37
  @current_text = +''
38
+ @current_thinking_text = +''
38
39
  @current_tool_input_json = +''
39
40
  @stop_reason = nil
41
+ @stop_details = nil
40
42
  @usage = nil
41
43
  end
42
44
 
@@ -51,6 +53,7 @@ module RubynCode
51
53
  id: @response_id,
52
54
  content: @content_blocks.compact,
53
55
  stop_reason: @stop_reason,
56
+ stop_details: @stop_details,
54
57
  usage: @usage
55
58
  )
56
59
  end
@@ -107,6 +110,8 @@ module RubynCode
107
110
  case block['type']
108
111
  when 'text'
109
112
  @current_text = +(block['text'] || '')
113
+ when 'thinking'
114
+ @current_thinking_text = +(block['thinking'] || '')
110
115
  when 'tool_use'
111
116
  @current_tool_id = block['id']
112
117
  @current_tool_name = block['name']
@@ -124,6 +129,10 @@ module RubynCode
124
129
  text = delta['text'] || ''
125
130
  @current_text << text
126
131
  emit(:text_delta, { index: data['index'], text: text })
132
+ when 'thinking_delta'
133
+ text = delta['thinking'] || ''
134
+ @current_thinking_text << text
135
+ emit(:thinking_delta, { index: data['index'], text: text })
127
136
  when 'input_json_delta'
128
137
  json_chunk = delta['partial_json'] || ''
129
138
  @current_tool_input_json << json_chunk
@@ -141,6 +150,7 @@ module RubynCode
141
150
  def handle_message_delta(data)
142
151
  delta = data['delta'] || {}
143
152
  @stop_reason = delta['stop_reason'] if delta['stop_reason']
153
+ @stop_details = delta['stop_details'] if delta['stop_details']
144
154
  update_output_tokens(data['usage']) if data['usage']
145
155
  emit(:message_delta, data)
146
156
  end
@@ -167,6 +177,9 @@ module RubynCode
167
177
  elsif !@current_text.empty?
168
178
  @content_blocks[index] = TextBlock.new(text: @current_text.dup)
169
179
  @current_text = +''
180
+ elsif !@current_thinking_text.empty?
181
+ @content_blocks[index] = ThinkingBlock.new(text: @current_thinking_text.dup)
182
+ @current_thinking_text = +''
170
183
  end
171
184
  end
172
185
 
@@ -15,8 +15,9 @@ module RubynCode
15
15
  # @param system [String, nil] System prompt text
16
16
  # @param on_text [Proc, nil] Streaming text callback
17
17
  # @param task_budget [Hash, nil] Optional task budget context
18
+ # @param thinking [Hash, nil] Optional {budget_tokens: Integer} for extended reasoning
18
19
  # @return [LLM::Response]
19
- def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil) # rubocop:disable Metrics/ParameterLists -- LLM adapter interface requires these params
20
+ def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists -- LLM adapter interface requires these params
20
21
  raise NotImplementedError, "#{self.class}#chat must be implemented"
21
22
  end
22
23
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
3
  require 'json'
5
4
  require_relative '../message_builder'
6
5
 
@@ -31,7 +30,7 @@ module RubynCode
31
30
  AVAILABLE_MODELS
32
31
  end
33
32
 
34
- def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil) # rubocop:disable Metrics/ParameterLists, Lint/UnusedMethodArgument -- LLM adapter interface requires these params
33
+ def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists, Lint/UnusedMethodArgument -- LLM adapter interface requires these params
35
34
  body = build_request_body(
36
35
  messages: messages, model: model, max_tokens: max_tokens,
37
36
  tools: tools, system: system
@@ -141,6 +140,7 @@ module RubynCode
141
140
  end
142
141
 
143
142
  def build_faraday_connection
143
+ require 'faraday'
144
144
  Faraday.new do |f|
145
145
  f.options.timeout = 300
146
146
  f.options.open_timeout = 30
@@ -27,6 +27,10 @@ module RubynCode
27
27
  return translate_tool_results(content) if tool_results?(content)
28
28
  return translate_assistant_tool_use(content) if role == 'assistant' && tool_use_blocks?(content)
29
29
 
30
+ if content.is_a?(Array) && content.any? { |b| block_type(b) == 'image' }
31
+ return { role: role, content: translate_user_content_with_images(content) }
32
+ end
33
+
30
34
  { role: role, content: stringify_content(content) }
31
35
  end
32
36
 
@@ -34,6 +38,23 @@ module RubynCode
34
38
  content.is_a?(Array) && content.any? { |b| block_type(b) == 'tool_result' }
35
39
  end
36
40
 
41
+ # Emit OpenAI multipart content: text blocks stay text, image blocks
42
+ # become {type: 'image_url', image_url: {url: 'data:<media>;base64,...'}}.
43
+ def translate_user_content_with_images(content)
44
+ content.map do |block|
45
+ case block_type(block)
46
+ when 'image'
47
+ source = block[:source] || block['source']
48
+ media = source[:media_type] || source['media_type']
49
+ data = source[:data] || source['data']
50
+ { type: 'image_url', image_url: { url: "data:#{media};base64,#{data}" } }
51
+ else
52
+ text = block[:text] || block['text'] || ''
53
+ { type: 'text', text: text }
54
+ end
55
+ end
56
+ end
57
+
37
58
  def tool_use_blocks?(content)
38
59
  content.is_a?(Array) && content.any? { |b| block_type(b) == 'tool_use' }
39
60
  end
@@ -36,7 +36,11 @@ module RubynCode
36
36
  def add_message_cache_breakpoint(messages)
37
37
  return messages if messages.nil? || messages.empty?
38
38
 
39
- tagged = messages.map(&:dup)
39
+ # Only the last message gets the cache_control tag, so only it
40
+ # needs to be duped — copying the whole history every call is
41
+ # wasted work on long conversations.
42
+ tagged = messages.dup
43
+ tagged[-1] = tagged[-1].dup
40
44
  tag_last_message_content(tagged.last)
41
45
  tagged
42
46
  end
@@ -15,20 +15,22 @@ module RubynCode
15
15
  class PromptTooLongError < RequestError; end
16
16
 
17
17
  attr_reader :adapter
18
- attr_accessor :model
18
+ attr_accessor :model, :thinking_budget_tokens, :effort
19
19
 
20
20
  def initialize(model: nil, provider: nil, adapter: nil)
21
21
  settings = Config::Settings.new
22
22
  @model = model || settings.model
23
23
  @provider = provider || settings.provider
24
24
  @adapter = adapter || resolve_adapter(@provider)
25
+ @thinking_budget_tokens = 0
25
26
  end
26
27
 
27
28
  def chat(messages:, tools: nil, system: nil, model: nil, **opts)
28
29
  effective_model = model || @model
29
30
  max_tokens = opts[:max_tokens] || Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS
31
+ effective_thinking = opts[:thinking] || current_thinking_hash
30
32
 
31
- @adapter.chat(
33
+ kwargs = {
32
34
  messages: messages,
33
35
  tools: tools,
34
36
  system: system,
@@ -36,7 +38,18 @@ module RubynCode
36
38
  max_tokens: max_tokens,
37
39
  on_text: opts[:on_text],
38
40
  task_budget: opts[:task_budget]
39
- )
41
+ }
42
+ kwargs[:thinking] = effective_thinking if effective_thinking
43
+ kwargs[:effort] = @effort if @effort
44
+
45
+ @adapter.chat(**kwargs)
46
+ end
47
+
48
+ def current_thinking_hash
49
+ budget = @thinking_budget_tokens.to_i
50
+ return nil unless budget.positive?
51
+
52
+ { budget_tokens: budget }
40
53
  end
41
54
 
42
55
  def stream(messages:, tools: nil, system: nil, model: nil,
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module RubynCode
6
+ # Pulls in ImageBlock so the constant is loaded before ImageReader resolves it.
7
+ module LLM
8
+ autoload :ImageBlock, 'rubyn_code/llm/message_builder'
9
+ end
10
+
11
+ module LLM
12
+ # Reads image files from disk and returns image content blocks suitable for
13
+ # sending to the LLM as part of a user turn. Supports common raster formats
14
+ # accepted by both Anthropic and OpenAI vision APIs.
15
+ module ImageReader
16
+ MAX_BYTES = 8 * 1024 * 1024
17
+
18
+ MEDIA_TYPES = {
19
+ '.png' => 'image/png',
20
+ '.jpg' => 'image/jpeg',
21
+ '.jpeg' => 'image/jpeg',
22
+ '.gif' => 'image/gif',
23
+ '.webp' => 'image/webp'
24
+ }.freeze
25
+
26
+ EXTENSIONS_REGEX = /\.(png|jpe?g|gif|webp)\z/i
27
+
28
+ module_function
29
+
30
+ # Build a base64 data URI of the form:
31
+ # "data:image/png;base64,iVBORw0KG..."
32
+ # Returns nil for non-image paths or unreadable/oversized files.
33
+ def data_uri(path)
34
+ block = for_path(path)
35
+ return nil unless block
36
+
37
+ "data:#{block.media_type};base64,#{block.data}"
38
+ end
39
+
40
+ # @return [LLM::ImageBlock, nil] nil for non-image / unreadable paths
41
+ def for_path(path)
42
+ ext = File.extname(path)
43
+ media = MEDIA_TYPES[ext.downcase] || MEDIA_TYPES[".#{ext.sub(/^\./, '').downcase}"]
44
+ return nil unless media
45
+ return nil unless File.file?(path)
46
+
47
+ bytes = File.binread(path)
48
+ return nil if bytes.bytesize > MAX_BYTES
49
+
50
+ ImageBlock.new(media_type: media, data: Base64.strict_encode64(bytes))
51
+ rescue Errno::ENOENT, Errno::EACCES, ArgumentError
52
+ nil
53
+ end
54
+
55
+ def image_extension?(path)
56
+ path.to_s.match?(EXTENSIONS_REGEX)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -6,6 +6,14 @@ module RubynCode
6
6
  def type = 'text'
7
7
  end
8
8
 
9
+ ThinkingBlock = Data.define(:text) do
10
+ def type = 'thinking'
11
+ end
12
+
13
+ ImageBlock = Data.define(:media_type, :data) do
14
+ def type = 'image'
15
+ end
16
+
9
17
  ToolUseBlock = Data.define(:id, :name, :input) do
10
18
  def type = 'tool_use'
11
19
  end
@@ -24,7 +32,11 @@ module RubynCode
24
32
  end
25
33
  end
26
34
 
27
- Response = Data.define(:id, :content, :stop_reason, :usage) do
35
+ Response = Data.define(:id, :content, :stop_reason, :stop_details, :usage) do
36
+ def initialize(id:, content:, stop_reason:, usage:, stop_details: nil)
37
+ super
38
+ end
39
+
28
40
  def text
29
41
  content.select { |b| b.type == 'text' }.map(&:text).join
30
42
  end
@@ -36,6 +48,10 @@ module RubynCode
36
48
  def tool_use?
37
49
  stop_reason == 'tool_use'
38
50
  end
51
+
52
+ def refusal?
53
+ stop_reason == 'refusal'
54
+ end
39
55
  end
40
56
 
41
57
  class MessageBuilder
@@ -102,6 +118,10 @@ module RubynCode
102
118
  case block
103
119
  when TextBlock
104
120
  { type: 'text', text: block.text }
121
+ when ThinkingBlock
122
+ { type: 'thinking', text: block.text }
123
+ when ImageBlock
124
+ { type: 'image', source: { type: 'base64', media_type: block.media_type, data: block.data } }
105
125
  when ToolUseBlock
106
126
  { type: 'tool_use', id: block.id, name: block.name, input: block.input }
107
127
  when ToolResultBlock
@@ -13,8 +13,8 @@ module RubynCode
13
13
  # env_key: ANTHROPIC_API_KEY
14
14
  # models:
15
15
  # cheap: claude-haiku-4-5
16
- # mid: claude-sonnet-4-6
17
- # top: claude-opus-4-6
16
+ # mid: claude-sonnet-5
17
+ # top: claude-opus-5
18
18
  # openai:
19
19
  # env_key: OPENAI_API_KEY
20
20
  # models:
@@ -46,11 +46,11 @@ module RubynCode
46
46
  %w[openai gpt-5.4-nano]
47
47
  ].freeze,
48
48
  mid: [
49
- %w[anthropic claude-sonnet-4-6],
49
+ %w[anthropic claude-sonnet-5],
50
50
  %w[openai gpt-5.4-mini]
51
51
  ].freeze,
52
52
  top: [
53
- %w[anthropic claude-opus-4-6],
53
+ %w[anthropic claude-opus-5],
54
54
  %w[openai gpt-5.4]
55
55
  ].freeze
56
56
  }.freeze