rails_console_ai 0.24.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: 41b94c2b3a01cba1210242c6c8752510fc1976bf9b7195272f4a109dceadcdab
4
- data.tar.gz: 6c813abfeeda804b115dfd2a385bc67e1796b0af80cc438e5f41d66886fa519e
3
+ metadata.gz: a2194416a93ce8522de376169eb627b90c8b2477ba253f0f1b877144251f9ee6
4
+ data.tar.gz: 65dcbeb6eef9dd2641181529aa671058e85a6cd1c5263acd51678c52c89d3567
5
5
  SHA512:
6
- metadata.gz: 52281835764610528b039d12ac786ec7056d035e9055d5574bee3a3143c9833e8fa5943a5932980c7ee28ae77351a178186dc15b3afb2f0e9acb7c71b1104c5f
7
- data.tar.gz: d1f56cd511ff3411b6e2783c0790bbc3d012a420971fe2cd400f7f573ffc168f5b6bca5c692618bf5c4ef2eaa9d0d0283958afcf1b7ac71d426dd4f2f4377425
6
+ metadata.gz: e4d5a1f2fe8ef6ae4593829ac1a8997eeeac652ea0d0a491f91a64b8e45a246d024a10aa269e853949e0d106779d4f1b9d671af21057bd6af802ff6a541ac15d
7
+ data.tar.gz: ec0e0d25f7bb2605a7c4180ebc56ab56d8f590d3632c98310ec65c3aecd987d04118e7199534a8ea5a1a8023a505de9ac467ce05d01393df667c2436bcebc68c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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
+
13
+ ## [0.25.0]
14
+
15
+ - Expand truncation limits
16
+ - Allow any user on allow list to interact with Slack bot in a thread
17
+ - Handle ctrl-c better in console
18
+ - Fix stdout capture in Slack sessions
19
+ - Improve Slack bot logging
20
+ - Fix thread safety issues in Slack bot
21
+
5
22
  ## [0.24.0]
6
23
 
7
24
  - Refactor thinking text display and include in Slack with more technical detail
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.
@@ -127,8 +127,8 @@ module RailsConsoleAi
127
127
 
128
128
  # --- Omitted output tracking (shared with Executor) ---
129
129
 
130
- MAX_DISPLAY_LINES = 10
131
- MAX_DISPLAY_CHARS = 2000
130
+ MAX_DISPLAY_LINES = 20
131
+ MAX_DISPLAY_CHARS = 4000
132
132
 
133
133
  def init_omitted_tracking
134
134
  @omitted_outputs = {}
@@ -230,7 +230,16 @@ module RailsConsoleAi
230
230
  @interactive_console_capture.write("ai> #{input}\n")
231
231
  @engine.log_interactive_turn
232
232
 
233
- status = @engine.send_and_execute
233
+ expected_stdout = $stdout
234
+ begin
235
+ status = @engine.send_and_execute
236
+ rescue Interrupt
237
+ $stdout = expected_stdout
238
+ $stdout.puts "\n\e[33m Cancelled.\e[0m"
239
+ @engine.pop_last_message
240
+ @engine.log_interactive_turn
241
+ next
242
+ end
234
243
  if status == :interrupted
235
244
  @engine.pop_last_message
236
245
  @engine.log_interactive_turn
@@ -413,6 +422,7 @@ module RailsConsoleAi
413
422
  @real_stdout.puts "\e[2m /debug Toggle debug summaries (context stats, cost per call)\e[0m"
414
423
  @real_stdout.puts "\e[2m /retry Re-execute the last code block\e[0m"
415
424
  @real_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
425
+ @real_stdout.puts "\e[2m Ctrl-C Cancel the current operation\e[0m"
416
426
  @real_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
417
427
  end
418
428
 
@@ -42,7 +42,7 @@ module RailsConsoleAi
42
42
  post(stripped)
43
43
  else
44
44
  @output_log.write("#{stripped}\n")
45
- STDOUT.puts "#{@log_prefix} (status) #{stripped}"
45
+ log_prefixed("(status)", stripped)
46
46
  end
47
47
  end
48
48
 
@@ -56,7 +56,7 @@ module RailsConsoleAi
56
56
 
57
57
  def display_tool_call(text)
58
58
  @output_log.write("-> #{text}\n")
59
- STDOUT.puts "#{@log_prefix} -> #{text}"
59
+ log_prefixed("->", text)
60
60
  end
61
61
 
62
62
  def display_code(code)
@@ -161,7 +161,7 @@ module RailsConsoleAi
161
161
  def post(text)
162
162
  return if text.nil? || text.strip.empty?
163
163
  @output_log.write("#{text}\n")
164
- STDOUT.puts "#{@log_prefix} >> #{text}"
164
+ log_prefixed(">>", text)
165
165
  @slack_bot.send(:post_message,
166
166
  channel: @channel_id,
167
167
  thread_ts: @thread_ts,
@@ -171,6 +171,10 @@ module RailsConsoleAi
171
171
  RailsConsoleAi.logger.error("Slack post failed: #{e.message}")
172
172
  end
173
173
 
174
+ def log_prefixed(tag, text)
175
+ text.each_line { |line| STDOUT.puts "#{@log_prefix} #{tag} #{line.rstrip}" }
176
+ end
177
+
174
178
  def strip_ansi(text)
175
179
  text.to_s.gsub(ANSI_REGEX, '')
176
180
  end
@@ -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)
@@ -135,7 +139,7 @@ module RailsConsoleAi
135
139
  end
136
140
 
137
141
  if allow
138
- Array(allow).each { |key| safety_guards.allow(guard_name, key) }
142
+ Array(allow).each { |key| safety_guards.allow_global(guard_name, key) }
139
143
  end
140
144
  end
141
145
 
@@ -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?
@@ -3,8 +3,8 @@ module RailsConsoleAi
3
3
  attr_reader :history, :total_input_tokens, :total_output_tokens,
4
4
  :interactive_session_id, :session_name
5
5
 
6
- LARGE_OUTPUT_THRESHOLD = 10_000 # chars — truncate tool results larger than this immediately
7
- LARGE_OUTPUT_PREVIEW_CHARS = 8_000 # chars — how much of the output the LLM sees upfront
6
+ LARGE_OUTPUT_THRESHOLD = 20_000 # chars — truncate tool results larger than this immediately
7
+ LARGE_OUTPUT_PREVIEW_CHARS = 16_000 # chars — how much of the output the LLM sees upfront
8
8
  LOOP_WARN_THRESHOLD = 3 # same tool+args repeated → inject warning
9
9
  LOOP_BREAK_THRESHOLD = 5 # same tool+args repeated → break loop
10
10
 
@@ -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
@@ -100,13 +100,18 @@ module RailsConsoleAi
100
100
  @last_safety_exception = nil
101
101
  captured_output = StringIO.new
102
102
  old_stdout = $stdout
103
- # When a channel is present it handles display (with truncation), so capture only.
104
- # Without a channel, tee so output appears live on the terminal.
105
- $stdout = if @channel
106
- captured_output
107
- else
108
- TeeIO.new(old_stdout, captured_output)
109
- end
103
+ # Three capture strategies:
104
+ # 1. Slack mode (PrefixedIO active): thread-local capture to avoid cross-thread pollution
105
+ # 2. Console mode (channel present): capture-only, channel.display_result_output shows it after
106
+ # 3. No channel (tests/one-shot): TeeIO so output appears live AND is captured
107
+ use_thread_local = defined?(RailsConsoleAi::PrefixedIO) && $stdout.is_a?(RailsConsoleAi::PrefixedIO)
108
+ if use_thread_local
109
+ Thread.current[:capture_io] = captured_output
110
+ elsif @channel
111
+ $stdout = captured_output
112
+ else
113
+ $stdout = TeeIO.new(old_stdout, captured_output)
114
+ end
110
115
 
111
116
  RailsConsoleAi::SafetyError.clear!
112
117
 
@@ -114,7 +119,7 @@ module RailsConsoleAi
114
119
  binding_context.eval(code, "(rails_console_ai)", 1)
115
120
  end
116
121
 
117
- $stdout = old_stdout
122
+ restore_stdout(use_thread_local, old_stdout)
118
123
 
119
124
  # Check if a SafetyError was raised but swallowed by a rescue inside the eval'd code
120
125
  if (swallowed = RailsConsoleAi::SafetyError.last_raised)
@@ -136,8 +141,12 @@ module RailsConsoleAi
136
141
 
137
142
  @last_output = captured_output.string
138
143
  result
144
+ rescue Interrupt
145
+ restore_stdout(use_thread_local, old_stdout)
146
+ @last_output = captured_output&.string
147
+ raise
139
148
  rescue RailsConsoleAi::SafetyError => e
140
- $stdout = old_stdout if old_stdout
149
+ restore_stdout(use_thread_local, old_stdout)
141
150
  RailsConsoleAi::SafetyError.clear!
142
151
  @last_error = "SafetyError: #{e.message}"
143
152
  @last_safety_error = true
@@ -146,13 +155,13 @@ module RailsConsoleAi
146
155
  @last_output = captured_output&.string
147
156
  nil
148
157
  rescue SyntaxError => e
149
- $stdout = old_stdout if old_stdout
158
+ restore_stdout(use_thread_local, old_stdout)
150
159
  @last_error = "SyntaxError: #{e.message}"
151
160
  log_execution_error(@last_error)
152
161
  @last_output = nil
153
162
  nil
154
163
  rescue => e
155
- $stdout = old_stdout if old_stdout
164
+ restore_stdout(use_thread_local, old_stdout)
156
165
  # Check if a SafetyError is wrapped (e.g. ActiveRecord::StatementInvalid wrapping our error)
157
166
  if safety_error?(e)
158
167
  safety_exc = extract_safety_exception(e)
@@ -333,6 +342,14 @@ module RailsConsoleAi
333
342
 
334
343
  private
335
344
 
345
+ def restore_stdout(use_thread_local, old_stdout)
346
+ if use_thread_local
347
+ Thread.current[:capture_io] = nil
348
+ else
349
+ $stdout = old_stdout if old_stdout
350
+ end
351
+ end
352
+
336
353
  def danger_allowed?
337
354
  @channel.nil? || @channel.supports_danger?
338
355
  end
@@ -5,6 +5,9 @@ module RailsConsoleAi
5
5
  end
6
6
 
7
7
  def write(str)
8
+ if (capture = Thread.current[:capture_io])
9
+ return capture.write(str)
10
+ end
8
11
  prefix = Thread.current[:log_prefix]
9
12
  if prefix && str.is_a?(String) && !str.strip.empty?
10
13
  prefixed = str.gsub(/^(?=.)/, "#{prefix} ")
@@ -15,6 +18,9 @@ module RailsConsoleAi
15
18
  end
16
19
 
17
20
  def puts(*args)
21
+ if (capture = Thread.current[:capture_io])
22
+ return capture.puts(*args)
23
+ end
18
24
  prefix = Thread.current[:log_prefix]
19
25
  if prefix
20
26
  args = [""] if args.empty?
@@ -32,6 +38,9 @@ module RailsConsoleAi
32
38
  end
33
39
 
34
40
  def print(*args)
41
+ if (capture = Thread.current[:capture_io])
42
+ return capture.print(*args)
43
+ end
35
44
  @io.print(*args)
36
45
  end
37
46