rails_console_ai 0.30.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +18 -1
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +1 -8
  5. data/app/controllers/rails_console_ai/agents_controller.rb +24 -47
  6. data/app/controllers/rails_console_ai/memories_controller.rb +13 -36
  7. data/app/controllers/rails_console_ai/memory_versions_controller.rb +1 -5
  8. data/app/controllers/rails_console_ai/skill_versions_controller.rb +1 -7
  9. data/app/controllers/rails_console_ai/skills_controller.rb +18 -47
  10. data/app/models/rails_console_ai/agent.rb +33 -65
  11. data/app/models/rails_console_ai/agent_version.rb +8 -20
  12. data/app/models/rails_console_ai/memory.rb +28 -23
  13. data/app/models/rails_console_ai/memory_version.rb +5 -20
  14. data/app/models/rails_console_ai/skill.rb +55 -105
  15. data/app/models/rails_console_ai/skill_version.rb +7 -28
  16. data/app/views/rails_console_ai/agents/_form.html.erb +9 -34
  17. data/app/views/rails_console_ai/agents/diff.html.erb +1 -5
  18. data/app/views/rails_console_ai/agents/new.html.erb +0 -16
  19. data/app/views/rails_console_ai/memories/_form.html.erb +6 -13
  20. data/app/views/rails_console_ai/memories/diff.html.erb +1 -5
  21. data/app/views/rails_console_ai/memories/new.html.erb +0 -15
  22. data/app/views/rails_console_ai/skills/_form.html.erb +7 -29
  23. data/app/views/rails_console_ai/skills/diff.html.erb +1 -8
  24. data/app/views/rails_console_ai/skills/new.html.erb +0 -17
  25. data/config/routes.rb +0 -3
  26. data/lib/rails_console_ai/agent_loader.rb +18 -10
  27. data/lib/rails_console_ai/agent_runner.rb +55 -4
  28. data/lib/rails_console_ai/session_logger.rb +4 -0
  29. data/lib/rails_console_ai/skill_loader.rb +18 -9
  30. data/lib/rails_console_ai/storage/database_storage.rb +14 -20
  31. data/lib/rails_console_ai/tools/memory_tools.rb +8 -0
  32. data/lib/rails_console_ai/version.rb +1 -1
  33. data/lib/rails_console_ai.rb +54 -70
  34. metadata +1 -1
@@ -1,3 +1,4 @@
1
+ require 'json'
1
2
  require 'rails_console_ai/prefixed_io'
2
3
  require 'rails_console_ai/session_logger'
3
4
  require 'rails_console_ai/channel/api'
@@ -7,6 +8,8 @@ require 'rails_console_ai/providers/base'
7
8
  require 'rails_console_ai/executor'
8
9
 
9
10
  module RailsConsoleAi
11
+ class RunnerTimeoutError < StandardError; end
12
+
10
13
  # Long-running worker that polls the sessions table for queued agent_api
11
14
  # rows, claims them atomically, and runs each in its own Thread via
12
15
  # ConversationEngine#one_shot. Started by `rake rails_console_ai:agents`.
@@ -87,13 +90,20 @@ module RailsConsoleAi
87
90
 
88
91
  def spawn(session)
89
92
  tag = "[agent/#{session.id}] @#{session.user_name || '?'}"
90
- puts "#{tag} << #{session.query.to_s.strip}"
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}"
91
101
 
92
102
  t = Thread.new do
93
103
  Thread.current.report_on_exception = false
94
104
  Thread.current[:log_prefix] = tag
95
105
  begin
96
- run_one(session)
106
+ run_one(session, opts)
97
107
  ensure
98
108
  ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
99
109
  end
@@ -101,13 +111,27 @@ module RailsConsoleAi
101
111
  @mutex.synchronize { @threads[session.id] = t }
102
112
  end
103
113
 
104
- def run_one(session)
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)
105
126
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
127
  channel = Channel::Api.new(user_name: session.user_name)
107
128
  sandbox_binding = Object.new.instance_eval { binding }
108
129
  engine = ConversationEngine.new(binding_context: sandbox_binding, channel: channel)
130
+ engine.upgrade_to_thinking_model if opts['use_thinking_model']
109
131
 
110
- exec_result = engine.one_shot(session.query, existing_session_id: session.id)
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
111
135
  result_text = compose_result(channel.captured_output, exec_result)
112
136
 
113
137
  SessionLogger.update(session.id, status: 'ready', result: result_text)
@@ -116,6 +140,9 @@ module RailsConsoleAi
116
140
  preview = result_text.to_s.strip.lines.first.to_s.strip
117
141
  preview = preview[0, 120] + '…' if preview.length > 120
118
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)
119
146
  rescue => e
120
147
  warn ">> FAILED #{e.class}: #{e.message}"
121
148
  e.backtrace&.first(5)&.each { |line| warn " #{line}" }
@@ -125,6 +152,30 @@ module RailsConsoleAi
125
152
  )
126
153
  end
127
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
+
128
179
  # Build the `result` payload returned via get_agent_response. The
129
180
  # LLM's prose lands in the channel's captured_output; the value the
130
181
  # generated code returned lands in exec_result. Without including the
@@ -28,6 +28,10 @@ module RailsConsoleAi
28
28
  create_attrs[:status] = attrs[:status] if attrs.key?(:status)
29
29
  create_attrs[:result] = attrs[:result] if attrs.key?(:result)
30
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
31
35
  record = session_class.create!(create_attrs)
32
36
  record.id
33
37
  rescue => e
@@ -122,15 +122,10 @@ module RailsConsoleAi
122
122
  key = skill_key(name)
123
123
  existing = load_skill_file(key)
124
124
 
125
- frontmatter = {
126
- 'name' => name,
127
- 'description' => description,
128
- 'tags' => Array(tags)
129
- }
130
- bypasses = Array(bypass_guards_for_methods)
131
- frontmatter['bypass_guards_for_methods'] = bypasses unless bypasses.empty?
132
-
133
- content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
125
+ content = self.class.dump(
126
+ name: name, description: description, body: body,
127
+ tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
128
+ )
134
129
  @storage.write(key, content)
135
130
 
136
131
  path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
@@ -190,5 +185,19 @@ module RailsConsoleAi
190
185
  rescue Psych::SyntaxError
191
186
  nil
192
187
  end
188
+
189
+ # Inverse of parse: emit a canonical .md (frontmatter + body) string from
190
+ # structured attrs. Used by AI tool callers and file-fallback writers so
191
+ # the on-disk and DB representations stay byte-identical.
192
+ def self.dump(name:, description:, body:, tags: [], bypass_guards_for_methods: [])
193
+ frontmatter = {
194
+ 'name' => name,
195
+ 'description' => description,
196
+ 'tags' => Array(tags)
197
+ }
198
+ bypasses = Array(bypass_guards_for_methods)
199
+ frontmatter['bypass_guards_for_methods'] = bypasses unless bypasses.empty?
200
+ "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
201
+ end
193
202
  end
194
203
  end
@@ -68,14 +68,12 @@ module RailsConsoleAi
68
68
  record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
69
69
  record ||= RailsConsoleAi::Skill.new
70
70
  was_new = record.new_record?
71
+ content = RailsConsoleAi::SkillLoader.dump(
72
+ name: name, description: description, body: body,
73
+ tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
74
+ )
71
75
  record.update_with_version!(
72
- {
73
- name: name,
74
- description: description,
75
- body: body,
76
- tags: Array(tags),
77
- bypass_guards_for_methods: Array(bypass_guards_for_methods)
78
- },
76
+ { content: content },
79
77
  edited_by: edited_by,
80
78
  change_note: change_note
81
79
  )
@@ -114,12 +112,11 @@ module RailsConsoleAi
114
112
  record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
115
113
  record ||= RailsConsoleAi::Memory.new
116
114
  was_new = record.new_record?
115
+ content = RailsConsoleAi::Tools::MemoryTools.dump(
116
+ name: name, description: description, tags: tags
117
+ )
117
118
  record.update_with_version!(
118
- {
119
- name: name,
120
- description: description,
121
- tags: Array(tags)
122
- },
119
+ { content: content },
123
120
  edited_by: edited_by,
124
121
  change_note: change_note
125
122
  )
@@ -158,15 +155,12 @@ module RailsConsoleAi
158
155
  record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
159
156
  record ||= RailsConsoleAi::Agent.new
160
157
  was_new = record.new_record?
158
+ content = RailsConsoleAi::AgentLoader.dump(
159
+ name: name, description: description, body: body,
160
+ max_rounds: max_rounds, model: model, tools: tools
161
+ )
161
162
  record.update_with_version!(
162
- {
163
- name: name,
164
- description: description,
165
- body: body,
166
- max_rounds: max_rounds,
167
- model: model,
168
- tools: Array(tools)
169
- },
163
+ { content: content },
170
164
  edited_by: edited_by,
171
165
  change_note: change_note
172
166
  )
@@ -211,6 +211,14 @@ module RailsConsoleAi
211
211
  nil
212
212
  end
213
213
 
214
+ # Inverse of parse: emit a canonical .md string. The DB store uses this
215
+ # minimal form (name + tags only in frontmatter); the file store layers
216
+ # created_at/updated_at on top via save_memory_to_file.
217
+ def self.dump(name:, description:, tags: [])
218
+ frontmatter = { 'name' => name, 'tags' => Array(tags) }
219
+ "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
220
+ end
221
+
214
222
  def find_memory_key_by_name(name)
215
223
  keys = @storage.list("#{MEMORIES_DIR}/*.md")
216
224
  keys.find do |key|
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.30.0'.freeze
2
+ VERSION = '0.31.0'.freeze
3
3
  end
@@ -57,8 +57,17 @@ module RailsConsoleAi
57
57
 
58
58
  # Enqueue an agent run. Returns the Integer session id immediately;
59
59
  # the actual work is picked up by `rake rails_console_ai:agents`.
60
- def run_agent(query, name: nil, user_name: nil)
60
+ #
61
+ # use_thinking_model: run on the configured thinking-tier model
62
+ # max_wall_clock_seconds: hard kill the run after N seconds (nil = no cap)
63
+ def run_agent(query, name: nil, user_name: nil,
64
+ use_thinking_model: false,
65
+ max_wall_clock_seconds: 600)
61
66
  require 'rails_console_ai/session_logger'
67
+ options = {
68
+ 'use_thinking_model' => !!use_thinking_model,
69
+ 'max_wall_clock_seconds' => max_wall_clock_seconds
70
+ }
62
71
  id = SessionLogger.log(
63
72
  query: query,
64
73
  conversation: [],
@@ -66,7 +75,8 @@ module RailsConsoleAi
66
75
  name: name,
67
76
  user_name: user_name,
68
77
  status: 'queued',
69
- executed: false
78
+ executed: false,
79
+ options: options
70
80
  )
71
81
  raise 'Failed to enqueue agent run (session logging disabled or table missing)' unless id
72
82
  id
@@ -145,6 +155,7 @@ module RailsConsoleAi
145
155
  t.string :slack_thread_ts, limit: 255
146
156
  t.string :slack_channel_name, limit: 255
147
157
  t.integer :duration_ms
158
+ t.text :options
148
159
  t.datetime :created_at, null: false
149
160
  end
150
161
 
@@ -169,13 +180,22 @@ module RailsConsoleAi
169
180
  skills_table = 'rails_console_ai_skills'
170
181
  versions_table = 'rails_console_ai_skill_versions'
171
182
 
183
+ # Old shape had per-field columns (body, tags, bypass_guards_for_methods,
184
+ # description). New shape stores the raw .md in `content`. Pre-production,
185
+ # so we drop and recreate when the old shape is detected.
186
+ if conn.table_exists?(skills_table) && !conn.column_exists?(skills_table, :content)
187
+ conn.drop_table(skills_table)
188
+ $stdout.puts "\e[33mRailsConsoleAi: dropped legacy #{skills_table} (replaced with single-content schema).\e[0m"
189
+ end
190
+ if conn.table_exists?(versions_table) && !conn.column_exists?(versions_table, :content)
191
+ conn.drop_table(versions_table)
192
+ $stdout.puts "\e[33mRailsConsoleAi: dropped legacy #{versions_table}.\e[0m"
193
+ end
194
+
172
195
  unless conn.table_exists?(skills_table)
173
196
  conn.create_table(skills_table) do |t|
174
197
  t.string :name, limit: 255, null: false
175
- t.text :description
176
- t.text :body
177
- t.text :tags
178
- t.text :bypass_guards_for_methods
198
+ t.text :content, null: false
179
199
  t.string :status, limit: 20, default: 'proposed', null: false
180
200
  t.string :approved_by, limit: 255
181
201
  t.datetime :approved_at
@@ -187,34 +207,13 @@ module RailsConsoleAi
187
207
  conn.add_index(skills_table, :name, unique: true)
188
208
  conn.add_index(skills_table, :status)
189
209
  $stdout.puts "\e[32mRailsConsoleAi: created #{skills_table} table.\e[0m"
190
- else
191
- # Idempotent column-add probes for existing installs.
192
- unless conn.column_exists?(skills_table, :status)
193
- conn.add_column(skills_table, :status, :string, limit: 20, default: 'proposed', null: false)
194
- conn.add_index(skills_table, :status) unless conn.index_exists?(skills_table, :status)
195
- end
196
- unless conn.column_exists?(skills_table, :approved_by)
197
- conn.add_column(skills_table, :approved_by, :string, limit: 255)
198
- end
199
- unless conn.column_exists?(skills_table, :approved_at)
200
- conn.add_column(skills_table, :approved_at, :datetime)
201
- end
202
- unless conn.column_exists?(skills_table, :use_count)
203
- conn.add_column(skills_table, :use_count, :integer, default: 0, null: false)
204
- end
205
- unless conn.column_exists?(skills_table, :last_used_at)
206
- conn.add_column(skills_table, :last_used_at, :datetime)
207
- end
208
210
  end
209
211
 
210
212
  unless conn.table_exists?(versions_table)
211
213
  conn.create_table(versions_table) do |t|
212
214
  t.integer :skill_id
213
215
  t.string :name, limit: 255
214
- t.text :description
215
- t.text :body
216
- t.text :tags
217
- t.text :bypass_guards_for_methods
216
+ t.text :content
218
217
  t.string :status, limit: 20
219
218
  t.string :edited_by, limit: 255
220
219
  t.text :change_note
@@ -223,10 +222,6 @@ module RailsConsoleAi
223
222
  conn.add_index(versions_table, :skill_id)
224
223
  conn.add_index(versions_table, :created_at)
225
224
  $stdout.puts "\e[32mRailsConsoleAi: created #{versions_table} table.\e[0m"
226
- else
227
- unless conn.column_exists?(versions_table, :status)
228
- conn.add_column(versions_table, :status, :string, limit: 20)
229
- end
230
225
  end
231
226
  end
232
227
 
@@ -234,11 +229,19 @@ module RailsConsoleAi
234
229
  memories_table = 'rails_console_ai_memories'
235
230
  versions_table = 'rails_console_ai_memory_versions'
236
231
 
232
+ if conn.table_exists?(memories_table) && !conn.column_exists?(memories_table, :content)
233
+ conn.drop_table(memories_table)
234
+ $stdout.puts "\e[33mRailsConsoleAi: dropped legacy #{memories_table}.\e[0m"
235
+ end
236
+ if conn.table_exists?(versions_table) && !conn.column_exists?(versions_table, :content)
237
+ conn.drop_table(versions_table)
238
+ $stdout.puts "\e[33mRailsConsoleAi: dropped legacy #{versions_table}.\e[0m"
239
+ end
240
+
237
241
  unless conn.table_exists?(memories_table)
238
242
  conn.create_table(memories_table) do |t|
239
243
  t.string :name, limit: 255, null: false
240
- t.text :description
241
- t.text :tags
244
+ t.text :content, null: false
242
245
  t.integer :use_count, default: 0, null: false
243
246
  t.datetime :last_used_at
244
247
  t.datetime :created_at, null: false
@@ -246,21 +249,13 @@ module RailsConsoleAi
246
249
  end
247
250
  conn.add_index(memories_table, :name, unique: true)
248
251
  $stdout.puts "\e[32mRailsConsoleAi: created #{memories_table} table.\e[0m"
249
- else
250
- unless conn.column_exists?(memories_table, :use_count)
251
- conn.add_column(memories_table, :use_count, :integer, default: 0, null: false)
252
- end
253
- unless conn.column_exists?(memories_table, :last_used_at)
254
- conn.add_column(memories_table, :last_used_at, :datetime)
255
- end
256
252
  end
257
253
 
258
254
  unless conn.table_exists?(versions_table)
259
255
  conn.create_table(versions_table) do |t|
260
256
  t.integer :memory_id
261
257
  t.string :name, limit: 255
262
- t.text :description
263
- t.text :tags
258
+ t.text :content
264
259
  t.string :edited_by, limit: 255
265
260
  t.text :change_note
266
261
  t.datetime :created_at, null: false
@@ -275,14 +270,19 @@ module RailsConsoleAi
275
270
  agents_table = 'rails_console_ai_agents'
276
271
  versions_table = 'rails_console_ai_agent_versions'
277
272
 
273
+ if conn.table_exists?(agents_table) && !conn.column_exists?(agents_table, :content)
274
+ conn.drop_table(agents_table)
275
+ $stdout.puts "\e[33mRailsConsoleAi: dropped legacy #{agents_table}.\e[0m"
276
+ end
277
+ if conn.table_exists?(versions_table) && !conn.column_exists?(versions_table, :content)
278
+ conn.drop_table(versions_table)
279
+ $stdout.puts "\e[33mRailsConsoleAi: dropped legacy #{versions_table}.\e[0m"
280
+ end
281
+
278
282
  unless conn.table_exists?(agents_table)
279
283
  conn.create_table(agents_table) do |t|
280
284
  t.string :name, limit: 255, null: false
281
- t.text :description
282
- t.text :body
283
- t.integer :max_rounds
284
- t.string :model, limit: 100
285
- t.text :tools
285
+ t.text :content, null: false
286
286
  t.string :status, limit: 20, default: 'proposed', null: false
287
287
  t.string :approved_by, limit: 255
288
288
  t.datetime :approved_at
@@ -294,34 +294,13 @@ module RailsConsoleAi
294
294
  conn.add_index(agents_table, :name, unique: true)
295
295
  conn.add_index(agents_table, :status)
296
296
  $stdout.puts "\e[32mRailsConsoleAi: created #{agents_table} table.\e[0m"
297
- else
298
- unless conn.column_exists?(agents_table, :status)
299
- conn.add_column(agents_table, :status, :string, limit: 20, default: 'proposed', null: false)
300
- conn.add_index(agents_table, :status) unless conn.index_exists?(agents_table, :status)
301
- end
302
- unless conn.column_exists?(agents_table, :approved_by)
303
- conn.add_column(agents_table, :approved_by, :string, limit: 255)
304
- end
305
- unless conn.column_exists?(agents_table, :approved_at)
306
- conn.add_column(agents_table, :approved_at, :datetime)
307
- end
308
- unless conn.column_exists?(agents_table, :use_count)
309
- conn.add_column(agents_table, :use_count, :integer, default: 0, null: false)
310
- end
311
- unless conn.column_exists?(agents_table, :last_used_at)
312
- conn.add_column(agents_table, :last_used_at, :datetime)
313
- end
314
297
  end
315
298
 
316
299
  unless conn.table_exists?(versions_table)
317
300
  conn.create_table(versions_table) do |t|
318
301
  t.integer :agent_id
319
302
  t.string :name, limit: 255
320
- t.text :description
321
- t.text :body
322
- t.integer :max_rounds
323
- t.string :model, limit: 100
324
- t.text :tools
303
+ t.text :content
325
304
  t.string :status, limit: 20
326
305
  t.string :edited_by, limit: 255
327
306
  t.text :change_note
@@ -376,6 +355,11 @@ module RailsConsoleAi
376
355
  migrations << 'error_message'
377
356
  end
378
357
 
358
+ unless conn.column_exists?(table, :options)
359
+ conn.add_column(table, :options, :text)
360
+ migrations << 'options'
361
+ end
362
+
379
363
  unless conn.index_exists?(table, [:mode, :status], name: 'idx_rca_sessions_mode_status')
380
364
  conn.add_index(table, [:mode, :status], name: 'idx_rca_sessions_mode_status')
381
365
  migrations << 'idx_rca_sessions_mode_status'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr