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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f91f304118c243f82ce9165db84f303b111d9c18daf74c75cdc7dcec4289647b
|
|
4
|
+
data.tar.gz: 2cfc8263c1532d805ea9b9592c3601033e4fdab8dc19c6b0329a0eec40316cbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
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 (
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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] ==
|
|
154
|
+
next unless msg[:role] == role && msg[:content].is_a?(Array)
|
|
147
155
|
|
|
148
156
|
msg[:content].each do |block|
|
|
149
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
176
|
-
when
|
|
177
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|