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
@@ -1,115 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'system_prompt_builder'
4
+ require_relative 'response_parser'
5
+ require_relative 'tool_processor'
6
+ require_relative 'background_job_handler'
7
+ require_relative 'feedback_handler'
8
+ require_relative 'llm_caller'
9
+
3
10
  module RubynCode
4
11
  module Agent
5
12
  class Loop
13
+ include SystemPromptBuilder
14
+ include ResponseParser
15
+ include ToolProcessor
16
+ include BackgroundJobHandler
17
+ include FeedbackHandler
18
+ include LlmCaller
19
+
6
20
  MAX_ITERATIONS = Config::Defaults::MAX_ITERATIONS
7
21
 
8
- # @param llm_client [LLM::Client]
9
- # @param tool_executor [Tools::Executor]
10
- # @param context_manager [Context::Manager]
11
- # @param hook_runner [Hooks::Runner]
12
- # @param conversation [Agent::Conversation]
13
- # @param permission_tier [Symbol] one of Permissions::Tier::ALL
14
- # @param deny_list [Permissions::DenyList]
15
- # @param budget_enforcer [Observability::BudgetEnforcer, nil]
16
- # @param background_manager [Background::Worker, nil]
17
- # @param stall_detector [Agent::LoopDetector]
18
- def initialize(
19
- llm_client:,
20
- tool_executor:,
21
- context_manager:,
22
- hook_runner:,
23
- conversation:,
24
- permission_tier: Permissions::Tier::ALLOW_READ,
25
- deny_list: Permissions::DenyList.new,
26
- budget_enforcer: nil,
27
- background_manager: nil,
28
- stall_detector: LoopDetector.new,
29
- on_tool_call: nil,
30
- on_tool_result: nil,
31
- on_text: nil,
32
- skill_loader: nil,
33
- project_root: nil
34
- )
35
- @llm_client = llm_client
36
- @tool_executor = tool_executor
37
- @context_manager = context_manager
38
- @hook_runner = hook_runner
39
- @conversation = conversation
40
- @permission_tier = permission_tier
41
- @deny_list = deny_list
42
- @budget_enforcer = budget_enforcer
43
- @background_manager = background_manager
44
- @stall_detector = stall_detector
45
- @on_tool_call = on_tool_call
46
- @on_tool_result = on_tool_result
47
- @on_text = on_text
48
- @skill_loader = skill_loader
49
- @project_root = project_root
50
- @plan_mode = false
22
+ # @param opts [Hash] keyword arguments for loop configuration
23
+ # @option opts [LLM::Client] :llm_client
24
+ # @option opts [Tools::Executor] :tool_executor
25
+ # @option opts [Context::Manager] :context_manager
26
+ # @option opts [Hooks::Runner] :hook_runner
27
+ # @option opts [Agent::Conversation] :conversation
28
+ # @option opts [Symbol] :permission_tier
29
+ # @option opts [Permissions::DenyList] :deny_list
30
+ # @option opts [Observability::BudgetEnforcer] :budget_enforcer
31
+ # @option opts [Background::Worker] :background_manager
32
+ # @option opts [Agent::LoopDetector] :stall_detector
33
+ # @option opts [Proc] :on_tool_call
34
+ # @option opts [Proc] :on_tool_result
35
+ # @option opts [Proc] :on_text
36
+ # @option opts [Object] :skill_loader
37
+ # @option opts [String] :project_root
38
+ def initialize(**opts)
39
+ assign_dependencies(opts)
40
+ assign_callbacks(opts)
41
+ @plan_mode = false
51
42
  end
52
43
 
53
44
  # @return [Boolean]
54
45
  attr_accessor :plan_mode
55
46
 
56
- # Send a user message and run the agent loop until a final text response
57
- # is produced or the iteration limit is reached.
47
+ # Send a user message and run the agent loop until a final text
48
+ # response is produced or the iteration limit is reached.
58
49
  #
59
50
  # @param user_input [String]
60
51
  # @return [String] the final assistant text response
61
52
  def send_message(user_input)
53
+ initialize_session!
62
54
  check_user_feedback(user_input)
63
-
64
- # Drain any completed background jobs BEFORE adding the user message,
65
- # so the LLM sees the results in the right order
66
55
  drain_background_notifications
67
-
56
+ inject_skill_listing unless @skills_injected
57
+ @decision_compactor&.detect_topic_switch(user_input)
58
+ @skill_ttl&.tick!
68
59
  @conversation.add_user_message(user_input)
69
- @max_tokens_override = nil
70
- @output_recovery_count = 0
71
- @task_budget_remaining = nil
60
+ reset_iteration_state
72
61
 
73
62
  MAX_ITERATIONS.times do |iteration|
74
- RubynCode::Debug.loop_tick("iteration=#{iteration} messages=#{@conversation.length} max_tokens_override=#{@max_tokens_override || 'default'}")
75
-
76
- response = call_llm
77
- tool_calls = extract_tool_calls(response)
78
- stop_reason = response.respond_to?(:stop_reason) ? response.stop_reason : nil
79
-
80
- RubynCode::Debug.llm("stop_reason=#{stop_reason} tool_calls=#{tool_calls.size} content_blocks=#{get_content(response).size}")
81
-
82
- if tool_calls.empty?
83
- if truncated?(response)
84
- RubynCode::Debug.recovery('Text response truncated, entering recovery')
85
- response = recover_truncated_response(response)
86
- end
87
-
88
- # If background jobs are running, wait for them instead of burning LLM calls
89
- if has_pending_background_jobs?
90
- @conversation.add_assistant_message(response_content(response))
91
- wait_for_background_jobs
92
- next
93
- end
94
-
95
- @conversation.add_assistant_message(response_content(response))
96
- return extract_response_text(response)
97
- end
98
-
99
- # Tier 1: If a tool-use response was truncated, silently escalate and retry
100
- if truncated?(response) && !@max_tokens_override
101
- RubynCode::Debug.recovery("Tier 1: Escalating max_tokens from #{Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS} to #{Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS}")
102
- @max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
103
- next
104
- end
105
-
106
- @conversation.add_assistant_message(get_content(response))
107
- process_tool_calls(tool_calls)
108
-
109
- # Drain notifications after tool execution — jobs may have finished
110
- drain_background_notifications
111
-
112
- run_maintenance(iteration)
63
+ result = run_iteration(iteration)
64
+ return result if result
113
65
  end
114
66
 
115
67
  RubynCode::Debug.warn("Hit MAX_ITERATIONS (#{MAX_ITERATIONS})")
@@ -118,658 +70,198 @@ module RubynCode
118
70
 
119
71
  private
120
72
 
121
- # ── LLM interaction ──────────────────────────────────────────────
122
-
123
- TASK_BUDGET_TOTAL = 100_000 # tokens per user message
124
-
125
- def call_llm
126
- @hook_runner.fire(:pre_llm_call, conversation: @conversation)
127
-
128
- opts = {
129
- messages: @conversation.to_api_format,
130
- tools: @plan_mode ? read_only_tool_definitions : tool_definitions,
131
- system: build_system_prompt,
132
- on_text: @on_text
133
- }
134
- opts[:max_tokens] = @max_tokens_override if @max_tokens_override
135
-
136
- # Task budget: tell the model how many tokens remain for this task
137
- opts[:task_budget] = { total: TASK_BUDGET_TOTAL, remaining: @task_budget_remaining } if @task_budget_remaining
138
-
139
- response = @llm_client.chat(**opts)
140
-
141
- @hook_runner.fire(:post_llm_call, response: response, conversation: @conversation)
142
- track_usage(response)
143
- update_task_budget(response)
144
-
145
- response
146
- rescue LLM::Client::PromptTooLongError
147
- # 413: context too large — compact and retry once
148
- RubynCode::Debug.recovery('413 prompt too long — running emergency compaction')
149
- @context_manager.check_compaction!(@conversation)
150
-
151
- response = @llm_client.chat(**opts, messages: @conversation.to_api_format)
152
- @hook_runner.fire(:post_llm_call, response: response, conversation: @conversation)
153
- track_usage(response)
154
-
155
- response
73
+ def assign_dependencies(opts)
74
+ assign_required_deps(opts)
75
+ assign_optional_deps(opts)
156
76
  end
157
77
 
158
- SYSTEM_PROMPT = <<~PROMPT
159
- You are Rubyn — a snarky but lovable AI coding assistant who lives and breathes Ruby.
160
- You're the kind of pair programmer who'll roast your colleague's `if/elsif/elsif/else` chain
161
- with a smirk, then immediately rewrite it as a beautiful `case/in` with pattern matching.
162
- You're sharp, opinionated, and genuinely helpful. Think of yourself as the senior Ruby dev
163
- who's seen every Rails antipattern in production and somehow still loves this language.
164
-
165
- ## Personality
166
- - Snarky but never mean. You tease the code, not the coder.
167
- - You celebrate good Ruby — "Oh, a proper guard clause? You love to see it."
168
- - You mourn bad Ruby — "A `for` loop? In MY Ruby? It's more likely than you think."
169
- - Brief and punchy. No walls of text unless teaching something important.
170
- - You use Ruby metaphors: "Let's refactor this like Matz intended."
171
- - When something is genuinely good code, you say so. No notes.
172
-
173
- ## Ruby Convictions (non-negotiable)
174
- - `frozen_string_literal: true` in every file. Every. Single. One.
175
- - Prefer `each`, `map`, `select`, `reduce` over manual iteration. Always.
176
- - Guard clauses over nested conditionals. Return early, return often.
177
- - `Data.define` for value objects (Ruby 3.2+). `Struct` only if you need mutability.
178
- - `snake_case` methods, `CamelCase` classes, `SCREAMING_SNAKE` constants. No exceptions.
179
- - Single quotes unless you're interpolating. Fight me.
180
- - Methods under 15 lines. Classes under 100. Extract or explain why not.
181
- - Explicit over clever. Metaprogramming is a spice, not the main course.
182
- - `raise` over `fail`. Rescue specific exceptions, never bare `rescue`.
183
- - Prefer composition over inheritance. Mixins are not inheritance.
184
- - `&&` / `||` over `and` / `or`. The precedence difference has burned too many.
185
- - `dig` for nested hashes. `fetch` with defaults over `[]` with `||`.
186
- - `freeze` your constants. Frozen arrays, frozen hashes, frozen regexps.
187
- - No `OpenStruct`. Ever. It's slow, it's a footgun, and `Data.define` exists.
188
-
189
- ## Rails Convictions
190
- - Skinny controllers, fat models is dead. Skinny controllers, skinny models, service objects.
191
- - `has_many :through` over `has_and_belongs_to_many`. Every time.
192
- - Add database indexes for every foreign key and every column you query.
193
- - Migrations are generated, not handwritten. `rails generate migration`.
194
- - Strong parameters in controllers. No `permit!`. Ever.
195
- - Use `find_each` for batch processing. `each` on a large scope is a memory bomb.
196
- - `exists?` over `present?` for checking DB existence. One is a COUNT, the other loads the record.
197
- - Scopes over class methods for chainable queries.
198
- - Background jobs for anything that takes more than 100ms.
199
- - Don't put business logic in callbacks. That way lies madness.
200
-
201
- ## Testing Convictions
202
- - RSpec > Minitest (but you'll work with either without complaining... much)
203
- - FactoryBot over fixtures. Factories are explicit. Fixtures are magic.
204
- - One assertion per test when practical. "It does three things" is three tests.
205
- - `let` over instance variables. `let!` only when you need eager evaluation.
206
- - `described_class` over repeating the class name.
207
- - Test behavior, not implementation. Mock the boundary, not the internals.
208
-
209
- ## How You Work
210
- - For greetings and casual chat, just respond naturally. No need to run tools.
211
- - Only use tools when the user asks you to DO something (read, write, search, run, review).
212
- - Read before you write. Always understand existing code before suggesting changes.
213
- - Use tools to verify. Don't guess if a file exists — check.
214
- - Show diffs when editing. The human should see what changed.
215
- - Run specs after changes. If they break, fix them.
216
- - When you are asked to work in a NEW directory you haven't seen yet, check for RUBYN.md, CLAUDE.md, or AGENT.md there. But don't do this unprompted on startup — those files are already loaded into your context.
217
- - Load skills when you need deep knowledge on a topic. Don't wing it.
218
- - You have 112 curated best-practice skill documents covering Ruby, Rails, RSpec, design patterns, and code quality. When writing new code or reviewing existing code, load the relevant skill BEFORE implementing. Don't reinvent patterns that are already documented.
219
- - HOWEVER: always respect patterns already established in the codebase. If the project uses a specific convention (e.g. service objects, a particular test style, a custom base class), follow that convention even if it differs from the skill doc. Consistency with the codebase beats textbook best practice. Only break from established patterns if they are genuinely harmful (security issues, major performance problems, or bugs).
220
- - Keep responses concise. Code speaks louder than paragraphs.
221
- - Use spawn_agent sparingly — only for tasks that require reading many files (10+) or deep exploration. For simple reads or edits, use tools directly. Don't spawn a sub-agent when a single read_file or grep will do.
222
- - IMPORTANT: You can call MULTIPLE tools in a single response. When you need to read several files, search multiple patterns, or perform independent operations, return all tool_use blocks at once rather than one at a time. This is dramatically faster and cheaper. For example, if you need to read 5 files, emit 5 read_file tool calls in one response — don't read them one by one across 5 turns.
223
-
224
- ## Memory
225
- You have persistent memory across sessions via `memory_write` and `memory_search` tools.
226
- Use them proactively:
227
- - When the user tells you a preference or convention, save it: memory_write(content: "User prefers Grape over Rails controllers for APIs", category: "user_preference")
228
- - When you discover a project pattern (e.g. "this app uses service objects in app/services/"), save it: memory_write(content: "...", category: "project_convention")
229
- - When you fix a tricky bug, save the resolution: memory_write(content: "...", category: "error_resolution")
230
- - When you learn a key architectural decision, save it: memory_write(content: "...", category: "decision")
231
- - Before starting work on a project, search memory for context: memory_search(query: "project conventions")
232
- - Don't save trivial things. Save what would be useful in a future session.
233
- Categories: user_preference, project_convention, error_resolution, decision, code_pattern
234
- PROMPT
235
-
236
- PLAN_MODE_PROMPT = <<~PLAN
237
- ## 🧠 Plan Mode Active
238
-
239
- You are in PLAN MODE. This means:
240
- - Reason through the problem step by step
241
- - You have READ-ONLY tools available — use them to explore the codebase
242
- - Read files, grep, glob, check git status/log/diff — gather context
243
- - Do NOT write, edit, execute, or modify anything
244
- - Outline your plan with numbered steps
245
- - Identify files you'd need to read or modify
246
- - Call out risks, edge cases, and trade-offs
247
- - Ask clarifying questions if the request is ambiguous
248
- - When the user is satisfied with the plan, they'll toggle plan mode off with /plan
249
-
250
- You CAN use read-only tools. You MUST NOT use any tool that writes, edits, or executes.
251
- PLAN
252
-
253
- PLAN_MODE_RISK_LEVELS = %i[read].freeze
254
-
255
- def build_system_prompt
256
- parts = [SYSTEM_PROMPT]
257
-
258
- parts << PLAN_MODE_PROMPT if @plan_mode
259
- parts << "Working directory: #{@project_root}" if @project_root
260
-
261
- # Inject memories from previous sessions
262
- memories = load_memories
263
- parts << "\n## Your Memories (from previous sessions)\n#{memories}" unless memories.empty?
264
-
265
- # Load RUBYN.md / CLAUDE.md / AGENT.md files
266
- rubyn_instructions = load_rubyn_md
267
- parts << "\n## Project Instructions\n#{rubyn_instructions}" unless rubyn_instructions.empty?
268
-
269
- # Inject learned instincts from previous sessions
270
- instincts = load_instincts
271
- parts << "\n## Learned Instincts (from previous sessions)\n#{instincts}" unless instincts.empty?
272
-
273
- # Load custom skills
274
- if @skill_loader
275
- descriptions = @skill_loader.descriptions_for_prompt
276
- unless descriptions.empty?
277
- parts << "\n## Available Skills (use load_skill tool to load full content)"
278
- parts << descriptions
279
- end
280
- end
281
-
282
- # List deferred tools so the LLM knows they exist
283
- deferred = deferred_tool_names
284
- unless deferred.empty?
285
- parts << "\n## Additional Tools Available"
286
- parts << 'These tools are available but not loaded yet. Just call them by name and they will work:'
287
- parts << deferred.map { |n| "- #{n}" }.join("\n")
288
- end
289
-
290
- parts.join("\n")
78
+ def assign_required_deps(opts)
79
+ @llm_client = opts.fetch(:llm_client)
80
+ @tool_executor = opts.fetch(:tool_executor)
81
+ @context_manager = opts.fetch(:context_manager)
82
+ @hook_runner = opts.fetch(:hook_runner)
83
+ @conversation = opts.fetch(:conversation)
84
+ @permission_tier = opts.fetch(:permission_tier, Permissions::Tier::ALLOW_READ)
85
+ @deny_list = opts.fetch(:deny_list, Permissions::DenyList.new)
291
86
  end
292
87
 
293
- def deferred_tool_names
294
- all_names = @tool_executor.tool_definitions.map { |t| t[:name] || t['name'] }
295
- active_names = tool_definitions.map { |t| t[:name] || t['name'] }
296
- all_names - active_names
88
+ def assign_optional_deps(opts)
89
+ @budget_enforcer = opts[:budget_enforcer]
90
+ @background_manager = opts[:background_manager]
91
+ @stall_detector = opts.fetch(:stall_detector, LoopDetector.new)
92
+ @skill_loader = opts[:skill_loader]
93
+ @project_root = opts[:project_root]
94
+ @decision_compactor = build_decision_compactor
95
+ @skill_ttl = Skills::TtlManager.new
96
+ @session_initialized = false
297
97
  end
298
98
 
299
- def load_memories
300
- return '' unless @project_root
301
-
302
- db = DB::Connection.instance
303
- search = Memory::Search.new(db, project_path: @project_root)
304
- recent = search.recent(limit: 20)
305
-
306
- return '' if recent.empty?
307
-
308
- recent.map do |m|
309
- category = m.respond_to?(:category) ? m.category : (m[:category] || m['category'])
310
- content = m.respond_to?(:content) ? m.content : (m[:content] || m['content'])
311
- "[#{category}] #{content}"
312
- end.join("\n")
99
+ def build_decision_compactor
100
+ Context::DecisionCompactor.new(context_manager: @context_manager)
313
101
  rescue StandardError
314
- ''
102
+ nil
315
103
  end
316
104
 
317
- def load_instincts
318
- return '' unless @project_root
105
+ # One-time session initialization: build project profile and
106
+ # codebase index so the AI doesn't have to explore from scratch.
107
+ def initialize_session!
108
+ return if @session_initialized || !@project_root
319
109
 
320
- db = DB::Connection.instance
321
- Learning::Injector.call(db: db, project_path: @project_root)
322
- rescue StandardError
323
- ''
110
+ @session_initialized = true
111
+ build_project_profile!
112
+ build_codebase_index!
324
113
  end
325
114
 
326
- # ── Instinct reinforcement ───────────────────────────────────
327
-
328
- POSITIVE_PATTERNS = /\b(yes that fixed it|that worked|perfect|thanks|exactly|great|nailed it|that.s right|correct)\b/i
329
- NEGATIVE_PATTERNS = /\b(no[, ]+use|wrong|that.s not right|instead use|don.t do that|actually[, ]+use|incorrect)\b/i
330
-
331
- def check_user_feedback(user_input)
332
- return unless @project_root
333
-
334
- db = DB::Connection.instance
335
- recent_instincts = db.query(
336
- 'SELECT id FROM instincts WHERE project_path = ? ORDER BY updated_at DESC LIMIT 5',
337
- [@project_root]
338
- ).to_a
339
-
340
- return if recent_instincts.empty?
341
-
342
- if user_input.match?(POSITIVE_PATTERNS)
343
- recent_instincts.first(2).each do |row|
344
- Learning::InstinctMethods.reinforce_in_db(row['id'], db, helpful: true)
345
- end
346
- elsif user_input.match?(NEGATIVE_PATTERNS)
347
- recent_instincts.first(2).each do |row|
348
- Learning::InstinctMethods.reinforce_in_db(row['id'], db, helpful: false)
349
- end
350
- end
351
- rescue StandardError
352
- # Non-critical; don't interrupt the conversation
115
+ def build_project_profile!
116
+ profile = Config::ProjectProfile.new(project_root: @project_root)
117
+ profile.load_or_detect!
118
+ RubynCode::Debug.agent("Project profile loaded (#{profile.data.size} keys)")
119
+ rescue StandardError => e
120
+ RubynCode::Debug.warn("Project profile failed: #{e.message}")
353
121
  end
354
122
 
355
- # Load instruction files from multiple locations.
356
- # Detects RUBYN.md, CLAUDE.md, and AGENT.md — so projects that already
357
- # have CLAUDE.md or AGENT.md work out of the box with Rubyn Code.
358
- INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENT.md].freeze
359
-
360
- def load_rubyn_md
361
- found = []
362
-
363
- if @project_root
364
- # Walk UP from project root to find parent instruction files
365
- walk_up_for_instructions(@project_root, found)
366
-
367
- # Project root
368
- INSTRUCTION_FILES.each do |name|
369
- collect_instruction(File.join(@project_root, name), found)
370
- end
371
- collect_instruction(File.join(@project_root, '.rubyn-code', 'RUBYN.md'), found)
372
-
373
- # One level of child directories
374
- INSTRUCTION_FILES.each do |name|
375
- Dir.glob(File.join(@project_root, '*', name)).each do |path|
376
- collect_instruction(path, found)
377
- end
378
- end
379
- end
380
-
381
- # User global
382
- collect_instruction(File.join(Config::Defaults::HOME_DIR, 'RUBYN.md'), found)
383
-
384
- found.uniq.join("\n\n")
123
+ def build_codebase_index!
124
+ index = Index::CodebaseIndex.new(project_root: @project_root)
125
+ index.load_or_build!
126
+ RubynCode::Debug.agent("Codebase index: #{index.stats[:nodes]} nodes, #{index.stats[:files_indexed]} files")
127
+ rescue StandardError => e
128
+ RubynCode::Debug.warn("Codebase index failed: #{e.message}")
385
129
  end
386
130
 
387
- def walk_up_for_instructions(start_dir, found)
388
- dir = File.dirname(start_dir)
389
- home = File.expand_path('~')
390
-
391
- while dir.length >= home.length
392
- INSTRUCTION_FILES.each do |name|
393
- collect_instruction(File.join(dir, name), found)
394
- end
395
- break if dir == home
396
-
397
- dir = File.dirname(dir)
398
- end
131
+ def assign_callbacks(opts)
132
+ @on_tool_call = opts[:on_tool_call]
133
+ @on_tool_result = opts[:on_tool_result]
134
+ @on_text = opts[:on_text]
135
+ @skills_injected = false
399
136
  end
400
137
 
401
- def collect_instruction(path, found)
402
- return unless File.exist?(path) && File.file?(path)
403
-
404
- content = File.read(path, encoding: 'utf-8')
405
- .encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
406
- .strip
407
- return if content.empty?
408
-
409
- found << "# From #{path}\n#{content}"
138
+ def reset_iteration_state
139
+ @max_tokens_override = nil
140
+ @output_recovery_count = 0
141
+ @task_budget_remaining = nil
410
142
  end
411
143
 
412
- # Core tools always included. Others load on first use.
413
- CORE_TOOLS = %w[
414
- read_file write_file edit_file glob grep bash
415
- spawn_agent background_run
416
- ].freeze
144
+ def run_iteration(iteration)
145
+ log_iteration(iteration)
146
+ compact_if_needed # ensure context is under threshold before LLM call
147
+ response = call_llm
148
+ tool_calls = extract_tool_calls(response)
149
+ log_response(response, tool_calls)
417
150
 
418
- def tool_definitions
419
- all_tools = @tool_executor.tool_definitions
420
- return all_tools if all_tools.size <= CORE_TOOLS.size
151
+ return handle_text_response(response) if tool_calls.empty?
421
152
 
422
- @discovered_tools ||= Set.new
423
-
424
- all_tools.select do |t|
425
- name = t[:name] || t['name']
426
- CORE_TOOLS.include?(name) || @discovered_tools.include?(name)
427
- end
153
+ handle_tool_response(response, tool_calls, iteration)
428
154
  end
429
155
 
430
- def discover_tool(name)
431
- @discovered_tools ||= Set.new
432
- @discovered_tools.add(name)
156
+ def log_iteration(iteration)
157
+ RubynCode::Debug.loop_tick(
158
+ "iteration=#{iteration} messages=#{@conversation.length} " \
159
+ "max_tokens_override=#{@max_tokens_override || 'default'}"
160
+ )
433
161
  end
434
162
 
435
- def read_only_tool_definitions
436
- Tools::Registry.all
437
- .select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }
438
- .map(&:to_schema)
163
+ def log_response(response, tool_calls)
164
+ stop_reason = extract_stop_reason(response)
165
+ RubynCode::Debug.llm(
166
+ "stop_reason=#{stop_reason} tool_calls=#{tool_calls.size} " \
167
+ "content_blocks=#{get_content(response).size}"
168
+ )
439
169
  end
440
170
 
441
- # ── Background job waiting ────────────────────────────────────────
442
-
443
- def wait_for_background_jobs
444
- max_wait = 300 # 5 minutes max
445
- poll_interval = 3
446
-
447
- RubynCode::Debug.agent("Waiting for background jobs to finish (polling every #{poll_interval}s, max #{max_wait}s)")
448
-
449
- elapsed = 0
450
- while elapsed < max_wait && has_pending_background_jobs?
451
- sleep poll_interval
452
- elapsed += poll_interval
453
- drain_background_notifications
454
- end
455
-
456
- # Final drain to pick up any last results
457
- drain_background_notifications
458
- RubynCode::Debug.agent("Background wait done (#{elapsed}s)")
459
- end
460
-
461
- # ── Tool processing ──────────────────────────────────────────────
462
-
463
- def process_tool_calls(tool_calls)
464
- aggregate_chars = 0
465
- budget = Config::Defaults::MAX_MESSAGE_TOOL_RESULTS_CHARS
466
-
467
- tool_calls.each do |tool_call|
468
- tool_name = field(tool_call, :name)
469
- tool_input = field(tool_call, :input) || {}
470
- tool_id = field(tool_call, :id)
471
-
472
- decision = Permissions::Policy.check(
473
- tool_name: tool_name,
474
- tool_input: tool_input,
475
- tier: @permission_tier,
476
- deny_list: @deny_list
171
+ def handle_text_response(response)
172
+ if truncated?(response)
173
+ RubynCode::Debug.recovery(
174
+ 'Text response truncated, entering recovery'
477
175
  )
478
-
479
- begin
480
- @on_tool_call&.call(tool_name, tool_input)
481
- rescue StandardError
482
- nil
483
- end
484
-
485
- result, is_error = execute_with_permission(decision, tool_name, tool_input, tool_id)
486
-
487
- # Enforce per-message aggregate tool result budget
488
- aggregate_chars += result.to_s.length
489
- if aggregate_chars > budget
490
- remaining = [budget - (aggregate_chars - result.to_s.length), 500].max
491
- result = "#{result.to_s[0,
492
- remaining]}\n\n[truncated — tool result budget exceeded (#{budget} chars/message)]"
493
- RubynCode::Debug.token("Tool result budget exceeded: #{aggregate_chars}/#{budget} chars")
494
- end
495
-
496
- begin
497
- @on_tool_result&.call(tool_name, result, is_error)
498
- rescue StandardError
499
- nil
500
- end
501
-
502
- @stall_detector.record(tool_name, tool_input)
503
- # CRITICAL: always add tool_result to conversation — without this the
504
- # API will reject the next request with "tool_use without tool_result"
505
- @conversation.add_tool_result(tool_id, tool_name, result, is_error: is_error)
506
- end
507
- end
508
-
509
- def execute_with_permission(decision, tool_name, tool_input, _tool_id)
510
- case decision
511
- when :deny
512
- ["Tool '#{tool_name}' is blocked by the deny list.", true]
513
- when :ask
514
- if prompt_user(tool_name, tool_input)
515
- execute_tool(tool_name, tool_input)
516
- else
517
- ["User denied permission for '#{tool_name}'.", true]
518
- end
519
- when :allow
520
- execute_tool(tool_name, tool_input)
521
- else
522
- ["Unknown permission decision: #{decision}", true]
176
+ response = recover_truncated_response(response)
523
177
  end
524
- end
525
-
526
- def execute_tool(tool_name, tool_input)
527
- # Auto-discover tools on first use so they appear in future calls
528
- discover_tool(tool_name)
529
-
530
- @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
531
-
532
- result = @tool_executor.execute(tool_name, symbolize_keys(tool_input))
533
- @hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
534
-
535
- [result.to_s, false]
536
- rescue StandardError => e
537
- ["Error executing #{tool_name}: #{e.message}", true]
538
- end
539
-
540
- def prompt_user(tool_name, tool_input)
541
- risk = resolve_tool_risk(tool_name)
542
-
543
- if risk == :destructive
544
- Permissions::Prompter.confirm_destructive(tool_name, tool_input)
545
- else
546
- Permissions::Prompter.confirm(tool_name, tool_input)
547
- end
548
- end
549
-
550
- def resolve_tool_risk(tool_name)
551
- tool_class = Tools::Registry.get(tool_name)
552
- tool_class.risk_level
553
- rescue ToolNotFoundError
554
- :unknown
555
- end
556
-
557
- # ── Maintenance ──────────────────────────────────────────────────
558
-
559
- def run_maintenance(_iteration)
560
- run_compaction
561
- check_budget
562
- check_stall_detection
563
- end
564
-
565
- def run_compaction
566
- before = @conversation.length
567
- est = @context_manager.estimated_tokens(@conversation.messages)
568
- RubynCode::Debug.token("context=#{est} tokens (~#{before} messages, threshold=#{Config::Defaults::CONTEXT_THRESHOLD_TOKENS})")
569
-
570
- @context_manager.check_compaction!(@conversation)
571
-
572
- after = @conversation.length
573
- if after < before
574
- new_est = @context_manager.estimated_tokens(@conversation.messages)
575
- RubynCode::Debug.loop_tick("Compacted: #{before} -> #{after} messages (#{est} -> #{new_est} tokens)")
576
- end
577
- rescue NoMethodError
578
- # context_manager does not implement check_compaction! yet
579
- end
580
-
581
- def check_budget
582
- return unless @budget_enforcer
583
-
584
- @budget_enforcer.check!
585
- rescue BudgetExceededError
586
- raise
587
- rescue NoMethodError
588
- # budget_enforcer does not implement check! yet
589
- end
590
178
 
591
- def check_stall_detection
592
- return unless @stall_detector.stalled?
593
-
594
- nudge = @stall_detector.nudge_message
595
- @conversation.add_user_message(nudge)
596
- @stall_detector.reset!
597
- end
598
-
599
- def drain_background_notifications
600
- return unless @background_manager
601
-
602
- notifications = @background_manager.drain_notifications
603
- return if notifications.nil? || notifications.empty?
604
-
605
- summary = notifications.map { |n| format_background_notification(n) }.join("\n\n")
606
- @conversation.add_user_message("[Background job results]\n#{summary}")
607
- rescue NoMethodError
608
- # background_manager does not support drain_notifications yet
609
- end
610
-
611
- def has_pending_background_jobs?
612
- return false unless @background_manager
613
-
614
- @background_manager.active_count.positive?
615
- rescue NoMethodError
616
- false
617
- end
618
-
619
- def format_background_notification(notification)
620
- case notification
621
- when Hash
622
- status = notification[:status] || 'unknown'
623
- job_id = notification[:job_id]&.[](0..7) || 'unknown'
624
- duration = notification[:duration] ? "#{'%.1f' % notification[:duration]}s" : 'unknown'
625
- result = notification[:result] || '(no output)'
626
- "Job #{job_id} [#{status}] (#{duration}):\n#{result}"
627
- else
628
- notification.to_s
179
+ # Wait for background jobs before finalizing
180
+ if pending_background_jobs?
181
+ @conversation.add_assistant_message(response_content(response))
182
+ wait_for_background_jobs
183
+ return nil # signal: keep iterating
629
184
  end
630
- end
631
185
 
632
- # ── Output token recovery (3-tier, matches Claude Code) ──────────
633
- #
634
- # Tier 1: Silent escalation (8K → 32K) — handled in send_message
635
- # Tier 2: Multi-turn recovery — inject continuation message, retry up to 3x
636
- # Tier 3: Surface what we have — return partial response after exhausting retries
637
-
638
- def truncated?(response)
639
- reason = if response.respond_to?(:stop_reason)
640
- response.stop_reason
641
- elsif response.is_a?(Hash)
642
- response[:stop_reason] || response['stop_reason']
643
- end
644
- reason == 'max_tokens'
645
- end
186
+ text = extract_response_text(response)
646
187
 
647
- def recover_truncated_response(response)
648
- @max_tokens_override ||= Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
188
+ return handle_empty_response if text.strip.empty?
649
189
 
650
190
  @conversation.add_assistant_message(response_content(response))
651
191
 
652
- max_retries = Config::Defaults::MAX_OUTPUT_TOKENS_RECOVERY_LIMIT
653
-
654
- max_retries.times do |attempt|
655
- @output_recovery_count += 1
656
- RubynCode::Debug.recovery("Tier 2: Recovery attempt #{attempt + 1}/#{max_retries}")
657
-
658
- @conversation.add_user_message(
659
- 'Output token limit hit. Resume directly — no apology, no recap, ' \
660
- 'just continue exactly where you left off.'
661
- )
662
-
663
- response = call_llm
192
+ # Decision-based compaction (topic switch, milestone)
193
+ @decision_compactor&.check!(@conversation)
664
194
 
665
- unless truncated?(response)
666
- RubynCode::Debug.recovery("Recovery successful on attempt #{attempt + 1}")
667
- break
668
- end
195
+ # Compact after the response if context is over threshold
196
+ compact_if_needed
669
197
 
670
- RubynCode::Debug.recovery("Still truncated after attempt #{attempt + 1}")
671
- @conversation.add_assistant_message(response_content(response))
672
- end
673
-
674
- if truncated?(response)
675
- RubynCode::Debug.recovery("Tier 3: Exhausted #{max_retries} recovery attempts, returning partial response")
676
- end
677
-
678
- response
198
+ text
679
199
  end
680
200
 
681
- # ── Response helpers ─────────────────────────────────────────────
682
-
683
- def extract_tool_calls(response)
684
- get_content(response).select { |block| block_type(block) == 'tool_use' }
685
- end
686
-
687
- def response_content(response)
688
- get_content(response)
689
- end
690
-
691
- def extract_response_text(response)
692
- blocks = get_content(response)
693
- blocks.select { |b| block_type(b) == 'text' }
694
- .map { |b| b.respond_to?(:text) ? b.text : (b[:text] || b['text']) }
695
- .compact.join("\n")
696
- end
201
+ # Empty LLM response (0 content blocks). Common after dispatching
202
+ # background_run — the LLM has nothing to say until results arrive.
203
+ # Wait briefly for jobs, then either continue or accept the empty response.
204
+ def handle_empty_response
205
+ RubynCode::Debug.llm('Empty response — waiting for background jobs')
206
+ sleep 2 # give jobs a moment to register as active
207
+ drain_background_notifications
697
208
 
698
- def get_content(response)
699
- case response
700
- when ->(r) { r.respond_to?(:content) }
701
- Array(response.content)
702
- when Hash
703
- Array(response[:content] || response['content'])
209
+ if pending_background_jobs?
210
+ wait_for_background_jobs
211
+ nil # keep iterating — job results are now in conversation
704
212
  else
705
- []
213
+ RubynCode::Debug.llm('No background jobs — accepting empty response')
214
+ '' # return empty string to stop the loop
706
215
  end
707
216
  end
708
217
 
709
- def block_type(block)
710
- if block.respond_to?(:type)
711
- block.type.to_s
712
- elsif block.is_a?(Hash)
713
- (block[:type] || block['type']).to_s
218
+ def handle_tool_response(response, tool_calls, iteration)
219
+ if truncated?(response) && !@max_tokens_override
220
+ escalate_max_tokens
221
+ return nil
714
222
  end
715
- end
716
223
 
717
- def track_usage(response)
718
- usage = if response.respond_to?(:usage)
719
- response.usage
720
- elsif response.is_a?(Hash)
721
- response[:usage] || response['usage']
722
- end
723
- return unless usage
724
-
725
- input_tokens = usage.respond_to?(:input_tokens) ? usage.input_tokens : usage[:input_tokens]
726
- output_tokens = usage.respond_to?(:output_tokens) ? usage.output_tokens : usage[:output_tokens]
727
- cache_create = usage.respond_to?(:cache_creation_input_tokens) ? usage.cache_creation_input_tokens.to_i : 0
728
- cache_read = usage.respond_to?(:cache_read_input_tokens) ? usage.cache_read_input_tokens.to_i : 0
729
- cache_info = cache_create.positive? || cache_read.positive? ? " cache_create=#{cache_create} cache_read=#{cache_read}" : ''
730
- RubynCode::Debug.token("in=#{input_tokens} out=#{output_tokens}#{cache_info}")
731
-
732
- @context_manager.track_usage(usage)
733
- rescue NoMethodError
734
- # context_manager does not implement track_usage yet
224
+ @conversation.add_assistant_message(get_content(response))
225
+ process_tool_calls(tool_calls)
226
+ drain_background_notifications
227
+ run_maintenance(iteration)
228
+ nil
735
229
  end
736
230
 
737
- def update_task_budget(response)
738
- usage = response.respond_to?(:usage) ? response.usage : nil
739
- return unless usage
231
+ # Check if context needs compaction. Runs before LLM calls and
232
+ # after text responses mirrors Claude Code's "pause for compaction"
233
+ # behavior that keeps context manageable in long sessions.
234
+ def compact_if_needed
235
+ return unless @context_manager.needs_compaction?(@conversation.messages)
740
236
 
741
- output = usage.respond_to?(:output_tokens) ? usage.output_tokens.to_i : 0
742
- input = usage.respond_to?(:input_tokens) ? usage.input_tokens.to_i : 0
237
+ est = @context_manager.estimated_tokens(@conversation.messages)
238
+ RubynCode::Debug.token(
239
+ "Context over threshold (#{est}) — running compaction"
240
+ )
241
+ @context_manager.check_compaction!(@conversation)
743
242
 
744
- # Initialize on first response, then decrement
745
- @task_budget_remaining ||= TASK_BUDGET_TOTAL
746
- @task_budget_remaining = [@task_budget_remaining - input - output, 0].max
243
+ after = @context_manager.estimated_tokens(@conversation.messages)
244
+ RubynCode::Debug.token("Compacted: #{est} → #{after} tokens")
245
+ rescue StandardError => e
246
+ RubynCode::Debug.warn("Compaction failed: #{e.message}")
247
+ end
747
248
 
748
- RubynCode::Debug.token("task_budget_remaining=#{@task_budget_remaining}/#{TASK_BUDGET_TOTAL}")
249
+ def escalate_max_tokens
250
+ RubynCode::Debug.recovery(
251
+ 'Tier 1: Escalating max_tokens from ' \
252
+ "#{Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS} to " \
253
+ "#{Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS}"
254
+ )
255
+ @max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
749
256
  end
750
257
 
751
258
  def max_iterations_warning
752
259
  warning = "Reached maximum iteration limit (#{MAX_ITERATIONS}). " \
753
- 'The conversation may be incomplete. Please review the current state ' \
754
- 'and continue if needed.'
260
+ 'The conversation may be incomplete. Please review the ' \
261
+ 'current state and continue if needed.'
755
262
  @conversation.add_assistant_message([{ type: 'text', text: warning }])
756
263
  warning
757
264
  end
758
-
759
- # Extract a field from a Data object or Hash
760
- def field(obj, key)
761
- if obj.respond_to?(key)
762
- obj.send(key)
763
- elsif obj.is_a?(Hash)
764
- obj[key] || obj[key.to_s]
765
- end
766
- end
767
-
768
- def symbolize_keys(hash)
769
- return {} unless hash.is_a?(Hash)
770
-
771
- hash.transform_keys(&:to_sym)
772
- end
773
265
  end
774
266
  end
775
267
  end