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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +61 -0
- data/lib/rails_console_ai/agent_loader.rb +126 -0
- data/lib/rails_console_ai/agents/explore-data.md +24 -0
- data/lib/rails_console_ai/agents/investigate-code.md +23 -0
- data/lib/rails_console_ai/channel/console.rb +13 -3
- data/lib/rails_console_ai/channel/slack.rb +7 -3
- data/lib/rails_console_ai/channel/sub_agent.rb +84 -0
- data/lib/rails_console_ai/configuration.rb +6 -2
- data/lib/rails_console_ai/context_builder.rb +22 -0
- data/lib/rails_console_ai/conversation_engine.rb +21 -2
- data/lib/rails_console_ai/executor.rb +28 -11
- data/lib/rails_console_ai/prefixed_io.rb +9 -0
- data/lib/rails_console_ai/safety_guards.rb +33 -14
- data/lib/rails_console_ai/slack_bot.rb +23 -13
- data/lib/rails_console_ai/sub_agent.rb +217 -0
- data/lib/rails_console_ai/tools/model_tools.rb +2 -2
- data/lib/rails_console_ai/tools/registry.rb +138 -17
- data/lib/rails_console_ai/tools/schema_tools.rb +20 -3
- data/lib/rails_console_ai/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2194416a93ce8522de376169eb627b90c8b2477ba253f0f1b877144251f9ee6
|
|
4
|
+
data.tar.gz: 65dcbeb6eef9dd2641181529aa671058e85a6cd1c5263acd51678c52c89d3567
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
131
|
-
MAX_DISPLAY_CHARS =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
7
|
-
LARGE_OUTPUT_PREVIEW_CHARS =
|
|
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
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|