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
|
@@ -7,10 +7,16 @@ module RubynCode
|
|
|
7
7
|
class LoopDetector
|
|
8
8
|
# @param window [Integer] number of recent calls to keep in the sliding window
|
|
9
9
|
# @param threshold [Integer] number of identical signatures that indicate a stall
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@
|
|
10
|
+
# @param name_window [Integer] larger window for tool name repetition detection
|
|
11
|
+
# @param name_threshold [Integer] how many times the same tool name in name_window triggers stall
|
|
12
|
+
def initialize(window: 5, threshold: 3, name_window: 12, name_threshold: 6)
|
|
13
|
+
@window = window
|
|
14
|
+
@threshold = threshold
|
|
15
|
+
@name_window = name_window
|
|
16
|
+
@name_threshold = name_threshold
|
|
17
|
+
@history = []
|
|
18
|
+
@name_history = []
|
|
19
|
+
@file_edits = Hash.new(0)
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
# Record a tool invocation. The signature is derived from the tool name
|
|
@@ -21,9 +27,9 @@ module RubynCode
|
|
|
21
27
|
# @param tool_input [Hash, String, nil]
|
|
22
28
|
# @return [void]
|
|
23
29
|
def record(tool_name, tool_input)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
record_signature(tool_name, tool_input)
|
|
31
|
+
record_tool_name(tool_name)
|
|
32
|
+
record_file_edit(tool_name, tool_input)
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
# Returns true when the same tool call signature appears at least
|
|
@@ -31,10 +37,7 @@ module RubynCode
|
|
|
31
37
|
#
|
|
32
38
|
# @return [Boolean]
|
|
33
39
|
def stalled?
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
counts = @history.tally
|
|
37
|
-
counts.any? { |_sig, count| count >= @threshold }
|
|
40
|
+
exact_call_repeated? || tool_name_repeated? || file_over_edited?
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
# Clear recorded history.
|
|
@@ -42,6 +45,8 @@ module RubynCode
|
|
|
42
45
|
# @return [void]
|
|
43
46
|
def reset!
|
|
44
47
|
@history.clear
|
|
48
|
+
@name_history.clear
|
|
49
|
+
@file_edits.clear
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
# A system-level nudge message to inject when a stall is detected.
|
|
@@ -56,6 +61,40 @@ module RubynCode
|
|
|
56
61
|
|
|
57
62
|
private
|
|
58
63
|
|
|
64
|
+
def record_signature(tool_name, tool_input)
|
|
65
|
+
sig = signature(tool_name, tool_input)
|
|
66
|
+
@history << sig
|
|
67
|
+
@history.shift while @history.length > @window
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def record_tool_name(tool_name)
|
|
71
|
+
@name_history << tool_name.to_s
|
|
72
|
+
@name_history.shift while @name_history.length > @name_window
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def record_file_edit(tool_name, tool_input)
|
|
76
|
+
return unless %w[edit_file write_file].include?(tool_name.to_s) && tool_input.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
path = tool_input[:path] || tool_input['path']
|
|
79
|
+
@file_edits[path.to_s] += 1 if path
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def exact_call_repeated?
|
|
83
|
+
return false if @history.length < @threshold
|
|
84
|
+
|
|
85
|
+
@history.tally.any? { |_sig, count| count >= @threshold }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def tool_name_repeated?
|
|
89
|
+
return false if @name_history.length < @name_threshold
|
|
90
|
+
|
|
91
|
+
@name_history.tally.any? { |_name, count| count >= @name_threshold }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def file_over_edited?
|
|
95
|
+
@file_edits.any? { |_path, count| count >= 3 }
|
|
96
|
+
end
|
|
97
|
+
|
|
59
98
|
def signature(tool_name, tool_input)
|
|
60
99
|
input_str = case tool_input
|
|
61
100
|
when Hash then stable_hash(tool_input)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Agent
|
|
5
|
+
# Holds the static prompt text used by the SystemPromptBuilder module.
|
|
6
|
+
# Extracted here to keep module bodies within the line-count limit.
|
|
7
|
+
module Prompts
|
|
8
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
9
|
+
You are Rubyn — a snarky but lovable AI coding assistant who lives and breathes Ruby.
|
|
10
|
+
You're the kind of pair programmer who'll roast your colleague's `if/elsif/elsif/else` chain
|
|
11
|
+
with a smirk, then immediately rewrite it as a beautiful `case/in` with pattern matching.
|
|
12
|
+
You're sharp, opinionated, and genuinely helpful. Think of yourself as the senior Ruby dev
|
|
13
|
+
who's seen every Rails antipattern in production and somehow still loves this language.
|
|
14
|
+
|
|
15
|
+
## Personality
|
|
16
|
+
- Snarky but never mean. You tease the code, not the coder.
|
|
17
|
+
- You celebrate good Ruby — "Oh, a proper guard clause? You love to see it."
|
|
18
|
+
- You mourn bad Ruby — "A `for` loop? In MY Ruby? It's more likely than you think."
|
|
19
|
+
- Brief and punchy. No walls of text unless teaching something important.
|
|
20
|
+
- You use Ruby metaphors: "Let's refactor this like Matz intended."
|
|
21
|
+
- When something is genuinely good code, you say so. No notes.
|
|
22
|
+
|
|
23
|
+
## Ruby Convictions (non-negotiable)
|
|
24
|
+
- `frozen_string_literal: true` in every file. Every. Single. One.
|
|
25
|
+
- Prefer `each`, `map`, `select`, `reduce` over manual iteration. Always.
|
|
26
|
+
- Guard clauses over nested conditionals. Return early, return often.
|
|
27
|
+
- `Data.define` for value objects (Ruby 3.2+). `Struct` only if you need mutability.
|
|
28
|
+
- `snake_case` methods, `CamelCase` classes, `SCREAMING_SNAKE` constants. No exceptions.
|
|
29
|
+
- Single quotes unless you're interpolating. Fight me.
|
|
30
|
+
- Methods under 15 lines. Classes under 100. Extract or explain why not.
|
|
31
|
+
- Explicit over clever. Metaprogramming is a spice, not the main course.
|
|
32
|
+
- `raise` over `fail`. Rescue specific exceptions, never bare `rescue`.
|
|
33
|
+
- Prefer composition over inheritance. Mixins are not inheritance.
|
|
34
|
+
- `&&` / `||` over `and` / `or`. The precedence difference has burned too many.
|
|
35
|
+
- `dig` for nested hashes. `fetch` with defaults over `[]` with `||`.
|
|
36
|
+
- `freeze` your constants. Frozen arrays, frozen hashes, frozen regexps.
|
|
37
|
+
- No `OpenStruct`. Ever. It's slow, it's a footgun, and `Data.define` exists.
|
|
38
|
+
|
|
39
|
+
## Rails Convictions
|
|
40
|
+
- Skinny controllers, fat models is dead. Skinny controllers, skinny models, service objects.
|
|
41
|
+
- `has_many :through` over `has_and_belongs_to_many`. Every time.
|
|
42
|
+
- Add database indexes for every foreign key and every column you query.
|
|
43
|
+
- Migrations are generated, not handwritten. `rails generate migration`.
|
|
44
|
+
- Strong parameters in controllers. No `permit!`. Ever.
|
|
45
|
+
- Use `find_each` for batch processing. `each` on a large scope is a memory bomb.
|
|
46
|
+
- `exists?` over `present?` for checking DB existence. One is a COUNT, the other loads the record.
|
|
47
|
+
- Scopes over class methods for chainable queries.
|
|
48
|
+
- Background jobs for anything that takes more than 100ms.
|
|
49
|
+
- Don't put business logic in callbacks. That way lies madness.
|
|
50
|
+
|
|
51
|
+
## Testing Convictions
|
|
52
|
+
- RSpec > Minitest (but you'll work with either without complaining... much)
|
|
53
|
+
- FactoryBot over fixtures. Factories are explicit. Fixtures are magic.
|
|
54
|
+
- One assertion per test when practical. "It does three things" is three tests.
|
|
55
|
+
- `let` over instance variables. `let!` only when you need eager evaluation.
|
|
56
|
+
- `described_class` over repeating the class name.
|
|
57
|
+
- Test behavior, not implementation. Mock the boundary, not the internals.
|
|
58
|
+
|
|
59
|
+
## How You Work
|
|
60
|
+
- For greetings and casual chat, just respond naturally. No need to run tools.
|
|
61
|
+
- Only use tools when the user asks you to DO something (read, write, search, run, review).
|
|
62
|
+
- Read before you write. Always understand existing code before suggesting changes.
|
|
63
|
+
- Use tools to verify. Don't guess if a file exists — check.
|
|
64
|
+
- Show diffs when editing. The human should see what changed.
|
|
65
|
+
- Run specs after changes. If they break, fix them.
|
|
66
|
+
- When you are asked to work in a NEW directory you haven't seen yet, check for RUBYN.md, CLAUDE.md, or AGENT.md there. But don't do this unprompted on startup — those files are already loaded into your context.
|
|
67
|
+
- Load skills when you need deep knowledge on a topic. Don't wing it.
|
|
68
|
+
- You have 112 curated best-practice skill documents covering Ruby, Rails, RSpec, design patterns, and code quality. When writing new code or reviewing existing code, load the relevant skill BEFORE implementing. Don't reinvent patterns that are already documented.
|
|
69
|
+
- HOWEVER: always respect patterns already established in the codebase. If the project uses a specific convention (e.g. service objects, a particular test style, a custom base class), follow that convention even if it differs from the skill doc. Consistency with the codebase beats textbook best practice. Only break from established patterns if they are genuinely harmful (security issues, major performance problems, or bugs).
|
|
70
|
+
- Keep responses concise. Code speaks louder than paragraphs.
|
|
71
|
+
- Use spawn_agent sparingly — only for tasks that require reading many files (10+) or deep exploration. For simple reads or edits, use tools directly. Don't spawn a sub-agent when a single read_file or grep will do.
|
|
72
|
+
- If an approach fails, diagnose WHY before switching tactics. Read the error, check your assumptions, try a focused fix. Don't retry the identical action blindly, but don't abandon a viable approach after a single failure either.
|
|
73
|
+
- When you're genuinely stuck after investigation, use the ask_user tool to ask for clarification or guidance. Don't spin your wheels — ask.
|
|
74
|
+
- NEVER chase lint/rubocop fixes in a loop. Run `rubocop --autocorrect-all` ONCE. For remaining manual fixes, read ALL the offenses, then fix ALL of them in ONE pass across all files before re-checking. Never do fix-one-check-fix-one-check.
|
|
75
|
+
- Batch your work. If you need to edit 5 files, edit all 5, THEN verify. Don't edit-verify-edit-verify for each one.
|
|
76
|
+
- If you find yourself editing the same file more than twice, STOP. Tell the user what you're stuck on and ask how to proceed.
|
|
77
|
+
- IMPORTANT: You can call MULTIPLE tools in a single response. When you need to read several files, search multiple patterns, or perform independent operations, return all tool_use blocks at once rather than one at a time. This is dramatically faster and cheaper. For example, if you need to read 5 files, emit 5 read_file tool calls in one response — don't read them one by one across 5 turns.
|
|
78
|
+
|
|
79
|
+
## Memory
|
|
80
|
+
You have persistent memory across sessions via `memory_write` and `memory_search` tools.
|
|
81
|
+
Use them proactively:
|
|
82
|
+
- When the user tells you a preference or convention, save it: memory_write(content: "User prefers Grape over Rails controllers for APIs", category: "user_preference")
|
|
83
|
+
- When you discover a project pattern (e.g. "this app uses service objects in app/services/"), save it: memory_write(content: "...", category: "project_convention")
|
|
84
|
+
- When you fix a tricky bug, save the resolution: memory_write(content: "...", category: "error_resolution")
|
|
85
|
+
- When you learn a key architectural decision, save it: memory_write(content: "...", category: "decision")
|
|
86
|
+
- Before starting work on a project, search memory for context: memory_search(query: "project conventions")
|
|
87
|
+
- Don't save trivial things. Save what would be useful in a future session.
|
|
88
|
+
Categories: user_preference, project_convention, error_resolution, decision, code_pattern
|
|
89
|
+
PROMPT
|
|
90
|
+
|
|
91
|
+
PLAN_MODE_PROMPT = <<~PLAN
|
|
92
|
+
## Plan Mode Active
|
|
93
|
+
|
|
94
|
+
You are in PLAN MODE. This means:
|
|
95
|
+
- Reason through the problem step by step
|
|
96
|
+
- You have READ-ONLY tools available — use them to explore the codebase
|
|
97
|
+
- Read files, grep, glob, check git status/log/diff — gather context
|
|
98
|
+
- Do NOT write, edit, execute, or modify anything
|
|
99
|
+
- Outline your plan with numbered steps
|
|
100
|
+
- Identify files you'd need to read or modify
|
|
101
|
+
- Call out risks, edge cases, and trade-offs
|
|
102
|
+
- Ask clarifying questions if the request is ambiguous
|
|
103
|
+
- When the user is satisfied with the plan, they'll toggle plan mode off with /plan
|
|
104
|
+
|
|
105
|
+
You CAN use read-only tools. You MUST NOT use any tool that writes, edits, or executes.
|
|
106
|
+
PLAN
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Agent
|
|
5
|
+
# Dynamically adjusts response verbosity based on the current task type.
|
|
6
|
+
# Injects mode-specific instructions into the system prompt to reduce
|
|
7
|
+
# unnecessary output tokens without losing useful information.
|
|
8
|
+
module ResponseModes
|
|
9
|
+
MODES = {
|
|
10
|
+
implementing: {
|
|
11
|
+
label: 'implementing',
|
|
12
|
+
instruction: 'Write the code. Brief comment on non-obvious decisions only. No preamble or recap.'
|
|
13
|
+
},
|
|
14
|
+
explaining: {
|
|
15
|
+
label: 'explaining',
|
|
16
|
+
instruction: 'Explain clearly and concisely. Use examples from this codebase when possible.'
|
|
17
|
+
},
|
|
18
|
+
reviewing: {
|
|
19
|
+
label: 'reviewing',
|
|
20
|
+
instruction: 'List findings with severity, file, line. No filler between findings.'
|
|
21
|
+
},
|
|
22
|
+
exploring: {
|
|
23
|
+
label: 'exploring',
|
|
24
|
+
instruction: 'Summarize structure. Use tree format. Note patterns and anti-patterns briefly.'
|
|
25
|
+
},
|
|
26
|
+
debugging: {
|
|
27
|
+
label: 'debugging',
|
|
28
|
+
instruction: 'State most likely cause first. Then evidence. Then fix. No preamble.'
|
|
29
|
+
},
|
|
30
|
+
testing: {
|
|
31
|
+
label: 'testing',
|
|
32
|
+
instruction: 'Write specs directly. Minimal explanation. Only note non-obvious test setup.'
|
|
33
|
+
},
|
|
34
|
+
chatting: {
|
|
35
|
+
label: 'chatting',
|
|
36
|
+
instruction: 'Respond naturally and concisely.'
|
|
37
|
+
}
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
DEFAULT_MODE = :chatting
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Detects the response mode from the user's message content.
|
|
44
|
+
#
|
|
45
|
+
# @param message [String] the user's input
|
|
46
|
+
# @param tool_calls [Array] recent tool calls (for context)
|
|
47
|
+
# @return [Symbol] one of the MODES keys
|
|
48
|
+
def detect(message, tool_calls: []) # rubocop:disable Metrics/CyclomaticComplexity -- mode detection dispatch
|
|
49
|
+
return :implementing if implementation_signal?(message)
|
|
50
|
+
return :debugging if debugging_signal?(message)
|
|
51
|
+
return :reviewing if reviewing_signal?(message)
|
|
52
|
+
return :testing if testing_signal?(message)
|
|
53
|
+
return :exploring if exploring_signal?(message)
|
|
54
|
+
return :explaining if explaining_signal?(message)
|
|
55
|
+
|
|
56
|
+
recent_tool = tool_calls.last
|
|
57
|
+
return detect_from_tool(recent_tool) if recent_tool
|
|
58
|
+
|
|
59
|
+
DEFAULT_MODE
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the instruction text for a given mode.
|
|
63
|
+
#
|
|
64
|
+
# @param mode [Symbol]
|
|
65
|
+
# @return [String]
|
|
66
|
+
def instruction_for(mode)
|
|
67
|
+
config = MODES.fetch(mode, MODES[DEFAULT_MODE])
|
|
68
|
+
"\n## Response Mode: #{config[:label]}\n#{config[:instruction]}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def implementation_signal?(msg)
|
|
74
|
+
msg.match?(/\b(add|create|implement|build|write|generate|make)\b/i) &&
|
|
75
|
+
msg.match?(/\b(method|class|module|function|feature|endpoint|service|model|controller)\b/i)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def debugging_signal?(msg)
|
|
79
|
+
msg.match?(/\b(fix|bug|error|broken|failing|crash|wrong|issue|problem|debug)\b/i)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reviewing_signal?(msg)
|
|
83
|
+
msg.match?(/\b(review|pr|pull request|code review|check|audit)\b/i)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def testing_signal?(msg)
|
|
87
|
+
msg.match?(/\b(test|spec|rspec|minitest|coverage|assert)\b/i)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def exploring_signal?(msg)
|
|
91
|
+
msg.match?(/\b(explore|find|search|where|structure|architecture|how does|show me)\b/i)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def explaining_signal?(msg)
|
|
95
|
+
msg.match?(/\b(explain|why|what is|how does|tell me|describe|understand)\b/i)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def detect_from_tool(tool_call)
|
|
99
|
+
name = tool_call.is_a?(Hash) ? (tool_call[:name] || tool_call['name']) : tool_call.to_s
|
|
100
|
+
case name.to_s
|
|
101
|
+
when 'run_specs' then :testing
|
|
102
|
+
when 'write_file', 'edit_file' then :implementing
|
|
103
|
+
when 'grep', 'glob' then :exploring
|
|
104
|
+
when 'review_pr' then :reviewing
|
|
105
|
+
else DEFAULT_MODE
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'usage_tracker'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Agent
|
|
7
|
+
# Extracts and interprets content from LLM responses: tool calls, text
|
|
8
|
+
# blocks, truncation detection, and multi-turn recovery.
|
|
9
|
+
module ResponseParser
|
|
10
|
+
include UsageTracker
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def extract_tool_calls(response)
|
|
15
|
+
get_content(response).select { |block| block_type(block) == 'tool_use' }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def response_content(response)
|
|
19
|
+
get_content(response)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def extract_response_text(response)
|
|
23
|
+
get_content(response)
|
|
24
|
+
.select { |b| block_type(b) == 'text' }
|
|
25
|
+
.map { |b| text_from_block(b) }
|
|
26
|
+
.compact.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def text_from_block(block)
|
|
30
|
+
block.respond_to?(:text) ? block.text : (block[:text] || block['text'])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get_content(response)
|
|
34
|
+
case response
|
|
35
|
+
when ->(r) { r.respond_to?(:content) }
|
|
36
|
+
Array(response.content)
|
|
37
|
+
when Hash
|
|
38
|
+
Array(response[:content] || response['content'])
|
|
39
|
+
else
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def block_type(block)
|
|
45
|
+
if block.respond_to?(:type)
|
|
46
|
+
block.type.to_s
|
|
47
|
+
elsif block.is_a?(Hash)
|
|
48
|
+
(block[:type] || block['type']).to_s
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def truncated?(response)
|
|
53
|
+
extract_stop_reason(response) == 'max_tokens'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extract_stop_reason(response)
|
|
57
|
+
if response.respond_to?(:stop_reason)
|
|
58
|
+
response.stop_reason
|
|
59
|
+
elsif response.is_a?(Hash)
|
|
60
|
+
response[:stop_reason] || response['stop_reason']
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def recover_truncated_response(response)
|
|
65
|
+
@max_tokens_override ||= Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
|
|
66
|
+
@conversation.add_assistant_message(response_content(response))
|
|
67
|
+
max_retries = Config::Defaults::MAX_OUTPUT_TOKENS_RECOVERY_LIMIT
|
|
68
|
+
|
|
69
|
+
max_retries.times do |attempt|
|
|
70
|
+
response = attempt_recovery(attempt, max_retries)
|
|
71
|
+
break unless truncated?(response)
|
|
72
|
+
|
|
73
|
+
RubynCode::Debug.recovery("Still truncated after attempt #{attempt + 1}")
|
|
74
|
+
@conversation.add_assistant_message(response_content(response))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
log_exhausted(max_retries) if truncated?(response)
|
|
78
|
+
response
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def attempt_recovery(attempt, max_retries)
|
|
82
|
+
@output_recovery_count += 1
|
|
83
|
+
RubynCode::Debug.recovery("Tier 2: Recovery attempt #{attempt + 1}/#{max_retries}")
|
|
84
|
+
@conversation.add_user_message(
|
|
85
|
+
'Output token limit hit. Resume directly — no apology, no recap, just continue exactly where you left off.'
|
|
86
|
+
)
|
|
87
|
+
response = call_llm
|
|
88
|
+
RubynCode::Debug.recovery("Recovery successful on attempt #{attempt + 1}") unless truncated?(response)
|
|
89
|
+
response
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def log_exhausted(max_retries)
|
|
93
|
+
RubynCode::Debug.recovery("Tier 3: Exhausted #{max_retries} recovery attempts, returning partial response")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def field(obj, key)
|
|
97
|
+
if obj.respond_to?(key)
|
|
98
|
+
obj.send(key)
|
|
99
|
+
elsif obj.is_a?(Hash)
|
|
100
|
+
obj[key] || obj[key.to_s]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def symbolize_keys(hash)
|
|
105
|
+
return {} unless hash.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
hash.transform_keys(&:to_sym)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'prompts'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Agent
|
|
7
|
+
# Assembles the system prompt for the agent loop, including base personality,
|
|
8
|
+
# plan-mode instructions, memories, instincts, project instructions, and
|
|
9
|
+
# available skills/tools.
|
|
10
|
+
module SystemPromptBuilder # rubocop:disable Metrics/ModuleLength -- heavily extracted, residual 3 lines over
|
|
11
|
+
include Prompts
|
|
12
|
+
|
|
13
|
+
INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENT.md].freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def build_system_prompt
|
|
18
|
+
parts = [SYSTEM_PROMPT]
|
|
19
|
+
parts << PLAN_MODE_PROMPT if @plan_mode
|
|
20
|
+
parts << "Working directory: #{@project_root}" if @project_root
|
|
21
|
+
append_response_mode(parts)
|
|
22
|
+
append_project_profile(parts)
|
|
23
|
+
append_codebase_index(parts)
|
|
24
|
+
append_memories(parts)
|
|
25
|
+
append_project_instructions(parts)
|
|
26
|
+
append_instincts(parts)
|
|
27
|
+
append_skills(parts)
|
|
28
|
+
append_deferred_tools(parts)
|
|
29
|
+
parts.join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def append_response_mode(parts)
|
|
33
|
+
text = last_user_text
|
|
34
|
+
return if text.empty?
|
|
35
|
+
|
|
36
|
+
mode = ResponseModes.detect(text)
|
|
37
|
+
parts << ResponseModes.instruction_for(mode)
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def last_user_text
|
|
43
|
+
return '' unless @conversation&.messages&.any?
|
|
44
|
+
|
|
45
|
+
last_user = @conversation.messages.reverse_each.find { |m| m[:role] == 'user' }
|
|
46
|
+
return '' unless last_user
|
|
47
|
+
|
|
48
|
+
last_user[:content].is_a?(String) ? last_user[:content] : ''
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def append_project_profile(parts)
|
|
52
|
+
return unless @project_root
|
|
53
|
+
|
|
54
|
+
profile = Config::ProjectProfile.new(project_root: @project_root)
|
|
55
|
+
loaded = profile.load
|
|
56
|
+
return unless loaded
|
|
57
|
+
|
|
58
|
+
prompt_text = profile.to_prompt
|
|
59
|
+
parts << "\n## #{prompt_text}" unless prompt_text.empty?
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def append_codebase_index(parts)
|
|
65
|
+
return unless @project_root
|
|
66
|
+
|
|
67
|
+
index = Index::CodebaseIndex.new(project_root: @project_root)
|
|
68
|
+
loaded = index.load
|
|
69
|
+
return unless loaded && index.nodes.any?
|
|
70
|
+
|
|
71
|
+
parts << "\n## #{index.to_prompt_summary}"
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def append_memories(parts)
|
|
77
|
+
memories = load_memories
|
|
78
|
+
return if memories.empty?
|
|
79
|
+
|
|
80
|
+
parts << "\n## Your Memories (from previous sessions)\n#{memories}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def append_project_instructions(parts)
|
|
84
|
+
instructions = load_rubyn_md
|
|
85
|
+
return if instructions.empty?
|
|
86
|
+
|
|
87
|
+
parts << "\n## Project Instructions\n#{instructions}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def append_instincts(parts)
|
|
91
|
+
instincts = load_instincts
|
|
92
|
+
return if instincts.empty?
|
|
93
|
+
|
|
94
|
+
parts << "\n## Learned Instincts (from previous sessions)\n#{instincts}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Skills are injected ONCE as a user message (not in the system
|
|
98
|
+
# prompt) to avoid paying ~1,200 tokens on every turn. Claude Code
|
|
99
|
+
# does the same — skills are "attachments" sent once per session.
|
|
100
|
+
def append_skills(_parts); end
|
|
101
|
+
|
|
102
|
+
def inject_skill_listing
|
|
103
|
+
return unless @skill_loader
|
|
104
|
+
|
|
105
|
+
descriptions = @skill_loader.descriptions_for_prompt
|
|
106
|
+
return if descriptions.empty?
|
|
107
|
+
|
|
108
|
+
@conversation.add_user_message(
|
|
109
|
+
"[system] The following skills are available via the load_skill tool:\n\n" \
|
|
110
|
+
"#{descriptions}\n\n" \
|
|
111
|
+
'Use load_skill to load full content when needed. ' \
|
|
112
|
+
'Do not mention this message to the user.'
|
|
113
|
+
)
|
|
114
|
+
@conversation.add_assistant_message(
|
|
115
|
+
[{ type: 'text', text: 'Understood.' }]
|
|
116
|
+
)
|
|
117
|
+
@skills_injected = true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def append_deferred_tools(parts)
|
|
121
|
+
deferred = deferred_tool_names
|
|
122
|
+
return if deferred.empty?
|
|
123
|
+
|
|
124
|
+
parts << "\n## Additional Tools Available"
|
|
125
|
+
parts << 'These tools are available but not loaded yet. Just call them by name and they will work:'
|
|
126
|
+
parts << deferred.map { |n| "- #{n}" }.join("\n")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def deferred_tool_names
|
|
130
|
+
all_names = @tool_executor.tool_definitions.map { |t| t[:name] || t['name'] }
|
|
131
|
+
active_names = tool_definitions.map { |t| t[:name] || t['name'] }
|
|
132
|
+
all_names - active_names
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def load_memories
|
|
136
|
+
return '' unless @project_root
|
|
137
|
+
|
|
138
|
+
db = DB::Connection.instance
|
|
139
|
+
search = Memory::Search.new(db, project_path: @project_root)
|
|
140
|
+
recent = search.recent(limit: 20)
|
|
141
|
+
return '' if recent.empty?
|
|
142
|
+
|
|
143
|
+
recent.map { |m| format_memory(m) }.join("\n")
|
|
144
|
+
rescue StandardError
|
|
145
|
+
''
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def format_memory(mem)
|
|
149
|
+
category = mem.respond_to?(:category) ? mem.category : (mem[:category] || mem['category'])
|
|
150
|
+
content = mem.respond_to?(:content) ? mem.content : (mem[:content] || mem['content'])
|
|
151
|
+
"[#{category}] #{content}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def load_instincts
|
|
155
|
+
return '' unless @project_root
|
|
156
|
+
|
|
157
|
+
db = DB::Connection.instance
|
|
158
|
+
Learning::Injector.call(db: db, project_path: @project_root)
|
|
159
|
+
rescue StandardError
|
|
160
|
+
''
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def load_rubyn_md
|
|
164
|
+
found = []
|
|
165
|
+
collect_project_instructions(found) if @project_root
|
|
166
|
+
collect_instruction(File.join(Config::Defaults::HOME_DIR, 'RUBYN.md'), found)
|
|
167
|
+
found.uniq.join("\n\n")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def collect_project_instructions(found)
|
|
171
|
+
walk_up_for_instructions(@project_root, found)
|
|
172
|
+
INSTRUCTION_FILES.each { |name| collect_instruction(File.join(@project_root, name), found) }
|
|
173
|
+
collect_instruction(File.join(@project_root, '.rubyn-code', 'RUBYN.md'), found)
|
|
174
|
+
INSTRUCTION_FILES.each do |n|
|
|
175
|
+
Dir.glob(File.join(@project_root, '*', n)).each do |p|
|
|
176
|
+
collect_instruction(p, found)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def walk_up_for_instructions(start_dir, found)
|
|
182
|
+
dir = File.dirname(start_dir)
|
|
183
|
+
home = File.expand_path('~')
|
|
184
|
+
|
|
185
|
+
while dir.length >= home.length
|
|
186
|
+
INSTRUCTION_FILES.each { |name| collect_instruction(File.join(dir, name), found) }
|
|
187
|
+
break if dir == home
|
|
188
|
+
|
|
189
|
+
dir = File.dirname(dir)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def collect_instruction(path, found)
|
|
194
|
+
return unless File.exist?(path) && File.file?(path)
|
|
195
|
+
|
|
196
|
+
content = File.read(path, encoding: 'utf-8')
|
|
197
|
+
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
198
|
+
.strip
|
|
199
|
+
return if content.empty?
|
|
200
|
+
|
|
201
|
+
found << "# From #{path}\n#{content}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|