rubyn-code 0.7.0 → 0.9.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 +45 -17
- data/lib/rubyn_code/agent/conversation.rb +11 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +5 -1
- data/lib/rubyn_code/agent/loop.rb +57 -3
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
- data/lib/rubyn_code/agent/tool_processor.rb +10 -0
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/cli/commands/context.rb +27 -0
- data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
- data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/think.rb +47 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/mention_expander.rb +19 -0
- data/lib/rubyn_code/cli/repl.rb +31 -1
- data/lib/rubyn_code/cli/repl_commands.rb +1 -1
- data/lib/rubyn_code/cli/repl_setup.rb +8 -6
- data/lib/rubyn_code/config/defaults.rb +2 -1
- data/lib/rubyn_code/config/schema.json +5 -0
- data/lib/rubyn_code/config/settings.rb +4 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +64 -3
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/discovery.rb +93 -0
- data/lib/rubyn_code/memory/session_persistence.rb +1 -1
- data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/teammate.rb +0 -15
- data/lib/rubyn_code/tools/RUBYN.md +3 -3
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +6 -1
- data/lib/rubyn_code/tools/phone_a_friend.rb +135 -0
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +14 -8
- data/skills/rubyn_self_test.md +140 -0
- metadata +11 -7
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d43be98f4e24cc9bc78bc541ed0554731ed88985adb56ec5700d643dc4fd2ba2
|
|
4
|
+
data.tar.gz: d6adda167ad528fde80900dfb4c5d7114029695c4ae83f814ad2130b2f531781
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3352ad014212276c17d5a96adb3d3dcf43165dffb77b8a95d01ce22bf3fdd57786abca291c4604e937cef26b070b3d43ae791430da30d84baa49a76e753d8f28
|
|
7
|
+
data.tar.gz: 767c3be54cee4d94e157dd7ae73287c5306159586f3057eac199175aa0ac79aba3ef35046cc4eef6936c6f80802277bf578e5588c35b2fd8940d8aa58037c07e
|
data/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<a href="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml"><img src="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
|
-
Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus
|
|
18
|
+
Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 5, running on your existing Claude subscription.
|
|
19
19
|
|
|
20
20
|
<img width="1230" height="280" alt="image" src="https://github.com/user-attachments/assets/14e07ce8-def0-4a8f-ac89-46661361a4eb" />
|
|
21
21
|
|
|
@@ -31,7 +31,7 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
|
|
|
31
31
|
- [Quick Start](#quick-start)
|
|
32
32
|
- [What Can Rubyn Do?](#what-can-rubyn-do)
|
|
33
33
|
- [VS Code Extension](#vs-code-extension)
|
|
34
|
-
- [
|
|
34
|
+
- [32 Built-in Tools](#32-built-in-tools)
|
|
35
35
|
- [MCP — External Tool Servers](#mcp--external-tool-servers)
|
|
36
36
|
- [Codebase Indexing](#codebase-indexing)
|
|
37
37
|
- [112 Best Practice Skills](#112-best-practice-skills)
|
|
@@ -69,6 +69,22 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
|
|
|
69
69
|
- **IDE-ready** — works in the terminal and inside VS Code with full bidirectional communication
|
|
70
70
|
- **Extensible** — connect external tool servers via MCP, add custom skills, or wire up your own providers
|
|
71
71
|
|
|
72
|
+
## Claude Code Feature Parity
|
|
73
|
+
|
|
74
|
+
Phase 4 ships nine parity features with Claude Code:
|
|
75
|
+
|
|
76
|
+
- **Extended thinking** — `/think <budget>` toggles per-session reasoning; the Anthropic adapter emits `thinking: {type: 'adaptive'}` on Claude 4.6+ models (`{type: 'enabled', budget_tokens}` on older ones).
|
|
77
|
+
- **Reasoning effort** — `/effort <low|medium|high|xhigh|max>` sets the request-level reasoning depth; the Anthropic adapter emits `output_config: {effort}` on the wire. GA on Claude 4.6+ (Opus 4.6/4.7/4.8/5, Sonnet 4.6/5, Fable 5); `xhigh` needs Opus 4.7+ / Sonnet 5 / Fable 5. Model default is `high`.
|
|
78
|
+
- **Task budgets (beta)** — the agent loop's remaining token budget is sent as `output_config: {task_budget: {type: 'tokens', total}}` on supported models (Fable 5, Sonnet 5, Opus 5, Opus 4.7/4.8), advisory pacing only — `max_tokens` stays the enforced per-response cap.
|
|
79
|
+
- **Image / vision input** — `@chart.png` (and `.jpg` / `.jpeg` / `.gif` / `.webp`) becomes a real image content block attached to the user turn; Anthropic and OpenAI each emit their native shape.
|
|
80
|
+
- **TodoWrite live checklist** — `TodoWrite` tool; the checklist refreshes above the spinner on every tool result so you see in-turn progress at a glance.
|
|
81
|
+
- **Custom-command frontmatter** — `argument-hint`, `allowed-tools`, and `model:` keys in `~/.rubyn-code/commands/*.md`; the loop honors the per-prompt tool restriction and model override.
|
|
82
|
+
- **`.mcp.json` auto-discovery** — project-root MCP servers flow into the REPL automatically; `/mcp` shows entries prefixed `[project]` or `[user]`. OAuth for URL transports is still deferred.
|
|
83
|
+
- **`/export` transcript** — `/export <path>` writes the conversation as Markdown (default) or `--jsonl` for tooling.
|
|
84
|
+
- **Fable 5 refusal handling** — a declined request (`stop_reason: "refusal"`) surfaces a clear message instead of an empty turn; `claude-fable-5` / `claude-mythos-5` requests opt into a server-side Opus 4.8 fallback by default, so most refusals are transparently re-served in the same call.
|
|
85
|
+
|
|
86
|
+
See `docs/04-feature-parity/` for per-feature design + tasks docs.
|
|
87
|
+
|
|
72
88
|
## Install
|
|
73
89
|
|
|
74
90
|
Requires **Ruby 4.0.2+**. Install with your latest Ruby, then pin it so it works in every project:
|
|
@@ -199,7 +215,7 @@ Rubyn Code includes a VS Code extension that provides a full IDE experience with
|
|
|
199
215
|
- Full session management — resume, list, fork, and reset conversations
|
|
200
216
|
- Structured code review feedback with severity ratings
|
|
201
217
|
- IDE config get/set for persistent settings
|
|
202
|
-
- All
|
|
218
|
+
- All 32 tools available, including MCP tools
|
|
203
219
|
|
|
204
220
|
**Permission modes:**
|
|
205
221
|
|
|
@@ -210,19 +226,19 @@ Rubyn Code includes a VS Code extension that provides a full IDE experience with
|
|
|
210
226
|
|
|
211
227
|
The extension communicates over 19 RPC methods: `initialize`, `prompt`, `cancel`, `review`, `approveToolUse`, `acceptEdit`, `session/*`, `config/*`, `models/list`, `plan/propose`, `plan/interview/*` (chat-resident [megaplan](#megaplan--phased-planning)), `recover_ci`, and `shutdown`.
|
|
212
228
|
|
|
213
|
-
##
|
|
229
|
+
## 32 Built-in Tools
|
|
214
230
|
|
|
215
231
|
| Category | Tools |
|
|
216
232
|
|----------|-------|
|
|
217
233
|
| **File I/O** | `read_file`, `write_file`, `edit_file` |
|
|
218
|
-
| **Search** | `glob`, `grep` |
|
|
234
|
+
| **Search** | `glob`, `grep`, `code_graph` |
|
|
219
235
|
| **Execution** | `bash` (sandboxed, dangerous commands blocked) |
|
|
220
236
|
| **Web** | `web_search`, `web_fetch` |
|
|
221
237
|
| **Git** | `git_status`, `git_diff`, `git_log`, `git_commit` |
|
|
222
238
|
| **Rails** | `rails_generate`, `db_migrate`, `run_specs`, `bundle_install`, `bundle_add` |
|
|
223
239
|
| **Review** | `review_pr` (diff-based best practice code review) |
|
|
224
|
-
| **Agents** | `spawn_agent`, `spawn_teammate`, `background_run` |
|
|
225
|
-
| **Context** | `compact`, `load_skill`, `task` |
|
|
240
|
+
| **Agents** | `spawn_agent`, `spawn_teammate`, `background_run`, `phone_a_friend` |
|
|
241
|
+
| **Context** | `compact`, `load_skill`, `task`, `todo_write` |
|
|
226
242
|
| **Memory** | `memory_search`, `memory_write` |
|
|
227
243
|
| **Teams** | `send_message`, `read_inbox` |
|
|
228
244
|
| **Interactive** | `ask_user` (ask clarifying questions mid-task) |
|
|
@@ -502,6 +518,18 @@ rubyn > Send alice a message to write specs for the User model
|
|
|
502
518
|
|
|
503
519
|
Teammates run in background threads with their own agent loop and mailbox.
|
|
504
520
|
|
|
521
|
+
### Phone a Friend (second opinion)
|
|
522
|
+
|
|
523
|
+
When the agent is stuck or weighing two approaches, the `phone_a_friend` tool asks a *different* model for a one-shot second opinion. The friend is picked for perspective diversity: the top-tier model of another configured provider whose API key is present (a genuinely different model family), falling back to the active provider's top tier. The friend gets no tools and no conversation history — only the question and whatever context the agent chooses to send — and its answer comes back as plain text labeled with the provider and model that gave it.
|
|
524
|
+
|
|
525
|
+
```
|
|
526
|
+
rubyn > I keep going back and forth on STI vs polymorphic here...
|
|
527
|
+
|
|
528
|
+
[tool] phone_a_friend
|
|
529
|
+
## Second Opinion — openai/gpt-5.4
|
|
530
|
+
Commit to the polymorphic association. The key reason: ...
|
|
531
|
+
```
|
|
532
|
+
|
|
505
533
|
## GOLEM — Autonomous Daemon
|
|
506
534
|
|
|
507
535
|
GOLEM is an always-on autonomous agent that claims tasks from a queue and works through them independently. It runs a full agent loop per task with access to all tools, MCP servers, and memory.
|
|
@@ -675,7 +703,7 @@ Rubyn snapshots a checkpoint at the start of every turn — capturing the conver
|
|
|
675
703
|
| 2 | Token file | `~/.rubyn-code/tokens.yml` |
|
|
676
704
|
| 3 | Environment | `export ANTHROPIC_API_KEY=sk-ant-...` |
|
|
677
705
|
|
|
678
|
-
Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus
|
|
706
|
+
Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 5**.
|
|
679
707
|
|
|
680
708
|
### OpenAI
|
|
681
709
|
|
|
@@ -693,7 +721,7 @@ Add a provider and its API key in one command:
|
|
|
693
721
|
/provider add groq https://api.groq.com/openai/v1 --key gsk-xxx --models llama-3.3-70b
|
|
694
722
|
|
|
695
723
|
# For Anthropic-format proxies (e.g., Bedrock, custom gateways)
|
|
696
|
-
/provider add my-proxy https://proxy.example.com/v1 --format anthropic --key sk-xxx --models claude-sonnet-
|
|
724
|
+
/provider add my-proxy https://proxy.example.com/v1 --format anthropic --key sk-xxx --models claude-sonnet-5
|
|
697
725
|
|
|
698
726
|
# Update a key later
|
|
699
727
|
/provider set-key groq gsk-new-key
|
|
@@ -724,7 +752,7 @@ providers:
|
|
|
724
752
|
base_url: https://proxy.example.com/v1
|
|
725
753
|
env_key: PROXY_API_KEY
|
|
726
754
|
models:
|
|
727
|
-
top: claude-sonnet-
|
|
755
|
+
top: claude-sonnet-5
|
|
728
756
|
```
|
|
729
757
|
|
|
730
758
|
Then switch with `/model groq:llama-3.3-70b`.
|
|
@@ -751,7 +779,7 @@ Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't req
|
|
|
751
779
|
│ Layer 5: Skills (112 best practice docs, on-demand loading) │
|
|
752
780
|
│ Layer 4: Context Management (3-layer compression pipeline) │
|
|
753
781
|
│ Layer 3: Permissions (tiered access + deny lists + hooks) │
|
|
754
|
-
│ Layer 2: Tool System (
|
|
782
|
+
│ Layer 2: Tool System (32 tools, dispatch map registry) │
|
|
755
783
|
│ Layer 1: THE AGENT LOOP (while tool_use → execute → repeat) │
|
|
756
784
|
└──────────────────────────────────────────────────────────────┘
|
|
757
785
|
```
|
|
@@ -764,7 +792,7 @@ These must match a provider defined in the `providers` section (or a built-in li
|
|
|
764
792
|
```yaml
|
|
765
793
|
# ~/.rubyn-code/config.yml (global)
|
|
766
794
|
provider: anthropic # default provider on startup
|
|
767
|
-
model: claude-opus-
|
|
795
|
+
model: claude-opus-5 # default model on startup
|
|
768
796
|
permission_mode: allow_read
|
|
769
797
|
session_budget: 5.00
|
|
770
798
|
daily_budget: 10.00
|
|
@@ -782,15 +810,15 @@ Rubyn can automatically route tasks to different AI models based on complexity.
|
|
|
782
810
|
```yaml
|
|
783
811
|
# ~/.rubyn-code/config.yml
|
|
784
812
|
provider: anthropic
|
|
785
|
-
model: claude-opus-
|
|
813
|
+
model: claude-opus-5
|
|
786
814
|
|
|
787
815
|
providers:
|
|
788
816
|
anthropic:
|
|
789
817
|
env_key: ANTHROPIC_API_KEY
|
|
790
818
|
models:
|
|
791
819
|
cheap: claude-haiku-4-5 # file search, git ops, formatting
|
|
792
|
-
mid: claude-sonnet-
|
|
793
|
-
top: claude-opus-
|
|
820
|
+
mid: claude-sonnet-5 # code gen, specs, refactors, reviews
|
|
821
|
+
top: claude-opus-5 # architecture, security, complex work
|
|
794
822
|
|
|
795
823
|
openai:
|
|
796
824
|
env_key: OPENAI_API_KEY
|
|
@@ -822,8 +850,8 @@ providers:
|
|
|
822
850
|
| Tier | Task types | Default model |
|
|
823
851
|
|------|-----------|---------------|
|
|
824
852
|
| **cheap** | File search, git ops, formatting, summaries | `claude-haiku-4-5` |
|
|
825
|
-
| **mid** | Code generation, specs, refactors, code review, bug fixes | `claude-sonnet-
|
|
826
|
-
| **top** | Architecture, security review, complex refactors, planning | `claude-opus-
|
|
853
|
+
| **mid** | Code generation, specs, refactors, code review, bug fixes | `claude-sonnet-5` |
|
|
854
|
+
| **top** | Architecture, security review, complex refactors, planning | `claude-opus-5` |
|
|
827
855
|
|
|
828
856
|
You can also set custom pricing per model so `/cost` reports accurate spending for third-party providers.
|
|
829
857
|
|
|
@@ -14,7 +14,10 @@ module RubynCode
|
|
|
14
14
|
|
|
15
15
|
# Append a user turn to the conversation.
|
|
16
16
|
#
|
|
17
|
-
# @param content [String]
|
|
17
|
+
# @param content [String, Array<Hash>] either a plain string or an
|
|
18
|
+
# array of content blocks (text, image, etc.). Strings get wrapped
|
|
19
|
+
# as `[{ type: 'text', text: content }]` only if there's a planned
|
|
20
|
+
# Array; we keep raw String for backward compatibility with the API.
|
|
18
21
|
# @return [Hash] the appended message
|
|
19
22
|
def add_user_message(content)
|
|
20
23
|
message = { role: 'user', content: content }
|
|
@@ -155,6 +158,13 @@ module RubynCode
|
|
|
155
158
|
reset_derived_state!
|
|
156
159
|
end
|
|
157
160
|
|
|
161
|
+
# Alias for `messages`. Common shorthand for callers that want to
|
|
162
|
+
# treat the conversation as an Array of message hashes.
|
|
163
|
+
def to_a
|
|
164
|
+
@messages
|
|
165
|
+
end
|
|
166
|
+
alias to_ary to_a
|
|
167
|
+
|
|
158
168
|
private
|
|
159
169
|
|
|
160
170
|
# Derived bookkeeping kept in sync with @messages so hot paths stay
|
|
@@ -25,7 +25,7 @@ module RubynCode
|
|
|
25
25
|
def build_llm_opts
|
|
26
26
|
opts = {
|
|
27
27
|
messages: @conversation.to_api_format,
|
|
28
|
-
tools:
|
|
28
|
+
tools: filtered_tool_definitions,
|
|
29
29
|
system: build_system_prompt,
|
|
30
30
|
on_text: @on_text
|
|
31
31
|
}
|
|
@@ -36,6 +36,8 @@ module RubynCode
|
|
|
36
36
|
total: UsageTracker::TASK_BUDGET_TOTAL, remaining: @task_budget_remaining
|
|
37
37
|
}
|
|
38
38
|
end
|
|
39
|
+
budget = @llm_client.respond_to?(:thinking_budget_tokens) ? @llm_client.thinking_budget_tokens.to_i : 0
|
|
40
|
+
opts[:thinking] = { budget_tokens: budget } if budget.positive?
|
|
39
41
|
opts
|
|
40
42
|
end
|
|
41
43
|
|
|
@@ -45,6 +47,8 @@ module RubynCode
|
|
|
45
47
|
# Falls back to nil (use client's default) if routing fails.
|
|
46
48
|
# -- guard clauses for provider/mode checks
|
|
47
49
|
def routed_model
|
|
50
|
+
return @model_override if @model_override
|
|
51
|
+
|
|
48
52
|
return nil if manual_model_mode?
|
|
49
53
|
|
|
50
54
|
last_user = last_user_message_text
|
|
@@ -53,8 +53,11 @@ module RubynCode
|
|
|
53
53
|
# response is produced or the iteration limit is reached.
|
|
54
54
|
#
|
|
55
55
|
# @param user_input [String]
|
|
56
|
+
# @param blocks [Array<Hash>, nil] extra content blocks to attach to the
|
|
57
|
+
# user message (e.g. image blocks from @-mentions). When non-empty the
|
|
58
|
+
# user message is stored as a mixed content array instead of a string.
|
|
56
59
|
# @return [String] the final assistant text response
|
|
57
|
-
def send_message(user_input)
|
|
60
|
+
def send_message(user_input, blocks: nil)
|
|
58
61
|
initialize_session!
|
|
59
62
|
check_user_feedback(user_input)
|
|
60
63
|
drain_background_notifications
|
|
@@ -62,7 +65,7 @@ module RubynCode
|
|
|
62
65
|
@decision_compactor&.detect_topic_switch(user_input)
|
|
63
66
|
@skill_ttl&.tick!
|
|
64
67
|
autoload_triggered_skills(user_input)
|
|
65
|
-
|
|
68
|
+
append_user_message(user_input, blocks)
|
|
66
69
|
reset_system_prompt_cache!
|
|
67
70
|
reset_iteration_state
|
|
68
71
|
|
|
@@ -79,8 +82,38 @@ module RubynCode
|
|
|
79
82
|
max_iterations_warning(iteration)
|
|
80
83
|
end
|
|
81
84
|
|
|
85
|
+
# Append the user turn to the conversation. If image blocks were passed
|
|
86
|
+
# via `blocks:`, the message is stored as a mixed content array with a
|
|
87
|
+
# leading text block and the image blocks appended in order.
|
|
88
|
+
def append_user_message(user_input, blocks)
|
|
89
|
+
if blocks.is_a?(Array) && !blocks.empty?
|
|
90
|
+
content = [{ type: 'text', text: user_input.to_s }, *blocks]
|
|
91
|
+
@conversation.add_user_message(content)
|
|
92
|
+
else
|
|
93
|
+
@conversation.add_user_message(user_input)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Tools::TodoStore] shared checklist store, exposed for the REPL renderer
|
|
98
|
+
attr_reader :todo_store
|
|
99
|
+
|
|
100
|
+
# Apply a one-shot model override for the duration of the next LLM call.
|
|
101
|
+
# Used by `Context#with_optional_model` for custom-command frontmatter.
|
|
102
|
+
def model_override=(model_name)
|
|
103
|
+
stripped = model_name.to_s.strip
|
|
104
|
+
@model_override = stripped.empty? ? nil : stripped
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Restrict the available tool set for the next LLM call only.
|
|
108
|
+
# @param allowed [Array<String>, nil] tool names, or nil to clear the override.
|
|
109
|
+
def allowed_tools_override=(allowed)
|
|
110
|
+
@allowed_tools_override = allowed.is_a?(Array) && !allowed.empty? ? allowed : nil
|
|
111
|
+
end
|
|
112
|
+
|
|
82
113
|
private
|
|
83
114
|
|
|
115
|
+
# Overrides the region above. The internal helpers below are private.
|
|
116
|
+
|
|
84
117
|
# Decide whether the loop should run another iteration after `iteration`
|
|
85
118
|
# turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
|
|
86
119
|
# active /goal) is keeping the agent alive we extend up to a hard ceiling
|
|
@@ -119,6 +152,8 @@ module RubynCode
|
|
|
119
152
|
@tool_wrapper = opts[:tool_wrapper]
|
|
120
153
|
@decision_compactor = build_decision_compactor
|
|
121
154
|
@skill_ttl = Skills::TtlManager.new
|
|
155
|
+
@todo_store = opts.fetch(:todo_store, Tools::TodoStore.new)
|
|
156
|
+
@tool_executor.todo_store = @todo_store
|
|
122
157
|
@session_initialized = false
|
|
123
158
|
end
|
|
124
159
|
|
|
@@ -133,6 +168,7 @@ module RubynCode
|
|
|
133
168
|
def initialize_session!
|
|
134
169
|
return if @session_initialized || !@project_root
|
|
135
170
|
|
|
171
|
+
@todo_store&.clear
|
|
136
172
|
@session_initialized = true
|
|
137
173
|
build_project_profile!
|
|
138
174
|
build_codebase_index!
|
|
@@ -168,13 +204,17 @@ module RubynCode
|
|
|
168
204
|
@output_recovery_count = 0
|
|
169
205
|
@task_budget_remaining = nil
|
|
170
206
|
@stop_block_active = false # true while a Stop hook keeps us going
|
|
207
|
+
@allowed_tools_override = nil # set by Context#with_allowed_tools
|
|
208
|
+
@model_override = nil # set by Context#with_optional_model
|
|
171
209
|
end
|
|
172
210
|
|
|
173
211
|
def run_iteration(iteration)
|
|
174
212
|
log_iteration(iteration)
|
|
175
213
|
@context_manager.advance_turn!
|
|
176
214
|
compact_if_needed # ensure context is under threshold before LLM call
|
|
177
|
-
response
|
|
215
|
+
response = call_llm
|
|
216
|
+
return handle_refusal(response) if extract_stop_reason(response) == 'refusal'
|
|
217
|
+
|
|
178
218
|
tool_calls = extract_tool_calls(response)
|
|
179
219
|
log_response(response, tool_calls)
|
|
180
220
|
|
|
@@ -183,6 +223,20 @@ module RubynCode
|
|
|
183
223
|
handle_tool_response(response, tool_calls, iteration)
|
|
184
224
|
end
|
|
185
225
|
|
|
226
|
+
# Claude's safety classifiers declined the request outright (HTTP 200,
|
|
227
|
+
# stop_reason: "refusal"). Surface a clear message instead of falling
|
|
228
|
+
# through to text/empty-response handling, which would misread the
|
|
229
|
+
# empty or partial content as "waiting on background jobs".
|
|
230
|
+
def handle_refusal(response)
|
|
231
|
+
details = extract_stop_details(response)
|
|
232
|
+
category = details.is_a?(Hash) ? (details['category'] || details[:category]) : nil
|
|
233
|
+
RubynCode::Debug.llm("Refusal: category=#{category || 'unknown'}")
|
|
234
|
+
|
|
235
|
+
message = "Claude's safety system declined this request (category: #{category || 'unknown'})."
|
|
236
|
+
@conversation.add_assistant_message([{ type: 'text', text: message }])
|
|
237
|
+
message
|
|
238
|
+
end
|
|
239
|
+
|
|
186
240
|
def log_iteration(iteration)
|
|
187
241
|
RubynCode::Debug.loop_tick(
|
|
188
242
|
"iteration=#{iteration} messages=#{@conversation.length} " \
|
|
@@ -61,6 +61,14 @@ module RubynCode
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
def extract_stop_details(response)
|
|
65
|
+
if response.respond_to?(:stop_details)
|
|
66
|
+
response.stop_details
|
|
67
|
+
elsif response.is_a?(Hash)
|
|
68
|
+
response[:stop_details] || response['stop_details']
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
64
72
|
def recover_truncated_response(response)
|
|
65
73
|
@max_tokens_override ||= Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
|
|
66
74
|
@conversation.add_assistant_message(response_content(response))
|
|
@@ -101,6 +101,9 @@ module RubynCode
|
|
|
101
101
|
return unless index&.nodes&.any?
|
|
102
102
|
|
|
103
103
|
parts << "\n## #{index.to_structural_summary}"
|
|
104
|
+
parts << 'This project has a code graph. To locate or understand code, call `code_graph` FIRST — ' \
|
|
105
|
+
'it returns matching symbols with source, callers, and callees in one call. ' \
|
|
106
|
+
'Fall back to grep/glob only when code_graph finds nothing.'
|
|
104
107
|
rescue StandardError
|
|
105
108
|
nil
|
|
106
109
|
end
|
|
@@ -52,6 +52,16 @@ module RubynCode
|
|
|
52
52
|
Tools::Registry.all.select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }.map(&:to_schema)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Tool definitions for the next LLM call: plan mode narrows to
|
|
56
|
+
# read-only tools; a per-turn allowed-tools override (custom-command
|
|
57
|
+
# frontmatter, set via Loop#allowed_tools_override=) narrows by name.
|
|
58
|
+
def filtered_tool_definitions
|
|
59
|
+
defs = @plan_mode ? read_only_tool_definitions : tool_definitions
|
|
60
|
+
return defs unless @allowed_tools_override
|
|
61
|
+
|
|
62
|
+
DynamicToolSchema.filter(defs, active_names: @allowed_tools_override)
|
|
63
|
+
end
|
|
64
|
+
|
|
55
65
|
# -- tool dispatch with budget + signals
|
|
56
66
|
def process_tool_calls(tool_calls)
|
|
57
67
|
aggregate_chars = 0
|
|
@@ -212,7 +212,7 @@ module RubynCode
|
|
|
212
212
|
tokens = extract_token_counts(cm)
|
|
213
213
|
return if tokens.values.all?(&:zero?)
|
|
214
214
|
|
|
215
|
-
model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-
|
|
215
|
+
model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-5'
|
|
216
216
|
@total_cost += Observability::CostCalculator.calculate(model: model, **tokens)
|
|
217
217
|
rescue StandardError
|
|
218
218
|
# Non-critical — cost tracking is best-effort
|
|
@@ -40,6 +40,33 @@ module RubynCode
|
|
|
40
40
|
|
|
41
41
|
# @return [Boolean]
|
|
42
42
|
def plan_mode? = plan_mode
|
|
43
|
+
|
|
44
|
+
# Restrict the tool set for a single agent invocation. The next
|
|
45
|
+
# `send_message` call inside the block sees only the tools in
|
|
46
|
+
# `allowed`; the override is cleared after the call.
|
|
47
|
+
def with_allowed_tools(allowed = nil, &block)
|
|
48
|
+
apply_loop_override(:allowed_tools_override=, allowed, &block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Apply a one-shot model override for the duration of a block.
|
|
52
|
+
def with_optional_model(model_name = nil, &block)
|
|
53
|
+
apply_loop_override(:model_override=, model_name, &block)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Set an override on the agent loop (when one is wired in) for the
|
|
59
|
+
# duration of the block, then clear it. Custom commands without
|
|
60
|
+
# a loop (e.g. test doubles) fall through to a plain yield.
|
|
61
|
+
def apply_loop_override(method, value)
|
|
62
|
+
loop = agent_loop
|
|
63
|
+
return yield unless loop.respond_to?(method)
|
|
64
|
+
|
|
65
|
+
loop.public_send(method, value)
|
|
66
|
+
yield
|
|
67
|
+
ensure
|
|
68
|
+
loop&.public_send(method, nil) if loop.respond_to?(method)
|
|
69
|
+
end
|
|
43
70
|
end
|
|
44
71
|
end
|
|
45
72
|
end
|
|
@@ -14,13 +14,24 @@ module RubynCode
|
|
|
14
14
|
attr_reader :name
|
|
15
15
|
# @return [String] one-line description for /help
|
|
16
16
|
attr_reader :description
|
|
17
|
+
# @return [String, nil] argument hint shown next to the name in /help
|
|
18
|
+
attr_reader :argument_hint
|
|
19
|
+
# @return [Array<String>, nil] tool names the command restricts to
|
|
20
|
+
attr_reader :allowed_tools
|
|
21
|
+
# @return [String, nil] model override for this prompt
|
|
22
|
+
attr_reader :model
|
|
17
23
|
# @return [String, nil] originating file path
|
|
18
24
|
attr_reader :source
|
|
19
25
|
|
|
20
|
-
def initialize(name:, description:, body:,
|
|
26
|
+
def initialize(name:, description:, body:, # rubocop:disable Metrics/ParameterLists -- explicit kwargs document the frontmatter surface
|
|
27
|
+
source: nil,
|
|
28
|
+
argument_hint: nil, allowed_tools: nil, model: nil)
|
|
21
29
|
@name = name
|
|
22
30
|
@description = description
|
|
23
31
|
@template = CommandTemplate.new(body)
|
|
32
|
+
@argument_hint = argument_hint
|
|
33
|
+
@allowed_tools = allowed_tools
|
|
34
|
+
@model = model
|
|
24
35
|
@source = source
|
|
25
36
|
end
|
|
26
37
|
|
|
@@ -29,11 +40,42 @@ module RubynCode
|
|
|
29
40
|
def hidden? = false
|
|
30
41
|
def all_names = [command_name].freeze
|
|
31
42
|
|
|
43
|
+
# Render the help label, including [hint] when frontmatter provides one.
|
|
44
|
+
# The argument_hint is expected to be the placeholder text already wrapped
|
|
45
|
+
# in [ ] (e.g. "[env]") — we don't add another set of brackets.
|
|
46
|
+
def help_label
|
|
47
|
+
hint = @argument_hint.to_s.strip
|
|
48
|
+
hint.empty? ? description : "#{description} #{hint}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] true if the frontmatter restricted the tool set
|
|
52
|
+
def restricts_tools?
|
|
53
|
+
!@allowed_tools.nil? && !@allowed_tools.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Boolean] true if the frontmatter overrode the model
|
|
57
|
+
def overrides_model?
|
|
58
|
+
!@model.to_s.strip.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
32
61
|
# @param args [Array<String>]
|
|
33
62
|
# @param ctx [Commands::Context]
|
|
34
63
|
# @return [nil]
|
|
35
64
|
def execute(args, ctx)
|
|
36
|
-
|
|
65
|
+
rendered = @template.render(args)
|
|
66
|
+
if restricts_tools?
|
|
67
|
+
ctx.with_allowed_tools(@allowed_tools) do
|
|
68
|
+
ctx.with_optional_model(@model) do
|
|
69
|
+
ctx.send_message(rendered)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
elsif overrides_model?
|
|
73
|
+
ctx.with_optional_model(@model) do
|
|
74
|
+
ctx.send_message(rendered)
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
ctx.send_message(rendered)
|
|
78
|
+
end
|
|
37
79
|
nil
|
|
38
80
|
end
|
|
39
81
|
end
|
|
@@ -40,9 +40,18 @@ module RubynCode
|
|
|
40
40
|
name = File.basename(path, '.md').strip
|
|
41
41
|
return nil unless name.match?(NAME)
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
description =
|
|
45
|
-
|
|
43
|
+
meta, body = parse(File.read(path))
|
|
44
|
+
description = meta['description'].to_s
|
|
45
|
+
description = "Custom command: /#{name}" if description.strip.empty?
|
|
46
|
+
CustomCommand.new(
|
|
47
|
+
name: name,
|
|
48
|
+
description: description,
|
|
49
|
+
body: body,
|
|
50
|
+
source: path,
|
|
51
|
+
argument_hint: blank_to_nil(stringify(meta['argument_hint'] || meta['argument-hint'])),
|
|
52
|
+
allowed_tools: list_of_tools(meta['allowed_tools'] || meta['allowed-tools']),
|
|
53
|
+
model: blank_to_nil(stringify(meta['model']))
|
|
54
|
+
)
|
|
46
55
|
rescue StandardError => e
|
|
47
56
|
RubynCode::Debug.warn("Failed to load custom command #{path}: #{e.message}")
|
|
48
57
|
nil
|
|
@@ -51,13 +60,35 @@ module RubynCode
|
|
|
51
60
|
def parse(content)
|
|
52
61
|
if (match = FRONTMATTER.match(content))
|
|
53
62
|
frontmatter = YAML.safe_load(match[1]) || {}
|
|
54
|
-
[frontmatter
|
|
63
|
+
[frontmatter, match[2].to_s.strip]
|
|
55
64
|
else
|
|
56
65
|
body = content.to_s.strip
|
|
57
|
-
[first_line(body), body]
|
|
66
|
+
[{ 'description' => first_line(body) }, body]
|
|
58
67
|
end
|
|
59
68
|
end
|
|
60
69
|
|
|
70
|
+
def blank_to_nil(value)
|
|
71
|
+
value.to_s.strip.empty? ? nil : value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stringify(value)
|
|
75
|
+
return '' if value.nil?
|
|
76
|
+
|
|
77
|
+
# YAML's flow-style `[env]` is parsed as a single-element array.
|
|
78
|
+
# Stringify the array element directly so users see exactly the
|
|
79
|
+
# characters they typed.
|
|
80
|
+
str = value.is_a?(Array) ? value.join : value
|
|
81
|
+
str.to_s.strip
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def list_of_tools(value)
|
|
85
|
+
return nil if value.nil?
|
|
86
|
+
|
|
87
|
+
arr = value.is_a?(Array) ? value : value.to_s.split(',')
|
|
88
|
+
cleaned = arr.map { |name| name.to_s.strip }.reject(&:empty?)
|
|
89
|
+
cleaned.empty? ? nil : cleaned
|
|
90
|
+
end
|
|
91
|
+
|
|
61
92
|
def first_line(body)
|
|
62
93
|
line = body.lines.first.to_s.strip
|
|
63
94
|
line.sub(/\A#+\s*/, '')[0, 80]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Set or clear the reasoning effort level (output_config.effort).
|
|
7
|
+
#
|
|
8
|
+
# /effort # show current value
|
|
9
|
+
# /effort off # clear back to model default
|
|
10
|
+
# /effort <level> # low | medium | high | xhigh | max
|
|
11
|
+
class Effort < Base
|
|
12
|
+
LEVELS = %w[low medium high xhigh max].freeze
|
|
13
|
+
|
|
14
|
+
def self.command_name = '/effort'
|
|
15
|
+
def self.description = 'Set reasoning effort (low/medium/high/xhigh/max)'
|
|
16
|
+
|
|
17
|
+
def execute(args, ctx)
|
|
18
|
+
arg = args.first
|
|
19
|
+
|
|
20
|
+
current = ctx.llm_client.respond_to?(:effort) ? ctx.llm_client.effort : nil
|
|
21
|
+
|
|
22
|
+
case arg
|
|
23
|
+
when nil
|
|
24
|
+
show_status(current, ctx)
|
|
25
|
+
when 'off'
|
|
26
|
+
ctx.llm_client.effort = nil
|
|
27
|
+
ctx.renderer.info('Reasoning effort cleared — using model default 🎚️')
|
|
28
|
+
when *LEVELS
|
|
29
|
+
ctx.llm_client.effort = arg
|
|
30
|
+
ctx.renderer.info("Reasoning effort set to #{arg} 🎚️")
|
|
31
|
+
else
|
|
32
|
+
ctx.renderer.warning("Usage: /effort [off|#{LEVELS.join('|')}]. Got: #{arg}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def show_status(current, ctx)
|
|
39
|
+
state = current || 'not set (model default: high on 4.6+)'
|
|
40
|
+
ctx.renderer.info("Reasoning effort: #{state}")
|
|
41
|
+
ctx.renderer.info("Usage: /effort <level> levels: #{LEVELS.join(', ')} (or /effort off)")
|
|
42
|
+
ctx.renderer.info('Supported on Claude 4.6+ models; xhigh requires Opus 4.7+ / Sonnet 5 / Fable 5.')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|