anima-core 0.1.0 → 0.2.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 +13 -3
- data/Gemfile +17 -0
- data/Procfile +2 -0
- data/Procfile.dev +2 -0
- data/README.md +56 -26
- data/Rakefile +19 -7
- data/anima-core.gemspec +40 -0
- data/app/channels/application_cable/channel.rb +6 -0
- data/app/channels/application_cable/connection.rb +6 -0
- data/app/channels/session_channel.rb +126 -0
- data/app/controllers/api/sessions_controller.rb +25 -0
- data/app/controllers/application_controller.rb +4 -0
- data/app/jobs/agent_request_job.rb +59 -0
- data/app/jobs/count_event_tokens_job.rb +1 -1
- data/app/models/session.rb +18 -9
- data/bin/jobs +6 -0
- data/bin/rails +6 -0
- data/bin/rake +6 -0
- data/config/application.rb +4 -0
- data/config/cable.yml +14 -0
- data/config/database.yml +12 -0
- data/config/initializers/event_subscribers.rb +11 -0
- data/config/puma.rb +13 -0
- data/config/routes.rb +8 -0
- data/config.ru +5 -0
- data/db/cable_schema.rb +11 -0
- data/lib/agent_loop.rb +97 -0
- data/lib/anima/cli.rb +64 -9
- data/lib/anima/installer.rb +4 -3
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -0
- data/lib/events/subscribers/action_cable_bridge.rb +35 -0
- data/lib/events/subscribers/persister.rb +14 -4
- data/lib/providers/anthropic.rb +11 -2
- data/lib/tui/app.rb +71 -13
- data/lib/tui/cable_client.rb +377 -0
- data/lib/tui/message_store.rb +49 -0
- data/lib/tui/screens/chat.rb +179 -68
- metadata +80 -3
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket-client-simple"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module TUI
|
|
7
|
+
# Action Cable WebSocket client for connecting the TUI to the brain server.
|
|
8
|
+
# Runs the WebSocket connection in a background thread and exposes a
|
|
9
|
+
# thread-safe message queue for the TUI render loop to drain.
|
|
10
|
+
#
|
|
11
|
+
# Implements the +actioncable-v1-json+ protocol: subscribe to a
|
|
12
|
+
# {SessionChannel}, receive event broadcasts, and send user input
|
|
13
|
+
# via the +speak+ action.
|
|
14
|
+
#
|
|
15
|
+
# Automatically reconnects with exponential backoff when the connection
|
|
16
|
+
# drops unexpectedly. Detects stale connections via Action Cable ping
|
|
17
|
+
# heartbeat monitoring.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# client = TUI::CableClient.new(host: "localhost:42134", session_id: 1)
|
|
21
|
+
# client.connect
|
|
22
|
+
# client.speak("Hello!")
|
|
23
|
+
# messages = client.drain_messages
|
|
24
|
+
# client.disconnect
|
|
25
|
+
class CableClient
|
|
26
|
+
DISCONNECT_TIMEOUT = 2 # seconds to wait for WebSocket thread to finish
|
|
27
|
+
POLL_INTERVAL = 0.1 # seconds between connection status checks
|
|
28
|
+
CONNECTION_TIMEOUT = 10 # seconds to wait for the connecting state to advance
|
|
29
|
+
MAX_RECONNECT_ATTEMPTS = 10
|
|
30
|
+
BACKOFF_BASE = 1.0 # initial backoff delay in seconds
|
|
31
|
+
BACKOFF_CAP = 30.0 # maximum backoff delay
|
|
32
|
+
PING_STALE_THRESHOLD = 6.0 # seconds without ping before connection is stale
|
|
33
|
+
|
|
34
|
+
# @return [String] brain server host:port
|
|
35
|
+
attr_reader :host
|
|
36
|
+
|
|
37
|
+
# @return [Integer] current session ID
|
|
38
|
+
attr_reader :session_id
|
|
39
|
+
|
|
40
|
+
# @return [Symbol] connection status (:disconnected, :connecting, :connected, :subscribed, :reconnecting)
|
|
41
|
+
attr_reader :status
|
|
42
|
+
|
|
43
|
+
# @return [Integer] current reconnection attempt (0 when connected)
|
|
44
|
+
attr_reader :reconnect_attempt
|
|
45
|
+
|
|
46
|
+
# @param host [String] brain server address (e.g. "localhost:42134")
|
|
47
|
+
# @param session_id [Integer] session to subscribe to
|
|
48
|
+
def initialize(host:, session_id:)
|
|
49
|
+
@host = host
|
|
50
|
+
@session_id = session_id
|
|
51
|
+
@status = :disconnected
|
|
52
|
+
@message_queue = Thread::Queue.new
|
|
53
|
+
@mutex = Mutex.new
|
|
54
|
+
@ws = nil
|
|
55
|
+
@ws_thread = nil
|
|
56
|
+
@intentional_disconnect = false
|
|
57
|
+
@reconnect_attempt = 0
|
|
58
|
+
@last_ping_at = nil
|
|
59
|
+
@connection_generation = 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Opens the WebSocket connection in a background thread.
|
|
63
|
+
# The connection subscribes to the session channel automatically
|
|
64
|
+
# after receiving the Action Cable welcome message. Reconnects
|
|
65
|
+
# automatically on unexpected disconnection.
|
|
66
|
+
def connect
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@intentional_disconnect = false
|
|
69
|
+
@status = :connecting
|
|
70
|
+
end
|
|
71
|
+
@ws_thread = Thread.new { run_websocket_loop }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Sends user input to the brain for processing.
|
|
75
|
+
#
|
|
76
|
+
# @param content [String] the user's message text
|
|
77
|
+
def speak(content)
|
|
78
|
+
send_action("speak", {"content" => content})
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Requests the brain to create a new session and switch to it.
|
|
82
|
+
# The server responds with a session_changed message followed by history.
|
|
83
|
+
def create_session
|
|
84
|
+
send_action("create_session", {})
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Requests the brain to switch to an existing session.
|
|
88
|
+
# The server responds with a session_changed message followed by history.
|
|
89
|
+
#
|
|
90
|
+
# @param session_id [Integer] target session to resume
|
|
91
|
+
def switch_session(session_id)
|
|
92
|
+
send_action("switch_session", {"session_id" => session_id})
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Requests a list of recent sessions from the brain.
|
|
96
|
+
# The server responds with a sessions_list message.
|
|
97
|
+
#
|
|
98
|
+
# @param limit [Integer] max sessions to return (default 10)
|
|
99
|
+
def list_sessions(limit: 10)
|
|
100
|
+
send_action("list_sessions", {"limit" => limit})
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Updates the local session ID reference after a server-side session switch.
|
|
104
|
+
#
|
|
105
|
+
# @param new_id [Integer] the new session ID
|
|
106
|
+
def update_session_id(new_id)
|
|
107
|
+
@mutex.synchronize { @session_id = new_id }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Drains all pending messages from the queue (non-blocking).
|
|
111
|
+
# Call this from the TUI render loop to process incoming events.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<Hash>] messages received since last drain
|
|
114
|
+
def drain_messages
|
|
115
|
+
messages = []
|
|
116
|
+
loop do
|
|
117
|
+
messages << @message_queue.pop(true)
|
|
118
|
+
rescue ThreadError
|
|
119
|
+
break
|
|
120
|
+
end
|
|
121
|
+
messages
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Unsubscribes from the current session and subscribes to a new one.
|
|
125
|
+
#
|
|
126
|
+
# @deprecated Use {#create_session} or {#switch_session} instead.
|
|
127
|
+
# The server now handles stream switching via the session protocol.
|
|
128
|
+
# @param new_session_id [Integer] session to switch to
|
|
129
|
+
def resubscribe(new_session_id)
|
|
130
|
+
unsubscribe_current
|
|
131
|
+
@mutex.synchronize { @session_id = new_session_id }
|
|
132
|
+
subscribe
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Closes the WebSocket connection and cleans up the background thread.
|
|
136
|
+
# Prevents automatic reconnection.
|
|
137
|
+
def disconnect
|
|
138
|
+
@mutex.synchronize do
|
|
139
|
+
@intentional_disconnect = true
|
|
140
|
+
@status = :disconnected
|
|
141
|
+
end
|
|
142
|
+
@ws&.close
|
|
143
|
+
@ws_thread&.join(DISCONNECT_TIMEOUT)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# Main connection loop: connect -> monitor -> reconnect if needed.
|
|
149
|
+
# Runs in a background thread spawned by {#connect}.
|
|
150
|
+
def run_websocket_loop
|
|
151
|
+
loop do
|
|
152
|
+
return if intentional_disconnect?
|
|
153
|
+
|
|
154
|
+
if open_websocket
|
|
155
|
+
monitor_connection
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
return if intentional_disconnect?
|
|
159
|
+
break unless schedule_reconnect
|
|
160
|
+
end
|
|
161
|
+
rescue => _e
|
|
162
|
+
on_disconnected
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Establishes WebSocket connection and registers event handlers.
|
|
166
|
+
#
|
|
167
|
+
# @return [Boolean] true if connection was opened, false on failure
|
|
168
|
+
def open_websocket
|
|
169
|
+
begin
|
|
170
|
+
@ws&.close
|
|
171
|
+
rescue IOError, Errno::ECONNRESET
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
generation = @mutex.synchronize do
|
|
176
|
+
@connection_generation += 1
|
|
177
|
+
@status = :connecting
|
|
178
|
+
@last_ping_at = nil
|
|
179
|
+
@connection_generation
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
url = "ws://#{@host}/cable"
|
|
183
|
+
client = self
|
|
184
|
+
|
|
185
|
+
@ws = WebSocket::Client::Simple.connect(url, headers: {
|
|
186
|
+
"Sec-WebSocket-Protocol" => "actioncable-v1-json"
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
@ws.on :open do
|
|
190
|
+
# Wait for welcome message from Action Cable
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
@ws.on :message do |msg|
|
|
194
|
+
next if client.send(:stale_generation?, generation)
|
|
195
|
+
data = JSON.parse(msg.data)
|
|
196
|
+
client.send(:handle_protocol_message, data)
|
|
197
|
+
rescue JSON::ParserError
|
|
198
|
+
# Ignore malformed messages
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
@ws.on :close do |_e|
|
|
202
|
+
client.send(:on_disconnected) unless client.send(:stale_generation?, generation)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
@ws.on :error do |_e|
|
|
206
|
+
client.send(:on_disconnected) unless client.send(:stale_generation?, generation)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
true
|
|
210
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
|
|
211
|
+
SocketError, IOError => _e
|
|
212
|
+
false
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Polls connection status until disconnect is detected.
|
|
216
|
+
# Also monitors for stale connections and connection timeout.
|
|
217
|
+
def monitor_connection
|
|
218
|
+
connection_start = Time.now
|
|
219
|
+
|
|
220
|
+
loop do
|
|
221
|
+
break if @status == :disconnected
|
|
222
|
+
|
|
223
|
+
if @status == :connecting && (Time.now - connection_start) > CONNECTION_TIMEOUT
|
|
224
|
+
on_disconnected
|
|
225
|
+
break
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
check_stale_connection
|
|
229
|
+
sleep POLL_INTERVAL
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Detects stale connections by monitoring ping heartbeat interval.
|
|
234
|
+
# Action Cable sends pings approximately every 3 seconds;
|
|
235
|
+
# a 6-second gap indicates 2 missed pings.
|
|
236
|
+
def check_stale_connection
|
|
237
|
+
stale = @mutex.synchronize do
|
|
238
|
+
next false unless @last_ping_at && @status == :subscribed
|
|
239
|
+
(Time.now - @last_ping_at) >= PING_STALE_THRESHOLD
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
on_disconnected if stale
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Waits with exponential backoff before next reconnection attempt.
|
|
246
|
+
#
|
|
247
|
+
# @return [Boolean] true if reconnection should proceed, false if max attempts reached
|
|
248
|
+
def schedule_reconnect
|
|
249
|
+
attempt = @mutex.synchronize do
|
|
250
|
+
@reconnect_attempt += 1
|
|
251
|
+
@reconnect_attempt
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if attempt > MAX_RECONNECT_ATTEMPTS
|
|
255
|
+
@mutex.synchronize { @status = :disconnected }
|
|
256
|
+
@message_queue << {
|
|
257
|
+
"type" => "connection",
|
|
258
|
+
"status" => "failed",
|
|
259
|
+
"message" => "Reconnection failed after #{MAX_RECONNECT_ATTEMPTS} attempts"
|
|
260
|
+
}
|
|
261
|
+
return false
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
delay = backoff_delay(attempt)
|
|
265
|
+
@mutex.synchronize { @status = :reconnecting }
|
|
266
|
+
@message_queue << {
|
|
267
|
+
"type" => "connection",
|
|
268
|
+
"status" => "reconnecting",
|
|
269
|
+
"attempt" => attempt,
|
|
270
|
+
"max_attempts" => MAX_RECONNECT_ATTEMPTS,
|
|
271
|
+
"delay" => delay.round(1)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
sleep delay
|
|
275
|
+
!intentional_disconnect?
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Full jitter backoff: random delay between 0 and min(cap, base * 2^attempt).
|
|
279
|
+
# Prevents thundering herd when multiple clients reconnect simultaneously.
|
|
280
|
+
#
|
|
281
|
+
# @param attempt [Integer] current attempt number (1-based)
|
|
282
|
+
# @return [Float] delay in seconds
|
|
283
|
+
def backoff_delay(attempt)
|
|
284
|
+
max_delay = [BACKOFF_CAP, BACKOFF_BASE * (2**(attempt - 1))].min
|
|
285
|
+
rand(0.0..max_delay)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Checks if a captured connection generation is outdated.
|
|
289
|
+
# WebSocket event handlers capture the generation at connection time;
|
|
290
|
+
# if a new connection starts, older handlers must ignore their events
|
|
291
|
+
# to prevent stale callbacks from corrupting current state.
|
|
292
|
+
#
|
|
293
|
+
# @param generation [Integer] the generation captured by an event handler
|
|
294
|
+
# @return [Boolean] true if the given generation is no longer current
|
|
295
|
+
def stale_generation?(generation)
|
|
296
|
+
@mutex.synchronize { generation != @connection_generation }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# @return [Boolean] true if disconnect was initiated by the application
|
|
300
|
+
def intentional_disconnect?
|
|
301
|
+
@mutex.synchronize { @intentional_disconnect }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def handle_protocol_message(data)
|
|
305
|
+
case data["type"]
|
|
306
|
+
when "welcome"
|
|
307
|
+
@mutex.synchronize { @status = :connected }
|
|
308
|
+
@last_ping_at = Time.now
|
|
309
|
+
subscribe
|
|
310
|
+
when "ping"
|
|
311
|
+
@last_ping_at = Time.now
|
|
312
|
+
when "confirm_subscription"
|
|
313
|
+
@mutex.synchronize do
|
|
314
|
+
@status = :subscribed
|
|
315
|
+
@reconnect_attempt = 0
|
|
316
|
+
end
|
|
317
|
+
@message_queue << {"type" => "connection", "status" => "subscribed"}
|
|
318
|
+
when "reject_subscription"
|
|
319
|
+
on_disconnected
|
|
320
|
+
@message_queue << {"type" => "connection", "status" => "rejected"}
|
|
321
|
+
when "disconnect"
|
|
322
|
+
if data["reconnect"] == false
|
|
323
|
+
@mutex.synchronize do
|
|
324
|
+
@intentional_disconnect = true
|
|
325
|
+
@status = :disconnected
|
|
326
|
+
end
|
|
327
|
+
@message_queue << {"type" => "connection", "status" => "disconnected"}
|
|
328
|
+
else
|
|
329
|
+
on_disconnected
|
|
330
|
+
end
|
|
331
|
+
else
|
|
332
|
+
# Regular broadcast or transmit from the channel
|
|
333
|
+
if data["message"]
|
|
334
|
+
@message_queue << data["message"]
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Transitions to disconnected state. Guards against duplicate calls
|
|
340
|
+
# from concurrent close/error handlers.
|
|
341
|
+
def on_disconnected
|
|
342
|
+
@mutex.synchronize do
|
|
343
|
+
return if @status == :disconnected || @status == :reconnecting
|
|
344
|
+
@status = :disconnected
|
|
345
|
+
end
|
|
346
|
+
@message_queue << {"type" => "connection", "status" => "disconnected"}
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def subscribe
|
|
350
|
+
identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
|
|
351
|
+
send_command("subscribe", identifier)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def unsubscribe_current
|
|
355
|
+
identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
|
|
356
|
+
send_command("unsubscribe", identifier)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def send_action(action, data = {})
|
|
360
|
+
identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
|
|
361
|
+
payload = data.merge("action" => action).to_json
|
|
362
|
+
|
|
363
|
+
@ws&.send({
|
|
364
|
+
command: "message",
|
|
365
|
+
identifier: identifier,
|
|
366
|
+
data: payload
|
|
367
|
+
}.to_json)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def send_command(command, identifier)
|
|
371
|
+
@ws&.send({
|
|
372
|
+
command: command,
|
|
373
|
+
identifier: identifier
|
|
374
|
+
}.to_json)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TUI
|
|
4
|
+
# Thread-safe in-memory store for chat messages displayed in the TUI.
|
|
5
|
+
# Replaces {Events::Subscribers::MessageCollector} in the WebSocket-based
|
|
6
|
+
# TUI, with no dependency on Rails or the Events module.
|
|
7
|
+
#
|
|
8
|
+
# Accepts Action Cable event payloads and extracts displayable messages.
|
|
9
|
+
class MessageStore
|
|
10
|
+
DISPLAYABLE_TYPES = %w[user_message agent_message].freeze
|
|
11
|
+
|
|
12
|
+
ROLE_MAP = {
|
|
13
|
+
"user_message" => "user",
|
|
14
|
+
"agent_message" => "assistant"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@messages = []
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<Hash>] thread-safe copy of collected messages
|
|
23
|
+
def messages
|
|
24
|
+
@mutex.synchronize { @messages.dup }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Processes a raw event payload from the WebSocket channel.
|
|
28
|
+
# Only user_message and agent_message events are stored.
|
|
29
|
+
#
|
|
30
|
+
# @param event_data [Hash] Action Cable event payload with "type" and "content"
|
|
31
|
+
# @return [Boolean] true if the message was stored
|
|
32
|
+
def process_event(event_data)
|
|
33
|
+
type = event_data["type"]
|
|
34
|
+
return false unless DISPLAYABLE_TYPES.include?(type)
|
|
35
|
+
|
|
36
|
+
content = event_data["content"]
|
|
37
|
+
return false if content.nil?
|
|
38
|
+
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@messages << {role: ROLE_MAP.fetch(type), content: content}
|
|
41
|
+
end
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clear
|
|
46
|
+
@mutex.synchronize { @messages = [] }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|