rails_console_ai 0.25.0 → 0.26.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89e0f9e402a40af4755fe61d2029f10a90c487c1d957e007032dcdd1f54a1c82
4
- data.tar.gz: 7e68825010aed2bd0f7a07cdcd68a39d54edec2616913ab14c9a10aa972e6d4f
3
+ metadata.gz: a2194416a93ce8522de376169eb627b90c8b2477ba253f0f1b877144251f9ee6
4
+ data.tar.gz: 65dcbeb6eef9dd2641181529aa671058e85a6cd1c5263acd51678c52c89d3567
5
5
  SHA512:
6
- metadata.gz: f0efcfa92b22f8cd4df14a643904aba9c0e3c05ac7873de6a303f72fd6cdd4df5c457cf1fdcafb120a02784c7247663e48141500f69214ac5bbfafa945ec9ce0
7
- data.tar.gz: 2aed178645529500d0b1d72772e19307d4b42de2a0db586f928a46df2a6503d78f3693cde4222006ad0e4227ea840b4bd510a6ef5631e9395466cf9035db679a
6
+ metadata.gz: e4d5a1f2fe8ef6ae4593829ac1a8997eeeac652ea0d0a491f91a64b8e45a246d024a10aa269e853949e0d106779d4f1b9d671af21057bd6af802ff6a541ac15d
7
+ data.tar.gz: ec0e0d25f7bb2605a7c4180ebc56ab56d8f590d3632c98310ec65c3aecd987d04118e7199534a8ea5a1a8023a505de9ac467ce05d01393df667c2436bcebc68c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.26.0]
6
+
7
+ - Add sub-agent support
8
+ - Add integration tests
9
+ - Increase max conversation rounds
10
+ - Fix sub-agent model resolution
11
+ - Improve plan step failure handling
12
+
5
13
  ## [0.25.0]
6
14
 
7
15
  - Expand truncation limits
data/README.md CHANGED
@@ -105,8 +105,69 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
105
105
  - **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
106
106
  - **Output trimming** — older execution outputs are automatically replaced with references; the LLM can recall them on demand via `recall_output`, and you can `/expand <id>` to see them
107
107
  - **Debug mode** — `/debug` shows context breakdown, token counts, and per-call cost estimates before and after each LLM call
108
+ - **Sub-agents** — delegate multi-step investigations to a separate LLM context that returns only a concise summary, keeping the main conversation lean
108
109
  - **Safe mode** — configurable guards that block side effects (DB writes, HTTP mutations, email delivery) during AI code execution
109
110
 
111
+ ## Sub-Agents
112
+
113
+ Sub-agents solve the context bloat problem. When the AI needs to investigate something (find a user's shard, explore model relationships, search code), those intermediate tool calls can inflate the main conversation to 90K+ tokens, causing the LLM to cut corners. Sub-agents fork the investigation into a separate LLM conversation and return only a concise summary.
114
+
115
+ The AI decides when to use sub-agents via the `delegate_task` tool. It can target a custom agent by name or use a general-purpose investigation.
116
+
117
+ ### Custom Agents
118
+
119
+ Define agents as markdown files in `.rails_console_ai/agents/`:
120
+
121
+ ```markdown
122
+ ---
123
+ name: Find shard
124
+ description: Given a user ID, determines which database shard they are on
125
+ max_rounds: 5
126
+ tools:
127
+ - execute_code
128
+ - recall_memory
129
+ ---
130
+
131
+ You are a shard finder for a sharded Rails application.
132
+
133
+ Steps:
134
+ 1. Find the user: User.find(id)
135
+ 2. Check user.shard
136
+ 3. Report: "User {username} (ID {id}) is on shard {shard}."
137
+ ```
138
+
139
+ **Frontmatter fields:**
140
+
141
+ | Field | Required | Description |
142
+ |-------|----------|-------------|
143
+ | `name` | yes | Agent name (shown in system prompt, used with `delegate_task`) |
144
+ | `description` | yes | One-line description of what this agent does |
145
+ | `max_rounds` | no | Max tool-use rounds (default: `sub_agent_max_rounds` config, default 15) |
146
+ | `model` | no | Model override (e.g. use Haiku for simple lookups) |
147
+ | `tools` | no | Array of tool names to include (default: all sub-agent tools) |
148
+
149
+ The markdown body becomes additional system prompt instructions for the sub-agent.
150
+
151
+ ### How It Works
152
+
153
+ 1. Agent summaries appear in the AI's system prompt under `## Agents`
154
+ 2. The AI calls `delegate_task(task: "find user 56653's shard", agent: "Find shard")`
155
+ 3. A sub-agent spins up with its own context, tools, and provider
156
+ 4. It runs the investigation (up to `max_rounds` tool calls)
157
+ 5. The main conversation receives only: `"Sub-agent result: User 56653 is on shard 5."`
158
+
159
+ Sub-agents have access to read-only memory tools (`recall_memory`, `recall_memories`), code execution (`execute_code`), and all schema/code introspection tools. They cannot ask the user questions, write memories, or spawn further sub-agents.
160
+
161
+ ### Configuration
162
+
163
+ ```ruby
164
+ RailsConsoleAi.configure do |config|
165
+ config.sub_agent_max_rounds = 15 # default max rounds per sub-agent
166
+ config.sub_agent_model = nil # nil = same model as main conversation
167
+ # config.sub_agent_model = 'claude-haiku-4-5-20251001' # use a cheaper model
168
+ end
169
+ ```
170
+
110
171
  ## Safety Guards
111
172
 
112
173
  Safety guards prevent AI-generated code from causing side effects. When a guard blocks an operation, the user is prompted to re-run with safe mode disabled.
@@ -0,0 +1,126 @@
1
+ require 'yaml'
2
+
3
+ module RailsConsoleAi
4
+ class AgentLoader
5
+ AGENTS_DIR = 'agents'
6
+ BUILTIN_DIR = File.expand_path('../agents', __FILE__)
7
+
8
+ def initialize(storage = nil)
9
+ @storage = storage || RailsConsoleAi.storage
10
+ end
11
+
12
+ def load_all_agents
13
+ agents = load_builtin_agents
14
+ app_agents = load_app_agents
15
+ # App-specific agents override built-ins with the same name
16
+ app_names = app_agents.map { |a| a['name'].to_s.downcase }.to_set
17
+ agents.reject! { |a| app_names.include?(a['name'].to_s.downcase) }
18
+ agents.concat(app_agents)
19
+ agents
20
+ rescue => e
21
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load agents: #{e.message}")
22
+ []
23
+ end
24
+
25
+ def agent_summaries
26
+ agents = load_all_agents
27
+ return nil if agents.empty?
28
+
29
+ agents.map { |a|
30
+ "- **#{a['name']}**: #{a['description']}"
31
+ }
32
+ end
33
+
34
+ def find_agent(name)
35
+ agents = load_all_agents
36
+ agents.find { |a| a['name'].to_s.downcase == name.to_s.downcase }
37
+ end
38
+
39
+ def save_agent(name:, description:, body:, max_rounds: nil, model: nil, tools: nil)
40
+ key = agent_key(name)
41
+ existing = find_agent(name)
42
+
43
+ frontmatter = {
44
+ 'name' => name,
45
+ 'description' => description
46
+ }
47
+ frontmatter['max_rounds'] = max_rounds if max_rounds
48
+ frontmatter['model'] = model if model
49
+ frontmatter['tools'] = Array(tools) if tools && !tools.empty?
50
+
51
+ content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
52
+ @storage.write(key, content)
53
+
54
+ path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
55
+ if existing
56
+ "Agent updated: \"#{name}\" (#{path})"
57
+ else
58
+ "Agent created: \"#{name}\" (#{path})"
59
+ end
60
+ rescue Storage::StorageError => e
61
+ "FAILED to save agent (#{e.message})."
62
+ end
63
+
64
+ def delete_agent(name:)
65
+ key = agent_key(name)
66
+ unless @storage.exists?(key)
67
+ found = load_all_agents.find { |a| a['name'].to_s.downcase == name.to_s.downcase }
68
+ return "No agent found: \"#{name}\"" unless found
69
+ key = agent_key(found['name'])
70
+ end
71
+
72
+ agent = load_agent(key)
73
+ @storage.delete(key)
74
+ "Agent deleted: \"#{agent ? agent['name'] : name}\""
75
+ rescue Storage::StorageError => e
76
+ "FAILED to delete agent (#{e.message})."
77
+ end
78
+
79
+ private
80
+
81
+ def load_builtin_agents
82
+ return [] unless File.directory?(BUILTIN_DIR)
83
+ Dir.glob(File.join(BUILTIN_DIR, '*.md')).sort.filter_map do |path|
84
+ content = File.read(path)
85
+ agent = parse_agent(content)
86
+ agent['builtin'] = true if agent
87
+ agent
88
+ end
89
+ rescue => e
90
+ RailsConsoleAi.logger.debug("RailsConsoleAi: failed to load built-in agents: #{e.message}")
91
+ []
92
+ end
93
+
94
+ def load_app_agents
95
+ keys = @storage.list("#{AGENTS_DIR}/*.md")
96
+ keys.filter_map { |key| load_agent(key) }
97
+ rescue => e
98
+ []
99
+ end
100
+
101
+ def agent_key(name)
102
+ slug = name.downcase.strip
103
+ .gsub(/[^a-z0-9\s-]/, '')
104
+ .gsub(/[\s]+/, '-')
105
+ .gsub(/-+/, '-')
106
+ .sub(/^-/, '').sub(/-$/, '')
107
+ "#{AGENTS_DIR}/#{slug}.md"
108
+ end
109
+
110
+ def load_agent(key)
111
+ content = @storage.read(key)
112
+ return nil if content.nil? || content.strip.empty?
113
+ parse_agent(content)
114
+ rescue => e
115
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load agent #{key}: #{e.message}")
116
+ nil
117
+ end
118
+
119
+ def parse_agent(content)
120
+ return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
121
+ frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
122
+ body = $2.strip
123
+ frontmatter.merge('body' => body)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: Explore data
3
+ description: Run queries to investigate records, follow associations, and gather specific data
4
+ max_rounds: 20
5
+ ---
6
+
7
+ You are a data investigator for a Rails application. Your job is to run queries, check
8
+ records, follow associations, and gather specific data points.
9
+
10
+ Strategy:
11
+ 1. Use describe_model to understand the model's columns and associations before querying
12
+ 2. Use execute_code to run ActiveRecord queries
13
+ 3. Follow associations to find related data (e.g., user -> booking_pages -> booking_requests)
14
+ 4. Use model methods to get computed values — NEVER fabricate URLs, tokens, or derived data manually
15
+
16
+ Rules:
17
+ - Always use describe_model before querying a model you haven't seen yet.
18
+ - Prefer ActiveRecord over raw SQL.
19
+ - When you find a model has methods that return what you need (check with
20
+ `.methods.grep(/pattern/)`), USE those methods instead of constructing values yourself.
21
+ - Include specific IDs, values, and counts in your findings.
22
+
23
+ End with a concise, factual summary of what you found. Include specific record IDs, values,
24
+ and relationships. Keep your summary under 300 words.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: Investigate code
3
+ description: Search and read source code to understand how a feature, pattern, or method works
4
+ max_rounds: 20
5
+ ---
6
+
7
+ You are a code investigator for a Rails application. Your job is to search the codebase,
8
+ read relevant files, and understand how a specific feature or pattern works.
9
+
10
+ Strategy:
11
+ 1. Start with search_code to find where the relevant code lives
12
+ 2. Use read_file to examine the key files (read specific sections, not entire files)
13
+ 3. Follow the chain: find the method, check what it calls, trace the data flow
14
+ 4. Use describe_model if database models are involved
15
+
16
+ Rules:
17
+ - Be thorough but efficient. Search first, then read specific files.
18
+ - If a search returns too many results, narrow it with a more specific query or directory.
19
+ - Look at actual method implementations, not just where things are called.
20
+ - Note file paths and line numbers in your findings.
21
+
22
+ End with a concise summary of your findings: key files, classes, methods, and how they
23
+ connect. Keep your summary under 500 words.
@@ -0,0 +1,84 @@
1
+ require 'rails_console_ai/channel/base'
2
+
3
+ module RailsConsoleAi
4
+ module Channel
5
+ class SubAgent < Base
6
+ def initialize(parent_channel:, task_label: nil)
7
+ @parent = parent_channel
8
+ @label = task_label
9
+ end
10
+
11
+ def display(text)
12
+ # Swallowed — sub-agent final text is returned as tool result, not displayed
13
+ end
14
+
15
+ def display_thinking(text)
16
+ @parent.display_thinking(text)
17
+ end
18
+
19
+ def display_status(text)
20
+ @parent.display_status(text)
21
+ end
22
+
23
+ def display_warning(text)
24
+ @parent.display_warning(text)
25
+ end
26
+
27
+ def display_error(text)
28
+ @parent.display_error(text)
29
+ end
30
+
31
+ def display_tool_call(text)
32
+ @parent.display_tool_call(text)
33
+ end
34
+
35
+ def display_code(code)
36
+ # Swallowed — sub-agent auto-executes, no need to show code
37
+ end
38
+
39
+ def display_result_output(output)
40
+ @parent.display_result_output(output)
41
+ end
42
+
43
+ def display_result(result)
44
+ # Swallowed — sub-agent return values aren't useful to show
45
+ end
46
+
47
+ def prompt(text)
48
+ '(sub-agent cannot ask user)'
49
+ end
50
+
51
+ def confirm(text)
52
+ 'y'
53
+ end
54
+
55
+ def user_identity
56
+ @parent.user_identity
57
+ end
58
+
59
+ def mode
60
+ 'sub_agent'
61
+ end
62
+
63
+ def cancelled?
64
+ @parent.cancelled?
65
+ end
66
+
67
+ def supports_danger?
68
+ false # Sub-agents must never silently bypass safety guards
69
+ end
70
+
71
+ def supports_editing?
72
+ false
73
+ end
74
+
75
+ def wrap_llm_call(&block)
76
+ yield
77
+ end
78
+
79
+ def system_instructions
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ end
@@ -31,7 +31,9 @@ module RailsConsoleAi
31
31
  :code_search_paths,
32
32
  :channels,
33
33
  :bypass_guards_for_methods,
34
- :user_extra_info
34
+ :user_extra_info,
35
+ :sub_agent_max_rounds,
36
+ :sub_agent_model
35
37
 
36
38
  def initialize
37
39
  @provider = :anthropic
@@ -64,6 +66,8 @@ module RailsConsoleAi
64
66
  @channels = {}
65
67
  @bypass_guards_for_methods = []
66
68
  @user_extra_info = {}
69
+ @sub_agent_max_rounds = 15
70
+ @sub_agent_model = nil
67
71
  end
68
72
 
69
73
  def resolve_user_extra_info(username)
@@ -20,6 +20,7 @@ module RailsConsoleAi
20
20
  parts << guide_context
21
21
  parts << trusted_methods_context
22
22
  parts << skills_context
23
+ parts << agents_context
23
24
  parts << user_extra_info_context
24
25
  parts << pinned_memory_context
25
26
  parts << memory_context
@@ -93,6 +94,14 @@ module RailsConsoleAi
93
94
  a new skill with a step-by-step recipe. Skills are procedures (how to do X); memories
94
95
  are facts (what you learned about X). Do NOT use save_memory when asked to create a skill.
95
96
 
97
+ You have a delegate_task tool to spawn sub-agents for investigation tasks:
98
+ - Use delegate_task when a task requires multiple tool calls to investigate
99
+ (e.g., finding a user's shard, exploring how a feature works in the code,
100
+ gathering data across multiple models).
101
+ - The sub-agent runs in a separate context and returns only a concise summary.
102
+ - This keeps your conversation context small and efficient.
103
+ - If a custom agent is available for the task (see Agents section), specify it by name.
104
+
96
105
  RULES:
97
106
  - Give ONE concise answer. Do not offer multiple alternatives or variations.
98
107
  - For multi-step tasks, use execute_plan to break the work into small, clear steps.
@@ -145,6 +154,19 @@ module RailsConsoleAi
145
154
  nil
146
155
  end
147
156
 
157
+ def agents_context
158
+ require 'rails_console_ai/agent_loader'
159
+ summaries = RailsConsoleAi::AgentLoader.new.agent_summaries
160
+ return nil if summaries.nil? || summaries.empty?
161
+
162
+ lines = ["## Agents (use delegate_task tool to invoke)"]
163
+ lines.concat(summaries)
164
+ lines.join("\n")
165
+ rescue => e
166
+ RailsConsoleAi.logger.debug("RailsConsoleAi: agents context failed: #{e.message}")
167
+ nil
168
+ end
169
+
148
170
  def user_extra_info_context
149
171
  info = @config.resolve_user_extra_info(@user_name)
150
172
  return nil if info.nil? || info.strip.empty?
@@ -882,6 +882,17 @@ module RailsConsoleAi
882
882
  @channel.display_status(" #{preview}#{cached_tag}")
883
883
  end
884
884
 
885
+ # Aggregate sub-agent token usage into parent's cost tracking
886
+ if tc[:name] == 'delegate_task' && tools.last_sub_agent_usage
887
+ sa = tools.last_sub_agent_usage
888
+ @total_input_tokens += sa[:input] || 0
889
+ @total_output_tokens += sa[:output] || 0
890
+ if sa[:model]
891
+ @token_usage[sa[:model]][:input] += sa[:input] || 0
892
+ @token_usage[sa[:model]][:output] += sa[:output] || 0
893
+ end
894
+ end
895
+
885
896
  if RailsConsoleAi.configuration.debug
886
897
  $stderr.puts "\e[35m[debug] tool result (#{tool_result.to_s.length} chars)\e[0m"
887
898
  end
@@ -1033,6 +1044,11 @@ module RailsConsoleAi
1033
1044
  when 'execute_plan'
1034
1045
  steps = args['steps']
1035
1046
  steps ? "(#{steps.length} steps)" : ''
1047
+ when 'delegate_task'
1048
+ task_preview = args['task'].to_s[0, 100]
1049
+ task_preview += '...' if args['task'].to_s.length > 100
1050
+ agent = args['agent'] ? ", agent: \"#{args['agent']}\"" : ''
1051
+ "(\"#{task_preview}\"#{agent})"
1036
1052
  else ''
1037
1053
  end
1038
1054
  end
@@ -1104,6 +1120,9 @@ module RailsConsoleAi
1104
1120
  when 'execute_plan'
1105
1121
  steps_done = result.scan(/^Step \d+/).length
1106
1122
  steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
1123
+ when 'delegate_task'
1124
+ # Show the full sub-agent result — this is the whole point of delegation
1125
+ result
1107
1126
  else
1108
1127
  truncate(result, 80)
1109
1128
  end
@@ -0,0 +1,217 @@
1
+ require 'rails_console_ai/channel/sub_agent'
2
+ require 'rails_console_ai/tools/registry'
3
+ require 'rails_console_ai/providers/base'
4
+ require 'rails_console_ai/executor'
5
+
6
+ module RailsConsoleAi
7
+ class SubAgent
8
+ LOOP_WARN_THRESHOLD = 3
9
+ LOOP_BREAK_THRESHOLD = 5
10
+ LARGE_OUTPUT_THRESHOLD = 10_000
11
+ LARGE_OUTPUT_PREVIEW_CHARS = 8_000
12
+
13
+ attr_reader :input_tokens, :output_tokens, :model_used
14
+
15
+ def initialize(task:, agent_config:, binding_context:, parent_channel:, executor:)
16
+ @task = task
17
+ @agent_config = agent_config || {}
18
+ @binding_context = binding_context
19
+ @parent_channel = parent_channel
20
+ @parent_executor = executor
21
+ @input_tokens = 0
22
+ @output_tokens = 0
23
+ @model_used = nil
24
+ end
25
+
26
+ def run
27
+ channel = Channel::SubAgent.new(
28
+ parent_channel: @parent_channel,
29
+ task_label: @agent_config['name']
30
+ )
31
+
32
+ executor = Executor.new(@binding_context, channel: channel)
33
+ allowed_tools = @agent_config['tools'] ? Array(@agent_config['tools']) : nil
34
+ tools = Tools::Registry.new(executor: executor, mode: :sub_agent, channel: channel, allowed_tools: allowed_tools)
35
+ provider = build_provider
36
+ system_prompt = build_system_prompt
37
+ max_rounds = @agent_config['max_rounds'] || RailsConsoleAi.configuration.sub_agent_max_rounds
38
+
39
+ messages = [{ role: :user, content: @task }]
40
+
41
+ run_tool_loop(messages, system_prompt: system_prompt, tools: tools,
42
+ provider: provider, channel: channel, executor: executor,
43
+ max_rounds: max_rounds)
44
+ end
45
+
46
+ private
47
+
48
+ def run_tool_loop(messages, system_prompt:, tools:, provider:, channel:, executor:, max_rounds:)
49
+ result = nil
50
+ tool_call_counts = Hash.new(0)
51
+ exhausted = false
52
+ last_thinking = nil
53
+
54
+ max_rounds.times do |round|
55
+ break if channel.cancelled?
56
+
57
+ if round == 0
58
+ channel.display_status("Thinking...")
59
+ end
60
+
61
+ begin
62
+ result = provider.chat_with_tools(messages, tools: tools, system_prompt: system_prompt)
63
+ rescue Providers::ProviderError => e
64
+ raise
65
+ end
66
+ @input_tokens += result.input_tokens || 0
67
+ @output_tokens += result.output_tokens || 0
68
+
69
+ break if channel.cancelled?
70
+ break unless result.tool_use?
71
+
72
+ # Display the LLM's reasoning text before executing its tool calls
73
+ if result.text && !result.text.strip.empty?
74
+ result.text.strip.split("\n").each do |line|
75
+ channel.display_thinking(" #{line}")
76
+ end
77
+ end
78
+
79
+ assistant_msg = provider.format_assistant_message(result)
80
+ messages << assistant_msg
81
+
82
+ result.tool_calls.each do |tc|
83
+ break if channel.cancelled?
84
+
85
+ args_display = tc[:arguments].map { |k, v|
86
+ val = v.to_s
87
+ val = val[0, 60] + '...' if val.length > 60
88
+ "#{k}: #{val.inspect}"
89
+ }.join(', ')
90
+ channel.display_tool_call("#{tc[:name]}(#{args_display})")
91
+
92
+ tool_result = tools.execute(tc[:name], tc[:arguments])
93
+
94
+ preview = tool_result.to_s.lines.first(3).join.strip
95
+ preview = preview[0, 120] + '...' if preview.length > 120
96
+ channel.display_status(" #{preview}")
97
+
98
+ # Truncate large outputs to keep sub-agent context lean
99
+ tool_result_str = tool_result.to_s
100
+ if tool_result_str.length > LARGE_OUTPUT_THRESHOLD
101
+ tool_result_str = tool_result_str[0, LARGE_OUTPUT_PREVIEW_CHARS] +
102
+ "\n\n[Output truncated at #{LARGE_OUTPUT_PREVIEW_CHARS} of #{tool_result_str.length} chars]"
103
+ end
104
+
105
+ tool_msg = provider.format_tool_result(tc[:id], tool_result_str)
106
+ messages << tool_msg
107
+ end
108
+
109
+ # Loop detection
110
+ result.tool_calls.each do |tc|
111
+ key = "#{tc[:name]}:#{tc[:arguments].to_json}"
112
+ tool_call_counts[key] += 1
113
+
114
+ if tool_call_counts[key] >= LOOP_BREAK_THRESHOLD
115
+ channel.display_status("Loop detected — stopping.")
116
+ exhausted = true
117
+ elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
118
+ messages << { role: :user, content: "You are repeating the same tool call with the same arguments. Try a different approach or provide your answer now." }
119
+ end
120
+ end
121
+ break if exhausted
122
+
123
+ break if executor.last_cancelled?
124
+
125
+ exhausted = true if round == max_rounds - 1
126
+ end
127
+
128
+ if exhausted
129
+ messages << { role: :user, content: "Provide your best answer now based on what you've learned." }
130
+ result = provider.chat(messages, system_prompt: system_prompt)
131
+ @input_tokens += result.input_tokens || 0
132
+ @output_tokens += result.output_tokens || 0
133
+ end
134
+
135
+ result&.text || '(sub-agent returned no result)'
136
+ end
137
+
138
+ def build_provider
139
+ config = RailsConsoleAi.configuration
140
+ model_override = @agent_config['model'] || config.sub_agent_model
141
+
142
+ if model_override
143
+ config_dup = config.dup
144
+ config_dup.model = model_override
145
+ @model_used = model_override
146
+ Providers.build(config_dup)
147
+ else
148
+ @model_used = config.resolved_model
149
+ Providers.build(config)
150
+ end
151
+ end
152
+
153
+ def build_system_prompt
154
+ parts = []
155
+ parts << base_instructions
156
+ parts << guide_context
157
+ parts << pinned_memory_context
158
+ parts << @agent_config['body'] if @agent_config['body'] && !@agent_config['body'].strip.empty?
159
+ parts.compact.join("\n\n")
160
+ end
161
+
162
+ def base_instructions
163
+ <<~PROMPT.strip
164
+ You are a sub-agent assistant for a Ruby on Rails application. You have been delegated
165
+ a specific investigation task. Your job is to use the available tools to find the answer
166
+ efficiently, then provide a concise summary of your findings.
167
+
168
+ RULES:
169
+ - Focus on the specific task you were given. Do not go on tangents.
170
+ - Use tools to look up schema/model details rather than guessing.
171
+ - Prefer ActiveRecord query interface over raw SQL.
172
+ - Use describe_model to understand models before querying them.
173
+ - NEVER fabricate URLs, IDs, or data. Always look things up using model methods.
174
+ - When you find methods on a model (e.g. via .methods.grep), USE them rather than
175
+ constructing values manually.
176
+ - End with a concise, factual summary. Include specific IDs, values, and findings.
177
+ - Keep your final answer under 300 words.
178
+ PROMPT
179
+ end
180
+
181
+ def guide_context
182
+ content = RailsConsoleAi.storage.read(RailsConsoleAi::GUIDE_KEY)
183
+ return nil if content.nil? || content.strip.empty?
184
+
185
+ "## Application Guide\n\n#{content.strip}"
186
+ rescue => e
187
+ RailsConsoleAi.logger.debug("SubAgent: guide context failed: #{e.message}")
188
+ nil
189
+ end
190
+
191
+ def pinned_memory_context
192
+ channel_mode = @parent_channel.respond_to?(:mode) ? @parent_channel.mode : nil
193
+ # For sub-agents spawned from Slack, use the Slack channel's pinned memories.
194
+ # For sub-agents spawned from console, use the console channel's pinned memories.
195
+ # Fall back to 'slack' if the parent is a sub_agent channel (nested, though we block this).
196
+ effective_mode = channel_mode == 'sub_agent' ? 'slack' : channel_mode
197
+ return nil unless effective_mode
198
+
199
+ channel_cfg = RailsConsoleAi.configuration.channels[effective_mode] || {}
200
+ pinned_tags = channel_cfg['pinned_memory_tags'] || []
201
+ return nil if pinned_tags.empty?
202
+
203
+ require 'rails_console_ai/tools/memory_tools'
204
+ sections = pinned_tags.filter_map do |tag|
205
+ content = Tools::MemoryTools.new.recall_memories(tag: tag)
206
+ next if content.nil? || content.include?("No memories")
207
+ content
208
+ end
209
+ return nil if sections.empty?
210
+
211
+ "## Key Context (from pinned memories)\n\n" + sections.join("\n\n")
212
+ rescue => e
213
+ RailsConsoleAi.logger.debug("SubAgent: pinned memory context failed: #{e.message}")
214
+ nil
215
+ end
216
+ end
217
+ end
@@ -37,8 +37,8 @@ module RailsConsoleAi
37
37
 
38
38
  # Columns and indexes from the database table
39
39
  begin
40
- if ActiveRecord::Base.connected?
41
- conn = ActiveRecord::Base.connection
40
+ conn = model.connection
41
+ if conn
42
42
  if conn.tables.include?(model.table_name)
43
43
  cols = conn.columns(model.table_name).map do |c|
44
44
  parts = ["#{c.name}:#{c.type}"]
@@ -3,19 +3,21 @@ require 'json'
3
3
  module RailsConsoleAi
4
4
  module Tools
5
5
  class Registry
6
- attr_reader :definitions
6
+ attr_reader :definitions, :last_sub_agent_usage
7
7
 
8
8
  # Tools that should never be cached (side effects or user interaction)
9
- NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill].freeze
9
+ NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill delegate_task].freeze
10
10
 
11
- def initialize(executor: nil, mode: :default, channel: nil)
11
+ def initialize(executor: nil, mode: :default, channel: nil, allowed_tools: nil)
12
12
  @executor = executor
13
13
  @mode = mode
14
14
  @channel = channel
15
+ @allowed_tools = allowed_tools
15
16
  @definitions = []
16
17
  @handlers = {}
17
18
  @cache = {}
18
19
  @last_cached = false
20
+ @last_sub_agent_usage = nil
19
21
  register_all
20
22
  end
21
23
 
@@ -215,23 +217,130 @@ module RailsConsoleAi
215
217
  end
216
218
 
217
219
  unless @mode == :init
218
- register(
219
- name: 'ask_user',
220
- description: 'Ask the console user a clarifying question. Use this when you need specific information to write accurate code (e.g. which user they are, which record to target, what value to use). Do NOT generate placeholder values like YOUR_USER_ID — ask instead.',
221
- parameters: {
222
- 'type' => 'object',
223
- 'properties' => {
224
- 'question' => { 'type' => 'string', 'description' => 'The question to ask the user' }
220
+ # Sub-agents get execute_code and read-only memory tools, but not ask_user,
221
+ # memory writes, skill writes, execute_plan, or delegate_task.
222
+ if @mode == :sub_agent
223
+ register_memory_recall_tools
224
+ register_execute_code
225
+ else
226
+ register(
227
+ name: 'ask_user',
228
+ description: 'Ask the console user a clarifying question. Use this when you need specific information to write accurate code (e.g. which user they are, which record to target, what value to use). Do NOT generate placeholder values like YOUR_USER_ID — ask instead.',
229
+ parameters: {
230
+ 'type' => 'object',
231
+ 'properties' => {
232
+ 'question' => { 'type' => 'string', 'description' => 'The question to ask the user' }
233
+ },
234
+ 'required' => ['question']
225
235
  },
226
- 'required' => ['question']
236
+ handler: ->(args) { ask_user(args['question']) }
237
+ )
238
+
239
+ register_memory_tools
240
+ register_skill_tools
241
+ register_execute_plan
242
+ register_delegate_task
243
+ end
244
+ end
245
+ end
246
+
247
+ def register_memory_recall_tools
248
+ return unless RailsConsoleAi.configuration.memories_enabled
249
+
250
+ require 'rails_console_ai/tools/memory_tools'
251
+ memory = MemoryTools.new
252
+
253
+ register(
254
+ name: 'recall_memory',
255
+ description: 'Retrieve a specific memory by name.',
256
+ parameters: {
257
+ 'type' => 'object',
258
+ 'properties' => {
259
+ 'name' => { 'type' => 'string', 'description' => 'The exact memory name' }
227
260
  },
228
- handler: ->(args) { ask_user(args['question']) }
229
- )
261
+ 'required' => ['name']
262
+ },
263
+ handler: ->(args) { memory.recall_memory(name: args['name']) }
264
+ )
230
265
 
231
- register_memory_tools
232
- register_skill_tools
233
- register_execute_plan
266
+ register(
267
+ name: 'recall_memories',
268
+ description: 'Search saved memories about this codebase. Call with no args to list all, or pass a query/tag to filter.',
269
+ parameters: {
270
+ 'type' => 'object',
271
+ 'properties' => {
272
+ 'query' => { 'type' => 'string', 'description' => 'Search term to filter by name, description, or tags' },
273
+ 'tag' => { 'type' => 'string', 'description' => 'Filter by a specific tag' }
274
+ }
275
+ },
276
+ handler: ->(args) { memory.recall_memories(query: args['query'], tag: args['tag']) }
277
+ )
278
+ end
279
+
280
+ def register_execute_code
281
+ return unless @executor
282
+
283
+ register(
284
+ name: 'execute_code',
285
+ description: 'Execute Ruby code in the Rails console and return the result.',
286
+ parameters: {
287
+ 'type' => 'object',
288
+ 'properties' => {
289
+ 'code' => { 'type' => 'string', 'description' => 'Ruby code to execute' }
290
+ },
291
+ 'required' => ['code']
292
+ },
293
+ handler: ->(args) { execute_code(args['code']) }
294
+ )
295
+ end
296
+
297
+ def register_delegate_task
298
+ return unless @executor
299
+
300
+ register(
301
+ name: 'delegate_task',
302
+ description: 'Delegate an investigation to a sub-agent that runs in a separate context. ' \
303
+ 'Use this for tasks that require multiple tool calls to figure out ' \
304
+ '(e.g., "find which shard user X is on", "search the codebase for how URL generation works", ' \
305
+ '"describe the relationships between models A, B, and C"). ' \
306
+ 'The sub-agent runs independently and returns only a concise summary, ' \
307
+ 'keeping this conversation\'s context small and efficient.',
308
+ parameters: {
309
+ 'type' => 'object',
310
+ 'properties' => {
311
+ 'task' => { 'type' => 'string', 'description' => 'What to investigate. Be specific about what you need to know.' },
312
+ 'agent' => { 'type' => 'string', 'description' => 'Optional: name of a custom agent to use (see Agents list in system prompt). Omit for general investigation.' }
313
+ },
314
+ 'required' => ['task']
315
+ },
316
+ handler: ->(args) { delegate_task(args['task'], args['agent']) }
317
+ )
318
+ end
319
+
320
+ def delegate_task(task, agent_name = nil)
321
+ require 'rails_console_ai/sub_agent'
322
+ require 'rails_console_ai/agent_loader'
323
+
324
+ agent_config = nil
325
+ if agent_name
326
+ loader = AgentLoader.new
327
+ agent_config = loader.find_agent(agent_name)
328
+ unless agent_config
329
+ available = loader.load_all_agents.map { |a| a['name'] }
330
+ return "Agent not found: \"#{agent_name}\". Available agents: #{available.join(', ')}"
331
+ end
234
332
  end
333
+
334
+ sub = SubAgent.new(
335
+ task: task,
336
+ agent_config: agent_config,
337
+ binding_context: @executor.binding_context,
338
+ parent_channel: @channel,
339
+ executor: @executor
340
+ )
341
+ result = sub.run
342
+ @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used }
343
+ "Sub-agent result (#{sub.input_tokens + sub.output_tokens} tokens used):\n#{result}"
235
344
  end
236
345
 
237
346
  def register_memory_tools
@@ -412,7 +521,7 @@ module RailsConsoleAi
412
521
  # Show the code to the user
413
522
  @executor.display_code_block(code)
414
523
 
415
- exec_result = if @channel&.mode == 'slack' || RailsConsoleAi.configuration.auto_execute
524
+ exec_result = if @channel&.mode == 'slack' || @channel&.mode == 'sub_agent' || RailsConsoleAi.configuration.auto_execute
416
525
  @executor.execute(code)
417
526
  else
418
527
  @executor.confirm_and_execute(code)
@@ -547,6 +656,13 @@ module RailsConsoleAi
547
656
  end
548
657
  step_report += "Return value: #{exec_result.inspect}"
549
658
  results << step_report
659
+
660
+ # Stop on error so the LLM can fix the failed step before continuing
661
+ if error
662
+ remaining = steps.length - i - 1
663
+ results << "PLAN HALTED: Step #{i + 1} failed. #{remaining} remaining step(s) were not executed. Fix the error and retry the remaining steps." if remaining > 0
664
+ break
665
+ end
550
666
  end
551
667
 
552
668
  results.join("\n\n")
@@ -609,6 +725,11 @@ module RailsConsoleAi
609
725
  end
610
726
 
611
727
  def register(name:, description:, parameters:, handler:)
728
+ # When allowed_tools is set (sub-agent with tool filter), skip tools not in the list
729
+ if @allowed_tools && !@allowed_tools.include?(name)
730
+ return
731
+ end
732
+
612
733
  @definitions << {
613
734
  name: name,
614
735
  description: description,
@@ -18,18 +18,19 @@ module RailsConsoleAi
18
18
  return "Error: table_name is required." if table_name.nil? || table_name.strip.empty?
19
19
 
20
20
  table_name = table_name.strip
21
- unless connection.tables.include?(table_name)
21
+ conn = connection_for_table(table_name)
22
+ unless conn.tables.include?(table_name)
22
23
  return "Table '#{table_name}' not found. Use list_tables to see available tables."
23
24
  end
24
25
 
25
- cols = connection.columns(table_name).map do |c|
26
+ cols = conn.columns(table_name).map do |c|
26
27
  parts = ["#{c.name}:#{c.type}"]
27
28
  parts << "nullable" if c.null
28
29
  parts << "default=#{c.default}" unless c.default.nil?
29
30
  parts.join(" ")
30
31
  end
31
32
 
32
- indexes = connection.indexes(table_name).map do |idx|
33
+ indexes = conn.indexes(table_name).map do |idx|
33
34
  unique = idx.unique ? "UNIQUE " : ""
34
35
  "#{unique}INDEX on (#{idx.columns.join(', ')})"
35
36
  end
@@ -52,6 +53,22 @@ module RailsConsoleAi
52
53
  defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
53
54
  end
54
55
 
56
+ # Find the best connection for a table by checking if any model maps to it.
57
+ # Models may use a different connection (e.g. sharded databases).
58
+ def connection_for_table(table_name)
59
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.is_a?(Class)
60
+ base = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
61
+ model = ObjectSpace.each_object(Class).detect { |c|
62
+ c < base && !c.abstract_class? && c.name &&
63
+ begin; c.table_name == table_name; rescue; false; end
64
+ }
65
+ return model.connection if model
66
+ end
67
+ connection
68
+ rescue
69
+ connection
70
+ end
71
+
55
72
  def connection
56
73
  ActiveRecord::Base.connection
57
74
  end
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.25.0'.freeze
2
+ VERSION = '0.26.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr
@@ -100,9 +100,13 @@ files:
100
100
  - lib/generators/rails_console_ai/install_generator.rb
101
101
  - lib/generators/rails_console_ai/templates/initializer.rb
102
102
  - lib/rails_console_ai.rb
103
+ - lib/rails_console_ai/agent_loader.rb
104
+ - lib/rails_console_ai/agents/explore-data.md
105
+ - lib/rails_console_ai/agents/investigate-code.md
103
106
  - lib/rails_console_ai/channel/base.rb
104
107
  - lib/rails_console_ai/channel/console.rb
105
108
  - lib/rails_console_ai/channel/slack.rb
109
+ - lib/rails_console_ai/channel/sub_agent.rb
106
110
  - lib/rails_console_ai/configuration.rb
107
111
  - lib/rails_console_ai/console_methods.rb
108
112
  - lib/rails_console_ai/context_builder.rb
@@ -123,6 +127,7 @@ files:
123
127
  - lib/rails_console_ai/slack_bot.rb
124
128
  - lib/rails_console_ai/storage/base.rb
125
129
  - lib/rails_console_ai/storage/file_storage.rb
130
+ - lib/rails_console_ai/sub_agent.rb
126
131
  - lib/rails_console_ai/tools/code_tools.rb
127
132
  - lib/rails_console_ai/tools/memory_tools.rb
128
133
  - lib/rails_console_ai/tools/model_tools.rb