rubyn-code 0.2.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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ # Routes tasks to appropriate model tiers based on complexity.
6
+ # Integrates with the multi-provider adapter layer and reads
7
+ # per-provider model tier overrides from config.yml.
8
+ #
9
+ # Users can configure tier models per provider in config.yml:
10
+ #
11
+ # providers:
12
+ # anthropic:
13
+ # env_key: ANTHROPIC_API_KEY
14
+ # models:
15
+ # cheap: claude-haiku-4-5
16
+ # mid: claude-sonnet-4-6
17
+ # top: claude-opus-4-6
18
+ # openai:
19
+ # env_key: OPENAI_API_KEY
20
+ # models:
21
+ # cheap: gpt-5.4-nano
22
+ # mid: gpt-5.4-mini
23
+ # top: gpt-5.4
24
+ #
25
+ module ModelRouter # rubocop:disable Metrics/ModuleLength -- tier routing with provider integration
26
+ TASK_TIERS = {
27
+ cheap: %i[
28
+ file_search spec_summary schema_lookup format_code
29
+ git_operations memory_retrieval context_summary
30
+ chatting explore
31
+ ].freeze,
32
+ mid: %i[
33
+ generate_specs simple_refactor code_review
34
+ documentation bug_fix
35
+ ].freeze,
36
+ top: %i[
37
+ architecture complex_refactor security_review
38
+ performance planning
39
+ ].freeze
40
+ }.freeze
41
+
42
+ # Hardcoded fallbacks when no config override exists.
43
+ TIER_DEFAULTS = {
44
+ cheap: [
45
+ %w[anthropic claude-haiku-4-5],
46
+ %w[openai gpt-5.4-nano]
47
+ ].freeze,
48
+ mid: [
49
+ %w[anthropic claude-sonnet-4-6],
50
+ %w[openai gpt-5.4-mini]
51
+ ].freeze,
52
+ top: [
53
+ %w[anthropic claude-opus-4-6],
54
+ %w[openai gpt-5.4]
55
+ ].freeze
56
+ }.freeze
57
+
58
+ COST_MULTIPLIERS = { cheap: 0.07, mid: 0.20, top: 1.0 }.freeze
59
+ DEFAULT_COST_MULTIPLIER = 0.20
60
+ MESSAGE_PATTERNS = [
61
+ [/\b(architect|design|restructure|multi.?file)\b/, :architecture],
62
+ [/\b(security|vulnerab|audit|owasp)\b/, :security_review],
63
+ [/\b(n\+1|performance|slow|optimize|query)\b/, :performance],
64
+ [/\b(spec|test|rspec)\b/, :generate_specs],
65
+ [/\b(fix|bug|error|broken)\b/, :bug_fix],
66
+ [/\b(refactor|extract|rename|move)\b/, :simple_refactor],
67
+ [/\b(find|where|search|locate)\b/, :file_search],
68
+ [/\b(doc|readme|comment|explain)\b/, :documentation]
69
+ ].freeze
70
+
71
+ class << self
72
+ # Determine the appropriate model tier for a task.
73
+ def tier_for(task_type)
74
+ TASK_TIERS.each do |tier, tasks|
75
+ return tier if tasks.include?(task_type.to_sym)
76
+ end
77
+ :mid
78
+ end
79
+
80
+ # Resolve the best [provider, model] pair for a task type.
81
+ # Checks per-provider config overrides first, then falls back
82
+ # to TIER_DEFAULTS.
83
+ #
84
+ # @param task_type [Symbol]
85
+ # @param client [LLM::Client, nil] active client (for provider checks)
86
+ # @return [Hash] { provider:, model: }
87
+ def resolve(task_type, client: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- multi-source fallback chain
88
+ tier = tier_for(task_type)
89
+ active = active_provider
90
+
91
+ # 1. Config overrides — prefer the active provider's config
92
+ configured = config_tier_models(tier)
93
+ active_cfg = configured.find { |p, _| p == active }
94
+ return pair_to_hash(active_cfg) if active_cfg
95
+
96
+ # 2. Any other configured provider
97
+ configured.each do |pair|
98
+ return pair_to_hash(pair) if client.nil? || provider_available?(pair[0])
99
+ end
100
+
101
+ # 3. Hardcoded defaults — prefer the active provider
102
+ defaults = TIER_DEFAULTS[tier]
103
+ active_default = defaults.find { |p, _| p == active }
104
+ return pair_to_hash(active_default) if active_default
105
+
106
+ # 4. Active provider not in defaults (e.g. minimax) — use their configured model for all tiers
107
+ return { provider: active, model: active_model } if provider_available?(active)
108
+
109
+ # 5. Any available default
110
+ defaults.each do |pair|
111
+ return pair_to_hash(pair) if client.nil? || provider_available?(pair[0])
112
+ end
113
+
114
+ pair_to_hash(defaults.first)
115
+ end
116
+
117
+ # Returns just the model name for a task type (backward-compatible).
118
+ # -- config + defaults search
119
+ def model_for(task_type, available_models: [])
120
+ tier = tier_for(task_type)
121
+ candidates = build_candidate_list(tier)
122
+
123
+ if available_models.any?
124
+ candidates.each do |pair|
125
+ return pair[1] if available_models.any? { |m| m.start_with?(pair[1]) }
126
+ end
127
+ end
128
+
129
+ candidates.first&.at(1) || TIER_DEFAULTS[tier].first[1]
130
+ end
131
+
132
+ # Detect task type from a user message and recent tool calls.
133
+ def detect_task(message, recent_tools: [])
134
+ detect_from_message(message) || detect_from_tools(recent_tools) || :chatting
135
+ end
136
+
137
+ # Returns cost estimate multiplier for a tier relative to top tier.
138
+ def cost_multiplier(tier)
139
+ COST_MULTIPLIERS.fetch(tier, DEFAULT_COST_MULTIPLIER)
140
+ end
141
+
142
+ private
143
+
144
+ def pair_to_hash(pair)
145
+ { provider: pair[0], model: pair[1] }
146
+ end
147
+
148
+ # Returns the user's active provider and model from config.
149
+ def active_provider
150
+ Config::Settings.new.provider
151
+ rescue StandardError
152
+ Config::Defaults::DEFAULT_PROVIDER
153
+ end
154
+
155
+ def active_model
156
+ Config::Settings.new.model
157
+ rescue StandardError
158
+ Config::Defaults::DEFAULT_MODEL
159
+ end
160
+
161
+ # Builds an ordered candidate list: active provider first, then others.
162
+ # If the active provider isn't in TIER_DEFAULTS or config, the user's
163
+ # configured model is used as a catch-all for every tier.
164
+ def build_candidate_list(tier)
165
+ active = active_provider
166
+ configured = config_tier_models(tier)
167
+ defaults = TIER_DEFAULTS[tier]
168
+ all = configured + defaults.to_a
169
+
170
+ # Sort: active provider's entries first, then the rest
171
+ active_entries, others = all.partition { |p, _| p == active }
172
+ result = (active_entries + others).uniq { |_, m| m }
173
+
174
+ # If the active provider has no entries at all, add the user's
175
+ # configured model as a fallback so unknown providers still work
176
+ result.unshift([active, active_model]) if result.none? { |p, _| p == active }
177
+
178
+ result
179
+ end
180
+
181
+ # Read per-provider model tier overrides from config.yml.
182
+ # Returns array of [provider, model] pairs for the given tier.
183
+ # -- config traversal
184
+ def config_tier_models(tier)
185
+ settings = Config::Settings.new
186
+ providers = settings.to_h['providers']
187
+ return [] unless providers.is_a?(Hash)
188
+
189
+ tier_key = tier.to_s
190
+ results = []
191
+
192
+ providers.each do |provider_name, cfg|
193
+ next unless cfg.is_a?(Hash)
194
+
195
+ models = cfg['models']
196
+ next unless models.is_a?(Hash) && models[tier_key]
197
+
198
+ results << [provider_name, models[tier_key]]
199
+ end
200
+
201
+ results
202
+ rescue StandardError
203
+ []
204
+ end
205
+
206
+ def provider_available?(provider)
207
+ return true if %w[anthropic openai].include?(provider)
208
+
209
+ settings = Config::Settings.new
210
+ !settings.provider_config(provider).nil?
211
+ rescue StandardError
212
+ false
213
+ end
214
+
215
+ def detect_from_message(message)
216
+ msg = message.to_s.downcase
217
+ MESSAGE_PATTERNS.each do |pattern, task_type|
218
+ return task_type if msg.match?(pattern)
219
+ end
220
+ nil
221
+ end
222
+
223
+ def detect_from_tools(recent_tools)
224
+ return nil if recent_tools.empty?
225
+
226
+ last = recent_tools.last.to_s
227
+ case last
228
+ when 'grep', 'glob' then :file_search
229
+ when 'run_specs' then :generate_specs
230
+ when 'review_pr' then :code_review
231
+ when 'git_status', 'git_log', 'git_diff', 'git_commit' then :git_operations
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -2,232 +2,9 @@
2
2
 
3
3
  module RubynCode
4
4
  module LLM
5
- class Streaming
6
- class ParseError < RubynCode::Error
7
- end
8
-
9
- class OverloadError < RubynCode::Error
10
- end
11
-
12
- Event = Data.define(:type, :data)
13
-
14
- def initialize(&block)
15
- @callback = block
16
- @buffer = +''
17
- @response_id = nil
18
- @content_blocks = []
19
- @current_block_index = nil
20
- @current_text = +''
21
- @current_tool_input_json = +''
22
- @stop_reason = nil
23
- @usage = nil
24
- end
25
-
26
- # Feed raw SSE data chunk from the HTTP response body.
27
- def feed(chunk)
28
- @buffer << chunk
29
- consume_events
30
- end
31
-
32
- # Returns the fully assembled Response once the stream completes.
33
- def finalize
34
- Response.new(
35
- id: @response_id,
36
- content: build_content_blocks,
37
- stop_reason: @stop_reason,
38
- usage: @usage
39
- )
40
- end
41
-
42
- private
43
-
44
- def consume_events
45
- while (idx = @buffer.index("\n\n"))
46
- raw_event = @buffer.slice!(0..(idx + 1))
47
- parse_sse(raw_event)
48
- end
49
- end
50
-
51
- def parse_sse(raw)
52
- event_type = nil
53
- data_lines = []
54
-
55
- raw.each_line do |line|
56
- line = line.chomp
57
- case line
58
- when /\Aevent:\s*(.+)/
59
- event_type = ::Regexp.last_match(1).strip
60
- when /\Adata:\s*(.*)/
61
- data_lines << ::Regexp.last_match(1)
62
- end
63
- end
64
-
65
- return if data_lines.empty? && event_type.nil?
66
-
67
- data_str = data_lines.join("\n")
68
- data = data_str.empty? ? {} : parse_json(data_str)
69
-
70
- dispatch(event_type, data)
71
- end
72
-
73
- def dispatch(event_type, data)
74
- case event_type
75
- when 'message_start'
76
- handle_message_start(data)
77
- when 'content_block_start'
78
- handle_content_block_start(data)
79
- when 'content_block_delta'
80
- handle_content_block_delta(data)
81
- when 'content_block_stop'
82
- handle_content_block_stop(data)
83
- when 'message_delta'
84
- handle_message_delta(data)
85
- when 'message_stop'
86
- handle_message_stop
87
- when 'ping'
88
- # ignore
89
- when 'error'
90
- handle_error(data)
91
- end
92
- end
93
-
94
- def handle_message_start(data)
95
- message = data['message'] || data
96
- @response_id = message['id']
97
-
98
- if (u = message['usage'])
99
- @usage = Usage.new(
100
- input_tokens: u['input_tokens'].to_i,
101
- output_tokens: u['output_tokens'].to_i,
102
- cache_creation_input_tokens: u['cache_creation_input_tokens'].to_i,
103
- cache_read_input_tokens: u['cache_read_input_tokens'].to_i
104
- )
105
- end
106
-
107
- emit(:message_start, data)
108
- end
109
-
110
- def handle_content_block_start(data)
111
- @current_block_index = data['index']
112
- block = data['content_block'] || {}
113
-
114
- case block['type']
115
- when 'text'
116
- @current_text = +(block['text'] || '')
117
- when 'tool_use'
118
- @current_tool_id = block['id']
119
- @current_tool_name = block['name']
120
- @current_tool_input_json = +''
121
- end
122
-
123
- emit(:content_block_start, data)
124
- end
125
-
126
- def handle_content_block_delta(data)
127
- delta = data['delta'] || {}
128
-
129
- case delta['type']
130
- when 'text_delta'
131
- text = delta['text'] || ''
132
- @current_text << text
133
- emit(:text_delta, { index: data['index'], text: text })
134
- when 'input_json_delta'
135
- json_chunk = delta['partial_json'] || ''
136
- @current_tool_input_json << json_chunk
137
- emit(:input_json_delta, { index: data['index'], partial_json: json_chunk })
138
- end
139
-
140
- emit(:content_block_delta, data)
141
- end
142
-
143
- def handle_content_block_stop(data)
144
- index = data['index'].to_i
145
-
146
- if @current_tool_id
147
- input = parse_json(@current_tool_input_json)
148
- @content_blocks[index] = ToolUseBlock.new(
149
- id: @current_tool_id,
150
- name: @current_tool_name,
151
- input: input || {}
152
- )
153
- @current_tool_id = nil
154
- @current_tool_name = nil
155
- @current_tool_input_json = +''
156
- else
157
- @content_blocks[index] = TextBlock.new(text: @current_text.dup)
158
- @current_text = +''
159
- end
160
-
161
- emit(:content_block_stop, data)
162
- end
163
-
164
- def handle_message_delta(data)
165
- delta = data['delta'] || {}
166
- @stop_reason = delta['stop_reason'] if delta['stop_reason']
167
-
168
- if (u = data['usage'])
169
- @usage = Usage.new(
170
- input_tokens: @usage&.input_tokens || 0,
171
- output_tokens: u['output_tokens'].to_i,
172
- cache_creation_input_tokens: @usage&.cache_creation_input_tokens || 0,
173
- cache_read_input_tokens: @usage&.cache_read_input_tokens || 0
174
- )
175
- end
176
-
177
- emit(:message_delta, data)
178
- end
179
-
180
- def handle_message_stop
181
- flush_pending_block
182
- emit(:message_stop, {})
183
- end
184
-
185
- def handle_error(data)
186
- error = data['error'] || data
187
- error_type = error['type'] || 'unknown'
188
- message = error['message'] || 'Unknown streaming error'
189
-
190
- raise OverloadError, message if error_type == 'overloaded_error'
191
-
192
- raise ParseError, "Streaming error (#{error_type}): #{message}"
193
- end
194
-
195
- def emit(type, data)
196
- @callback&.call(Event.new(type: type, data: data))
197
- end
198
-
199
- def flush_pending_block
200
- return unless @current_block_index
201
-
202
- if @current_tool_id
203
- input = parse_json(@current_tool_input_json) || {}
204
- @content_blocks[@current_block_index] = ToolUseBlock.new(
205
- id: @current_tool_id,
206
- name: @current_tool_name,
207
- input: input
208
- )
209
- @current_tool_id = nil
210
- @current_tool_name = nil
211
- @current_tool_input_json = +''
212
- elsif !@current_text.empty?
213
- @content_blocks[@current_block_index] = TextBlock.new(text: @current_text.dup)
214
- @current_text = +''
215
- end
216
-
217
- @current_block_index = nil
218
- end
219
-
220
- def build_content_blocks
221
- @content_blocks.compact
222
- end
223
-
224
- def parse_json(str)
225
- return nil if str.nil? || str.strip.empty?
226
-
227
- JSON.parse(str)
228
- rescue JSON::ParserError
229
- nil
230
- end
231
- end
5
+ # Backward-compatibility shim.
6
+ # Delegates to Adapters::AnthropicStreaming so existing references
7
+ # to LLM::Streaming keep working during the migration.
8
+ Streaming = Adapters::AnthropicStreaming
232
9
  end
233
10
  end
@@ -66,7 +66,7 @@ module RubynCode
66
66
  def disconnect!
67
67
  @transport.stop!
68
68
  @initialized = false
69
- @tools_cache = nil
69
+ @tools = nil
70
70
  end
71
71
 
72
72
  # Returns whether the client is connected and the transport is alive.
@@ -30,18 +30,8 @@ module RubynCode
30
30
  config_path = File.join(project_path, CONFIG_FILENAME)
31
31
  return [] unless File.exist?(config_path)
32
32
 
33
- raw = File.read(config_path)
34
- data = JSON.parse(raw)
35
- servers = data['mcpServers'] || {}
36
-
37
- servers.map do |name, server_def|
38
- {
39
- name: name,
40
- command: server_def['command'],
41
- args: Array(server_def['args']),
42
- env: expand_env(server_def['env'] || {})
43
- }
44
- end
33
+ data = JSON.parse(File.read(config_path))
34
+ parse_servers(data['mcpServers'] || {})
45
35
  rescue JSON::ParserError => e
46
36
  warn "[MCP::Config] Failed to parse #{config_path}: #{e.message}"
47
37
  []
@@ -56,6 +46,13 @@ module RubynCode
56
46
  #
57
47
  # @param env_hash [Hash<String, String>] raw env key-value pairs
58
48
  # @return [Hash<String, String>] expanded env key-value pairs
49
+ def parse_servers(servers)
50
+ servers.map do |name, server_def|
51
+ { name: name, command: server_def['command'],
52
+ args: Array(server_def['args']), env: expand_env(server_def['env'] || {}) }
53
+ end
54
+ end
55
+
59
56
  def expand_env(env_hash)
60
57
  env_hash.each_with_object({}) do |(key, value), result|
61
58
  result[key] = expand_value(value)
@@ -124,7 +124,9 @@ module RubynCode
124
124
  req.body = JSON.generate(request)
125
125
  end
126
126
 
127
- raise TransportError, "MCP server returned HTTP #{response.status}: #{response.body}" unless response.success?
127
+ return if response.success?
128
+
129
+ raise TransportError, "MCP server returned HTTP #{response.status}: #{response.body}"
128
130
  rescue Faraday::Error => e
129
131
  raise TransportError, "Failed to send request to MCP server: #{e.message}"
130
132
  end
@@ -148,16 +150,10 @@ module RubynCode
148
150
  end
149
151
 
150
152
  def run_sse_listener
151
- sse_connection = Faraday.new(url: base_url) do |f|
152
- f.options.timeout = nil # Keep-alive
153
- f.options.open_timeout = @timeout
154
- f.headers['Accept'] = 'text/event-stream'
155
- f.adapter Faraday.default_adapter
156
- end
157
-
153
+ conn = build_sse_connection
158
154
  buffer = +''
159
155
 
160
- sse_connection.get(@url) do |req|
156
+ conn.get(@url) do |req|
161
157
  req.options.on_data = proc do |chunk, _bytes, _env|
162
158
  buffer << chunk
163
159
  process_sse_buffer(buffer)
@@ -168,6 +164,15 @@ module RubynCode
168
164
  warn "[MCP::SSETransport] SSE connection lost: #{e.message}"
169
165
  end
170
166
 
167
+ def build_sse_connection
168
+ Faraday.new(url: base_url) do |f|
169
+ f.options.timeout = nil
170
+ f.options.open_timeout = @timeout
171
+ f.headers['Accept'] = 'text/event-stream'
172
+ f.adapter Faraday.default_adapter
173
+ end
174
+ end
175
+
171
176
  def process_sse_buffer(buffer)
172
177
  while (idx = buffer.index("\n\n"))
173
178
  raw_event = buffer.slice!(0, idx + 2)
@@ -195,11 +200,8 @@ module RubynCode
195
200
  end
196
201
 
197
202
  def handle_sse_event(event)
198
- case event[:type]
199
- when 'endpoint'
203
+ if event[:type] == 'endpoint'
200
204
  @post_endpoint = event[:data]
201
- when 'message'
202
- dispatch_message(event[:data])
203
205
  else
204
206
  dispatch_message(event[:data])
205
207
  end
@@ -140,29 +140,27 @@ module RubynCode
140
140
  def read_response(expected_id)
141
141
  Timeout.timeout(@timeout, TimeoutError, "MCP server did not respond within #{@timeout}s") do
142
142
  loop do
143
- line = @stdout.gets
144
- raise TransportError, 'MCP server closed stdout unexpectedly' if line.nil?
143
+ message = read_next_message
144
+ next unless message&.key?('id') && message['id'] == expected_id
145
145
 
146
- line = line.strip
147
- next if line.empty?
148
-
149
- message = parse_json(line)
150
- next unless message
146
+ raise_mcp_error(message['error']) if message.key?('error')
147
+ return message['result']
148
+ end
149
+ end
150
+ end
151
151
 
152
- # Skip notifications (no id field)
153
- next unless message.key?('id')
152
+ def read_next_message
153
+ line = @stdout.gets
154
+ raise TransportError, 'MCP server closed stdout unexpectedly' if line.nil?
154
155
 
155
- # Skip responses for other requests
156
- next unless message['id'] == expected_id
156
+ stripped = line.strip
157
+ return nil if stripped.empty?
157
158
 
158
- if message.key?('error')
159
- err = message['error']
160
- raise TransportError, "MCP error (#{err['code']}): #{err['message']}"
161
- end
159
+ parse_json(stripped)
160
+ end
162
161
 
163
- return message['result']
164
- end
165
- end
162
+ def raise_mcp_error(err)
163
+ raise TransportError, "MCP error (#{err['code']}): #{err['message']}"
166
164
  end
167
165
 
168
166
  def parse_json(line)