rubyn-code 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -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
- def initialize(window: 5, threshold: 3)
11
- @window = window
12
- @threshold = threshold
13
- @history = []
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
- sig = signature(tool_name, tool_input)
25
- @history << sig
26
- @history.shift while @history.length > @window
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
- return false if @history.length < @threshold
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