console_agent 0.10.0 → 0.12.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 +15 -0
- data/README.md +101 -1
- data/app/helpers/console_agent/sessions_helper.rb +14 -0
- data/app/models/console_agent/session.rb +1 -1
- data/app/views/console_agent/sessions/index.html.erb +4 -4
- data/app/views/console_agent/sessions/show.html.erb +16 -6
- data/app/views/layouts/console_agent/application.html.erb +1 -0
- data/lib/console_agent/channel/base.rb +23 -0
- data/lib/console_agent/channel/console.rb +457 -0
- data/lib/console_agent/channel/slack.rb +182 -0
- data/lib/console_agent/configuration.rb +74 -5
- data/lib/console_agent/conversation_engine.rb +1122 -0
- data/lib/console_agent/executor.rb +239 -47
- data/lib/console_agent/providers/base.rb +7 -2
- data/lib/console_agent/providers/local.rb +112 -0
- data/lib/console_agent/railtie.rb +4 -0
- data/lib/console_agent/repl.rb +26 -1291
- data/lib/console_agent/safety_guards.rb +207 -0
- data/lib/console_agent/session_logger.rb +14 -3
- data/lib/console_agent/slack_bot.rb +473 -0
- data/lib/console_agent/tools/registry.rb +48 -16
- data/lib/console_agent/version.rb +1 -1
- data/lib/console_agent.rb +17 -3
- data/lib/generators/console_agent/templates/initializer.rb +34 -1
- data/lib/tasks/console_agent.rake +7 -0
- metadata +9 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'console_agent/channel/slack'
|
|
6
|
+
require 'console_agent/conversation_engine'
|
|
7
|
+
require 'console_agent/context_builder'
|
|
8
|
+
require 'console_agent/providers/base'
|
|
9
|
+
require 'console_agent/executor'
|
|
10
|
+
|
|
11
|
+
module ConsoleAgent
|
|
12
|
+
class SlackBot
|
|
13
|
+
def initialize
|
|
14
|
+
@bot_token = ConsoleAgent.configuration.slack_bot_token || ENV['SLACK_BOT_TOKEN']
|
|
15
|
+
@app_token = ConsoleAgent.configuration.slack_app_token || ENV['SLACK_APP_TOKEN']
|
|
16
|
+
@channel_ids = resolve_channel_ids
|
|
17
|
+
|
|
18
|
+
raise ConfigurationError, "SLACK_BOT_TOKEN is required" unless @bot_token
|
|
19
|
+
raise ConfigurationError, "SLACK_APP_TOKEN is required (Socket Mode)" unless @app_token
|
|
20
|
+
raise ConfigurationError, "slack_allowed_usernames must be configured (e.g. ['alice'] or 'ALL')" unless ConsoleAgent.configuration.slack_allowed_usernames
|
|
21
|
+
|
|
22
|
+
@bot_user_id = nil
|
|
23
|
+
@sessions = {} # thread_ts → { channel:, engine:, thread: }
|
|
24
|
+
@user_cache = {} # slack user_id → display_name
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@bot_user_id = slack_api("auth.test", token: @bot_token).dig("user_id")
|
|
30
|
+
log_startup
|
|
31
|
+
|
|
32
|
+
loop do
|
|
33
|
+
run_socket_mode
|
|
34
|
+
puts "Reconnecting in 5s..."
|
|
35
|
+
sleep 5
|
|
36
|
+
end
|
|
37
|
+
rescue Interrupt
|
|
38
|
+
puts "\nSlackBot shutting down."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# --- Socket Mode connection ---
|
|
44
|
+
|
|
45
|
+
def run_socket_mode
|
|
46
|
+
url = obtain_wss_url
|
|
47
|
+
uri = URI.parse(url)
|
|
48
|
+
|
|
49
|
+
tcp = TCPSocket.new(uri.host, uri.port || 443)
|
|
50
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
|
|
51
|
+
ssl.hostname = uri.host
|
|
52
|
+
ssl.connect
|
|
53
|
+
|
|
54
|
+
# WebSocket handshake
|
|
55
|
+
path = "#{uri.path}?#{uri.query}"
|
|
56
|
+
handshake = [
|
|
57
|
+
"GET #{path} HTTP/1.1",
|
|
58
|
+
"Host: #{uri.host}",
|
|
59
|
+
"Upgrade: websocket",
|
|
60
|
+
"Connection: Upgrade",
|
|
61
|
+
"Sec-WebSocket-Key: #{SecureRandom.base64(16)}",
|
|
62
|
+
"Sec-WebSocket-Version: 13",
|
|
63
|
+
"", ""
|
|
64
|
+
].join("\r\n")
|
|
65
|
+
|
|
66
|
+
ssl.write(handshake)
|
|
67
|
+
|
|
68
|
+
# Read HTTP 101 response headers
|
|
69
|
+
response_line = ssl.gets
|
|
70
|
+
unless response_line&.include?("101")
|
|
71
|
+
raise "WebSocket handshake failed: #{response_line}"
|
|
72
|
+
end
|
|
73
|
+
# Consume remaining headers
|
|
74
|
+
loop do
|
|
75
|
+
line = ssl.gets
|
|
76
|
+
break if line.nil? || line.strip.empty?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
puts "Connected to Slack Socket Mode."
|
|
80
|
+
|
|
81
|
+
# Main read loop
|
|
82
|
+
loop do
|
|
83
|
+
data = read_ws_frame(ssl)
|
|
84
|
+
next unless data
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
msg = JSON.parse(data, symbolize_names: true)
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Acknowledge immediately (Slack requires fast ack)
|
|
93
|
+
if msg[:envelope_id]
|
|
94
|
+
send_ws_frame(ssl, JSON.generate({ envelope_id: msg[:envelope_id] }))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
case msg[:type]
|
|
98
|
+
when "hello"
|
|
99
|
+
# Connection confirmed
|
|
100
|
+
when "disconnect"
|
|
101
|
+
puts "Slack disconnect: #{msg[:reason]}"
|
|
102
|
+
break
|
|
103
|
+
when "events_api"
|
|
104
|
+
handle_event(msg)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
rescue EOFError, IOError, Errno::ECONNRESET, OpenSSL::SSL::SSLError => e
|
|
108
|
+
puts "Socket Mode connection lost: #{e.message}"
|
|
109
|
+
ensure
|
|
110
|
+
ssl&.close rescue nil
|
|
111
|
+
tcp&.close rescue nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def obtain_wss_url
|
|
115
|
+
result = slack_api("apps.connections.open", token: @app_token)
|
|
116
|
+
raise "Failed to obtain WSS URL: #{result["error"]}" unless result["ok"]
|
|
117
|
+
result["url"]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# --- WebSocket frame reading/writing (RFC 6455 minimal implementation) ---
|
|
121
|
+
|
|
122
|
+
def read_ws_frame(ssl)
|
|
123
|
+
first_byte = ssl.read(1)&.unpack1("C")
|
|
124
|
+
return nil unless first_byte
|
|
125
|
+
|
|
126
|
+
opcode = first_byte & 0x0F
|
|
127
|
+
# Handle ping (opcode 9) → send pong (opcode 10)
|
|
128
|
+
if opcode == 9
|
|
129
|
+
payload = read_ws_payload(ssl)
|
|
130
|
+
send_ws_pong(ssl, payload)
|
|
131
|
+
return nil
|
|
132
|
+
end
|
|
133
|
+
# Close frame (opcode 8)
|
|
134
|
+
return nil if opcode == 8
|
|
135
|
+
# Only process text frames (opcode 1)
|
|
136
|
+
return nil unless opcode == 1
|
|
137
|
+
|
|
138
|
+
read_ws_payload(ssl)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def read_ws_payload(ssl)
|
|
142
|
+
second_byte = ssl.read(1)&.unpack1("C")
|
|
143
|
+
return nil unless second_byte
|
|
144
|
+
|
|
145
|
+
masked = (second_byte & 0x80) != 0
|
|
146
|
+
length = second_byte & 0x7F
|
|
147
|
+
|
|
148
|
+
if length == 126
|
|
149
|
+
length = ssl.read(2).unpack1("n")
|
|
150
|
+
elsif length == 127
|
|
151
|
+
length = ssl.read(8).unpack1("Q>")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if masked
|
|
155
|
+
mask_key = ssl.read(4).bytes
|
|
156
|
+
raw = ssl.read(length).bytes
|
|
157
|
+
raw.each_with_index.map { |b, i| (b ^ mask_key[i % 4]).chr }.join
|
|
158
|
+
else
|
|
159
|
+
ssl.read(length)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def send_ws_frame(ssl, text)
|
|
164
|
+
bytes = text.encode("UTF-8").bytes
|
|
165
|
+
# Client frames must be masked per RFC 6455
|
|
166
|
+
mask_key = 4.times.map { rand(256) }
|
|
167
|
+
masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
|
|
168
|
+
|
|
169
|
+
frame = [0x81].pack("C") # FIN + text opcode
|
|
170
|
+
if bytes.length < 126
|
|
171
|
+
frame << [(bytes.length | 0x80)].pack("C")
|
|
172
|
+
elsif bytes.length < 65536
|
|
173
|
+
frame << [126 | 0x80].pack("C")
|
|
174
|
+
frame << [bytes.length].pack("n")
|
|
175
|
+
else
|
|
176
|
+
frame << [127 | 0x80].pack("C")
|
|
177
|
+
frame << [bytes.length].pack("Q>")
|
|
178
|
+
end
|
|
179
|
+
frame << mask_key.pack("C*")
|
|
180
|
+
frame << masked.pack("C*")
|
|
181
|
+
ssl.write(frame)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def send_ws_pong(ssl, payload)
|
|
185
|
+
payload ||= ""
|
|
186
|
+
bytes = payload.bytes
|
|
187
|
+
mask_key = 4.times.map { rand(256) }
|
|
188
|
+
masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
|
|
189
|
+
|
|
190
|
+
frame = [0x8A].pack("C") # FIN + pong opcode
|
|
191
|
+
frame << [(bytes.length | 0x80)].pack("C")
|
|
192
|
+
frame << mask_key.pack("C*")
|
|
193
|
+
frame << masked.pack("C*")
|
|
194
|
+
ssl.write(frame)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- Slack Web API (minimal, uses Net::HTTP) ---
|
|
198
|
+
|
|
199
|
+
def slack_api(method, token: @bot_token, **params)
|
|
200
|
+
uri = URI("https://slack.com/api/#{method}")
|
|
201
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
202
|
+
http.use_ssl = true
|
|
203
|
+
|
|
204
|
+
if params.empty?
|
|
205
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
206
|
+
else
|
|
207
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
208
|
+
req.body = JSON.generate(params)
|
|
209
|
+
req["Content-Type"] = "application/json; charset=utf-8"
|
|
210
|
+
end
|
|
211
|
+
req["Authorization"] = "Bearer #{token}"
|
|
212
|
+
|
|
213
|
+
resp = http.request(req)
|
|
214
|
+
JSON.parse(resp.body)
|
|
215
|
+
rescue => e
|
|
216
|
+
{ "ok" => false, "error" => e.message }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def post_message(channel:, thread_ts:, text:)
|
|
220
|
+
slack_api("chat.postMessage", channel: channel, thread_ts: thread_ts, text: text)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# --- Event handling ---
|
|
224
|
+
|
|
225
|
+
def handle_event(msg)
|
|
226
|
+
event = msg.dig(:payload, :event)
|
|
227
|
+
return unless event
|
|
228
|
+
return unless event[:type] == "message"
|
|
229
|
+
|
|
230
|
+
# Ignore bot messages, subtypes (edits/deletes), own messages
|
|
231
|
+
return if event[:bot_id]
|
|
232
|
+
return if event[:user] == @bot_user_id
|
|
233
|
+
return if event[:subtype]
|
|
234
|
+
|
|
235
|
+
text = event[:text]
|
|
236
|
+
return unless text && !text.strip.empty?
|
|
237
|
+
|
|
238
|
+
channel_id = event[:channel]
|
|
239
|
+
return unless watched_channel?(channel_id)
|
|
240
|
+
|
|
241
|
+
thread_ts = event[:thread_ts] || event[:ts]
|
|
242
|
+
user_id = event[:user]
|
|
243
|
+
user_name = resolve_user_name(user_id)
|
|
244
|
+
|
|
245
|
+
allowed_list = Array(ConsoleAgent.configuration.slack_allowed_usernames).map(&:to_s).map(&:downcase)
|
|
246
|
+
unless allowed_list.include?('all') || allowed_list.include?(user_name.to_s.downcase)
|
|
247
|
+
puts "[#{channel_id}/#{thread_ts}] @#{user_name} << (ignored — not in allowed usernames)"
|
|
248
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{user_name}). Ask an admin to add you to the allowed usernames list.")
|
|
249
|
+
return
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
puts "[#{channel_id}/#{thread_ts}] @#{user_name} << #{text.strip}"
|
|
253
|
+
|
|
254
|
+
session = @mutex.synchronize { @sessions[thread_ts] }
|
|
255
|
+
|
|
256
|
+
command = text.strip.downcase
|
|
257
|
+
if command == 'cancel' || command == 'stop'
|
|
258
|
+
cancel_session(session, channel_id, thread_ts)
|
|
259
|
+
return
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if command == 'clear'
|
|
263
|
+
count = count_bot_messages(channel_id, thread_ts)
|
|
264
|
+
if count == 0
|
|
265
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "No bot messages to clear.")
|
|
266
|
+
else
|
|
267
|
+
post_message(channel: channel_id, thread_ts: thread_ts,
|
|
268
|
+
text: "This will permanently delete #{count} bot message#{'s' unless count == 1} from this thread. Type `clear!` to confirm.")
|
|
269
|
+
end
|
|
270
|
+
return
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if command == 'clear!'
|
|
274
|
+
cancel_session(session, channel_id, thread_ts) if session
|
|
275
|
+
clear_bot_messages(channel_id, thread_ts)
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
if session
|
|
280
|
+
handle_thread_reply(session, text.strip)
|
|
281
|
+
else
|
|
282
|
+
# New thread, or existing thread after bot restart — start a fresh session
|
|
283
|
+
start_session(channel_id, thread_ts, text.strip, user_name)
|
|
284
|
+
end
|
|
285
|
+
rescue => e
|
|
286
|
+
ConsoleAgent.logger.error("SlackBot event handling error: #{e.class}: #{e.message}")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def start_session(channel_id, thread_ts, text, user_name)
|
|
290
|
+
channel = Channel::Slack.new(
|
|
291
|
+
slack_bot: self,
|
|
292
|
+
channel_id: channel_id,
|
|
293
|
+
thread_ts: thread_ts,
|
|
294
|
+
user_name: user_name
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
sandbox_binding = Object.new.instance_eval { binding }
|
|
298
|
+
engine = ConversationEngine.new(
|
|
299
|
+
binding_context: sandbox_binding,
|
|
300
|
+
channel: channel,
|
|
301
|
+
slack_thread_ts: thread_ts
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Try to restore conversation history from a previous session (e.g. after bot restart)
|
|
305
|
+
restored = restore_from_db(engine, thread_ts)
|
|
306
|
+
|
|
307
|
+
session = { channel: channel, engine: engine, thread: nil }
|
|
308
|
+
@mutex.synchronize { @sessions[thread_ts] = session }
|
|
309
|
+
|
|
310
|
+
session[:thread] = Thread.new do
|
|
311
|
+
Thread.current.report_on_exception = false
|
|
312
|
+
begin
|
|
313
|
+
channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
|
|
314
|
+
if restored
|
|
315
|
+
puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
|
|
316
|
+
channel.display_dim("_(session restored — continuing from previous conversation)_")
|
|
317
|
+
end
|
|
318
|
+
engine.process_message(text)
|
|
319
|
+
rescue => e
|
|
320
|
+
channel.display_error("Error: #{e.class}: #{e.message}")
|
|
321
|
+
ConsoleAgent.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
322
|
+
ensure
|
|
323
|
+
ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def restore_from_db(engine, thread_ts)
|
|
329
|
+
require 'console_agent/session_logger'
|
|
330
|
+
saved = SessionLogger.find_by_slack_thread(thread_ts)
|
|
331
|
+
return false unless saved
|
|
332
|
+
|
|
333
|
+
engine.init_interactive
|
|
334
|
+
engine.restore_session(saved)
|
|
335
|
+
true
|
|
336
|
+
rescue => e
|
|
337
|
+
ConsoleAgent.logger.warn("SlackBot: failed to restore session for #{thread_ts}: #{e.message}")
|
|
338
|
+
false
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def handle_thread_reply(session, text)
|
|
342
|
+
channel = session[:channel]
|
|
343
|
+
engine = session[:engine]
|
|
344
|
+
|
|
345
|
+
# If the engine is blocked waiting for user input (ask_user), push to queue
|
|
346
|
+
if waiting_for_reply?(channel)
|
|
347
|
+
channel.receive_reply(text)
|
|
348
|
+
return
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Otherwise treat as a new message in the conversation
|
|
352
|
+
session[:thread] = Thread.new do
|
|
353
|
+
Thread.current.report_on_exception = false
|
|
354
|
+
begin
|
|
355
|
+
engine.process_message(text)
|
|
356
|
+
rescue => e
|
|
357
|
+
channel.display_error("Error: #{e.class}: #{e.message}")
|
|
358
|
+
ConsoleAgent.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
359
|
+
ensure
|
|
360
|
+
ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def cancel_session(session, channel_id, thread_ts)
|
|
366
|
+
if session
|
|
367
|
+
session[:channel].cancel!
|
|
368
|
+
session[:channel].display("Stopped.")
|
|
369
|
+
puts "[#{channel_id}/#{thread_ts}] cancel requested"
|
|
370
|
+
else
|
|
371
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "No active session to stop.")
|
|
372
|
+
puts "[#{channel_id}/#{thread_ts}] cancel: no session"
|
|
373
|
+
end
|
|
374
|
+
@mutex.synchronize { @sessions.delete(thread_ts) }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def count_bot_messages(channel_id, thread_ts)
|
|
378
|
+
result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
|
|
379
|
+
return 0 unless result["ok"]
|
|
380
|
+
(result["messages"] || []).count { |m| m["user"] == @bot_user_id }
|
|
381
|
+
rescue
|
|
382
|
+
0
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def clear_bot_messages(channel_id, thread_ts)
|
|
386
|
+
result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
|
|
387
|
+
unless result["ok"]
|
|
388
|
+
puts "[#{channel_id}/#{thread_ts}] clear: failed to fetch replies: #{result["error"]}"
|
|
389
|
+
return
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
bot_messages = (result["messages"] || []).select { |m| m["user"] == @bot_user_id }
|
|
393
|
+
bot_messages.each do |m|
|
|
394
|
+
puts "[#{channel_id}/#{thread_ts}] clearing #{channel_id.length} / #{m["ts"]}"
|
|
395
|
+
slack_api("chat.delete", channel: channel_id, ts: m["ts"])
|
|
396
|
+
end
|
|
397
|
+
puts "[#{channel_id}/#{thread_ts}] cleared #{bot_messages.length} bot messages"
|
|
398
|
+
rescue => e
|
|
399
|
+
puts "[#{channel_id}/#{thread_ts}] clear failed: #{e.message}"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def slack_get(method, **params)
|
|
403
|
+
uri = URI("https://slack.com/api/#{method}")
|
|
404
|
+
uri.query = URI.encode_www_form(params)
|
|
405
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
406
|
+
http.use_ssl = true
|
|
407
|
+
req = Net::HTTP::Get.new(uri)
|
|
408
|
+
req["Authorization"] = "Bearer #{@bot_token}"
|
|
409
|
+
resp = http.request(req)
|
|
410
|
+
JSON.parse(resp.body)
|
|
411
|
+
rescue => e
|
|
412
|
+
{ "ok" => false, "error" => e.message }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def waiting_for_reply?(channel)
|
|
416
|
+
channel.instance_variable_get(:@reply_queue).num_waiting > 0
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def watched_channel?(channel_id)
|
|
420
|
+
return true if @channel_ids.nil? || @channel_ids.empty?
|
|
421
|
+
@channel_ids.include?(channel_id)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def resolve_channel_ids
|
|
425
|
+
ids = ConsoleAgent.configuration.slack_channel_ids || ENV['CONSOLE_AGENT_SLACK_CHANNELS']
|
|
426
|
+
return nil if ids.nil?
|
|
427
|
+
ids = ids.split(',').map(&:strip) if ids.is_a?(String)
|
|
428
|
+
ids
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def resolve_user_name(user_id)
|
|
432
|
+
return @user_cache[user_id] if @user_cache.key?(user_id)
|
|
433
|
+
|
|
434
|
+
# users.info requires form-encoded params, not JSON
|
|
435
|
+
uri = URI("https://slack.com/api/users.info")
|
|
436
|
+
uri.query = URI.encode_www_form(user: user_id)
|
|
437
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
438
|
+
http.use_ssl = true
|
|
439
|
+
req = Net::HTTP::Get.new(uri)
|
|
440
|
+
req["Authorization"] = "Bearer #{@bot_token}"
|
|
441
|
+
resp = http.request(req)
|
|
442
|
+
result = JSON.parse(resp.body)
|
|
443
|
+
|
|
444
|
+
name = result.dig("user", "profile", "display_name")
|
|
445
|
+
name = result.dig("user", "real_name") if name.nil? || name.empty?
|
|
446
|
+
name = result.dig("user", "name") if name.nil? || name.empty?
|
|
447
|
+
@user_cache[user_id] = name || user_id
|
|
448
|
+
rescue => e
|
|
449
|
+
ConsoleAgent.logger.warn("Failed to resolve user name for #{user_id}: #{e.message}")
|
|
450
|
+
@user_cache[user_id] = user_id
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def log_startup
|
|
454
|
+
channel_info = if @channel_ids && !@channel_ids.empty?
|
|
455
|
+
"channels: #{@channel_ids.join(', ')}"
|
|
456
|
+
else
|
|
457
|
+
"all channels"
|
|
458
|
+
end
|
|
459
|
+
puts "ConsoleAgent SlackBot started (#{channel_info}, bot: #{@bot_user_id})"
|
|
460
|
+
|
|
461
|
+
channel = Channel::Slack.new(slack_bot: self, channel_id: "boot", thread_ts: "boot")
|
|
462
|
+
engine = ConversationEngine.new(
|
|
463
|
+
binding_context: Object.new.instance_eval { binding },
|
|
464
|
+
channel: channel
|
|
465
|
+
)
|
|
466
|
+
puts "\nFull system prompt for Slack sessions:"
|
|
467
|
+
puts "-" * 60
|
|
468
|
+
puts engine.context
|
|
469
|
+
puts "-" * 60
|
|
470
|
+
puts
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
@@ -8,9 +8,10 @@ module ConsoleAgent
|
|
|
8
8
|
# Tools that should never be cached (side effects or user interaction)
|
|
9
9
|
NO_CACHE = %w[ask_user save_memory delete_memory execute_plan].freeze
|
|
10
10
|
|
|
11
|
-
def initialize(executor: nil, mode: :default)
|
|
11
|
+
def initialize(executor: nil, mode: :default, channel: nil)
|
|
12
12
|
@executor = executor
|
|
13
13
|
@mode = mode
|
|
14
|
+
@channel = channel
|
|
14
15
|
@definitions = []
|
|
15
16
|
@handlers = {}
|
|
16
17
|
@cache = {}
|
|
@@ -302,8 +303,12 @@ module ConsoleAgent
|
|
|
302
303
|
# Ask for plan approval (unless auto-execute)
|
|
303
304
|
skip_confirmations = auto
|
|
304
305
|
unless auto
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
if @channel
|
|
307
|
+
answer = @channel.confirm(" Accept plan? [y/N/a(uto)] ")
|
|
308
|
+
else
|
|
309
|
+
$stdout.print "\e[33m Accept plan? [y/N/a(uto)] \e[0m"
|
|
310
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
311
|
+
end
|
|
307
312
|
case answer
|
|
308
313
|
when 'a', 'auto'
|
|
309
314
|
skip_confirmations = true
|
|
@@ -326,8 +331,12 @@ module ConsoleAgent
|
|
|
326
331
|
|
|
327
332
|
# Per-step confirmation (unless auto-execute or plan-level auto)
|
|
328
333
|
unless skip_confirmations
|
|
329
|
-
|
|
330
|
-
|
|
334
|
+
if @channel
|
|
335
|
+
step_answer = @channel.confirm(" Run? [y/N/edit] ")
|
|
336
|
+
else
|
|
337
|
+
$stdout.print "\e[33m Run? [y/N/edit] \e[0m"
|
|
338
|
+
step_answer = $stdin.gets.to_s.strip.downcase
|
|
339
|
+
end
|
|
331
340
|
|
|
332
341
|
case step_answer
|
|
333
342
|
when 'e', 'edit'
|
|
@@ -335,8 +344,12 @@ module ConsoleAgent
|
|
|
335
344
|
if edited && edited != step['code']
|
|
336
345
|
$stdout.puts "\e[33m # Edited code:\e[0m"
|
|
337
346
|
$stdout.puts highlight_plan_code(edited)
|
|
338
|
-
|
|
339
|
-
|
|
347
|
+
if @channel
|
|
348
|
+
confirm = @channel.confirm(" Run edited code? [y/N] ")
|
|
349
|
+
else
|
|
350
|
+
$stdout.print "\e[33m Run edited code? [y/N] \e[0m"
|
|
351
|
+
confirm = $stdin.gets.to_s.strip.downcase
|
|
352
|
+
end
|
|
340
353
|
unless confirm == 'y' || confirm == 'yes'
|
|
341
354
|
feedback = ask_feedback("What would you like changed?")
|
|
342
355
|
results << "Step #{i + 1}: User declined after edit. Feedback: #{feedback}"
|
|
@@ -354,6 +367,17 @@ module ConsoleAgent
|
|
|
354
367
|
end
|
|
355
368
|
|
|
356
369
|
exec_result = @executor.execute(step['code'])
|
|
370
|
+
|
|
371
|
+
# On safety error, offer to re-run with guards disabled (console only)
|
|
372
|
+
if @executor.last_safety_error
|
|
373
|
+
if @channel && !@channel.supports_danger?
|
|
374
|
+
results << "Step #{i + 1} (#{step['description']}):\nBLOCKED by safety guard: #{@executor.last_error}. Write operations are not permitted in this channel."
|
|
375
|
+
break
|
|
376
|
+
else
|
|
377
|
+
exec_result = @executor.offer_danger_retry(step['code'])
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
357
381
|
# Make result available as step1, step2, etc. for subsequent steps
|
|
358
382
|
@executor.binding_context.local_variable_set(:"step#{i + 1}", exec_result)
|
|
359
383
|
output = @executor.last_output
|
|
@@ -407,18 +431,26 @@ module ConsoleAgent
|
|
|
407
431
|
end
|
|
408
432
|
|
|
409
433
|
def ask_feedback(prompt)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
434
|
+
if @channel
|
|
435
|
+
@channel.prompt(" #{prompt} > ")
|
|
436
|
+
else
|
|
437
|
+
$stdout.print "\e[36m #{prompt} > \e[0m"
|
|
438
|
+
feedback = $stdin.gets
|
|
439
|
+
return '(no feedback provided)' if feedback.nil?
|
|
440
|
+
feedback.strip.empty? ? '(no feedback provided)' : feedback.strip
|
|
441
|
+
end
|
|
414
442
|
end
|
|
415
443
|
|
|
416
444
|
def ask_user(question)
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
445
|
+
if @channel
|
|
446
|
+
@channel.prompt(" ? #{question}\n > ")
|
|
447
|
+
else
|
|
448
|
+
$stdout.puts "\e[36m ? #{question}\e[0m"
|
|
449
|
+
$stdout.print "\e[36m > \e[0m"
|
|
450
|
+
answer = $stdin.gets
|
|
451
|
+
return '(no answer provided)' if answer.nil?
|
|
452
|
+
answer.strip.empty? ? '(no answer provided)' : answer.strip
|
|
453
|
+
end
|
|
422
454
|
end
|
|
423
455
|
|
|
424
456
|
def register(name:, description:, parameters:, handler:)
|
data/lib/console_agent.rb
CHANGED
|
@@ -58,8 +58,8 @@ module ConsoleAgent
|
|
|
58
58
|
def status
|
|
59
59
|
c = configuration
|
|
60
60
|
key = c.resolved_api_key
|
|
61
|
-
masked_key = if key.nil? || key.empty?
|
|
62
|
-
"\e[31m(not set)\e[0m"
|
|
61
|
+
masked_key = if key.nil? || key.empty? || key == 'no-key'
|
|
62
|
+
c.provider == :local ? "\e[32m(not required)\e[0m" : "\e[31m(not set)\e[0m"
|
|
63
63
|
else
|
|
64
64
|
key[0..6] + '...' + key[-4..-1]
|
|
65
65
|
end
|
|
@@ -69,11 +69,19 @@ module ConsoleAgent
|
|
|
69
69
|
lines << " Provider: #{c.provider}"
|
|
70
70
|
lines << " Model: #{c.resolved_model}"
|
|
71
71
|
lines << " API key: #{masked_key}"
|
|
72
|
-
lines << "
|
|
72
|
+
lines << " Local URL: #{c.local_url}" if c.provider == :local
|
|
73
|
+
lines << " Max tokens: #{c.max_tokens || '(auto)'}"
|
|
73
74
|
lines << " Temperature: #{c.temperature}"
|
|
74
75
|
lines << " Timeout: #{c.timeout}s"
|
|
75
76
|
lines << " Max tool rounds:#{c.max_tool_rounds}"
|
|
76
77
|
lines << " Auto-execute: #{c.auto_execute}"
|
|
78
|
+
guards = c.safety_guards
|
|
79
|
+
if guards.empty?
|
|
80
|
+
lines << " Safe mode: \e[33m(no guards configured)\e[0m"
|
|
81
|
+
else
|
|
82
|
+
status = guards.enabled? ? "\e[32mON\e[0m" : "\e[31mOFF\e[0m"
|
|
83
|
+
lines << " Safe mode: #{status} (#{guards.names.join(', ')})"
|
|
84
|
+
end
|
|
77
85
|
lines << " Memories: #{c.memories_enabled}"
|
|
78
86
|
lines << " Session logging:#{session_table_status}"
|
|
79
87
|
lines << " Debug: #{c.debug}"
|
|
@@ -134,6 +142,12 @@ module ConsoleAgent
|
|
|
134
142
|
migrations << 'name'
|
|
135
143
|
end
|
|
136
144
|
|
|
145
|
+
unless conn.column_exists?(table, :slack_thread_ts)
|
|
146
|
+
conn.add_column(table, :slack_thread_ts, :string, limit: 255)
|
|
147
|
+
conn.add_index(table, :slack_thread_ts) unless conn.index_exists?(table, :slack_thread_ts)
|
|
148
|
+
migrations << 'slack_thread_ts'
|
|
149
|
+
end
|
|
150
|
+
|
|
137
151
|
if migrations.empty?
|
|
138
152
|
$stdout.puts "\e[32mConsoleAgent: #{table} is up to date.\e[0m"
|
|
139
153
|
else
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
ConsoleAgent.configure do |config|
|
|
2
|
-
# LLM provider: :anthropic or :
|
|
2
|
+
# LLM provider: :anthropic, :openai, or :local
|
|
3
3
|
config.provider = :anthropic
|
|
4
4
|
|
|
5
5
|
# API key (or set ANTHROPIC_API_KEY / OPENAI_API_KEY env var)
|
|
@@ -23,6 +23,16 @@ ConsoleAgent.configure do |config|
|
|
|
23
23
|
# HTTP timeout in seconds
|
|
24
24
|
config.timeout = 30
|
|
25
25
|
|
|
26
|
+
# Local model provider (Ollama, vLLM, or any OpenAI-compatible server):
|
|
27
|
+
# config.provider = :local
|
|
28
|
+
# config.local_url = 'http://localhost:11434'
|
|
29
|
+
# config.local_model = 'qwen2.5:7b'
|
|
30
|
+
# config.local_api_key = nil
|
|
31
|
+
|
|
32
|
+
# Slack: which users the bot responds to (required for Slack mode)
|
|
33
|
+
# config.slack_allowed_usernames = ['alice', 'bob'] # specific users
|
|
34
|
+
# config.slack_allowed_usernames = 'ALL' # everyone
|
|
35
|
+
|
|
26
36
|
# Debug mode: prints full API requests/responses and tool calls to stderr
|
|
27
37
|
# config.debug = true
|
|
28
38
|
|
|
@@ -38,4 +48,27 @@ ConsoleAgent.configure do |config|
|
|
|
38
48
|
# When nil, all requests are denied. Set credentials or use config.authenticate.
|
|
39
49
|
# config.admin_username = 'admin'
|
|
40
50
|
# config.admin_password = 'changeme'
|
|
51
|
+
|
|
52
|
+
# Safety guards: prevent side effects (DB writes, HTTP calls, etc.) during code execution.
|
|
53
|
+
# When enabled, code runs in safe mode by default. Users can toggle with /danger in the REPL.
|
|
54
|
+
#
|
|
55
|
+
# Built-in guard for database writes (works on Rails 5+, all adapters):
|
|
56
|
+
# config.use_builtin_safety_guard :database_writes
|
|
57
|
+
#
|
|
58
|
+
# Built-in guard for HTTP mutations — blocks POST/PUT/PATCH/DELETE via Net::HTTP.
|
|
59
|
+
# Covers most Ruby HTTP libraries (HTTParty, RestClient, Faraday) since they use Net::HTTP:
|
|
60
|
+
# config.use_builtin_safety_guard :http_mutations
|
|
61
|
+
#
|
|
62
|
+
# Allowlist specific hosts or tables so they pass through without blocking:
|
|
63
|
+
# config.use_builtin_safety_guard :http_mutations,
|
|
64
|
+
# allow: [/s3\.amazonaws\.com/, /googleapis\.com/]
|
|
65
|
+
# config.use_builtin_safety_guard :database_writes,
|
|
66
|
+
# allow: ['console_agent_sessions']
|
|
67
|
+
#
|
|
68
|
+
# Built-in guard for mailers — disables ActionMailer delivery:
|
|
69
|
+
# config.use_builtin_safety_guard :mailers
|
|
70
|
+
#
|
|
71
|
+
# config.safety_guard :jobs do |&execute|
|
|
72
|
+
# Sidekiq::Testing.fake! { execute.call }
|
|
73
|
+
# end
|
|
41
74
|
end
|