rails_console_ai 0.30.0 → 0.32.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -26
  3. data/README.md +33 -11
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +2 -9
  5. data/app/controllers/rails_console_ai/agents_controller.rb +25 -48
  6. data/app/controllers/rails_console_ai/memories_controller.rb +35 -38
  7. data/app/controllers/rails_console_ai/memory_versions_controller.rb +2 -6
  8. data/app/controllers/rails_console_ai/sessions_controller.rb +1 -1
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +2 -8
  10. data/app/controllers/rails_console_ai/skills_controller.rb +19 -48
  11. data/app/models/rails_console_ai/agent.rb +33 -65
  12. data/app/models/rails_console_ai/agent_version.rb +8 -20
  13. data/app/models/rails_console_ai/memory.rb +67 -23
  14. data/app/models/rails_console_ai/memory_version.rb +5 -20
  15. data/app/models/rails_console_ai/skill.rb +55 -105
  16. data/app/models/rails_console_ai/skill_version.rb +7 -28
  17. data/app/views/rails_console_ai/agents/_form.html.erb +9 -34
  18. data/app/views/rails_console_ai/agents/diff.html.erb +1 -5
  19. data/app/views/rails_console_ai/agents/new.html.erb +0 -16
  20. data/app/views/rails_console_ai/memories/_form.html.erb +6 -13
  21. data/app/views/rails_console_ai/memories/diff.html.erb +1 -5
  22. data/app/views/rails_console_ai/memories/index.html.erb +13 -1
  23. data/app/views/rails_console_ai/memories/new.html.erb +0 -15
  24. data/app/views/rails_console_ai/memories/show.html.erb +23 -0
  25. data/app/views/rails_console_ai/skills/_form.html.erb +7 -29
  26. data/app/views/rails_console_ai/skills/diff.html.erb +1 -8
  27. data/app/views/rails_console_ai/skills/new.html.erb +0 -17
  28. data/config/routes.rb +3 -3
  29. data/lib/generators/rails_console_ai/templates/initializer.rb +5 -5
  30. data/lib/rails_console_ai/agent_loader.rb +18 -10
  31. data/lib/rails_console_ai/agent_runner.rb +66 -4
  32. data/lib/rails_console_ai/channel/console.rb +1 -1
  33. data/lib/rails_console_ai/configuration.rb +51 -33
  34. data/lib/rails_console_ai/conversation_engine.rb +3 -3
  35. data/lib/rails_console_ai/safety_guards.rb +36 -0
  36. data/lib/rails_console_ai/session_logger.rb +4 -0
  37. data/lib/rails_console_ai/skill_loader.rb +18 -9
  38. data/lib/rails_console_ai/slack_bot.rb +2 -2
  39. data/lib/rails_console_ai/storage/database_storage.rb +14 -20
  40. data/lib/rails_console_ai/sub_agent.rb +1 -1
  41. data/lib/rails_console_ai/tools/memory_tools.rb +37 -6
  42. data/lib/rails_console_ai/tools/registry.rb +18 -6
  43. data/lib/rails_console_ai/version.rb +1 -1
  44. data/lib/rails_console_ai.rb +92 -71
  45. metadata +1 -1
@@ -1,47 +1,23 @@
1
1
  <% if @agent.persisted? && @agent.respond_to?(:approved?) && @agent.approved? %>
2
2
  <div class="flash flash-alert" style="margin-bottom:16px;">
3
- Heads up: this agent is currently <strong>approved</strong>. Editing it will revert its status to <strong>proposed</strong> and the AI won't be able to invoke it again until someone re-approves it.
3
+ Heads up: this agent is currently <strong>approved</strong>. Editing it will revert its status to <strong>proposed</strong>.
4
4
  </div>
5
5
  <% elsif !@agent.persisted? %>
6
6
  <div class="flash flash-notice" style="margin-bottom:16px;">
7
- New DB agents start as <strong>proposed</strong>. After saving, an approver (likely you) needs to click the Approve button on the show page before the AI can invoke this agent via delegate_task.
7
+ New DB agents start as <strong>proposed</strong>. After saving, an approver (likely you) needs to click the Approve button before delegate_task can invoke it.
8
8
  </div>
9
9
  <% end %>
10
10
 
11
11
  <%= form_with model: @agent, url: form_url, method: form_method, local: true do |f| %>
12
12
  <div class="form-card">
13
13
  <div class="form-row">
14
- <label>Name</label>
15
- <%= f.text_field :name, value: @agent.name, required: true %>
16
- </div>
17
-
18
- <div class="form-row">
19
- <label>Description</label>
20
- <%= f.text_field :description, value: @agent.description, placeholder: 'One-line description of what this agent specializes in' %>
21
- </div>
22
-
23
- <div class="form-row">
24
- <label>Body / system instructions (markdown)</label>
25
- <%= f.text_area :body, value: @agent.body, rows: 18 %>
26
- <div class="hint">Persona + strategy + rules. Be specific about what tools/approaches the sub-agent should use and what format its summary should take.</div>
27
- </div>
28
-
29
- <div class="form-row">
30
- <label>Max rounds (optional)</label>
31
- <%= f.number_field :max_rounds, value: @agent.max_rounds, min: 1, max: 100 %>
32
- <div class="hint">Maximum tool-loop iterations for the sub-agent. Defaults to the global <code>config.sub_agent_max_rounds</code>. Smaller = tighter scope, fewer tokens.</div>
33
- </div>
34
-
35
- <div class="form-row">
36
- <label>Model (optional)</label>
37
- <%= f.text_field :model, value: @agent.model, placeholder: 'e.g. claude-haiku-4-5' %>
38
- <div class="hint">Model override. Leave blank to use the global model. Useful for cheap/fast agents.</div>
39
- </div>
40
-
41
- <div class="form-row">
42
- <label>Tool whitelist (one per line, optional)</label>
43
- <%= f.text_area :tools, value: Array(@agent.tools).join("\n"), rows: 4 %>
44
- <div class="hint">Tool names the sub-agent is allowed to call (e.g. <code>execute_code</code>, <code>search_code</code>, <code>read_file</code>). Leave blank to use the default tool set. Adding tools here can broaden what the sub-agent can do — this is why DB agents need approval.</div>
14
+ <label>Markdown (frontmatter + body)</label>
15
+ <%= f.text_area :content, value: @agent.content, rows: 28, required: true, style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;' %>
16
+ <div class="hint">
17
+ YAML frontmatter between <code>---</code> lines, then the agent persona / instructions. Required:
18
+ <code>name</code>, <code>description</code>. Optional: <code>max_rounds</code>, <code>model</code>,
19
+ <code>tools</code> (whitelist; empty/omitted = default set).
20
+ </div>
45
21
  </div>
46
22
 
47
23
  <hr style="margin: 20px 0; border: 0; border-top: 1px solid #eee;">
@@ -49,7 +25,6 @@
49
25
  <div class="form-row">
50
26
  <label>Edited by</label>
51
27
  <input type="text" name="edited_by" value="<%= params[:edited_by] %>" placeholder="Your name or handle">
52
- <div class="hint">Recorded with this version. Free text — the gem does not assume authentication beyond the admin login.</div>
53
28
  </div>
54
29
 
55
30
  <div class="form-row">
@@ -5,11 +5,7 @@
5
5
  Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
6
6
  </p>
7
7
 
8
- <h3 style="margin:16px 0 6px;">Body</h3>
9
- <%= render_text_diff(@from.body, @to_body, left_label: "v##{@from.id}", right_label: @to_label) %>
10
-
11
- <h3 style="margin:24px 0 6px;">Tool whitelist</h3>
12
- <%= render_json_diff(Array(@from.tools), @to_tools, left_label: "v##{@from.id}", right_label: @to_label) %>
8
+ <%= render_text_diff(@from.content, @to_content, left_label: "v##{@from.id}", right_label: @to_label) %>
13
9
 
14
10
  <div class="btn-bar" style="margin-top:24px;">
15
11
  <% if @to.nil? %>
@@ -1,22 +1,6 @@
1
1
  <%= link_to '← All agents', agents_path, class: 'back-link' %>
2
2
  <h2 style="margin-bottom:16px;">New agent</h2>
3
3
 
4
- <%= form_with url: import_agents_path, method: :post, local: true do %>
5
- <div class="form-card" style="background:#f8f9fa;">
6
- <div class="form-row" style="margin-bottom:8px;">
7
- <label>Paste a .md file (optional)</label>
8
- <textarea name="content" rows="14" placeholder="---&#10;name: My agent&#10;description: What it specializes in&#10;max_rounds: 10&#10;model: claude-haiku-4-5&#10;tools: [execute_code, search_code]&#10;---&#10;&#10;Persona, strategy, rules …"></textarea>
9
- <div class="hint">
10
- Paste the full contents of a Markdown file (YAML frontmatter + body) — same
11
- format as <code>.rails_console_ai/agents/*.md</code> or the built-in agents
12
- shipped with the gem. Useful for moving an existing on-disk agent into the
13
- versioned DB store.
14
- </div>
15
- </div>
16
- <button type="submit" class="btn btn-secondary">Parse pasted content ↓</button>
17
- </div>
18
- <% end %>
19
-
20
4
  <%= render 'form',
21
5
  form_url: agents_path,
22
6
  form_method: :post,
@@ -1,19 +1,12 @@
1
1
  <%= form_with model: @memory, url: form_url, method: form_method, local: true do |f| %>
2
2
  <div class="form-card">
3
3
  <div class="form-row">
4
- <label>Name</label>
5
- <%= f.text_field :name, value: @memory.name, required: true %>
6
- </div>
7
-
8
- <div class="form-row">
9
- <label>Description</label>
10
- <%= f.text_area :description, value: @memory.description, rows: 14 %>
11
- <div class="hint">The fact or pattern you're persisting. Markdown is fine.</div>
12
- </div>
13
-
14
- <div class="form-row">
15
- <label>Tags (comma-separated)</label>
16
- <%= f.text_field :tags, value: Array(@memory.tags).join(', '), placeholder: 'database, sharding' %>
4
+ <label>Markdown (frontmatter + body)</label>
5
+ <%= f.text_area :content, value: @memory.content, rows: 22, required: true, style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;' %>
6
+ <div class="hint">
7
+ YAML frontmatter between <code>---</code> lines (required: <code>name</code>; optional: <code>tags</code>),
8
+ then the memory body — the fact or pattern you're persisting.
9
+ </div>
17
10
  </div>
18
11
 
19
12
  <hr style="margin: 20px 0; border: 0; border-top: 1px solid #eee;">
@@ -5,11 +5,7 @@
5
5
  Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
6
6
  </p>
7
7
 
8
- <h3 style="margin:16px 0 6px;">Description</h3>
9
- <%= render_text_diff(@from.description, @to_description, left_label: "v##{@from.id}", right_label: @to_label) %>
10
-
11
- <h3 style="margin:24px 0 6px;">Tags</h3>
12
- <%= render_json_diff(Array(@from.tags), @to_tags, left_label: "v##{@from.id}", right_label: @to_label) %>
8
+ <%= render_text_diff(@from.content, @to_content, left_label: "v##{@from.id}", right_label: @to_label) %>
13
9
 
14
10
  <div class="btn-bar" style="margin-top:24px;">
15
11
  <% if @to.nil? %>
@@ -11,6 +11,13 @@
11
11
  </form>
12
12
  </div>
13
13
 
14
+ <% proposed_count = @memories.count { |m| m['source'] == :db && m['status'] != 'approved' } %>
15
+ <% if proposed_count > 0 %>
16
+ <div class="flash flash-alert" style="margin-bottom:16px;">
17
+ <strong><%= proposed_count %></strong> memor<%= proposed_count == 1 ? 'y' : 'ies' %> awaiting approval. The AI cannot recall them until a human approves.
18
+ </div>
19
+ <% end %>
20
+
14
21
  <% if @memories.empty? %>
15
22
  <div class="meta-card">
16
23
  <p class="text-muted">No memories yet. <%= link_to 'Create one', new_memory_path %> or drop a Markdown file in <code>.rails_console_ai/memories/</code>.</p>
@@ -22,7 +29,7 @@
22
29
  <th>Name</th>
23
30
  <th>Description</th>
24
31
  <th>Tags</th>
25
- <th>Source</th>
32
+ <th>Source / Status</th>
26
33
  <th title="Number of times the AI recalled this memory (DB only)">Uses</th>
27
34
  <th>Last used</th>
28
35
  <th></th>
@@ -40,6 +47,11 @@
40
47
  <td>
41
48
  <% if m['source'] == :db %>
42
49
  <span class="badge source-db">DB</span>
50
+ <% if m['status'] == 'approved' %>
51
+ <span class="badge status-approved">APPROVED</span>
52
+ <% else %>
53
+ <span class="badge status-proposed">PROPOSED</span>
54
+ <% end %>
43
55
  <% else %>
44
56
  <span class="badge source-file">FILE</span>
45
57
  <% end %>
@@ -1,21 +1,6 @@
1
1
  <%= link_to '← All memories', memories_path, class: 'back-link' %>
2
2
  <h2 style="margin-bottom:16px;">New memory</h2>
3
3
 
4
- <%= form_with url: import_memories_path, method: :post, local: true do %>
5
- <div class="form-card" style="background:#f8f9fa;">
6
- <div class="form-row" style="margin-bottom:8px;">
7
- <label>Paste a .md file (optional)</label>
8
- <textarea name="content" rows="12" placeholder="---&#10;name: My memory&#10;tags: [database]&#10;---&#10;&#10;The fact or pattern …"></textarea>
9
- <div class="hint">
10
- Paste the full contents of a Markdown file (YAML frontmatter + body) — same
11
- format as <code>.rails_console_ai/memories/*.md</code>. The fields below will
12
- be prefilled from the paste.
13
- </div>
14
- </div>
15
- <button type="submit" class="btn btn-secondary">Parse pasted content ↓</button>
16
- </div>
17
- <% end %>
18
-
19
4
  <%= render 'form',
20
5
  form_url: memories_path,
21
6
  form_method: :post,
@@ -8,11 +8,23 @@
8
8
 
9
9
  <%= link_to '← All memories', memories_path, class: 'back-link' %>
10
10
 
11
+ <% if is_db && !@memory.approved? %>
12
+ <div class="flash flash-alert" style="margin-bottom:16px;">
13
+ <strong>Awaiting approval.</strong> The AI cannot recall this memory until a human approves it.
14
+ Review the content below, then click <strong>Approve</strong>.
15
+ </div>
16
+ <% end %>
17
+
11
18
  <div class="meta-card">
12
19
  <h2 style="margin-bottom:8px;">
13
20
  <%= name %>
14
21
  <% if is_db %>
15
22
  <span class="badge source-db">DB</span>
23
+ <% if @memory.approved? %>
24
+ <span class="badge status-approved">APPROVED</span>
25
+ <% else %>
26
+ <span class="badge status-proposed">PROPOSED</span>
27
+ <% end %>
16
28
  <% else %>
17
29
  <span class="badge source-file">FILE</span>
18
30
  <% end %>
@@ -24,6 +36,10 @@
24
36
  <div class="meta-item"><label>Versions</label><span><%= @memory.versions.count %></span></div>
25
37
  <div class="meta-item"><label>Times recalled</label><span><%= @memory.use_count %></span></div>
26
38
  <div class="meta-item"><label>Last recalled</label><span><%= @memory.last_used_at&.strftime('%Y-%m-%d %H:%M') || 'never' %></span></div>
39
+ <% if @memory.approved? %>
40
+ <div class="meta-item"><label>Approved by</label><span><%= @memory.approved_by || '—' %></span></div>
41
+ <div class="meta-item"><label>Approved at</label><span><%= @memory.approved_at&.strftime('%Y-%m-%d %H:%M') || '—' %></span></div>
42
+ <% end %>
27
43
  <% else %>
28
44
  <div class="meta-item"><label>Path</label><span class="mono"><%= file_key %></span></div>
29
45
  <% end %>
@@ -32,6 +48,12 @@
32
48
 
33
49
  <div class="btn-bar">
34
50
  <% if is_db %>
51
+ <% unless @memory.approved? %>
52
+ <%= form_with url: approve_memory_path(@memory), method: :post, local: true, style: 'display:inline' do %>
53
+ <input type="text" name="approved_by" placeholder="Your name (required)" style="padding:7px 10px; border:1px solid #ccc; border-radius:4px; margin-right:6px;">
54
+ <button type="submit" class="btn btn-danger" data-confirm="Approve this memory? The AI will then be able to recall it.">Approve</button>
55
+ <% end %>
56
+ <% end %>
35
57
  <%= link_to 'Edit', edit_memory_path(@memory), class: 'btn' %>
36
58
  <%= link_to 'Versions', memory_versions_path(@memory), class: 'btn btn-secondary' %>
37
59
  <%= button_to 'Delete', memory_path(@memory), method: :delete, class: 'btn btn-danger', form: { style: 'display:inline' },data: { confirm: 'Delete this memory? Past versions remain in history.' } %>
@@ -49,6 +71,7 @@
49
71
  <div>
50
72
  <strong>v<%= v.id %></strong>
51
73
  <span class="ver-meta">— <%= v.created_at.strftime('%Y-%m-%d %H:%M') %> by <%= v.edited_by || 'unknown' %></span>
74
+ <% if v.status == 'approved' %><span class="badge status-approved">APPROVED</span><% end %>
52
75
  <% if v.change_note.present? %><div class="ver-meta"><%= v.change_note %></div><% end %>
53
76
  </div>
54
77
  <div>
@@ -11,36 +11,14 @@
11
11
  <%= form_with model: @skill, url: form_url, method: form_method, local: true do |f| %>
12
12
  <div class="form-card">
13
13
  <div class="form-row">
14
- <label>Name</label>
15
- <%= f.text_field :name, value: @skill.name, required: true %>
16
- </div>
17
-
18
- <div class="form-row">
19
- <label>Description</label>
20
- <%= f.text_field :description, value: @skill.description, placeholder: 'One-line description of when to use this skill' %>
21
- </div>
22
-
23
- <div class="form-row">
24
- <label>Body (markdown)</label>
25
- <%= f.text_area :body, value: @skill.body, rows: 18 %>
26
- <div class="hint">Include "## When to use", "## Recipe" (numbered steps with code blocks), "## Notes".</div>
27
- </div>
28
-
29
- <div class="form-row">
30
- <label>Tags (comma-separated)</label>
31
- <%= f.text_field :tags, value: Array(@skill.tags).join(', '), placeholder: 'booking-page, admin' %>
32
- </div>
33
-
34
- <div class="form-row">
35
- <label>Bypass guards for methods (one per line)</label>
36
- <%= f.text_area :bypass_guards_for_methods, value: Array(@skill.bypass_guards_for_methods).join("\n"), rows: 4 %>
14
+ <label>Markdown (frontmatter + body)</label>
15
+ <%= f.text_area :content, value: @skill.content, rows: 28, required: true, style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;' %>
37
16
  <div class="hint">
38
- Two forms accepted anything else is ignored:
39
- <ul style="margin:6px 0 0 18px; padding:0;">
40
- <li><code>ClassName#method</code> instance method, e.g. <code>BookingPage#save!</code></li>
41
- <li><code>ClassName.method</code> — class (singleton) method, e.g. <code>User.create_admin!</code></li>
42
- </ul>
43
- Namespaced classes work: <code>Acme::Booking#destroy</code>. The <code>!</code> is part of the match, so <code>save</code> and <code>save!</code> are different specs. While the skill is active, calls to these methods skip the configured safety guards (DB writes, HTTP mutations, email, etc.) <strong>for that tool-call only</strong>.
17
+ YAML frontmatter between <code>---</code> lines, then the markdown body. Required frontmatter:
18
+ <code>name</code>, <code>description</code>. Optional: <code>tags</code>,
19
+ <code>bypass_guards_for_methods</code> (entries shaped like <code>ClassName#method</code> or
20
+ <code>ClassName.method</code> — while the skill is active, calls to these methods skip the
21
+ configured safety guards for that tool-call only).
44
22
  </div>
45
23
  </div>
46
24
 
@@ -5,14 +5,7 @@
5
5
  Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
6
6
  </p>
7
7
 
8
- <h3 style="margin:16px 0 6px;">Body</h3>
9
- <%= render_text_diff(@from.body, @to_body, left_label: "v##{@from.id}", right_label: @to_label) %>
10
-
11
- <h3 style="margin:24px 0 6px;">Tags</h3>
12
- <%= render_json_diff(Array(@from.tags), @to_tags, left_label: "v##{@from.id}", right_label: @to_label) %>
13
-
14
- <h3 style="margin:24px 0 6px;">Bypass guards</h3>
15
- <%= render_json_diff(Array(@from.bypass_guards_for_methods), @to_bypass, left_label: "v##{@from.id}", right_label: @to_label) %>
8
+ <%= render_text_diff(@from.content, @to_content, left_label: "v##{@from.id}", right_label: @to_label) %>
16
9
 
17
10
  <div class="btn-bar" style="margin-top:24px;">
18
11
  <% if @to.nil? %>
@@ -1,23 +1,6 @@
1
1
  <%= link_to '← All skills', skills_path, class: 'back-link' %>
2
2
  <h2 style="margin-bottom:16px;">New skill</h2>
3
3
 
4
- <%= form_with url: import_skills_path, method: :post, local: true do %>
5
- <div class="form-card" style="background:#f8f9fa;">
6
- <div class="form-row" style="margin-bottom:8px;">
7
- <label>Paste a .md file (optional)</label>
8
- <textarea name="content" rows="14" placeholder="---&#10;name: My skill&#10;description: What it does&#10;tags: [admin]&#10;bypass_guards_for_methods: []&#10;---&#10;&#10;## When to use&#10;…&#10;&#10;## Recipe&#10;1. …"></textarea>
9
- <div class="hint">
10
- Paste the full contents of a Markdown file (YAML frontmatter between
11
- <code>---</code> lines, then a markdown body) — same format as
12
- <code>.rails_console_ai/skills/*.md</code>. The fields below will be
13
- prefilled from the paste. You can review and tweak before clicking
14
- <strong>Create skill</strong>.
15
- </div>
16
- </div>
17
- <button type="submit" class="btn btn-secondary">Parse pasted content ↓</button>
18
- </div>
19
- <% end %>
20
-
21
4
  <%= render 'form',
22
5
  form_url: skills_path,
23
6
  form_method: :post,
data/config/routes.rb CHANGED
@@ -8,7 +8,6 @@ RailsConsoleAi::Engine.routes.draw do
8
8
  end
9
9
  collection do
10
10
  get :diff
11
- post :import
12
11
  end
13
12
  resources :versions, only: [:index, :show], controller: 'skill_versions' do
14
13
  member do
@@ -18,9 +17,11 @@ RailsConsoleAi::Engine.routes.draw do
18
17
  end
19
18
 
20
19
  resources :memories do
20
+ member do
21
+ post :approve
22
+ end
21
23
  collection do
22
24
  get :diff
23
- post :import
24
25
  end
25
26
  resources :versions, only: [:index, :show], controller: 'memory_versions' do
26
27
  member do
@@ -35,7 +36,6 @@ RailsConsoleAi::Engine.routes.draw do
35
36
  end
36
37
  collection do
37
38
  get :diff
38
- post :import
39
39
  end
40
40
  resources :versions, only: [:index, :show], controller: 'agent_versions' do
41
41
  member do
@@ -5,11 +5,11 @@ RailsConsoleAi.configure do |config|
5
5
  # API key (or set ANTHROPIC_API_KEY / OPENAI_API_KEY env var)
6
6
  # config.api_key = 'sk-...'
7
7
 
8
- # Model override (defaults: claude-opus-4-6 for Anthropic, gpt-5.3-codex for OpenAI)
9
- # config.model = 'claude-opus-4-6'
8
+ # Model override (defaults: claude-sonnet-5 for Anthropic, gpt-5.3-codex for OpenAI)
9
+ # config.model = 'claude-opus-4-8'
10
10
 
11
- # Max tokens for LLM response
12
- config.max_tokens = 4096
11
+ # Max tokens for LLM response (default resolves per model family; 16000 for Claude models)
12
+ # config.max_tokens = 4096
13
13
 
14
14
  # Temperature (0.0 - 1.0)
15
15
  config.temperature = 0.2
@@ -35,7 +35,7 @@ RailsConsoleAi.configure do |config|
35
35
  # AWS Bedrock provider (uses AWS credential chain — no API key needed):
36
36
  # config.provider = :bedrock
37
37
  # config.bedrock_region = 'us-east-1'
38
- # config.model = 'us.anthropic.claude-sonnet-4-6'
38
+ # config.model = 'us.anthropic.claude-sonnet-5'
39
39
 
40
40
  # Debug mode: prints full API requests/responses and tool calls to stderr
41
41
  # config.debug = true
@@ -159,16 +159,10 @@ module RailsConsoleAi
159
159
  key = agent_key(name)
160
160
  existing = load_agent_file(key)
161
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"
162
+ content = self.class.dump(
163
+ name: name, description: description, body: body,
164
+ max_rounds: max_rounds, model: model, tools: tools
165
+ )
172
166
  @storage.write(key, content)
173
167
 
174
168
  path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
@@ -210,5 +204,19 @@ module RailsConsoleAi
210
204
  rescue Psych::SyntaxError
211
205
  nil
212
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"
220
+ end
213
221
  end
214
222
  end
@@ -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,30 +111,82 @@ 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
 
137
+ if aborted?(session.id)
138
+ puts ">> aborted (result discarded)"
139
+ return
140
+ end
113
141
  SessionLogger.update(session.id, status: 'ready', result: result_text)
114
142
 
115
143
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
116
144
  preview = result_text.to_s.strip.lines.first.to_s.strip
117
145
  preview = preview[0, 120] + '…' if preview.length > 120
118
146
  puts ">> ready (#{elapsed}ms) #{preview}"
147
+ rescue RunnerTimeoutError => e
148
+ warn ">> TIMEOUT #{e.message}"
149
+ SessionLogger.update(session.id, status: 'failed', error_message: e.message) unless aborted?(session.id)
119
150
  rescue => e
120
151
  warn ">> FAILED #{e.class}: #{e.message}"
121
152
  e.backtrace&.first(5)&.each { |line| warn " #{line}" }
153
+ return if aborted?(session.id)
122
154
  SessionLogger.update(session.id,
123
155
  status: 'failed',
124
156
  error_message: "#{e.class}: #{e.message}\n#{Array(e.backtrace).first(10).join("\n")}"
125
157
  )
126
158
  end
127
159
 
160
+ # An abort (RailsConsoleAi.abort_agent) flips the row to 'aborted' while we
161
+ # run; in that case the run's outcome must not overwrite the abort.
162
+ def aborted?(session_id)
163
+ Session.where(id: session_id).pluck(:status).first == 'aborted'
164
+ end
165
+
166
+ # When cap is nil or non-positive, run inline. Otherwise spawn a nested
167
+ # worker thread, join with the deadline, and kill + raise on overshoot.
168
+ # Kept localized (vs Timeout.timeout) so a runaway provider call can't
169
+ # raise from inside our own bookkeeping code.
170
+ def run_with_deadline(cap)
171
+ return yield if cap.nil? || cap.to_f <= 0
172
+ result = nil
173
+ error = nil
174
+ worker = Thread.new do
175
+ Thread.current.report_on_exception = false
176
+ begin
177
+ result = yield
178
+ rescue => e
179
+ error = e
180
+ end
181
+ end
182
+ if worker.join(cap.to_f).nil?
183
+ worker.kill
184
+ raise RunnerTimeoutError, "exceeded max_wall_clock_seconds (#{cap}s)"
185
+ end
186
+ raise error if error
187
+ result
188
+ end
189
+
128
190
  # Build the `result` payload returned via get_agent_response. The
129
191
  # LLM's prose lands in the channel's captured_output; the value the
130
192
  # generated code returned lands in exec_result. Without including the
@@ -368,7 +368,7 @@ module RailsConsoleAi
368
368
  config = RailsConsoleAi.configuration
369
369
  model = @engine.effective_model
370
370
  thinking = config.resolved_thinking_model
371
- pricing = Configuration::PRICING[model]
371
+ pricing = Configuration.pricing_for(model)
372
372
 
373
373
  @real_stdout.puts "\e[36m Model info:\e[0m"
374
374
  @real_stdout.puts "\e[2m Provider: #{config.provider}\e[0m"