rails_console_ai 0.29.0 → 0.30.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +48 -0
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +36 -0
  5. data/app/controllers/rails_console_ai/agents_controller.rb +199 -0
  6. data/app/controllers/rails_console_ai/application_controller.rb +5 -0
  7. data/app/controllers/rails_console_ai/memories_controller.rb +159 -0
  8. data/app/controllers/rails_console_ai/memory_versions_controller.rb +33 -0
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +35 -0
  10. data/app/controllers/rails_console_ai/skills_controller.rb +200 -0
  11. data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
  12. data/app/models/rails_console_ai/agent.rb +175 -0
  13. data/app/models/rails_console_ai/agent_version.rb +46 -0
  14. data/app/models/rails_console_ai/memory.rb +98 -0
  15. data/app/models/rails_console_ai/memory_version.rb +46 -0
  16. data/app/models/rails_console_ai/session.rb +1 -1
  17. data/app/models/rails_console_ai/skill.rb +198 -0
  18. data/app/models/rails_console_ai/skill_version.rb +54 -0
  19. data/app/views/layouts/rails_console_ai/application.html.erb +78 -1
  20. data/app/views/rails_console_ai/agent_versions/index.html.erb +28 -0
  21. data/app/views/rails_console_ai/agent_versions/show.html.erb +25 -0
  22. data/app/views/rails_console_ai/agents/_form.html.erb +65 -0
  23. data/app/views/rails_console_ai/agents/diff.html.erb +19 -0
  24. data/app/views/rails_console_ai/agents/edit.html.erb +7 -0
  25. data/app/views/rails_console_ai/agents/index.html.erb +80 -0
  26. data/app/views/rails_console_ai/agents/new.html.erb +24 -0
  27. data/app/views/rails_console_ai/agents/show.html.erb +108 -0
  28. data/app/views/rails_console_ai/memories/_form.html.erb +36 -0
  29. data/app/views/rails_console_ai/memories/diff.html.erb +19 -0
  30. data/app/views/rails_console_ai/memories/edit.html.erb +7 -0
  31. data/app/views/rails_console_ai/memories/index.html.erb +67 -0
  32. data/app/views/rails_console_ai/memories/new.html.erb +23 -0
  33. data/app/views/rails_console_ai/memories/show.html.erb +65 -0
  34. data/app/views/rails_console_ai/memory_versions/index.html.erb +26 -0
  35. data/app/views/rails_console_ai/memory_versions/show.html.erb +21 -0
  36. data/app/views/rails_console_ai/skill_versions/index.html.erb +28 -0
  37. data/app/views/rails_console_ai/skill_versions/show.html.erb +23 -0
  38. data/app/views/rails_console_ai/skills/_form.html.erb +65 -0
  39. data/app/views/rails_console_ai/skills/diff.html.erb +22 -0
  40. data/app/views/rails_console_ai/skills/edit.html.erb +7 -0
  41. data/app/views/rails_console_ai/skills/index.html.erb +79 -0
  42. data/app/views/rails_console_ai/skills/new.html.erb +25 -0
  43. data/app/views/rails_console_ai/skills/show.html.erb +94 -0
  44. data/config/routes.rb +42 -0
  45. data/lib/rails_console_ai/agent_loader.rb +131 -43
  46. data/lib/rails_console_ai/agent_runner.rb +158 -0
  47. data/lib/rails_console_ai/channel/api.rb +139 -0
  48. data/lib/rails_console_ai/conversation_engine.rb +19 -13
  49. data/lib/rails_console_ai/session_logger.rb +6 -0
  50. data/lib/rails_console_ai/skill_loader.rb +119 -27
  51. data/lib/rails_console_ai/storage/database_storage.rb +201 -0
  52. data/lib/rails_console_ai/tools/memory_tools.rb +102 -32
  53. data/lib/rails_console_ai/tools/registry.rb +99 -8
  54. data/lib/rails_console_ai/version.rb +1 -1
  55. data/lib/rails_console_ai.rb +256 -0
  56. data/lib/tasks/rails_console_ai.rake +7 -0
  57. metadata +55 -1
@@ -0,0 +1,94 @@
1
+ <%
2
+ # @skill can be either a Skill AR record or a Hash from a file source.
3
+ is_db = @skill.is_a?(RailsConsoleAi::Skill)
4
+ name = is_db ? @skill.name : @skill['name']
5
+ description = is_db ? @skill.description : @skill['description']
6
+ body = is_db ? @skill.body : @skill['body']
7
+ tags = is_db ? Array(@skill.tags) : Array(@skill['tags'])
8
+ bypass = is_db ? Array(@skill.bypass_guards_for_methods) : Array(@skill['bypass_guards_for_methods'])
9
+ file_key = is_db ? nil : @skill['file_key']
10
+ status = is_db ? @skill.status : 'approved' # file skills are implicitly approved
11
+ %>
12
+
13
+ <%= link_to '← All skills', skills_path, class: 'back-link' %>
14
+
15
+ <% if is_db && !@skill.approved? %>
16
+ <div class="flash flash-alert" style="margin-bottom:16px;">
17
+ <strong>Awaiting approval.</strong> The AI cannot activate this skill until a human approves it.
18
+ Review the recipe and bypass-guard list below, then click <strong>Approve</strong>.
19
+ </div>
20
+ <% end %>
21
+
22
+ <div class="meta-card">
23
+ <h2 style="margin-bottom:8px;">
24
+ <%= name %>
25
+ <% if is_db %>
26
+ <span class="badge source-db">DB</span>
27
+ <% if @skill.approved? %>
28
+ <span class="badge status-approved">APPROVED</span>
29
+ <% else %>
30
+ <span class="badge status-proposed">PROPOSED</span>
31
+ <% end %>
32
+ <% else %>
33
+ <span class="badge source-file">FILE</span>
34
+ <% end %>
35
+ </h2>
36
+ <p class="text-muted"><%= description %></p>
37
+ <div class="meta-grid" style="margin-top:16px;">
38
+ <div class="meta-item"><label>Tags</label><span><%= tags.empty? ? '—' : tags.join(', ') %></span></div>
39
+ <div class="meta-item"><label>Bypass guards</label><span><%= bypass.empty? ? '—' : bypass.join(', ') %></span></div>
40
+ <% if is_db %>
41
+ <div class="meta-item"><label>Updated</label><span><%= @skill.updated_at.strftime('%Y-%m-%d %H:%M') %></span></div>
42
+ <div class="meta-item"><label>Versions</label><span><%= @skill.versions.count %></span></div>
43
+ <div class="meta-item"><label>Times activated</label><span><%= @skill.use_count %></span></div>
44
+ <div class="meta-item"><label>Last activated</label><span><%= @skill.last_used_at&.strftime('%Y-%m-%d %H:%M') || 'never' %></span></div>
45
+ <% if @skill.approved? %>
46
+ <div class="meta-item"><label>Approved by</label><span><%= @skill.approved_by || '—' %></span></div>
47
+ <div class="meta-item"><label>Approved at</label><span><%= @skill.approved_at&.strftime('%Y-%m-%d %H:%M') || '—' %></span></div>
48
+ <% end %>
49
+ <% else %>
50
+ <div class="meta-item"><label>Path</label><span class="mono"><%= file_key %></span></div>
51
+ <% end %>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="btn-bar">
56
+ <% if is_db %>
57
+ <% unless @skill.approved? %>
58
+ <%= form_with url: approve_skill_path(@skill), method: :post, local: true, style: 'display:inline' do %>
59
+ <input type="text" name="approved_by" placeholder="Your name (required)" style="padding:7px 10px; border:1px solid #ccc; border-radius:4px; margin-right:6px;">
60
+ <button type="submit" class="btn btn-danger" data-confirm="Approve this skill? The AI will then be able to activate it and use its guard bypasses.">Approve</button>
61
+ <% end %>
62
+ <% end %>
63
+ <%= link_to 'Edit', edit_skill_path(@skill), class: 'btn' %>
64
+ <%= link_to 'Versions', skill_versions_path(@skill), class: 'btn btn-secondary' %>
65
+ <%= button_to 'Delete', skill_path(@skill), method: :delete, class: 'btn btn-danger', form: { style: 'display:inline' }, data: { confirm: 'Delete this skill? Past versions remain in history.' } %>
66
+ <% end %>
67
+ </div>
68
+
69
+ <h3 style="margin-bottom:8px;">Recipe</h3>
70
+ <div class="markdown-body"><%= body %></div>
71
+
72
+ <% if is_db && @versions && @versions.any? %>
73
+ <h3 style="margin:24px 0 8px;">Recent versions</h3>
74
+ <div class="versions-list">
75
+ <% @versions.first(5).each do |v| %>
76
+ <div class="ver-row">
77
+ <div>
78
+ <strong>v<%= v.id %></strong>
79
+ <span class="ver-meta">— <%= v.created_at.strftime('%Y-%m-%d %H:%M') %> by <%= v.edited_by || 'unknown' %></span>
80
+ <% if v.status == 'approved' %><span class="badge status-approved">APPROVED</span><% end %>
81
+ <% if v.change_note.present? %><div class="ver-meta"><%= v.change_note %></div><% end %>
82
+ </div>
83
+ <div>
84
+ <%= link_to 'View', skill_version_path(@skill, v) %>
85
+ ·
86
+ <%= link_to 'Diff vs current', diff_skills_path(skill_id: @skill.id, from: v.id) %>
87
+ </div>
88
+ </div>
89
+ <% end %>
90
+ </div>
91
+ <% if @versions.size > 5 %>
92
+ <p style="margin-top:8px;"><%= link_to "See all #{@versions.size} versions →", skill_versions_path(@skill) %></p>
93
+ <% end %>
94
+ <% end %>
data/config/routes.rb CHANGED
@@ -1,4 +1,46 @@
1
1
  RailsConsoleAi::Engine.routes.draw do
2
2
  root to: 'sessions#index'
3
3
  resources :sessions, only: [:index, :show]
4
+
5
+ resources :skills do
6
+ member do
7
+ post :approve
8
+ end
9
+ collection do
10
+ get :diff
11
+ post :import
12
+ end
13
+ resources :versions, only: [:index, :show], controller: 'skill_versions' do
14
+ member do
15
+ post :restore
16
+ end
17
+ end
18
+ end
19
+
20
+ resources :memories do
21
+ collection do
22
+ get :diff
23
+ post :import
24
+ end
25
+ resources :versions, only: [:index, :show], controller: 'memory_versions' do
26
+ member do
27
+ post :restore
28
+ end
29
+ end
30
+ end
31
+
32
+ resources :agents do
33
+ member do
34
+ post :approve
35
+ end
36
+ collection do
37
+ get :diff
38
+ post :import
39
+ end
40
+ resources :versions, only: [:index, :show], controller: 'agent_versions' do
41
+ member do
42
+ post :restore
43
+ end
44
+ end
45
+ end
4
46
  end
@@ -1,4 +1,5 @@
1
1
  require 'yaml'
2
+ require 'rails_console_ai/storage/database_storage'
2
3
 
3
4
  module RailsConsoleAi
4
5
  class AgentLoader
@@ -9,21 +10,32 @@ module RailsConsoleAi
9
10
  @storage = storage || RailsConsoleAi.storage
10
11
  end
11
12
 
13
+ # Three-source union: DB > file > built-in.
14
+ # Each record is tagged with `source: :db | :file | :builtin`. DB records
15
+ # also carry `status`, `approved_by`, `approved_at`. Proposed (unapproved)
16
+ # DB agents are surfaced here so the admin UI can render them — use
17
+ # #load_activatable_agents on AI-facing paths to filter them out.
12
18
  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
- []
19
+ db = safe_load_db_agents
20
+ file = safe_load_file_agents
21
+ builtin = safe_load_builtin_agents
22
+
23
+ db_names = db.map { |a| a['name'].to_s.downcase }
24
+ file.reject! { |a| db_names.include?(a['name'].to_s.downcase) }
25
+
26
+ file_names = file.map { |a| a['name'].to_s.downcase }
27
+ builtin.reject! { |a| db_names.include?(a['name'].to_s.downcase) || file_names.include?(a['name'].to_s.downcase) }
28
+
29
+ (db + file + builtin).sort_by { |a| a['name'].to_s.downcase }
30
+ end
31
+
32
+ # AI-facing: hides proposed DB agents. File + built-in are pre-approved.
33
+ def load_activatable_agents
34
+ load_all_agents.reject { |a| a['source'] == :db && a['status'] != 'approved' }
23
35
  end
24
36
 
25
37
  def agent_summaries
26
- agents = load_all_agents
38
+ agents = load_activatable_agents
27
39
  return nil if agents.empty?
28
40
 
29
41
  agents.map { |a|
@@ -31,45 +43,79 @@ module RailsConsoleAi
31
43
  }
32
44
  end
33
45
 
46
+ # AI-facing — returns nil for proposed DB agents so delegate_task can't reach them.
34
47
  def find_agent(name)
35
- agents = load_all_agents
36
- agents.find { |a| a['name'].to_s.downcase == name.to_s.downcase }
48
+ load_activatable_agents.find { |a| a['name'].to_s.downcase == name.to_s.downcase }
37
49
  end
38
50
 
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?
51
+ # UI-facing includes proposed DB agents.
52
+ def find_any_agent(name)
53
+ load_all_agents.find { |a| a['name'].to_s.downcase == name.to_s.downcase }
54
+ end
50
55
 
51
- content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
52
- @storage.write(key, content)
56
+ # target: :db (default) | :file
57
+ # Falls back to :file (with a notice in the return string) if DB tables aren't set up.
58
+ def save_agent(name:, description:, body:, max_rounds: nil, model: nil, tools: nil, target: :db, edited_by: nil, change_note: nil)
59
+ target = (target || :db).to_sym
60
+ db_fell_back = false
61
+ if target == :db && !Storage::DatabaseStorage.agents_available?
62
+ target = :file
63
+ db_fell_back = true
64
+ end
53
65
 
54
- path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
55
- if existing
56
- "Agent updated: \"#{name}\" (#{path})"
66
+ if target == :file
67
+ result = save_agent_to_file(
68
+ name: name, description: description, body: body,
69
+ max_rounds: max_rounds, model: model, tools: tools
70
+ )
71
+ if db_fell_back
72
+ result += "\nNOTE: DB storage was requested but the rails_console_ai_agents table does not exist. " \
73
+ "Run `ai_db_setup` in your Rails console to enable the versioned DB store. " \
74
+ "Saved to a file instead."
75
+ end
76
+ result
57
77
  else
58
- "Agent created: \"#{name}\" (#{path})"
78
+ record, was_new = Storage::DatabaseStorage.save_agent(
79
+ name: name, description: description, body: body,
80
+ max_rounds: max_rounds, model: model, tools: Array(tools),
81
+ edited_by: edited_by || 'ai', change_note: change_note
82
+ )
83
+ status_note = if record.respond_to?(:proposed?) && record.proposed?
84
+ ' — status: PROPOSED. A human must approve it at /rails_console_ai/agents before delegate_task can invoke it.'
85
+ else
86
+ ''
87
+ end
88
+ if was_new
89
+ "Agent created (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
90
+ else
91
+ "Agent updated (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
92
+ end
59
93
  end
60
- rescue Storage::StorageError => e
94
+ rescue Storage::StorageError, ::ActiveRecord::RecordInvalid => e
61
95
  "FAILED to save agent (#{e.message})."
62
96
  end
63
97
 
98
+ # Tries DB first, then file. Built-in agents can't be deleted.
64
99
  def delete_agent(name:)
100
+ if Storage::DatabaseStorage.delete_agent_by_name(name)
101
+ return "Agent deleted (db): \"#{name}\""
102
+ end
103
+
104
+ # Built-in agents are gem-shipped and not deletable.
105
+ builtin = safe_load_builtin_agents.find { |a| a['name'].to_s.downcase == name.to_s.downcase }
106
+ if builtin
107
+ return "Cannot delete built-in agent \"#{builtin['name']}\". Built-in agents ship with the gem. " \
108
+ "Create a same-named DB agent to override it instead."
109
+ end
110
+
65
111
  key = agent_key(name)
66
112
  unless @storage.exists?(key)
67
- found = load_all_agents.find { |a| a['name'].to_s.downcase == name.to_s.downcase }
113
+ found = safe_load_file_agents.find { |a| a['name'].to_s.downcase == name.to_s.downcase }
68
114
  return "No agent found: \"#{name}\"" unless found
69
115
  key = agent_key(found['name'])
70
116
  end
71
117
 
72
- agent = load_agent(key)
118
+ agent = load_agent_file(key)
73
119
  @storage.delete(key)
74
120
  "Agent deleted: \"#{agent ? agent['name'] : name}\""
75
121
  rescue Storage::StorageError => e
@@ -78,24 +124,59 @@ module RailsConsoleAi
78
124
 
79
125
  private
80
126
 
81
- def load_builtin_agents
127
+ def safe_load_db_agents
128
+ Storage::DatabaseStorage.all_agents
129
+ end
130
+
131
+ def safe_load_file_agents
132
+ keys = @storage.list("#{AGENTS_DIR}/*.md")
133
+ keys.filter_map { |key|
134
+ agent = load_agent_file(key)
135
+ next nil unless agent
136
+ agent.merge('source' => :file, 'file_key' => key)
137
+ }
138
+ rescue => e
139
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load file agents: #{e.message}")
140
+ []
141
+ end
142
+
143
+ def safe_load_builtin_agents
82
144
  return [] unless File.directory?(BUILTIN_DIR)
83
145
  Dir.glob(File.join(BUILTIN_DIR, '*.md')).sort.filter_map do |path|
84
- content = File.read(path)
146
+ # Explicit UTF-8 — File.read defaults to the locale encoding, which on
147
+ # some CI / spec setups is US-ASCII and chokes on em-dashes etc.
148
+ content = File.read(path, encoding: 'UTF-8')
85
149
  agent = parse_agent(content)
86
- agent['builtin'] = true if agent
87
- agent
150
+ next nil unless agent
151
+ agent.merge('source' => :builtin, 'builtin' => true, 'file_key' => path)
88
152
  end
89
153
  rescue => e
90
154
  RailsConsoleAi.logger.debug("RailsConsoleAi: failed to load built-in agents: #{e.message}")
91
155
  []
92
156
  end
93
157
 
94
- def load_app_agents
95
- keys = @storage.list("#{AGENTS_DIR}/*.md")
96
- keys.filter_map { |key| load_agent(key) }
97
- rescue => e
98
- []
158
+ def save_agent_to_file(name:, description:, body:, max_rounds:, model:, tools:)
159
+ key = agent_key(name)
160
+ existing = load_agent_file(key)
161
+
162
+ frontmatter = {
163
+ 'name' => name,
164
+ 'description' => description
165
+ }
166
+ frontmatter['max_rounds'] = max_rounds if max_rounds
167
+ frontmatter['model'] = model if model
168
+ tool_list = Array(tools)
169
+ frontmatter['tools'] = tool_list unless tool_list.empty?
170
+
171
+ content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
172
+ @storage.write(key, content)
173
+
174
+ path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
175
+ if existing
176
+ "Agent updated: \"#{name}\" (#{path})"
177
+ else
178
+ "Agent created: \"#{name}\" (#{path})"
179
+ end
99
180
  end
100
181
 
101
182
  def agent_key(name)
@@ -107,7 +188,7 @@ module RailsConsoleAi
107
188
  "#{AGENTS_DIR}/#{slug}.md"
108
189
  end
109
190
 
110
- def load_agent(key)
191
+ def load_agent_file(key)
111
192
  content = @storage.read(key)
112
193
  return nil if content.nil? || content.strip.empty?
113
194
  parse_agent(content)
@@ -117,10 +198,17 @@ module RailsConsoleAi
117
198
  end
118
199
 
119
200
  def parse_agent(content)
201
+ self.class.parse(content)
202
+ end
203
+
204
+ # Public: parse a raw .md (YAML frontmatter + body) string into a hash.
205
+ def self.parse(content)
120
206
  return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
121
207
  frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
122
208
  body = $2.strip
123
209
  frontmatter.merge('body' => body)
210
+ rescue Psych::SyntaxError
211
+ nil
124
212
  end
125
213
  end
126
214
  end
@@ -0,0 +1,158 @@
1
+ require 'rails_console_ai/prefixed_io'
2
+ require 'rails_console_ai/session_logger'
3
+ require 'rails_console_ai/channel/api'
4
+ require 'rails_console_ai/conversation_engine'
5
+ require 'rails_console_ai/context_builder'
6
+ require 'rails_console_ai/providers/base'
7
+ require 'rails_console_ai/executor'
8
+
9
+ module RailsConsoleAi
10
+ # Long-running worker that polls the sessions table for queued agent_api
11
+ # rows, claims them atomically, and runs each in its own Thread via
12
+ # ConversationEngine#one_shot. Started by `rake rails_console_ai:agents`.
13
+ class AgentRunner
14
+ POLL_INTERVAL = 2.0
15
+ DEFAULT_CONCURRENCY = 3
16
+ DRAIN_TIMEOUT = 60
17
+
18
+ def initialize(concurrency: DEFAULT_CONCURRENCY, poll_interval: POLL_INTERVAL)
19
+ @concurrency = concurrency
20
+ @poll_interval = poll_interval
21
+ @threads = {} # session_id => Thread
22
+ @mutex = Mutex.new
23
+ @stopping = false
24
+ end
25
+
26
+ def start
27
+ $stdout.sync = true
28
+ $stderr.sync = true
29
+ $stdout = RailsConsoleAi::PrefixedIO.new($stdout) unless $stdout.is_a?(RailsConsoleAi::PrefixedIO)
30
+ $stderr = RailsConsoleAi::PrefixedIO.new($stderr) unless $stderr.is_a?(RailsConsoleAi::PrefixedIO)
31
+
32
+ install_signal_handlers
33
+ puts "AgentRunner starting (concurrency=#{@concurrency}, poll=#{@poll_interval}s)"
34
+ loop do
35
+ break if @stopping
36
+ reap_finished
37
+ fill_slots
38
+ sleep @poll_interval
39
+ end
40
+ drain
41
+ puts "AgentRunner stopped."
42
+ end
43
+
44
+ private
45
+
46
+ def install_signal_handlers
47
+ %w[INT TERM].each do |sig|
48
+ Signal.trap(sig) { @stopping = true }
49
+ end
50
+ end
51
+
52
+ def reap_finished
53
+ @mutex.synchronize { @threads.reject! { |_, t| !t.alive? } }
54
+ end
55
+
56
+ def slots_available
57
+ @concurrency - @mutex.synchronize { @threads.size }
58
+ end
59
+
60
+ def fill_slots
61
+ slots = slots_available
62
+ return if slots <= 0
63
+ claim_next(slots).each { |session| spawn(session) }
64
+ rescue => e
65
+ warn "AgentRunner fill_slots failed: #{e.class}: #{e.message}"
66
+ end
67
+
68
+ # Returns up to `limit` Session records the runner exclusively owns.
69
+ # Atomic claim: only the runner whose UPDATE flips the row from
70
+ # 'queued' to 'running' may execute it.
71
+ def claim_next(limit)
72
+ candidates = Session.where(mode: 'agent_api', status: 'queued')
73
+ .order(:created_at)
74
+ .limit(limit)
75
+ .pluck(:id)
76
+ claimed = []
77
+ candidates.each do |id|
78
+ n = Session.where(id: id, status: 'queued', mode: 'agent_api')
79
+ .update_all(status: 'running')
80
+ claimed << Session.find(id) if n == 1
81
+ end
82
+ claimed
83
+ rescue => e
84
+ warn "AgentRunner claim failed: #{e.class}: #{e.message}"
85
+ []
86
+ end
87
+
88
+ def spawn(session)
89
+ tag = "[agent/#{session.id}] @#{session.user_name || '?'}"
90
+ puts "#{tag} << #{session.query.to_s.strip}"
91
+
92
+ t = Thread.new do
93
+ Thread.current.report_on_exception = false
94
+ Thread.current[:log_prefix] = tag
95
+ begin
96
+ run_one(session)
97
+ ensure
98
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
99
+ end
100
+ end
101
+ @mutex.synchronize { @threads[session.id] = t }
102
+ end
103
+
104
+ def run_one(session)
105
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
+ channel = Channel::Api.new(user_name: session.user_name)
107
+ sandbox_binding = Object.new.instance_eval { binding }
108
+ engine = ConversationEngine.new(binding_context: sandbox_binding, channel: channel)
109
+
110
+ exec_result = engine.one_shot(session.query, existing_session_id: session.id)
111
+ result_text = compose_result(channel.captured_output, exec_result)
112
+
113
+ SessionLogger.update(session.id, status: 'ready', result: result_text)
114
+
115
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
116
+ preview = result_text.to_s.strip.lines.first.to_s.strip
117
+ preview = preview[0, 120] + '…' if preview.length > 120
118
+ puts ">> ready (#{elapsed}ms) #{preview}"
119
+ rescue => e
120
+ warn ">> FAILED #{e.class}: #{e.message}"
121
+ e.backtrace&.first(5)&.each { |line| warn " #{line}" }
122
+ SessionLogger.update(session.id,
123
+ status: 'failed',
124
+ error_message: "#{e.class}: #{e.message}\n#{Array(e.backtrace).first(10).join("\n")}"
125
+ )
126
+ end
127
+
128
+ # Build the `result` payload returned via get_agent_response. The
129
+ # LLM's prose lands in the channel's captured_output; the value the
130
+ # generated code returned lands in exec_result. Without including the
131
+ # latter, the consumer gets only the preamble ("Let me query...")
132
+ # and not the actual answer.
133
+ def compose_result(prose, exec_result)
134
+ parts = []
135
+ trimmed = prose.to_s.strip
136
+ parts << trimmed unless trimmed.empty?
137
+ parts << "Result: #{exec_result.inspect}" unless exec_result.nil?
138
+ parts.empty? ? '' : (parts.join("\n\n") << "\n")
139
+ end
140
+
141
+ def drain
142
+ n = @mutex.synchronize { @threads.size }
143
+ return if n.zero?
144
+ puts "AgentRunner draining #{n} in-flight job(s)..."
145
+ deadline = Time.now + DRAIN_TIMEOUT
146
+ loop do
147
+ reap_finished
148
+ break unless @mutex.synchronize { @threads.any? }
149
+ break if Time.now >= deadline
150
+ sleep 0.5
151
+ end
152
+ stuck = @mutex.synchronize { @threads.keys }
153
+ stuck.each do |id|
154
+ SessionLogger.update(id, status: 'failed', error_message: 'Runner shut down before completion')
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,139 @@
1
+ require 'rails_console_ai/channel/base'
2
+
3
+ module RailsConsoleAi
4
+ module Channel
5
+ # Non-interactive channel used by the AgentRunner. It owns its own
6
+ # buffers — there is no parent channel and no user to prompt. The
7
+ # runner reads `captured_output` back as the agent's `result`.
8
+ #
9
+ # Mirrors Channel::Slack's pattern of logging every display event to
10
+ # STDOUT (tagged) so the rake task log shows progress in real time,
11
+ # while ALSO buffering display output into `captured_output` for the
12
+ # runner to compose the final result.
13
+ class Api < Base
14
+ ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]/.freeze
15
+
16
+ attr_reader :captured_output, :status_log
17
+
18
+ def initialize(user_name: nil)
19
+ @user_name = user_name
20
+ @captured_output = +''
21
+ @status_log = []
22
+ end
23
+
24
+ def display(text)
25
+ stripped = strip_ansi(text)
26
+ @captured_output << stripped << "\n"
27
+ log_prefixed(">>", stripped)
28
+ end
29
+
30
+ def display_result(text)
31
+ stripped = strip_ansi(text)
32
+ @captured_output << stripped << "\n"
33
+ log_prefixed(">>", stripped)
34
+ end
35
+
36
+ def display_result_output(text)
37
+ stripped = strip_ansi(text)
38
+ @captured_output << stripped << "\n"
39
+ log_prefixed(">>", stripped)
40
+ end
41
+
42
+ def display_code(code)
43
+ # Don't add to captured_output — code itself is persisted on the
44
+ # session row as code_executed. But DO log it to STDOUT so the
45
+ # rake task log shows what was generated.
46
+ log_prefixed("(code)", '')
47
+ code.to_s.each_line { |line| log_prefixed("(code)", line.rstrip) }
48
+ end
49
+
50
+ def display_thinking(text)
51
+ stripped = strip_ansi(text).strip
52
+ return if stripped.empty?
53
+ @status_log << stripped
54
+ log_prefixed("(thinking)", stripped)
55
+ end
56
+
57
+ def display_status(text)
58
+ stripped = strip_ansi(text).strip
59
+ return if stripped.empty?
60
+ @status_log << stripped
61
+ log_prefixed("(status)", stripped)
62
+ end
63
+
64
+ def display_tool_call(text)
65
+ stripped = strip_ansi(text)
66
+ @status_log << stripped
67
+ log_prefixed("->", stripped)
68
+ end
69
+
70
+ def display_warning(text)
71
+ stripped = strip_ansi(text)
72
+ @status_log << "WARN: #{stripped}"
73
+ log_prefixed("(warn)", stripped)
74
+ end
75
+
76
+ def display_error(text)
77
+ stripped = strip_ansi(text)
78
+ @status_log << "ERROR: #{stripped}"
79
+ log_prefixed("(error)", stripped)
80
+ end
81
+
82
+ def prompt(_text)
83
+ '' # non-interactive — no user to ask
84
+ end
85
+
86
+ def confirm(_text)
87
+ # No human present to confirm. Auto-yes matches Channel::SubAgent's
88
+ # behavior — actual safety comes from the safety guards plus
89
+ # supports_danger? = false below, not from this prompt.
90
+ 'y'
91
+ end
92
+
93
+ def user_identity
94
+ @user_name
95
+ end
96
+
97
+ def mode
98
+ 'api'
99
+ end
100
+
101
+ def cancelled?
102
+ false
103
+ end
104
+
105
+ def supports_danger?
106
+ false # like sub_agent: never bypass safety guards without a human
107
+ end
108
+
109
+ def supports_editing?
110
+ false
111
+ end
112
+
113
+ def wrap_llm_call(&block)
114
+ yield
115
+ end
116
+
117
+ def system_instructions
118
+ nil
119
+ end
120
+
121
+ private
122
+
123
+ # Mirror of Channel::Slack#log_prefixed — emit each line through
124
+ # $stdout so PrefixedIO (installed by AgentRunner#start) adds the
125
+ # per-session tag from Thread.current[:log_prefix].
126
+ def log_prefixed(tag, text)
127
+ if text.to_s.strip.empty?
128
+ $stdout.puts(tag)
129
+ else
130
+ text.to_s.each_line { |line| $stdout.puts "#{tag} #{line.rstrip}" }
131
+ end
132
+ end
133
+
134
+ def strip_ansi(text)
135
+ text.to_s.gsub(ANSI_REGEX, '')
136
+ end
137
+ end
138
+ end
139
+ end