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.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
@@ -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
|
-
|
|
34
|
-
data
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
143
|
+
message = read_next_message
|
|
144
|
+
next unless message&.key?('id') && message['id'] == expected_id
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
raise_mcp_error(message['error']) if message.key?('error')
|
|
147
|
+
return message['result']
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
def read_next_message
|
|
153
|
+
line = @stdout.gets
|
|
154
|
+
raise TransportError, 'MCP server closed stdout unexpectedly' if line.nil?
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
stripped = line.strip
|
|
157
|
+
return nil if stripped.empty?
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
raise TransportError, "MCP error (#{err['code']}): #{err['message']}"
|
|
161
|
-
end
|
|
159
|
+
parse_json(stripped)
|
|
160
|
+
end
|
|
162
161
|
|
|
163
|
-
|
|
164
|
-
|
|
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)
|