rails_console_ai 0.28.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -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/channel/slack.rb +33 -0
  49. data/lib/rails_console_ai/channel/sub_agent.rb +12 -0
  50. data/lib/rails_console_ai/conversation_engine.rb +50 -13
  51. data/lib/rails_console_ai/session_logger.rb +6 -0
  52. data/lib/rails_console_ai/skill_loader.rb +119 -27
  53. data/lib/rails_console_ai/slack_bot.rb +8 -0
  54. data/lib/rails_console_ai/storage/database_storage.rb +201 -0
  55. data/lib/rails_console_ai/sub_agent.rb +25 -0
  56. data/lib/rails_console_ai/tools/memory_tools.rb +102 -32
  57. data/lib/rails_console_ai/tools/registry.rb +99 -8
  58. data/lib/rails_console_ai/version.rb +1 -1
  59. data/lib/rails_console_ai.rb +256 -0
  60. data/lib/tasks/rails_console_ai.rake +7 -0
  61. 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
  module Tools
@@ -9,41 +10,61 @@ module RailsConsoleAi
9
10
  @storage = storage || RailsConsoleAi.storage
10
11
  end
11
12
 
12
- def save_memory(name:, description:, tags: [])
13
- key = memory_key(name)
14
- existing = load_memory(key)
15
-
16
- frontmatter = {
17
- 'name' => name,
18
- 'tags' => Array(tags).empty? && existing ? (existing['tags'] || []) : Array(tags),
19
- 'created_at' => existing ? existing['created_at'] : Time.now.utc.iso8601
20
- }
21
- frontmatter['updated_at'] = Time.now.utc.iso8601 if existing
22
-
23
- content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
24
- @storage.write(key, content)
13
+ # target: :db (default) | :file
14
+ # Falls back to :file (with a notice in the return string) if DB tables aren't set up.
15
+ def save_memory(name:, description:, tags: [], target: :db, edited_by: nil, change_note: nil)
16
+ target = (target || :db).to_sym
17
+ db_fell_back = false
18
+ if target == :db && !Storage::DatabaseStorage.memories_available?
19
+ target = :file
20
+ db_fell_back = true
21
+ end
25
22
 
26
- path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
27
- if existing
28
- "Memory updated: \"#{name}\" (#{path})"
23
+ if target == :file
24
+ result = save_memory_to_file(name: name, description: description, tags: tags)
25
+ if db_fell_back
26
+ result += "\nNOTE: DB storage was requested but the rails_console_ai_memories table does not exist. " \
27
+ "Run `ai_db_setup` in your Rails console to enable the versioned DB store. " \
28
+ "Saved to a file instead."
29
+ end
30
+ result
29
31
  else
30
- "Memory saved: \"#{name}\" (#{path})"
32
+ record, was_new = Storage::DatabaseStorage.save_memory(
33
+ name: name, description: description, tags: tags,
34
+ edited_by: edited_by || 'ai', change_note: change_note
35
+ )
36
+ if was_new
37
+ "Memory saved (db): \"#{record.name}\" (id=#{record.id})"
38
+ else
39
+ "Memory updated (db): \"#{record.name}\" (id=#{record.id})"
40
+ end
31
41
  end
32
42
  rescue Storage::StorageError => e
33
- "FAILED to save (#{e.message}). Add this manually to .rails_console_ai/#{key}:\n" \
34
- "---\nname: #{name}\ntags: #{Array(tags).inspect}\n---\n\n#{description}"
43
+ if target == :file
44
+ # Preserve the original behaviour: include a hint with the raw frontmatter
45
+ # so the user (or AI) can paste it manually when the filesystem is read-only.
46
+ "FAILED to save (#{e.message}). Add this manually to .rails_console_ai/#{memory_key(name)}:\n" \
47
+ "---\nname: #{name}\ntags: #{Array(tags).inspect}\n---\n\n#{description}"
48
+ else
49
+ "FAILED to save (#{e.message})."
50
+ end
51
+ rescue ::ActiveRecord::RecordInvalid => e
52
+ "FAILED to save (#{e.message})."
35
53
  end
36
54
 
37
55
  def delete_memory(name:)
56
+ if Storage::DatabaseStorage.delete_memory_by_name(name)
57
+ return "Memory deleted (db): \"#{name}\""
58
+ end
59
+
38
60
  key = memory_key(name)
39
61
  unless @storage.exists?(key)
40
- # Try to find by name match across all memory files
41
62
  found_key = find_memory_key_by_name(name)
42
63
  return "No memory found: \"#{name}\"" unless found_key
43
64
  key = found_key
44
65
  end
45
66
 
46
- memory = load_memory(key)
67
+ memory = load_memory_file(key)
47
68
  @storage.delete(key)
48
69
  "Memory deleted: \"#{memory ? memory['name'] : name}\""
49
70
  rescue Storage::StorageError => e
@@ -51,14 +72,11 @@ module RailsConsoleAi
51
72
  end
52
73
 
53
74
  def recall_memory(name:)
54
- key = memory_key(name)
55
- memory = load_memory(key)
56
- # Fall back to case-insensitive name search
57
- unless memory
58
- memory = load_all_memories.find { |m| m['name'].to_s.downcase == name.downcase }
59
- end
75
+ memory = load_all_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
60
76
  return "No memory found: \"#{name}\"" unless memory
61
77
 
78
+ record_use(memory)
79
+
62
80
  line = "**#{memory['name']}**\n#{memory['description']}"
63
81
  line += "\nTags: #{memory['tags'].join(', ')}" if memory['tags'] && !memory['tags'].empty?
64
82
  line
@@ -88,6 +106,9 @@ module RailsConsoleAi
88
106
 
89
107
  return "No memories matching your search." if results.empty?
90
108
 
109
+ # Every memory in the result set was loaded into the AI's context.
110
+ results.each { |m| record_use(m) }
111
+
91
112
  results.map { |m|
92
113
  line = "**#{m['name']}**\n#{m['description']}"
93
114
  line += "\nTags: #{m['tags'].join(', ')}" if m['tags'] && !m['tags'].empty?
@@ -106,8 +127,44 @@ module RailsConsoleAi
106
127
  }
107
128
  end
108
129
 
130
+ def load_all_memories
131
+ db = Storage::DatabaseStorage.all_memories
132
+ file = load_all_file_memories
133
+ names = db.map { |m| m['name'].to_s.downcase }
134
+ file.reject! { |m| names.include?(m['name'].to_s.downcase) }
135
+ (db + file).sort_by { |m| m['name'].to_s.downcase }
136
+ end
137
+
109
138
  private
110
139
 
140
+ # DB-backed memories only — file memories have no row to update.
141
+ def record_use(memory)
142
+ return unless memory.is_a?(Hash) && memory['source'] == :db && memory['id']
143
+ RailsConsoleAi::Memory.record_use!(memory['id'])
144
+ end
145
+
146
+ def save_memory_to_file(name:, description:, tags:)
147
+ key = memory_key(name)
148
+ existing = load_memory_file(key)
149
+
150
+ frontmatter = {
151
+ 'name' => name,
152
+ 'tags' => Array(tags).empty? && existing ? (existing['tags'] || []) : Array(tags),
153
+ 'created_at' => existing ? existing['created_at'] : Time.now.utc.iso8601
154
+ }
155
+ frontmatter['updated_at'] = Time.now.utc.iso8601 if existing
156
+
157
+ content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
158
+ @storage.write(key, content)
159
+
160
+ path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
161
+ if existing
162
+ "Memory updated: \"#{name}\" (#{path})"
163
+ else
164
+ "Memory saved: \"#{name}\" (#{path})"
165
+ end
166
+ end
167
+
111
168
  def memory_key(name)
112
169
  slug = name.downcase.strip
113
170
  .gsub(/[^a-z0-9\s-]/, '')
@@ -117,7 +174,7 @@ module RailsConsoleAi
117
174
  "#{MEMORIES_DIR}/#{slug}.md"
118
175
  end
119
176
 
120
- def load_memory(key)
177
+ def load_memory_file(key)
121
178
  content = @storage.read(key)
122
179
  return nil if content.nil? || content.strip.empty?
123
180
  parse_memory(content)
@@ -126,26 +183,39 @@ module RailsConsoleAi
126
183
  nil
127
184
  end
128
185
 
129
- def load_all_memories
186
+ def load_all_file_memories
130
187
  keys = @storage.list("#{MEMORIES_DIR}/*.md")
131
- keys.map { |key| load_memory(key) }.compact
188
+ keys.filter_map { |key|
189
+ memory = load_memory_file(key)
190
+ next nil unless memory
191
+ memory.merge('source' => :file, 'file_key' => key)
192
+ }
132
193
  rescue => e
133
194
  RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load memories: #{e.message}")
134
195
  []
135
196
  end
136
197
 
137
198
  def parse_memory(content)
199
+ self.class.parse(content)
200
+ end
201
+
202
+ # Public: parse a raw .md (YAML frontmatter + body) string into a hash.
203
+ # For memories, the body is stored under the 'description' key (memories
204
+ # don't have a separate description vs body — the markdown IS the memory).
205
+ def self.parse(content)
138
206
  return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
139
207
  frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
140
208
  description = $2.strip
141
209
  frontmatter.merge('description' => description)
210
+ rescue Psych::SyntaxError
211
+ nil
142
212
  end
143
213
 
144
214
  def find_memory_key_by_name(name)
145
215
  keys = @storage.list("#{MEMORIES_DIR}/*.md")
146
216
  keys.find do |key|
147
- memory = load_memory(key)
148
- memory && memory['name'].to_s.downcase == name.downcase
217
+ memory = load_memory_file(key)
218
+ memory && memory['name'].to_s.downcase == name.to_s.downcase
149
219
  end
150
220
  end
151
221
  end
@@ -6,7 +6,7 @@ module RailsConsoleAi
6
6
  attr_reader :definitions, :last_sub_agent_usage
7
7
 
8
8
  # Tools that should never be cached (side effects or user interaction)
9
- NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill delegate_task explore_output].freeze
9
+ NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill save_agent delete_agent delegate_task explore_output].freeze
10
10
 
11
11
  def initialize(executor: nil, mode: :default, channel: nil, allowed_tools: nil)
12
12
  @executor = executor
@@ -254,6 +254,7 @@ module RailsConsoleAi
254
254
 
255
255
  register_memory_tools
256
256
  register_skill_tools
257
+ register_agent_tools
257
258
  register_execute_plan
258
259
  register_delegate_task
259
260
  end
@@ -381,9 +382,20 @@ module RailsConsoleAi
381
382
  loader = AgentLoader.new
382
383
  agent_config = loader.find_agent(agent_name)
383
384
  unless agent_config
384
- available = loader.load_all_agents.map { |a| a['name'] }
385
+ # Distinguish "doesn't exist" from "exists but isn't approved yet".
386
+ proposed = loader.find_any_agent(agent_name)
387
+ if proposed && proposed['source'] == :db && proposed['status'] != 'approved'
388
+ return "Agent \"#{agent_name}\" exists but is awaiting human approval and cannot be invoked yet. " \
389
+ "Ask the user to approve it in the web UI at /rails_console_ai/agents."
390
+ end
391
+ available = loader.load_activatable_agents.map { |a| a['name'] }
385
392
  return "Agent not found: \"#{agent_name}\". Available agents: #{available.join(', ')}"
386
393
  end
394
+
395
+ # Usage tracking — DB-backed agents only.
396
+ if agent_config['source'] == :db && agent_config['id']
397
+ RailsConsoleAi::Agent.record_use!(agent_config['id'])
398
+ end
387
399
  end
388
400
 
389
401
  sub = SubAgent.new(
@@ -406,18 +418,26 @@ module RailsConsoleAi
406
418
 
407
419
  register(
408
420
  name: 'save_memory',
409
- description: 'Save a fact or pattern you learned about this codebase for future sessions. Use after discovering how something works (e.g. sharding, auth, custom business logic).',
421
+ description: 'Save a fact or pattern you learned about this codebase for future sessions. Use after discovering how something works (e.g. sharding, auth, custom business logic). Defaults to the versioned DB store; pass target: "file" to write to the on-disk .rails_console_ai/memories directory instead.',
410
422
  parameters: {
411
423
  'type' => 'object',
412
424
  'properties' => {
413
425
  'name' => { 'type' => 'string', 'description' => 'Short name for this memory (e.g. "Sharding architecture")' },
414
426
  'description' => { 'type' => 'string', 'description' => 'Detailed description of what you learned' },
415
- 'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Optional tags (e.g. ["database", "sharding"])' }
427
+ 'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Optional tags (e.g. ["database", "sharding"])' },
428
+ 'target' => { 'type' => 'string', 'enum' => ['db', 'file'], 'description' => 'Where to store this memory. "db" (default) is versioned and editable via the web UI; "file" writes a Markdown file under .rails_console_ai/memories/.' },
429
+ 'change_note' => { 'type' => 'string', 'description' => 'Optional one-line note describing this edit (DB store only).' }
416
430
  },
417
431
  'required' => ['name', 'description']
418
432
  },
419
433
  handler: ->(args) {
420
- memory.save_memory(name: args['name'], description: args['description'], tags: args['tags'] || [])
434
+ memory.save_memory(
435
+ name: args['name'],
436
+ description: args['description'],
437
+ tags: args['tags'] || [],
438
+ target: (args['target'] || 'db').to_sym,
439
+ change_note: args['change_note']
440
+ )
421
441
  }
422
442
  )
423
443
 
@@ -480,19 +500,30 @@ module RailsConsoleAi
480
500
  handler: ->(args) {
481
501
  skill = loader.find_skill(args['name'])
482
502
  unless skill
503
+ # Distinguish "doesn't exist" from "exists but isn't approved yet".
504
+ proposed = loader.find_any_skill(args['name'])
505
+ if proposed && proposed['source'] == :db && proposed['status'] != 'approved'
506
+ return "Skill \"#{args['name']}\" exists but is awaiting human approval and cannot be activated yet. " \
507
+ "Ask the user to approve it in the web UI at /rails_console_ai/skills."
508
+ end
483
509
  return "Skill not found: \"#{args['name']}\". Use the skills listed in the system prompt."
484
510
  end
485
511
 
486
512
  bypass_methods = Array(skill['bypass_guards_for_methods'])
487
513
  @executor.activate_skill_bypasses(bypass_methods) unless bypass_methods.empty?
488
514
 
515
+ # Usage tracking — DB-backed skills only (file skills have no row to update).
516
+ if skill['source'] == :db && skill['id']
517
+ RailsConsoleAi::Skill.record_use!(skill['id'])
518
+ end
519
+
489
520
  skill['body']
490
521
  }
491
522
  )
492
523
 
493
524
  register(
494
525
  name: 'save_skill',
495
- description: 'Create or update a skill — a reusable procedure for a specific operation. Use when the user asks you to create a skill, recipe, or runbook. Skills differ from memories: a skill is a step-by-step procedure to follow, while a memory is a fact or pattern you learned.',
526
+ description: 'Create or update a skill — a reusable procedure for a specific operation. Use when the user asks you to create a skill, recipe, or runbook. Skills differ from memories: a skill is a step-by-step procedure to follow, while a memory is a fact or pattern you learned. Defaults to the versioned DB store; pass target: "file" to write to the on-disk .rails_console_ai/skills directory instead. IMPORTANT: skills saved to the DB start in "proposed" state and must be approved by a human in the web UI before you can activate them. Edits to an approved skill also revert it to proposed. Tell the user to visit /rails_console_ai/skills to approve.',
496
527
  parameters: {
497
528
  'type' => 'object',
498
529
  'properties' => {
@@ -500,7 +531,9 @@ module RailsConsoleAi
500
531
  'description' => { 'type' => 'string', 'description' => 'One-line description of when to use this skill' },
501
532
  'body' => { 'type' => 'string', 'description' => 'The full skill recipe in markdown. Include: ## When to use, ## Recipe (numbered steps with code blocks), ## Notes (optional).' },
502
533
  'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Optional tags for categorization (e.g. ["booking-page", "admin"])' },
503
- 'bypass_guards_for_methods' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Methods that should bypass safety guards when this skill is active (e.g. ["BookingPage#save!", "BookingPage#ensure_subdomain_set!"])' }
534
+ 'bypass_guards_for_methods' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Methods that should bypass safety guards when this skill is active (e.g. ["BookingPage#save!", "BookingPage#ensure_subdomain_set!"])' },
535
+ 'target' => { 'type' => 'string', 'enum' => ['db', 'file'], 'description' => 'Where to store this skill. "db" (default) is versioned and editable via the web UI; "file" writes a Markdown file under .rails_console_ai/skills/.' },
536
+ 'change_note' => { 'type' => 'string', 'description' => 'Optional one-line note describing this edit (DB store only).' }
504
537
  },
505
538
  'required' => %w[name description body]
506
539
  },
@@ -510,7 +543,9 @@ module RailsConsoleAi
510
543
  description: args['description'],
511
544
  body: args['body'],
512
545
  tags: args['tags'] || [],
513
- bypass_guards_for_methods: args['bypass_guards_for_methods'] || []
546
+ bypass_guards_for_methods: args['bypass_guards_for_methods'] || [],
547
+ target: (args['target'] || 'db').to_sym,
548
+ change_note: args['change_note']
514
549
  )
515
550
  }
516
551
  )
@@ -529,6 +564,62 @@ module RailsConsoleAi
529
564
  )
530
565
  end
531
566
 
567
+ def register_agent_tools
568
+ return unless @executor
569
+
570
+ require 'rails_console_ai/agent_loader'
571
+ loader = RailsConsoleAi::AgentLoader.new
572
+
573
+ register(
574
+ name: 'save_agent',
575
+ description: 'Create or update a sub-agent definition — a reusable specialist that the main assistant can invoke via delegate_task. ' \
576
+ 'Use when the user asks you to create a new agent, recipe-agent, or sub-task specialist. ' \
577
+ 'Agents differ from skills: an agent runs in its own sub-conversation with a constrained tool set, while a skill is a procedure followed inline. ' \
578
+ 'Defaults to the versioned DB store; pass target: "file" to write to the on-disk .rails_console_ai/agents directory instead. ' \
579
+ 'IMPORTANT: agents saved to the DB start in "proposed" state and must be approved by a human in the web UI before delegate_task can invoke them. ' \
580
+ 'Edits to an approved agent also revert it to proposed. Tell the user to visit /rails_console_ai/agents to approve.',
581
+ parameters: {
582
+ 'type' => 'object',
583
+ 'properties' => {
584
+ 'name' => { 'type' => 'string', 'description' => 'Agent name (e.g. "Investigate billing"), shown in the Agents list and used as the delegate_task `agent` parameter' },
585
+ 'description' => { 'type' => 'string', 'description' => 'One-line description of what this agent specializes in' },
586
+ 'body' => { 'type' => 'string', 'description' => 'The agent\'s system instructions in markdown. Include: persona, strategy, rules, expected output format.' },
587
+ 'max_rounds' => { 'type' => 'integer', 'description' => 'Optional: maximum tool-loop iterations for the sub-agent (defaults to global config). Use a smaller number for tightly-scoped agents.' },
588
+ 'model' => { 'type' => 'string', 'description' => 'Optional: model override for this agent (e.g. "claude-haiku-4" for cheap fast agents)' },
589
+ 'tools' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Optional: whitelist of tool names the sub-agent is allowed to call. Omit to allow the default set.' },
590
+ 'target' => { 'type' => 'string', 'enum' => ['db', 'file'], 'description' => 'Where to store this agent. "db" (default) is versioned and editable via the web UI; "file" writes a Markdown file under .rails_console_ai/agents/.' },
591
+ 'change_note' => { 'type' => 'string', 'description' => 'Optional one-line note describing this edit (DB store only).' }
592
+ },
593
+ 'required' => %w[name description body]
594
+ },
595
+ handler: ->(args) {
596
+ loader.save_agent(
597
+ name: args['name'],
598
+ description: args['description'],
599
+ body: args['body'],
600
+ max_rounds: args['max_rounds'],
601
+ model: args['model'],
602
+ tools: args['tools'] || [],
603
+ target: (args['target'] || 'db').to_sym,
604
+ change_note: args['change_note']
605
+ )
606
+ }
607
+ )
608
+
609
+ register(
610
+ name: 'delete_agent',
611
+ description: 'Delete a sub-agent by name. Built-in (gem-shipped) agents cannot be deleted; this tool will tell you that and suggest creating a same-named override instead.',
612
+ parameters: {
613
+ 'type' => 'object',
614
+ 'properties' => {
615
+ 'name' => { 'type' => 'string', 'description' => 'The agent name to delete' }
616
+ },
617
+ 'required' => ['name']
618
+ },
619
+ handler: ->(args) { loader.delete_agent(name: args['name']) }
620
+ )
621
+ end
622
+
532
623
  def register_execute_plan
533
624
  return unless @executor
534
625
 
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.28.0'.freeze
2
+ VERSION = '0.30.0'.freeze
3
3
  end