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