anima-core 0.2.1 → 0.3.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 +6 -30
- data/app/channels/session_channel.rb +132 -30
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +31 -2
- data/app/jobs/count_event_tokens_job.rb +14 -3
- data/app/models/concerns/event/broadcasting.rb +63 -0
- data/app/models/event.rb +19 -0
- data/app/models/session.rb +23 -3
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/lib/agent_loop.rb +5 -2
- data/lib/anima/cli.rb +1 -40
- data/lib/anima/version.rb +1 -1
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/providers/anthropic.rb +3 -13
- data/lib/tools/edit.rb +227 -0
- data/lib/tools/read.rb +152 -0
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +688 -18
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +83 -8
- data/lib/tui/screens/chat.rb +95 -34
- metadata +7 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e3b4a76568897bb6c9d465151e61f6dd889c42c33b71dcf0c019ae561143b2e4
|
|
4
|
+
data.tar.gz: 44acf306c750f33b3bb006c7be209ec80924521f61a89d2e863179672f33751b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb66a71439efafa605eb7e6124269e82a0183c3d133d7e63658a1bdbe1c4f6adc9a4bf3303fb4bc724337319434eded5de40e9134c456ecbb3bb4508500b9370
|
|
7
|
+
data.tar.gz: b06ae76628e9abd1a76832703bc1769f24ea206c39c86b5fc1ef3bcfe9a59cfe87db222042aa7ea277714d18c3fb9c7c460769974fd75edd3b1f00923560e756
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### Added
|
|
4
|
+
- Real-time event broadcasting via `Event::Broadcasting` concern — `after_create_commit` and `after_update_commit` callbacks broadcast decorated payloads with database ID and action type to the session's ActionCable stream (#91)
|
|
5
|
+
- TUI `MessageStore` ID-indexed updates — events with `action: "update"` replace existing entries in-place (O(1) lookup) without changing display order
|
|
6
|
+
- `CountEventTokensJob` triggers broadcast — uses `update!` so token count updates push to connected clients in real time
|
|
7
|
+
- Connection status constants in `CableClient` — replaces magic strings with named constants for protocol message types
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Connection status indicator simplified — emoji-only `🟢` for normal state, descriptive text only for abnormal states (#80)
|
|
11
|
+
- `STATUS_STYLES` structure simplified from `{label, fg, bg}` to `{label, color}` (#80)
|
|
12
|
+
- `ActionCableBridge` removed — broadcasting moved from EventBus subscriber to AR callbacks, eliminating the timing gap where events were broadcast before persistence
|
|
13
|
+
- `SessionChannel` history includes event IDs for client-side correlation
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- TUI showed empty chat on reconnect — message store was cleared _after_ history arrived because `confirm_subscription` comes after `transmit` in Action Cable protocol; now clears on "subscribing" before history (#82)
|
|
17
|
+
|
|
3
18
|
## [0.2.1] - 2026-03-13
|
|
4
19
|
|
|
5
20
|
### Added
|
data/README.md
CHANGED
|
@@ -130,37 +130,13 @@ Updates: `gem update anima-core` — next launch runs pending migrations automat
|
|
|
130
130
|
|
|
131
131
|
### Authentication Setup
|
|
132
132
|
|
|
133
|
-
Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from Claude Code CLI.
|
|
133
|
+
Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code).
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
1. Run `claude setup-token` in a terminal to get your token
|
|
136
|
+
2. In the TUI, press `Ctrl+a → a` to open the token setup popup
|
|
137
|
+
3. Paste the token and press Enter — Anima validates it against the Anthropic API and saves it to encrypted credentials
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
claude setup-token
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
This requires [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and a Claude Pro or Max subscription.
|
|
142
|
-
|
|
143
|
-
**2. Store the token in Anima credentials:**
|
|
144
|
-
|
|
145
|
-
```bash
|
|
146
|
-
cd $(gem contents anima-core | head -1 | xargs dirname | xargs dirname)
|
|
147
|
-
bin/rails credentials:edit
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
Add your token:
|
|
151
|
-
|
|
152
|
-
```yaml
|
|
153
|
-
anthropic:
|
|
154
|
-
subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
**3. Verify the token works:**
|
|
158
|
-
|
|
159
|
-
```bash
|
|
160
|
-
bin/rails runner "Providers::Anthropic.validate!"
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
If the token expires or is revoked, repeat steps 1-2 with a new token.
|
|
139
|
+
The popup also activates automatically when Anima detects a missing or invalid token. If the token expires, repeat the process with a new one.
|
|
164
140
|
|
|
165
141
|
### Three Layers (mirroring biology)
|
|
166
142
|
|
|
@@ -186,7 +162,7 @@ Five event types form the agent's nervous system:
|
|
|
186
162
|
|
|
187
163
|
Events flow through two channels:
|
|
188
164
|
1. **In-process** — Rails Structured Event Reporter (local subscribers like Persister)
|
|
189
|
-
2. **Over the wire** — Action Cable WebSocket (
|
|
165
|
+
2. **Over the wire** — Action Cable WebSocket (`Event::Broadcasting` callbacks push to connected TUI clients)
|
|
190
166
|
|
|
191
167
|
Events fire, subscribers react, state updates, the cortex (LLM) reads the resulting desire landscape. The system prompt is assembled separately for each LLM call — it is not an event.
|
|
192
168
|
|
|
@@ -14,19 +14,25 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
14
14
|
MAX_LIST_LIMIT = 50
|
|
15
15
|
|
|
16
16
|
# Subscribes the client to the session-specific stream.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# When a valid session_id is provided, subscribes to that session.
|
|
18
|
+
# When omitted or zero, resolves to the most recent session (creating
|
|
19
|
+
# one if none exist) — this is the CQRS-compliant path where the
|
|
20
|
+
# server owns session resolution instead of a REST endpoint.
|
|
19
21
|
#
|
|
20
|
-
#
|
|
22
|
+
# Always transmits a session_changed signal so the client learns
|
|
23
|
+
# the authoritative session ID, followed by view_mode and history.
|
|
24
|
+
#
|
|
25
|
+
# @param params [Hash] optional :session_id (positive integer)
|
|
21
26
|
def subscribed
|
|
22
|
-
@current_session_id =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
@current_session_id = resolve_session_id
|
|
28
|
+
stream_from stream_name
|
|
29
|
+
|
|
30
|
+
session = Session.find_by(id: @current_session_id)
|
|
31
|
+
return unless session
|
|
32
|
+
|
|
33
|
+
transmit_session_changed(session)
|
|
34
|
+
transmit_view_mode(session)
|
|
35
|
+
transmit_history(session)
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
# Receives messages from clients and broadcasts them to all session subscribers.
|
|
@@ -37,14 +43,43 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# Processes user input: persists the message and enqueues LLM processing.
|
|
46
|
+
# When the session is actively processing an agent request, the message
|
|
47
|
+
# is queued as "pending" and picked up after the current loop completes.
|
|
40
48
|
#
|
|
41
49
|
# @param data [Hash] must include "content" with the user's message text
|
|
42
50
|
def speak(data)
|
|
43
51
|
content = data["content"].to_s.strip
|
|
44
|
-
return if content.empty?
|
|
52
|
+
return if content.empty?
|
|
53
|
+
|
|
54
|
+
session = Session.find_by(id: @current_session_id)
|
|
55
|
+
return unless session
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
if session.processing?
|
|
58
|
+
Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
|
|
59
|
+
else
|
|
60
|
+
Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
|
|
61
|
+
AgentRequestJob.perform_later(@current_session_id)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Recalls the most recent pending message for editing. Deletes the
|
|
66
|
+
# pending event and broadcasts the recall so all clients remove it.
|
|
67
|
+
#
|
|
68
|
+
# @param data [Hash] must include "event_id" (positive integer)
|
|
69
|
+
def recall_pending(data)
|
|
70
|
+
event_id = data["event_id"].to_i
|
|
71
|
+
return if event_id <= 0
|
|
72
|
+
|
|
73
|
+
event = Event.find_by(
|
|
74
|
+
id: event_id,
|
|
75
|
+
session_id: @current_session_id,
|
|
76
|
+
event_type: "user_message",
|
|
77
|
+
status: Event::PENDING_STATUS
|
|
78
|
+
)
|
|
79
|
+
return unless event
|
|
80
|
+
|
|
81
|
+
event.destroy!
|
|
82
|
+
ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "event_id" => event_id})
|
|
48
83
|
end
|
|
49
84
|
|
|
50
85
|
# Returns recent sessions with metadata for session picker UI.
|
|
@@ -86,6 +121,23 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
86
121
|
transmit_error("Session not found")
|
|
87
122
|
end
|
|
88
123
|
|
|
124
|
+
# Validates and saves an Anthropic subscription token to encrypted credentials.
|
|
125
|
+
# Format-validated and API-validated before storage. The token never enters the
|
|
126
|
+
# LLM context window — it flows directly from WebSocket to encrypted credentials.
|
|
127
|
+
#
|
|
128
|
+
# @param data [Hash] must include "token" (Anthropic subscription token string)
|
|
129
|
+
def save_token(data)
|
|
130
|
+
token = data["token"].to_s.strip
|
|
131
|
+
|
|
132
|
+
Providers::Anthropic.validate_token_format!(token)
|
|
133
|
+
Providers::Anthropic.validate_token_api!(token)
|
|
134
|
+
write_anthropic_token(token)
|
|
135
|
+
|
|
136
|
+
transmit({"action" => "token_saved"})
|
|
137
|
+
rescue Providers::Anthropic::TokenFormatError, Providers::Anthropic::AuthenticationError => error
|
|
138
|
+
transmit({"action" => "token_error", "message" => error.message})
|
|
139
|
+
end
|
|
140
|
+
|
|
89
141
|
# Changes the session's view mode and re-broadcasts the viewport.
|
|
90
142
|
# All clients on the session receive the mode change and fresh history.
|
|
91
143
|
#
|
|
@@ -109,6 +161,33 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
109
161
|
"session_#{@current_session_id}"
|
|
110
162
|
end
|
|
111
163
|
|
|
164
|
+
# Resolves the session to subscribe to. Uses the client-provided ID
|
|
165
|
+
# when valid, otherwise falls back to the most recent session or
|
|
166
|
+
# creates a new one.
|
|
167
|
+
#
|
|
168
|
+
# @return [Integer] resolved session ID
|
|
169
|
+
def resolve_session_id
|
|
170
|
+
id = params[:session_id].to_i
|
|
171
|
+
return id if id > 0
|
|
172
|
+
|
|
173
|
+
(Session.recent(1).first || Session.create!).id
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Transmits session metadata as a session_changed signal.
|
|
177
|
+
# Used on initial subscription and after session switches so the
|
|
178
|
+
# client can handle both paths with a single code path.
|
|
179
|
+
#
|
|
180
|
+
# @param session [Session] the session to announce
|
|
181
|
+
# @return [void]
|
|
182
|
+
def transmit_session_changed(session)
|
|
183
|
+
transmit({
|
|
184
|
+
"action" => "session_changed",
|
|
185
|
+
"session_id" => session.id,
|
|
186
|
+
"message_count" => session.events.llm_messages.count,
|
|
187
|
+
"view_mode" => session.view_mode
|
|
188
|
+
})
|
|
189
|
+
end
|
|
190
|
+
|
|
112
191
|
# Switches the channel to a different session: stops current stream,
|
|
113
192
|
# updates the session reference, starts the new stream, and sends
|
|
114
193
|
# a session_changed signal followed by chat history.
|
|
@@ -116,23 +195,18 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
116
195
|
stop_all_streams
|
|
117
196
|
@current_session_id = new_id
|
|
118
197
|
stream_from stream_name
|
|
198
|
+
|
|
119
199
|
session = Session.find(new_id)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"session_id" => new_id,
|
|
123
|
-
"message_count" => session.events.llm_messages.count,
|
|
124
|
-
"view_mode" => session.view_mode
|
|
125
|
-
})
|
|
126
|
-
transmit_history
|
|
200
|
+
transmit_session_changed(session)
|
|
201
|
+
transmit_history(session)
|
|
127
202
|
end
|
|
128
203
|
|
|
129
204
|
# Transmits the current view_mode so the TUI initializes correctly.
|
|
130
205
|
# Sends `{action: "view_mode", view_mode: <mode>}` to the subscribing client.
|
|
206
|
+
#
|
|
207
|
+
# @param session [Session] the session whose view_mode to transmit
|
|
131
208
|
# @return [void]
|
|
132
|
-
def transmit_view_mode
|
|
133
|
-
session = Session.find_by(id: @current_session_id)
|
|
134
|
-
return unless session
|
|
135
|
-
|
|
209
|
+
def transmit_view_mode(session)
|
|
136
210
|
transmit({"action" => "view_mode", "view_mode" => session.view_mode})
|
|
137
211
|
end
|
|
138
212
|
|
|
@@ -142,10 +216,9 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
142
216
|
# the transmitted payload. Tool events are included so the TUI can
|
|
143
217
|
# reconstruct tool call counters on reconnect.
|
|
144
218
|
# In debug mode, prepends the assembled system prompt as a special block.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
219
|
+
#
|
|
220
|
+
# @param session [Session] the session whose history to transmit
|
|
221
|
+
def transmit_history(session)
|
|
149
222
|
transmit_system_prompt(session) if session.view_mode == "debug"
|
|
150
223
|
|
|
151
224
|
session.viewport_events.each do |event|
|
|
@@ -166,8 +239,16 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
166
239
|
end
|
|
167
240
|
end
|
|
168
241
|
|
|
242
|
+
# Decorates an event for transmission to clients. Merges the event's
|
|
243
|
+
# database ID and structured decorator output into the payload.
|
|
244
|
+
# Used by {#transmit_history} and {#broadcast_viewport} for historical
|
|
245
|
+
# and viewport re-broadcast — live broadcasts use {Event::Broadcasting}.
|
|
246
|
+
#
|
|
247
|
+
# @param event [Event] persisted event record
|
|
248
|
+
# @param mode [String] view mode for decoration (default: "basic")
|
|
249
|
+
# @return [Hash] payload with "id" and optional "rendered" key
|
|
169
250
|
def decorate_event_payload(event, mode = "basic")
|
|
170
|
-
payload = event.payload
|
|
251
|
+
payload = event.payload.merge("id" => event.id)
|
|
171
252
|
decorator = EventDecorator.for(event)
|
|
172
253
|
return payload unless decorator
|
|
173
254
|
|
|
@@ -212,6 +293,27 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
212
293
|
}
|
|
213
294
|
end
|
|
214
295
|
|
|
296
|
+
# Merges the Anthropic subscription token into encrypted credentials,
|
|
297
|
+
# preserving existing keys (e.g. secret_key_base).
|
|
298
|
+
#
|
|
299
|
+
# @param token [String] validated Anthropic subscription token
|
|
300
|
+
# @return [void]
|
|
301
|
+
def write_anthropic_token(token)
|
|
302
|
+
creds = Rails.application.credentials
|
|
303
|
+
existing = begin
|
|
304
|
+
YAML.safe_load(creds.read) || {}
|
|
305
|
+
rescue ActiveSupport::EncryptedFile::MissingContentError
|
|
306
|
+
{}
|
|
307
|
+
end
|
|
308
|
+
existing["anthropic"] ||= {}
|
|
309
|
+
existing["anthropic"]["subscription_token"] = token
|
|
310
|
+
creds.write(existing.to_yaml)
|
|
311
|
+
# Rails memoizes the decrypted config in @config. Without clearing it,
|
|
312
|
+
# subsequent credential reads return stale data. No public API exists
|
|
313
|
+
# for cache invalidation as of Rails 8.1.
|
|
314
|
+
creds.instance_variable_set(:@config, nil)
|
|
315
|
+
end
|
|
316
|
+
|
|
215
317
|
def transmit_error(message)
|
|
216
318
|
transmit({"action" => "error", "message" => message})
|
|
217
319
|
end
|
|
@@ -3,22 +3,33 @@
|
|
|
3
3
|
# Decorates user_message events for display in the TUI.
|
|
4
4
|
# Basic mode returns role and content. Verbose mode adds a timestamp.
|
|
5
5
|
# Debug mode adds token count (exact when counted, estimated when not).
|
|
6
|
+
# Pending messages include `status: "pending"` so the TUI renders them
|
|
7
|
+
# with a visual indicator (dimmed, clock icon).
|
|
6
8
|
class UserMessageDecorator < EventDecorator
|
|
7
9
|
# @return [Hash] structured user message data
|
|
8
|
-
# `{role: :user, content: String}`
|
|
10
|
+
# `{role: :user, content: String}` or with `status: "pending"` when queued
|
|
9
11
|
def render_basic
|
|
10
|
-
{role: :user, content: content}
|
|
12
|
+
base = {role: :user, content: content}
|
|
13
|
+
base[:status] = "pending" if pending?
|
|
14
|
+
base
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
# @return [Hash] structured user message with nanosecond timestamp
|
|
14
|
-
# `{role: :user, content: String, timestamp: Integer|nil}`
|
|
15
18
|
def render_verbose
|
|
16
|
-
{role: :user, content: content, timestamp: timestamp}
|
|
19
|
+
base = {role: :user, content: content, timestamp: timestamp}
|
|
20
|
+
base[:status] = "pending" if pending?
|
|
21
|
+
base
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
# @return [Hash] verbose output plus token count for debugging
|
|
20
|
-
# `{role: :user, content: String, timestamp: Integer|nil, tokens: Integer, estimated: Boolean}`
|
|
21
25
|
def render_debug
|
|
22
26
|
render_verbose.merge(token_info)
|
|
23
27
|
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] true when this message is queued but not yet sent to LLM
|
|
32
|
+
def pending?
|
|
33
|
+
payload["status"] == Event::PENDING_STATUS
|
|
34
|
+
end
|
|
24
35
|
end
|
|
@@ -26,23 +26,52 @@ class AgentRequestJob < ApplicationJob
|
|
|
26
26
|
|
|
27
27
|
discard_on ActiveRecord::RecordNotFound
|
|
28
28
|
discard_on Providers::Anthropic::AuthenticationError do |job, error|
|
|
29
|
+
session_id = job.arguments.first
|
|
30
|
+
# Persistent system message for the event log
|
|
29
31
|
Events::Bus.emit(Events::SystemMessage.new(
|
|
30
32
|
content: "Authentication failed: #{error.message}",
|
|
31
|
-
session_id:
|
|
33
|
+
session_id: session_id
|
|
32
34
|
))
|
|
35
|
+
# Transient signal to trigger TUI token setup popup (not persisted)
|
|
36
|
+
ActionCable.server.broadcast(
|
|
37
|
+
"session_#{session_id}",
|
|
38
|
+
{"action" => "authentication_required", "message" => error.message}
|
|
39
|
+
)
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
# @param session_id [Integer] ID of the session to process
|
|
36
43
|
def perform(session_id)
|
|
37
44
|
session = Session.find(session_id)
|
|
45
|
+
|
|
46
|
+
# Atomic: only one job processes a session at a time. If another job
|
|
47
|
+
# is already running, this one exits — the running job will pick up
|
|
48
|
+
# any pending messages after its current loop completes.
|
|
49
|
+
return unless claim_processing(session_id)
|
|
50
|
+
|
|
38
51
|
agent_loop = AgentLoop.new(session: session)
|
|
39
|
-
|
|
52
|
+
loop do
|
|
53
|
+
agent_loop.run
|
|
54
|
+
promoted = session.promote_pending_messages!
|
|
55
|
+
break if promoted == 0
|
|
56
|
+
end
|
|
40
57
|
ensure
|
|
58
|
+
release_processing(session_id)
|
|
41
59
|
agent_loop&.finalize
|
|
42
60
|
end
|
|
43
61
|
|
|
44
62
|
private
|
|
45
63
|
|
|
64
|
+
# Sets the session's processing flag atomically. Returns true if this
|
|
65
|
+
# job claimed the lock, false if another job already holds it.
|
|
66
|
+
def claim_processing(session_id)
|
|
67
|
+
Session.where(id: session_id, processing: false).update_all(processing: true) == 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Clears the processing flag so the session can accept new jobs.
|
|
71
|
+
def release_processing(session_id)
|
|
72
|
+
Session.where(id: session_id).update_all(processing: false)
|
|
73
|
+
end
|
|
74
|
+
|
|
46
75
|
# Emits a system message before each retry so the user sees
|
|
47
76
|
# "retrying..." instead of nothing.
|
|
48
77
|
def retry_job(options = {})
|
|
@@ -12,7 +12,7 @@ class CountEventTokensJob < ApplicationJob
|
|
|
12
12
|
# @param event_id [Integer] the Event record to count tokens for
|
|
13
13
|
def perform(event_id)
|
|
14
14
|
event = Event.find(event_id)
|
|
15
|
-
return if event
|
|
15
|
+
return if already_counted?(event)
|
|
16
16
|
|
|
17
17
|
provider = Providers::Anthropic.new
|
|
18
18
|
messages = [{role: event.api_role, content: event.payload["content"].to_s}]
|
|
@@ -22,7 +22,18 @@ class CountEventTokensJob < ApplicationJob
|
|
|
22
22
|
messages: messages
|
|
23
23
|
)
|
|
24
24
|
|
|
25
|
-
#
|
|
26
|
-
|
|
25
|
+
# Guard against parallel jobs: reload and re-check before writing.
|
|
26
|
+
# Uses update! (not update_all) so {Event::Broadcasting} after_update_commit
|
|
27
|
+
# broadcasts the updated token count to connected clients.
|
|
28
|
+
event.reload
|
|
29
|
+
return if already_counted?(event)
|
|
30
|
+
|
|
31
|
+
event.update!(token_count: token_count)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def already_counted?(event)
|
|
37
|
+
event.token_count > 0
|
|
27
38
|
end
|
|
28
39
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Broadcasts Event records to connected WebSocket clients via ActionCable.
|
|
4
|
+
# Follows the Turbo Streams pattern: events are broadcast on both create
|
|
5
|
+
# and update, with an action type so clients can distinguish append from
|
|
6
|
+
# replace operations.
|
|
7
|
+
#
|
|
8
|
+
# Each broadcast includes the Event's database ID, enabling clients to
|
|
9
|
+
# maintain an ID-indexed store for efficient in-place updates (e.g. when
|
|
10
|
+
# token counts arrive asynchronously from {CountEventTokensJob}).
|
|
11
|
+
#
|
|
12
|
+
# @example Create broadcast payload
|
|
13
|
+
# {
|
|
14
|
+
# "type" => "user_message", "content" => "hello", ...,
|
|
15
|
+
# "id" => 42, "action" => "create",
|
|
16
|
+
# "rendered" => { "basic" => { "role" => "user", "content" => "hello" } }
|
|
17
|
+
# }
|
|
18
|
+
#
|
|
19
|
+
# @example Update broadcast payload (e.g. token count arrives)
|
|
20
|
+
# {
|
|
21
|
+
# "type" => "user_message", "content" => "hello", ...,
|
|
22
|
+
# "id" => 42, "action" => "update",
|
|
23
|
+
# "rendered" => { "debug" => { "role" => "user", "content" => "hello", "tokens" => 15 } }
|
|
24
|
+
# }
|
|
25
|
+
module Event::Broadcasting
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
|
|
28
|
+
ACTION_CREATE = "create"
|
|
29
|
+
ACTION_UPDATE = "update"
|
|
30
|
+
|
|
31
|
+
included do
|
|
32
|
+
after_create_commit :broadcast_create
|
|
33
|
+
after_update_commit :broadcast_update
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def broadcast_create
|
|
39
|
+
broadcast_event(action: ACTION_CREATE)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def broadcast_update
|
|
43
|
+
broadcast_event(action: ACTION_UPDATE)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Decorates the event for the session's current view mode and broadcasts
|
|
47
|
+
# the payload to the session's ActionCable stream.
|
|
48
|
+
#
|
|
49
|
+
# @param action [String] ACTION_CREATE or ACTION_UPDATE — tells clients how to handle the event
|
|
50
|
+
def broadcast_event(action:)
|
|
51
|
+
return unless session_id
|
|
52
|
+
|
|
53
|
+
mode = Session.where(id: session_id).pick(:view_mode) || "basic"
|
|
54
|
+
decorator = EventDecorator.for(self)
|
|
55
|
+
broadcast_payload = payload.merge("id" => id, "action" => action)
|
|
56
|
+
|
|
57
|
+
if decorator
|
|
58
|
+
broadcast_payload["rendered"] = {mode => decorator.render(mode)}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
|
|
62
|
+
end
|
|
63
|
+
end
|
data/app/models/event.rb
CHANGED
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
# @!attribute tool_use_id
|
|
17
17
|
# @return [String, nil] Anthropic-assigned ID correlating tool_call and tool_response
|
|
18
18
|
class Event < ApplicationRecord
|
|
19
|
+
include Event::Broadcasting
|
|
20
|
+
|
|
19
21
|
TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
20
22
|
LLM_TYPES = %w[user_message agent_message].freeze
|
|
21
23
|
CONTEXT_TYPES = %w[user_message agent_message tool_call tool_response].freeze
|
|
24
|
+
PENDING_STATUS = "pending"
|
|
22
25
|
|
|
23
26
|
ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
|
|
24
27
|
|
|
@@ -43,6 +46,17 @@ class Event < ApplicationRecord
|
|
|
43
46
|
# @return [ActiveRecord::Relation]
|
|
44
47
|
scope :context_events, -> { where(event_type: CONTEXT_TYPES) }
|
|
45
48
|
|
|
49
|
+
# @!method self.pending
|
|
50
|
+
# User messages queued during active agent processing, not yet sent to LLM.
|
|
51
|
+
# @return [ActiveRecord::Relation]
|
|
52
|
+
scope :pending, -> { where(status: PENDING_STATUS) }
|
|
53
|
+
|
|
54
|
+
# @!method self.deliverable
|
|
55
|
+
# Events eligible for LLM context (excludes pending messages).
|
|
56
|
+
# NULL status means delivered/processed — the only excluded value is "pending".
|
|
57
|
+
# @return [ActiveRecord::Relation]
|
|
58
|
+
scope :deliverable, -> { where(status: nil) }
|
|
59
|
+
|
|
46
60
|
# Maps event_type to the Anthropic Messages API role.
|
|
47
61
|
# @return [String] "user" or "assistant"
|
|
48
62
|
def api_role
|
|
@@ -59,6 +73,11 @@ class Event < ApplicationRecord
|
|
|
59
73
|
event_type.in?(CONTEXT_TYPES)
|
|
60
74
|
end
|
|
61
75
|
|
|
76
|
+
# @return [Boolean] true if this is a pending message not yet sent to the LLM
|
|
77
|
+
def pending?
|
|
78
|
+
status == PENDING_STATUS
|
|
79
|
+
end
|
|
80
|
+
|
|
62
81
|
# Heuristic token estimate: ~4 bytes per token for English prose.
|
|
63
82
|
# Tool events are estimated from the full payload JSON since tool_input
|
|
64
83
|
# and tool metadata contribute to token count. Messages use content only.
|
data/app/models/session.rb
CHANGED
|
@@ -28,12 +28,17 @@ class Session < ApplicationRecord
|
|
|
28
28
|
# is exhausted. Events are full-size or excluded entirely.
|
|
29
29
|
#
|
|
30
30
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
31
|
+
# @param include_pending [Boolean] whether to include pending messages (true for
|
|
32
|
+
# display, false for LLM context assembly)
|
|
31
33
|
# @return [Array<Event>] chronologically ordered
|
|
32
|
-
def viewport_events(token_budget: DEFAULT_TOKEN_BUDGET)
|
|
34
|
+
def viewport_events(token_budget: DEFAULT_TOKEN_BUDGET, include_pending: true)
|
|
35
|
+
scope = events.context_events
|
|
36
|
+
scope = scope.deliverable unless include_pending
|
|
37
|
+
|
|
33
38
|
selected = []
|
|
34
39
|
remaining = token_budget
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
scope.reorder(id: :desc).each do |event|
|
|
37
42
|
cost = (event.token_count > 0) ? event.token_count : estimate_tokens(event)
|
|
38
43
|
break if cost > remaining && selected.any?
|
|
39
44
|
|
|
@@ -58,11 +63,26 @@ class Session < ApplicationRecord
|
|
|
58
63
|
# Anthropic's wire format. Consecutive tool_call events are grouped
|
|
59
64
|
# into a single assistant message; consecutive tool_response events
|
|
60
65
|
# are grouped into a single user message with tool_result blocks.
|
|
66
|
+
# Pending messages are excluded — they haven't been delivered yet.
|
|
61
67
|
#
|
|
62
68
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
63
69
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
64
70
|
def messages_for_llm(token_budget: DEFAULT_TOKEN_BUDGET)
|
|
65
|
-
assemble_messages(viewport_events(token_budget: token_budget))
|
|
71
|
+
assemble_messages(viewport_events(token_budget: token_budget, include_pending: false))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Promotes all pending user messages to delivered status so they
|
|
75
|
+
# appear in the next LLM context. Triggers broadcast_update for
|
|
76
|
+
# each event so connected clients refresh the pending indicator.
|
|
77
|
+
#
|
|
78
|
+
# @return [Integer] number of promoted messages
|
|
79
|
+
def promote_pending_messages!
|
|
80
|
+
promoted = 0
|
|
81
|
+
events.where(event_type: "user_message", status: Event::PENDING_STATUS).find_each do |event|
|
|
82
|
+
event.update!(status: nil, payload: event.payload.except("status"))
|
|
83
|
+
promoted += 1
|
|
84
|
+
end
|
|
85
|
+
promoted
|
|
66
86
|
end
|
|
67
87
|
|
|
68
88
|
private
|
|
@@ -7,5 +7,4 @@ Rails.application.config.after_initialize do
|
|
|
7
7
|
# Global persister handles events from all sessions (brain server, background jobs).
|
|
8
8
|
# Skipped in test — specs manage their own persisters for isolation.
|
|
9
9
|
Events::Bus.subscribe(Events::Subscribers::Persister.new) unless Rails.env.test?
|
|
10
|
-
Events::Bus.subscribe(Events::Subscribers::ActionCableBridge.instance)
|
|
11
10
|
end
|
data/config/routes.rb
CHANGED
data/lib/agent_loop.rb
CHANGED
|
@@ -87,11 +87,14 @@ class AgentLoop
|
|
|
87
87
|
private
|
|
88
88
|
|
|
89
89
|
# Builds the default tool registry with all available tools.
|
|
90
|
-
# @return [Tools::Registry] registry with
|
|
90
|
+
# @return [Tools::Registry] registry with all available tools
|
|
91
91
|
def build_tool_registry
|
|
92
92
|
registry = Tools::Registry.new(context: {shell_session: @shell_session})
|
|
93
|
-
registry.register(Tools::WebGet)
|
|
94
93
|
registry.register(Tools::Bash)
|
|
94
|
+
registry.register(Tools::Read)
|
|
95
|
+
registry.register(Tools::Write)
|
|
96
|
+
registry.register(Tools::Edit)
|
|
97
|
+
registry.register(Tools::WebGet)
|
|
95
98
|
registry
|
|
96
99
|
end
|
|
97
100
|
end
|