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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17b949375b6599cc1899caffd006f644a7292244982103608bfe31ad9146f549
4
- data.tar.gz: 9c97ea6f5a1d73a91b220221b20f028833b86e7f6caa3904860e5000004f3fa9
3
+ metadata.gz: f91f304118c243f82ce9165db84f303b111d9c18daf74c75cdc7dcec4289647b
4
+ data.tar.gz: 2cfc8263c1532d805ea9b9592c3601033e4fdab8dc19c6b0329a0eec40316cbb
5
5
  SHA512:
6
- metadata.gz: 33bd180490d02c55def1d138ee8c71607c639ddb7f66f5fc05392e31bd41feead7bbf42d6224e67e36651410aeac6b67e55afc9522f965affc2787d91cc54967
7
- data.tar.gz: a55628e9fb27d9de1c9822c6fc215820fa6b639d9f9fd6f4750b79f26ab31a09c59da8def39d391ba54f3bcbe92516973904b40260c81afe0b60017746eefa3b
6
+ metadata.gz: 6bb339794d0cbe8d149e0b2e94c408b304d48f786aa2a297af5c8028c15c9796d4e5f0313be20c1ecf8b449c0ef80afa4263f581603c5c915b540c7a89b8257c
7
+ data.tar.gz: 2f1fc37549f2223a5c5f07be4817bade9a4d6466ef30b8927fb0de94cf4c78c41b1b5e9e8a3379fb69e503d34226df9d22e4988bb45231666afc49e4f325d789
data/README.md CHANGED
@@ -80,7 +80,7 @@ bundle exec ruby -Ilib exe/rubyn-code
80
80
 
81
81
  </details>
82
82
 
83
- **Authentication:** Rubyn Code reads your Claude Code OAuth token from the macOS Keychain automatically. Just make sure you've logged into Claude Code once (`claude` in your terminal). Also supports `ANTHROPIC_API_KEY` env var.
83
+ **Authentication:** Rubyn Code reads your Claude Code OAuth token from the macOS Keychain automatically. Just make sure you've logged into Claude Code once (`claude` in your terminal). Also supports `ANTHROPIC_API_KEY` env var. See [Authentication](#authentication) for OpenAI and other providers.
84
84
 
85
85
  ## Quick Start
86
86
 
@@ -142,7 +142,7 @@ Agent finished (23 tool calls).
142
142
  This is a Rails 7.1 e-commerce app with...
143
143
  ```
144
144
 
145
- ## 28 Built-in Tools
145
+ ## 29 Built-in Tools
146
146
 
147
147
  | Category | Tools |
148
148
  |----------|-------|
@@ -157,6 +157,7 @@ This is a Rails 7.1 e-commerce app with...
157
157
  | **Context** | `compact`, `load_skill`, `task` |
158
158
  | **Memory** | `memory_search`, `memory_write` |
159
159
  | **Teams** | `send_message`, `read_inbox` |
160
+ | **Interactive** | `ask_user` (ask clarifying questions mid-task) |
160
161
 
161
162
  ## 112 Best Practice Skills
162
163
 
@@ -323,6 +324,7 @@ rubyn-code --help # Show help
323
324
  |---------|---------|
324
325
  | `/help` | Show help |
325
326
  | `/quit` | Exit (saves session + extracts learnings) |
327
+ | `/new` | Save session and start a fresh conversation |
326
328
  | `/review [base]` | PR review against best practices |
327
329
  | `/spawn name role` | Spawn a persistent teammate |
328
330
  | `/compact` | Compress conversation context |
@@ -334,6 +336,8 @@ rubyn-code --help # Show help
334
336
 
335
337
  ## Authentication
336
338
 
339
+ ### Anthropic (default)
340
+
337
341
  | Priority | Source | Setup |
338
342
  |----------|--------|-------|
339
343
  | 1 | macOS Keychain | Log into Claude Code once: `claude` |
@@ -342,6 +346,24 @@ rubyn-code --help # Show help
342
346
 
343
347
  Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 4.6**.
344
348
 
349
+ ### OpenAI
350
+
351
+ ```bash
352
+ export OPENAI_API_KEY=sk-...
353
+ ```
354
+
355
+ Available models: `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-4o`, `gpt-4o-mini`, `o3`, `o4-mini`
356
+
357
+ ### OpenAI-Compatible Providers (Groq, Together, Ollama, etc.)
358
+
359
+ Set the provider-specific API key and configure via `config.yml`:
360
+
361
+ ```bash
362
+ export GROQ_API_KEY=gsk-...
363
+ ```
364
+
365
+ Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't require an API key.
366
+
345
367
  ## Architecture
346
368
 
347
369
  16-layer agentic architecture:
@@ -362,7 +384,7 @@ Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 4
362
384
  │ Layer 5: Skills (112 best practice docs, on-demand loading) │
363
385
  │ Layer 4: Context Management (3-layer compression pipeline) │
364
386
  │ Layer 3: Permissions (tiered access + deny lists + hooks) │
365
- │ Layer 2: Tool System (28 tools, dispatch map registry) │
387
+ │ Layer 2: Tool System (29 tools, dispatch map registry) │
366
388
  │ Layer 1: THE AGENT LOOP (while tool_use → execute → repeat) │
367
389
  └──────────────────────────────────────────────────────────────┘
368
390
  ```
@@ -379,8 +401,74 @@ daily_budget: 10.00
379
401
  # .rubyn-code/config.yml (project — overrides global)
380
402
  model: claude-sonnet-4-6
381
403
  permission_mode: autonomous
404
+
405
+ # Use OpenAI instead of Anthropic
406
+ # provider: openai
407
+ # model: gpt-4o
408
+
409
+ # Use an OpenAI-compatible provider
410
+ # provider: groq
411
+ # provider_base_url: https://api.groq.com/openai/v1
412
+ # model: llama-3.3-70b
413
+
414
+ # Local Ollama (no API key needed)
415
+ # provider: ollama
416
+ # provider_base_url: http://localhost:11434/v1
417
+ # model: llama3
418
+ ```
419
+
420
+ ### Multi-Provider Model Routing
421
+
422
+ Rubyn can automatically route tasks to different AI models based on complexity. Simple tasks (file search, git ops) use cheap, fast models. Complex tasks (architecture, security review) use the most capable model. Configure per-provider model tiers in `config.yml`:
423
+
424
+ ```yaml
425
+ # ~/.rubyn-code/config.yml
426
+ provider: anthropic
427
+ model: claude-opus-4-6
428
+
429
+ providers:
430
+ anthropic:
431
+ env_key: ANTHROPIC_API_KEY
432
+ models:
433
+ cheap: claude-haiku-4-5 # file search, git ops, formatting
434
+ mid: claude-sonnet-4-6 # code gen, specs, refactors, reviews
435
+ top: claude-opus-4-6 # architecture, security, complex work
436
+
437
+ openai:
438
+ env_key: OPENAI_API_KEY
439
+ models:
440
+ cheap: gpt-5.4-nano # lightweight tasks
441
+ mid: gpt-5.4-mini # regular coding
442
+ top: gpt-5.4 # complex reasoning
443
+
444
+ groq:
445
+ base_url: https://api.groq.com/openai/v1
446
+ env_key: GROQ_API_KEY
447
+ models:
448
+ cheap: llama-3-8b
449
+ mid: llama-3-70b
450
+ pricing:
451
+ llama-3-8b: [0.05, 0.08] # [input_rate, output_rate] per million tokens
452
+ llama-3-70b: [0.59, 0.79]
453
+
454
+ ollama:
455
+ base_url: http://localhost:11434/v1
456
+ models:
457
+ cheap: llama3
458
+ mid: llama3
459
+ top: llama3
382
460
  ```
383
461
 
462
+ **How it works:** When you ask Rubyn to do something, the Model Router detects the task type and picks the right tier. If you've configured model tiers for a provider, those are used first. Otherwise it falls back to the built-in defaults (Anthropic for all tiers).
463
+
464
+ | Tier | Task types | Default model |
465
+ |------|-----------|---------------|
466
+ | **cheap** | File search, git ops, formatting, summaries | `claude-haiku-4-5` |
467
+ | **mid** | Code generation, specs, refactors, code review, bug fixes | `claude-sonnet-4-6` |
468
+ | **top** | Architecture, security review, complex refactors, planning | `claude-opus-4-6` |
469
+
470
+ You can also set custom pricing per model so `/cost` reports accurate spending for third-party providers.
471
+
384
472
  ## Development
385
473
 
386
474
  Requires Ruby 4.0+.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Manages background job polling, waiting, and notification draining
6
+ # for the agent loop.
7
+ module BackgroundJobHandler
8
+ private
9
+
10
+ def wait_for_background_jobs
11
+ max_wait = 300 # 5 minutes max
12
+ poll_interval = 3
13
+
14
+ RubynCode::Debug.agent(
15
+ 'Waiting for background jobs to finish ' \
16
+ "(polling every #{poll_interval}s, max #{max_wait}s)"
17
+ )
18
+
19
+ elapsed = poll_until_done(max_wait, poll_interval)
20
+ drain_background_notifications
21
+ RubynCode::Debug.agent("Background wait done (#{elapsed}s)")
22
+ end
23
+
24
+ def poll_until_done(max_wait, poll_interval)
25
+ elapsed = 0
26
+ while elapsed < max_wait && pending_background_jobs?
27
+ sleep poll_interval
28
+ elapsed += poll_interval
29
+ drain_background_notifications
30
+ end
31
+ elapsed
32
+ end
33
+
34
+ def drain_background_notifications
35
+ return unless @background_manager
36
+
37
+ notifications = @background_manager.drain_notifications
38
+ return if notifications.nil? || notifications.empty?
39
+
40
+ summary = notifications.map { |n| format_background_notification(n) }.join("\n\n")
41
+ @conversation.add_user_message("[Background job results]\n#{summary}")
42
+ rescue NoMethodError
43
+ # background_manager does not support drain_notifications yet
44
+ end
45
+
46
+ def pending_background_jobs?
47
+ return false unless @background_manager
48
+
49
+ @background_manager.active_count.positive?
50
+ rescue NoMethodError
51
+ false
52
+ end
53
+
54
+ def format_background_notification(notification)
55
+ return notification.to_s unless notification.is_a?(Hash)
56
+
57
+ status = notification[:status] || 'unknown'
58
+ job_id = notification[:job_id]&.[](0..7) || 'unknown'
59
+ duration = format_duration(notification[:duration])
60
+ result = notification[:result] || '(no output)'
61
+ "Job #{job_id} [#{status}] (#{duration}):\n#{result}"
62
+ end
63
+
64
+ def format_duration(dur)
65
+ return 'unknown' unless dur
66
+
67
+ format('%.1fs', dur)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -128,64 +128,58 @@ module RubynCode
128
128
  # If a tool_use is orphaned (e.g. from Ctrl-C interruption),
129
129
  # inject a synthetic tool_result so the API doesn't reject the request.
130
130
  def repair_orphaned_tool_uses(formatted)
131
- # Collect all tool_use IDs from assistant messages
132
- tool_use_ids = Set.new
133
- formatted.each do |msg|
134
- next unless msg[:role] == 'assistant' && msg[:content].is_a?(Array)
131
+ orphaned = collect_tool_use_ids(formatted) - collect_tool_result_ids(formatted)
132
+ return formatted if orphaned.empty?
135
133
 
136
- msg[:content].each do |block|
137
- if block.is_a?(Hash) && (block[:type] == 'tool_use' || block['type'] == 'tool_use')
138
- tool_use_ids << (block[:id] || block['id'])
139
- end
140
- end
134
+ orphan_results = orphaned.map do |id|
135
+ { type: 'tool_result', tool_use_id: id, content: '[interrupted]', is_error: true }
141
136
  end
142
137
 
143
- # Collect all tool_result IDs from user messages
144
- tool_result_ids = Set.new
138
+ formatted << { role: 'user', content: orphan_results }
139
+ formatted
140
+ end
141
+
142
+ def collect_tool_use_ids(formatted)
143
+ collect_block_ids(formatted, role: 'assistant', type: 'tool_use', id_key: :id, id_str_key: 'id')
144
+ end
145
+
146
+ def collect_tool_result_ids(formatted)
147
+ collect_block_ids(formatted, role: 'user', type: 'tool_result', id_key: :tool_use_id,
148
+ id_str_key: 'tool_use_id')
149
+ end
150
+
151
+ def collect_block_ids(formatted, role:, type:, id_key:, id_str_key:) # rubocop:disable Metrics/CyclomaticComplexity -- iterates blocks with type+role guards
152
+ ids = Set.new
145
153
  formatted.each do |msg|
146
- next unless msg[:role] == 'user' && msg[:content].is_a?(Array)
154
+ next unless msg[:role] == role && msg[:content].is_a?(Array)
147
155
 
148
156
  msg[:content].each do |block|
149
- if block.is_a?(Hash) && (block[:type] == 'tool_result' || block['type'] == 'tool_result')
150
- tool_result_ids << (block[:tool_use_id] || block['tool_use_id'])
151
- end
152
- end
153
- end
154
-
155
- # Find orphans
156
- orphaned = tool_use_ids - tool_result_ids
157
- return formatted if orphaned.empty?
157
+ next unless block.is_a?(Hash) && block_matches_type?(block, type)
158
158
 
159
- # Inject synthetic tool_results for orphans
160
- orphan_results = orphaned.map do |id|
161
- { type: 'tool_result', tool_use_id: id, content: '[interrupted]', is_error: true }
159
+ ids << (block[id_key] || block[id_str_key])
160
+ end
162
161
  end
162
+ ids
163
+ end
163
164
 
164
- # Append as a user message after the last assistant message
165
- formatted << { role: 'user', content: orphan_results }
166
- formatted
165
+ def block_matches_type?(block, type)
166
+ block[:type] == type || block['type'] == type
167
167
  end
168
168
 
169
169
  # Normalize content and tool_calls into a single array of content blocks.
170
170
  def normalize_content(content, tool_calls)
171
- blocks = []
171
+ blocks = content_to_blocks(content)
172
+ tool_calls.each { |tc| blocks << block_to_hash(tc) }
173
+ blocks
174
+ end
172
175
 
176
+ def content_to_blocks(content)
173
177
  case content
174
- when Array
175
- content.each { |b| blocks << block_to_hash(b) }
176
- when String
177
- blocks << { type: 'text', text: content } unless content.empty?
178
- when Hash
179
- blocks << content
180
- else
181
- blocks << block_to_hash(content) if content.respond_to?(:type)
178
+ when Array then content.map { |b| block_to_hash(b) }
179
+ when String then content.empty? ? [] : [{ type: 'text', text: content }]
180
+ when Hash then [content]
181
+ else content.respond_to?(:type) ? [block_to_hash(content)] : []
182
182
  end
183
-
184
- tool_calls.each do |tc|
185
- blocks << block_to_hash(tc)
186
- end
187
-
188
- blocks
189
183
  end
190
184
 
191
185
  # Format message content for the API. Converts Data objects to hashes.
@@ -200,25 +194,30 @@ module RubynCode
200
194
 
201
195
  def block_to_hash(block)
202
196
  return block if block.is_a?(Hash)
197
+ return block unless block.respond_to?(:type)
203
198
 
204
- if block.respond_to?(:type)
205
- case block.type.to_s
206
- when 'text'
207
- { type: 'text', text: block.text }
208
- when 'tool_use'
209
- { type: 'tool_use', id: block.id, name: block.name, input: block.input }
210
- when 'tool_result'
211
- h = { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content.to_s }
212
- h[:is_error] = true if block.respond_to?(:is_error) && block.is_error
213
- h
214
- else
215
- block.respond_to?(:to_h) ? block.to_h : block
216
- end
199
+ typed_block_to_hash(block)
200
+ end
201
+
202
+ def typed_block_to_hash(block)
203
+ case block.type.to_s
204
+ when 'text'
205
+ { type: 'text', text: block.text }
206
+ when 'tool_use'
207
+ { type: 'tool_use', id: block.id, name: block.name, input: block.input }
208
+ when 'tool_result'
209
+ tool_result_block_to_hash(block)
217
210
  else
218
- block
211
+ block.respond_to?(:to_h) ? block.to_h : block
219
212
  end
220
213
  end
221
214
 
215
+ def tool_result_block_to_hash(block)
216
+ h = { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content.to_s }
217
+ h[:is_error] = true if block.respond_to?(:is_error) && block.is_error
218
+ h
219
+ end
220
+
222
221
  # Extract text from content blocks.
223
222
  def extract_text(content)
224
223
  case content
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Filters tool schemas sent to the LLM based on detected task context.
6
+ # Instead of sending all 28+ tool schemas on every call, only include
7
+ # tools relevant to the current task. This reduces per-turn system
8
+ # prompt overhead by 30-50%.
9
+ module DynamicToolSchema
10
+ BASE_TOOLS = %w[
11
+ read_file write_file edit_file glob grep bash
12
+ ].freeze
13
+
14
+ TASK_TOOLS = {
15
+ testing: %w[run_specs].freeze,
16
+ git: %w[git_status git_diff git_log git_commit].freeze,
17
+ review: %w[review_pr git_diff].freeze,
18
+ explore: %w[spawn_agent].freeze,
19
+ web: %w[web_search web_fetch].freeze,
20
+ memory: %w[memory_search memory_write].freeze,
21
+ skills: %w[load_skill].freeze,
22
+ tasks: %w[task].freeze,
23
+ teams: %w[spawn_teammate send_message read_inbox].freeze,
24
+ rails: %w[rails_generate db_migrate bundle_install bundle_add].freeze,
25
+ background: %w[background_run].freeze,
26
+ interaction: %w[ask_user compact].freeze
27
+ }.freeze
28
+
29
+ class << self
30
+ # Returns tool names relevant to the detected task context.
31
+ #
32
+ # @param task_context [Symbol, nil] detected task type
33
+ # @param discovered_tools [Set<String>] tools already discovered this session
34
+ # @return [Array<String>] tool names to include in the schema
35
+ def active_tools(task_context: nil, discovered_tools: Set.new)
36
+ tools = BASE_TOOLS.dup
37
+
38
+ # Always include interaction tools
39
+ tools.concat(TASK_TOOLS[:interaction])
40
+ tools.concat(TASK_TOOLS[:memory])
41
+
42
+ # Add task-specific tools
43
+ if task_context
44
+ context_tools = resolve_context_tools(task_context)
45
+ tools.concat(context_tools)
46
+ end
47
+
48
+ # Always include previously discovered tools
49
+ tools.concat(discovered_tools.to_a)
50
+
51
+ tools.uniq
52
+ end
53
+
54
+ # Detect task context from a user message.
55
+ #
56
+ # @param message [String]
57
+ # @return [Symbol, nil]
58
+ def detect_context(message) # rubocop:disable Metrics/CyclomaticComplexity -- context detection dispatch
59
+ msg = message.to_s.downcase
60
+ return :testing if msg.match?(/\b(test|spec|rspec)\b/)
61
+ return :git if msg.match?(/\b(commit|push|diff|branch|merge|git)\b/)
62
+ return :review if msg.match?(/\b(review|pr|pull request)\b/)
63
+ return :rails if msg.match?(/\b(migrate|generate|scaffold|rails)\b/)
64
+ return :web if msg.match?(/\b(search|fetch|url|http|api)\b/)
65
+ return :explore if msg.match?(/\b(explore|architecture|structure)\b/)
66
+ return :teams if msg.match?(/\b(team|spawn|message|inbox)\b/)
67
+
68
+ nil
69
+ end
70
+
71
+ # Filter full tool definitions to only include active tools.
72
+ #
73
+ # @param all_definitions [Array<Hash>] full tool schema list
74
+ # @param active_names [Array<String>] names of active tools
75
+ # @return [Array<Hash>] filtered definitions
76
+ def filter(all_definitions, active_names:)
77
+ name_set = active_names.to_set
78
+ all_definitions.select do |defn|
79
+ name = defn[:name] || defn['name']
80
+ name_set.include?(name)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def resolve_context_tools(context)
87
+ case context
88
+ when Symbol
89
+ TASK_TOOLS.fetch(context, [])
90
+ when Array
91
+ context.flat_map { |c| TASK_TOOLS.fetch(c, []) }
92
+ else
93
+ []
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Detects positive/negative user feedback and reinforces learned instincts.
6
+ module FeedbackHandler
7
+ POSITIVE_PATTERNS =
8
+ /\b(yes that fixed it|that worked|perfect|thanks|exactly|great|nailed it|that.s right|correct)\b/i
9
+ NEGATIVE_PATTERNS =
10
+ /\b(no[, ]+use|wrong|that.s not right|instead use|don.t do that|actually[, ]+use|incorrect)\b/i
11
+
12
+ private
13
+
14
+ def check_user_feedback(user_input)
15
+ return unless @project_root
16
+
17
+ recent_instincts = fetch_recent_instincts
18
+ return if recent_instincts.empty?
19
+
20
+ reinforce_instincts(user_input, recent_instincts)
21
+ rescue StandardError
22
+ # Non-critical; don't interrupt the conversation
23
+ end
24
+
25
+ def fetch_recent_instincts
26
+ db = DB::Connection.instance
27
+ db.query(
28
+ 'SELECT id FROM instincts WHERE project_path = ? ORDER BY updated_at DESC LIMIT 5',
29
+ [@project_root]
30
+ ).to_a
31
+ end
32
+
33
+ def reinforce_instincts(user_input, recent_instincts)
34
+ if user_input.match?(POSITIVE_PATTERNS)
35
+ reinforce_top(recent_instincts, helpful: true)
36
+ elsif user_input.match?(NEGATIVE_PATTERNS)
37
+ reinforce_top(recent_instincts, helpful: false)
38
+ end
39
+ end
40
+
41
+ def reinforce_top(instincts, helpful:)
42
+ db = DB::Connection.instance
43
+ instincts.first(2).each do |row|
44
+ Learning::InstinctMethods.reinforce_in_db(row['id'], db, helpful: helpful)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Handles LLM chat calls, option building, prompt-too-long recovery,
6
+ # and maintenance tasks (compaction, budget, stall detection).
7
+ module LlmCaller # rubocop:disable Metrics/ModuleLength -- LLM call pipeline with routing + recovery
8
+ private
9
+
10
+ def call_llm
11
+ @hook_runner.fire(:pre_llm_call, conversation: @conversation)
12
+
13
+ opts = build_llm_opts
14
+ log_llm_call(opts)
15
+ response = @llm_client.chat(**opts)
16
+
17
+ @hook_runner.fire(:post_llm_call, response: response, conversation: @conversation)
18
+ track_usage(response)
19
+ update_task_budget(response)
20
+ response
21
+ rescue LLM::Client::PromptTooLongError
22
+ recover_prompt_too_long(opts)
23
+ end
24
+
25
+ def build_llm_opts
26
+ opts = {
27
+ messages: @conversation.to_api_format,
28
+ tools: @plan_mode ? read_only_tool_definitions : tool_definitions,
29
+ system: build_system_prompt,
30
+ on_text: @on_text
31
+ }
32
+ opts[:max_tokens] = @max_tokens_override if @max_tokens_override
33
+ opts[:model] = routed_model
34
+ if @task_budget_remaining
35
+ opts[:task_budget] = {
36
+ total: UsageTracker::TASK_BUDGET_TOTAL, remaining: @task_budget_remaining
37
+ }
38
+ end
39
+ opts
40
+ end
41
+
42
+ # Uses ModelRouter to pick the right model for the current task.
43
+ # Only returns models from the active provider — never crosses
44
+ # provider boundaries (e.g., won't send a GPT model to Anthropic).
45
+ # Falls back to nil (use client's default) if routing fails.
46
+ def routed_model
47
+ last_user = last_user_message_text
48
+ return nil unless last_user
49
+
50
+ recent = @stall_detector.respond_to?(:recent_tools) ? @stall_detector.recent_tools : []
51
+ task = LLM::ModelRouter.detect_task(last_user, recent_tools: recent)
52
+ resolved = LLM::ModelRouter.resolve(task, client: @llm_client)
53
+
54
+ # Only use the routed model if it's from the same provider
55
+ active = @llm_client.respond_to?(:provider_name) ? @llm_client.provider_name : nil
56
+ return nil if active && resolved[:provider] != active
57
+
58
+ resolved[:model]
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ def last_user_message_text
64
+ msg = @conversation.messages.reverse_each.find { |m| m[:role] == 'user' }
65
+ return nil unless msg
66
+
67
+ content = msg[:content]
68
+ content.is_a?(String) ? content : nil
69
+ end
70
+
71
+ def log_llm_call(opts) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- safe accessor checks
72
+ default_model = @llm_client.respond_to?(:model) ? @llm_client.model : 'default'
73
+ routed = opts[:model]
74
+ effective = routed || default_model
75
+ provider = @llm_client.respond_to?(:provider_name) ? @llm_client.provider_name : 'unknown'
76
+ tool_count = opts[:tools]&.size || 0
77
+ routed_tag = routed && routed != default_model ? " (routed from #{default_model})" : ''
78
+ RubynCode::Debug.llm("chat provider=#{provider} model=#{effective}#{routed_tag} tools=#{tool_count}")
79
+ rescue StandardError
80
+ nil
81
+ end
82
+
83
+ def recover_prompt_too_long(opts)
84
+ RubynCode::Debug.recovery(
85
+ '413 prompt too long — running emergency compaction'
86
+ )
87
+ @context_manager.check_compaction!(@conversation)
88
+
89
+ response = @llm_client.chat(**opts, messages: @conversation.to_api_format)
90
+ @hook_runner.fire(
91
+ :post_llm_call, response: response, conversation: @conversation
92
+ )
93
+ track_usage(response)
94
+ response
95
+ end
96
+
97
+ # ── Maintenance ──────────────────────────────────────────────────
98
+
99
+ def run_maintenance(_iteration)
100
+ run_compaction
101
+ check_budget
102
+ check_stall_detection
103
+ end
104
+
105
+ def run_compaction
106
+ before = @conversation.length
107
+ est = @context_manager.estimated_tokens(@conversation.messages)
108
+ RubynCode::Debug.token(
109
+ "context=#{est} tokens (~#{before} messages, " \
110
+ "threshold=#{Config::Defaults::CONTEXT_THRESHOLD_TOKENS})"
111
+ )
112
+
113
+ @context_manager.check_compaction!(@conversation)
114
+ log_compaction(before, est)
115
+ rescue NoMethodError
116
+ # context_manager does not implement check_compaction! yet
117
+ end
118
+
119
+ def log_compaction(before, est)
120
+ after = @conversation.length
121
+ return unless after < before
122
+
123
+ new_est = @context_manager.estimated_tokens(@conversation.messages)
124
+ RubynCode::Debug.loop_tick(
125
+ "Compacted: #{before} -> #{after} messages " \
126
+ "(#{est} -> #{new_est} tokens)"
127
+ )
128
+ end
129
+
130
+ def check_budget
131
+ return unless @budget_enforcer
132
+
133
+ @budget_enforcer.check!
134
+ rescue BudgetExceededError
135
+ raise
136
+ rescue NoMethodError
137
+ # budget_enforcer does not implement check! yet
138
+ end
139
+
140
+ def check_stall_detection
141
+ return unless @stall_detector.stalled?
142
+
143
+ nudge = @stall_detector.nudge_message
144
+ @conversation.add_user_message(nudge)
145
+ @stall_detector.reset!
146
+ end
147
+ end
148
+ end
149
+ end