rails_console_ai 0.29.0 → 0.31.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 +46 -0
  3. data/README.md +65 -0
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +29 -0
  5. data/app/controllers/rails_console_ai/agents_controller.rb +176 -0
  6. data/app/controllers/rails_console_ai/application_controller.rb +5 -0
  7. data/app/controllers/rails_console_ai/memories_controller.rb +136 -0
  8. data/app/controllers/rails_console_ai/memory_versions_controller.rb +29 -0
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +29 -0
  10. data/app/controllers/rails_console_ai/skills_controller.rb +171 -0
  11. data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
  12. data/app/models/rails_console_ai/agent.rb +143 -0
  13. data/app/models/rails_console_ai/agent_version.rb +34 -0
  14. data/app/models/rails_console_ai/memory.rb +103 -0
  15. data/app/models/rails_console_ai/memory_version.rb +31 -0
  16. data/app/models/rails_console_ai/session.rb +1 -1
  17. data/app/models/rails_console_ai/skill.rb +148 -0
  18. data/app/models/rails_console_ai/skill_version.rb +33 -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 +40 -0
  23. data/app/views/rails_console_ai/agents/diff.html.erb +15 -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 +8 -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 +29 -0
  29. data/app/views/rails_console_ai/memories/diff.html.erb +15 -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 +8 -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 +43 -0
  39. data/app/views/rails_console_ai/skills/diff.html.erb +15 -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 +8 -0
  43. data/app/views/rails_console_ai/skills/show.html.erb +94 -0
  44. data/config/routes.rb +39 -0
  45. data/lib/rails_console_ai/agent_loader.rb +139 -43
  46. data/lib/rails_console_ai/agent_runner.rb +209 -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 +10 -0
  50. data/lib/rails_console_ai/skill_loader.rb +130 -29
  51. data/lib/rails_console_ai/storage/database_storage.rb +195 -0
  52. data/lib/rails_console_ai/tools/memory_tools.rb +110 -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 +240 -0
  56. data/lib/tasks/rails_console_ai.rake +7 -0
  57. metadata +55 -1
@@ -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,53 @@ 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
+ content = self.class.dump(
163
+ name: name, description: description, body: body,
164
+ max_rounds: max_rounds, model: model, tools: tools
165
+ )
166
+ @storage.write(key, content)
167
+
168
+ path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
169
+ if existing
170
+ "Agent updated: \"#{name}\" (#{path})"
171
+ else
172
+ "Agent created: \"#{name}\" (#{path})"
173
+ end
99
174
  end
100
175
 
101
176
  def agent_key(name)
@@ -107,7 +182,7 @@ module RailsConsoleAi
107
182
  "#{AGENTS_DIR}/#{slug}.md"
108
183
  end
109
184
 
110
- def load_agent(key)
185
+ def load_agent_file(key)
111
186
  content = @storage.read(key)
112
187
  return nil if content.nil? || content.strip.empty?
113
188
  parse_agent(content)
@@ -117,10 +192,31 @@ module RailsConsoleAi
117
192
  end
118
193
 
119
194
  def parse_agent(content)
195
+ self.class.parse(content)
196
+ end
197
+
198
+ # Public: parse a raw .md (YAML frontmatter + body) string into a hash.
199
+ def self.parse(content)
120
200
  return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
121
201
  frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
122
202
  body = $2.strip
123
203
  frontmatter.merge('body' => body)
204
+ rescue Psych::SyntaxError
205
+ nil
206
+ end
207
+
208
+ # Inverse of parse: emit a canonical .md (frontmatter + body) string from
209
+ # structured attrs.
210
+ def self.dump(name:, description:, body:, max_rounds: nil, model: nil, tools: nil)
211
+ frontmatter = {
212
+ 'name' => name,
213
+ 'description' => description
214
+ }
215
+ frontmatter['max_rounds'] = max_rounds if max_rounds
216
+ frontmatter['model'] = model if model
217
+ tool_list = Array(tools)
218
+ frontmatter['tools'] = tool_list unless tool_list.empty?
219
+ "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
124
220
  end
125
221
  end
126
222
  end
@@ -0,0 +1,209 @@
1
+ require 'json'
2
+ require 'rails_console_ai/prefixed_io'
3
+ require 'rails_console_ai/session_logger'
4
+ require 'rails_console_ai/channel/api'
5
+ require 'rails_console_ai/conversation_engine'
6
+ require 'rails_console_ai/context_builder'
7
+ require 'rails_console_ai/providers/base'
8
+ require 'rails_console_ai/executor'
9
+
10
+ module RailsConsoleAi
11
+ class RunnerTimeoutError < StandardError; end
12
+
13
+ # Long-running worker that polls the sessions table for queued agent_api
14
+ # rows, claims them atomically, and runs each in its own Thread via
15
+ # ConversationEngine#one_shot. Started by `rake rails_console_ai:agents`.
16
+ class AgentRunner
17
+ POLL_INTERVAL = 2.0
18
+ DEFAULT_CONCURRENCY = 3
19
+ DRAIN_TIMEOUT = 60
20
+
21
+ def initialize(concurrency: DEFAULT_CONCURRENCY, poll_interval: POLL_INTERVAL)
22
+ @concurrency = concurrency
23
+ @poll_interval = poll_interval
24
+ @threads = {} # session_id => Thread
25
+ @mutex = Mutex.new
26
+ @stopping = false
27
+ end
28
+
29
+ def start
30
+ $stdout.sync = true
31
+ $stderr.sync = true
32
+ $stdout = RailsConsoleAi::PrefixedIO.new($stdout) unless $stdout.is_a?(RailsConsoleAi::PrefixedIO)
33
+ $stderr = RailsConsoleAi::PrefixedIO.new($stderr) unless $stderr.is_a?(RailsConsoleAi::PrefixedIO)
34
+
35
+ install_signal_handlers
36
+ puts "AgentRunner starting (concurrency=#{@concurrency}, poll=#{@poll_interval}s)"
37
+ loop do
38
+ break if @stopping
39
+ reap_finished
40
+ fill_slots
41
+ sleep @poll_interval
42
+ end
43
+ drain
44
+ puts "AgentRunner stopped."
45
+ end
46
+
47
+ private
48
+
49
+ def install_signal_handlers
50
+ %w[INT TERM].each do |sig|
51
+ Signal.trap(sig) { @stopping = true }
52
+ end
53
+ end
54
+
55
+ def reap_finished
56
+ @mutex.synchronize { @threads.reject! { |_, t| !t.alive? } }
57
+ end
58
+
59
+ def slots_available
60
+ @concurrency - @mutex.synchronize { @threads.size }
61
+ end
62
+
63
+ def fill_slots
64
+ slots = slots_available
65
+ return if slots <= 0
66
+ claim_next(slots).each { |session| spawn(session) }
67
+ rescue => e
68
+ warn "AgentRunner fill_slots failed: #{e.class}: #{e.message}"
69
+ end
70
+
71
+ # Returns up to `limit` Session records the runner exclusively owns.
72
+ # Atomic claim: only the runner whose UPDATE flips the row from
73
+ # 'queued' to 'running' may execute it.
74
+ def claim_next(limit)
75
+ candidates = Session.where(mode: 'agent_api', status: 'queued')
76
+ .order(:created_at)
77
+ .limit(limit)
78
+ .pluck(:id)
79
+ claimed = []
80
+ candidates.each do |id|
81
+ n = Session.where(id: id, status: 'queued', mode: 'agent_api')
82
+ .update_all(status: 'running')
83
+ claimed << Session.find(id) if n == 1
84
+ end
85
+ claimed
86
+ rescue => e
87
+ warn "AgentRunner claim failed: #{e.class}: #{e.message}"
88
+ []
89
+ end
90
+
91
+ def spawn(session)
92
+ tag = "[agent/#{session.id}] @#{session.user_name || '?'}"
93
+ opts = parse_options(session)
94
+ banner_extras = []
95
+ banner_extras << 'thinking' if opts['use_thinking_model']
96
+ if (cap = opts['max_wall_clock_seconds'])
97
+ banner_extras << "cap=#{cap}s"
98
+ end
99
+ banner = banner_extras.empty? ? '' : " (#{banner_extras.join(', ')})"
100
+ puts "#{tag}#{banner} << #{session.query.to_s.strip}"
101
+
102
+ t = Thread.new do
103
+ Thread.current.report_on_exception = false
104
+ Thread.current[:log_prefix] = tag
105
+ begin
106
+ run_one(session, opts)
107
+ ensure
108
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
109
+ end
110
+ end
111
+ @mutex.synchronize { @threads[session.id] = t }
112
+ end
113
+
114
+ def parse_options(session)
115
+ raw = session.respond_to?(:options) ? session.options : nil
116
+ return {} if raw.nil? || raw.to_s.empty?
117
+ return raw if raw.is_a?(Hash)
118
+ JSON.parse(raw)
119
+ rescue => e
120
+ warn "AgentRunner: failed to parse session.options (#{e.class}: #{e.message}); ignoring."
121
+ {}
122
+ end
123
+
124
+ def run_one(session, opts = nil)
125
+ opts ||= parse_options(session)
126
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
+ channel = Channel::Api.new(user_name: session.user_name)
128
+ sandbox_binding = Object.new.instance_eval { binding }
129
+ engine = ConversationEngine.new(binding_context: sandbox_binding, channel: channel)
130
+ engine.upgrade_to_thinking_model if opts['use_thinking_model']
131
+
132
+ exec_result = run_with_deadline(opts['max_wall_clock_seconds']) do
133
+ engine.one_shot(session.query, existing_session_id: session.id)
134
+ end
135
+ result_text = compose_result(channel.captured_output, exec_result)
136
+
137
+ SessionLogger.update(session.id, status: 'ready', result: result_text)
138
+
139
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
140
+ preview = result_text.to_s.strip.lines.first.to_s.strip
141
+ preview = preview[0, 120] + '…' if preview.length > 120
142
+ puts ">> ready (#{elapsed}ms) #{preview}"
143
+ rescue RunnerTimeoutError => e
144
+ warn ">> TIMEOUT #{e.message}"
145
+ SessionLogger.update(session.id, status: 'failed', error_message: e.message)
146
+ rescue => e
147
+ warn ">> FAILED #{e.class}: #{e.message}"
148
+ e.backtrace&.first(5)&.each { |line| warn " #{line}" }
149
+ SessionLogger.update(session.id,
150
+ status: 'failed',
151
+ error_message: "#{e.class}: #{e.message}\n#{Array(e.backtrace).first(10).join("\n")}"
152
+ )
153
+ end
154
+
155
+ # When cap is nil or non-positive, run inline. Otherwise spawn a nested
156
+ # worker thread, join with the deadline, and kill + raise on overshoot.
157
+ # Kept localized (vs Timeout.timeout) so a runaway provider call can't
158
+ # raise from inside our own bookkeeping code.
159
+ def run_with_deadline(cap)
160
+ return yield if cap.nil? || cap.to_f <= 0
161
+ result = nil
162
+ error = nil
163
+ worker = Thread.new do
164
+ Thread.current.report_on_exception = false
165
+ begin
166
+ result = yield
167
+ rescue => e
168
+ error = e
169
+ end
170
+ end
171
+ if worker.join(cap.to_f).nil?
172
+ worker.kill
173
+ raise RunnerTimeoutError, "exceeded max_wall_clock_seconds (#{cap}s)"
174
+ end
175
+ raise error if error
176
+ result
177
+ end
178
+
179
+ # Build the `result` payload returned via get_agent_response. The
180
+ # LLM's prose lands in the channel's captured_output; the value the
181
+ # generated code returned lands in exec_result. Without including the
182
+ # latter, the consumer gets only the preamble ("Let me query...")
183
+ # and not the actual answer.
184
+ def compose_result(prose, exec_result)
185
+ parts = []
186
+ trimmed = prose.to_s.strip
187
+ parts << trimmed unless trimmed.empty?
188
+ parts << "Result: #{exec_result.inspect}" unless exec_result.nil?
189
+ parts.empty? ? '' : (parts.join("\n\n") << "\n")
190
+ end
191
+
192
+ def drain
193
+ n = @mutex.synchronize { @threads.size }
194
+ return if n.zero?
195
+ puts "AgentRunner draining #{n} in-flight job(s)..."
196
+ deadline = Time.now + DRAIN_TIMEOUT
197
+ loop do
198
+ reap_finished
199
+ break unless @mutex.synchronize { @threads.any? }
200
+ break if Time.now >= deadline
201
+ sleep 0.5
202
+ end
203
+ stuck = @mutex.synchronize { @threads.keys }
204
+ stuck.each do |id|
205
+ SessionLogger.update(id, status: 'failed', error_message: 'Runner shut down before completion')
206
+ end
207
+ end
208
+ end
209
+ 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
@@ -36,7 +36,7 @@ module RailsConsoleAi
36
36
 
37
37
  # --- Public API for channels ---
38
38
 
39
- def one_shot(query)
39
+ def one_shot(query, existing_session_id: nil)
40
40
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
41
  console_capture = StringIO.new
42
42
  exec_result = with_console_capture(console_capture) do
@@ -61,7 +61,8 @@ module RailsConsoleAi
61
61
  code_output: executed ? @executor.last_output : nil,
62
62
  code_result: executed && exec_result ? exec_result.inspect : nil,
63
63
  executed: executed,
64
- start_time: start_time
64
+ start_time: start_time,
65
+ existing_session_id: existing_session_id
65
66
  }
66
67
 
67
68
  exec_result
@@ -1016,18 +1017,23 @@ module RailsConsoleAi
1016
1017
 
1017
1018
  def log_session(attrs)
1018
1019
  require 'rails_console_ai/session_logger'
1019
- start_time = attrs.delete(:start_time)
1020
- duration_ms = if start_time
1021
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
1022
- end
1023
- SessionLogger.log(
1024
- attrs.merge(
1025
- input_tokens: @total_input_tokens,
1026
- output_tokens: @total_output_tokens,
1027
- duration_ms: duration_ms,
1028
- model: effective_model
1029
- )
1020
+ start_time = attrs.delete(:start_time)
1021
+ existing_id = attrs.delete(:existing_session_id)
1022
+ duration_ms = if start_time
1023
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
1024
+ end
1025
+ merged = attrs.merge(
1026
+ input_tokens: @total_input_tokens,
1027
+ output_tokens: @total_output_tokens,
1028
+ duration_ms: duration_ms,
1029
+ model: effective_model
1030
1030
  )
1031
+ if existing_id
1032
+ SessionLogger.update(existing_id, merged)
1033
+ existing_id
1034
+ else
1035
+ SessionLogger.log(merged)
1036
+ end
1031
1037
  end
1032
1038
 
1033
1039
  # --- Formatting helpers ---
@@ -25,6 +25,13 @@ module RailsConsoleAi
25
25
  }
26
26
  create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
27
27
  create_attrs[:slack_channel_name] = attrs[:slack_channel_name] if attrs[:slack_channel_name]
28
+ create_attrs[:status] = attrs[:status] if attrs.key?(:status)
29
+ create_attrs[:result] = attrs[:result] if attrs.key?(:result)
30
+ create_attrs[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
31
+ if attrs.key?(:options)
32
+ opts = attrs[:options]
33
+ create_attrs[:options] = opts.is_a?(String) ? opts : opts.to_json
34
+ end
28
35
  record = session_class.create!(create_attrs)
29
36
  record.id
30
37
  rescue => e
@@ -59,6 +66,9 @@ module RailsConsoleAi
59
66
  updates[:executed] = attrs[:executed] if attrs.key?(:executed)
60
67
  updates[:duration_ms] = attrs[:duration_ms] if attrs.key?(:duration_ms)
61
68
  updates[:name] = attrs[:name] if attrs.key?(:name)
69
+ updates[:status] = attrs[:status] if attrs.key?(:status)
70
+ updates[:result] = attrs[:result] if attrs.key?(:result)
71
+ updates[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
62
72
 
63
73
  session_class.where(id: id).update_all(updates) unless updates.empty?
64
74
  rescue => e