anima-core 1.4.0 → 1.5.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/.reek.yml +18 -20
- data/README.md +61 -95
- data/agents/thoughts-analyzer.md +12 -7
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +38 -58
- data/app/decorators/agent_message_decorator.rb +7 -2
- data/app/decorators/message_decorator.rb +31 -100
- data/app/decorators/pending_from_melete_decorator.rb +36 -0
- data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
- data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
- data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
- data/app/decorators/pending_from_mneme_decorator.rb +44 -0
- data/app/decorators/pending_message_decorator.rb +94 -0
- data/app/decorators/pending_subagent_decorator.rb +46 -0
- data/app/decorators/pending_tool_response_decorator.rb +51 -0
- data/app/decorators/pending_user_message_decorator.rb +22 -0
- data/app/decorators/system_message_decorator.rb +5 -0
- data/app/decorators/tool_call_decorator.rb +13 -2
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +7 -2
- data/app/jobs/count_tokens_job.rb +23 -0
- data/app/jobs/drain_job.rb +169 -0
- data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
- data/app/jobs/melete_enrichment_job.rb +48 -0
- data/app/jobs/mneme_enrichment_job.rb +46 -0
- data/app/jobs/tool_execution_job.rb +87 -0
- data/app/models/concerns/token_estimation.rb +54 -0
- data/app/models/goal.rb +21 -10
- data/app/models/message.rb +47 -36
- data/app/models/pending_message.rb +276 -29
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +468 -432
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +17 -4
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +3 -3
- data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
- data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
- data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
- data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
- data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
- data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
- data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
- data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
- data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
- data/db/queue_structure.sql +13 -13
- data/db/structure.sql +44 -31
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/settings.rb +7 -33
- data/lib/anima/version.rb +1 -1
- data/lib/events/authentication_required.rb +24 -0
- data/lib/events/bounce_back.rb +4 -4
- data/lib/events/eviction_completed.rb +28 -0
- data/lib/events/goal_created.rb +28 -0
- data/lib/events/goal_updated.rb +32 -0
- data/lib/events/llm_responded.rb +35 -0
- data/lib/events/message_created.rb +27 -0
- data/lib/events/message_updated.rb +25 -0
- data/lib/events/session_state_changed.rb +30 -0
- data/lib/events/skill_activated.rb +28 -0
- data/lib/events/start_melete.rb +36 -0
- data/lib/events/start_mneme.rb +33 -0
- data/lib/events/start_processing.rb +32 -0
- data/lib/events/subagent_evicted.rb +31 -0
- data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
- data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
- data/lib/events/subscribers/drain_kickoff.rb +20 -0
- data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
- data/lib/events/subscribers/llm_response_handler.rb +111 -0
- data/lib/events/subscribers/melete_kickoff.rb +24 -0
- data/lib/events/subscribers/message_broadcaster.rb +34 -0
- data/lib/events/subscribers/mneme_kickoff.rb +24 -0
- data/lib/events/subscribers/mneme_scheduler.rb +21 -0
- data/lib/events/subscribers/persister.rb +6 -8
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +26 -29
- data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
- data/lib/events/subscribers/tool_response_creator.rb +33 -0
- data/lib/events/subscribers/transient_broadcaster.rb +1 -1
- data/lib/events/tool_executed.rb +34 -0
- data/lib/events/workflow_activated.rb +27 -0
- data/lib/llm/client.rb +41 -201
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +63 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
- data/lib/{analytical_brain.rb → melete.rb} +6 -3
- data/lib/mneme/base_runner.rb +121 -0
- data/lib/mneme/l2_runner.rb +14 -20
- data/lib/mneme/recall_runner.rb +132 -0
- data/lib/mneme/runner.rb +118 -171
- data/lib/mneme/search.rb +104 -62
- data/lib/mneme/tools/nothing_to_surface.rb +25 -0
- data/lib/mneme/tools/save_snapshot.rb +2 -10
- data/lib/mneme/tools/surface_memory.rb +89 -0
- data/lib/mneme.rb +11 -5
- data/lib/shell_session.rb +287 -612
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -0
- data/lib/tools/bash.rb +25 -57
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +79 -3
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +16 -10
- data/lib/tools/spawn_subagent.rb +20 -14
- data/lib/tools/subagent_prompts.rb +4 -4
- data/lib/tools/think.rb +1 -1
- data/lib/tools/{remember.rb → view_messages.rb} +10 -10
- data/lib/tools/write.rb +2 -0
- data/lib/tui/app.rb +5 -4
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/decorators/base_decorator.rb +24 -3
- data/lib/tui/message_store.rb +93 -44
- data/lib/tui/screens/chat.rb +94 -20
- data/lib/tui/settings.rb +9 -2
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +4 -23
- data/workflows/review_pr.md +18 -14
- metadata +86 -28
- data/app/jobs/agent_request_job.rb +0 -199
- data/app/jobs/analytical_brain_job.rb +0 -33
- data/app/jobs/count_message_tokens_job.rb +0 -39
- data/app/jobs/passive_recall_job.rb +0 -24
- data/app/models/concerns/message/broadcasting.rb +0 -86
- data/lib/agent_loop.rb +0 -215
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
- data/lib/events/agent_message.rb +0 -25
- data/lib/events/subscribers/message_collector.rb +0 -64
- data/lib/events/tool_call.rb +0 -31
- data/lib/events/tool_response.rb +0 -33
- data/lib/mneme/compressed_viewport.rb +0 -204
- data/lib/mneme/passive_recall.rb +0 -138
data/app/models/session.rb
CHANGED
|
@@ -7,10 +7,55 @@
|
|
|
7
7
|
# Sessions form a hierarchy: a main session can spawn child sessions
|
|
8
8
|
# (sub-agents) that inherit the parent's viewport context at fork time.
|
|
9
9
|
class Session < ApplicationRecord
|
|
10
|
+
include AASM
|
|
11
|
+
|
|
10
12
|
class MissingSoulError < StandardError; end
|
|
11
13
|
|
|
12
14
|
VIEW_MODES = %w[basic verbose debug].freeze
|
|
13
15
|
|
|
16
|
+
# Non-default AASM options:
|
|
17
|
+
# - +whiny_transitions: false+ makes invalid transitions return +false+
|
|
18
|
+
# instead of raising. {DrainJob} depends on this: +start_processing!+
|
|
19
|
+
# returning +false+ signals that the session is busy (+:awaiting+) or
|
|
20
|
+
# that the current tool round is still incomplete, so the current
|
|
21
|
+
# invocation exits silently.
|
|
22
|
+
# - +no_direct_assignment: true+ blocks +session.aasm_state = ...+, forcing
|
|
23
|
+
# every transition through a named event so guards always run.
|
|
24
|
+
# - +requires_lock: true+ wraps each transition in a pessimistic row lock
|
|
25
|
+
# (+SELECT FOR UPDATE+ on PostgreSQL, +BEGIN IMMEDIATE+ on SQLite) so
|
|
26
|
+
# two workers racing +start_processing!+ on a parallel tool-use turn
|
|
27
|
+
# can't both succeed — the loser reads the updated +:awaiting+ state
|
|
28
|
+
# and bails silently.
|
|
29
|
+
aasm whiny_transitions: false, no_direct_assignment: true, requires_lock: true do
|
|
30
|
+
after_all_events :emit_state_change
|
|
31
|
+
after_all_events :clear_interrupt_flag_if_idle
|
|
32
|
+
after_all_events :wake_drain_pipeline_if_pending
|
|
33
|
+
|
|
34
|
+
state :idle, initial: true
|
|
35
|
+
state :awaiting
|
|
36
|
+
state :executing
|
|
37
|
+
|
|
38
|
+
# Drain claim. Two transitions, one event:
|
|
39
|
+
# - From +:idle+, the session is fresh — claim unconditionally.
|
|
40
|
+
# - From +:executing+, only claim once every +tool_use_id+ from the
|
|
41
|
+
# latest assistant turn has a matching tool_response (Message or
|
|
42
|
+
# PendingMessage). This collapses "tool round complete" and "drain
|
|
43
|
+
# claims" into one atomic, lock-protected transition so the LLM
|
|
44
|
+
# never sees a partial round.
|
|
45
|
+
event :start_processing do
|
|
46
|
+
transitions from: :idle, to: :awaiting
|
|
47
|
+
transitions from: :executing, to: :awaiting, guard: :tool_round_complete?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
event :tool_received do
|
|
51
|
+
transitions from: :awaiting, to: :executing
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
event :response_complete do
|
|
55
|
+
transitions from: :awaiting, to: :idle
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
14
59
|
attribute :view_mode, :string, default: -> { Anima::Settings.default_view_mode }
|
|
15
60
|
|
|
16
61
|
serialize :granted_tools, coder: JSON
|
|
@@ -28,75 +73,55 @@ class Session < ApplicationRecord
|
|
|
28
73
|
validates :name, length: {maximum: 255}, allow_nil: true
|
|
29
74
|
|
|
30
75
|
after_update_commit :broadcast_name_update, if: :saved_change_to_name?
|
|
31
|
-
after_update_commit :broadcast_active_skills_update, if: :saved_change_to_active_skills?
|
|
32
|
-
after_update_commit :broadcast_active_workflow_update, if: :saved_change_to_active_workflow?
|
|
33
76
|
|
|
34
77
|
scope :recent, ->(limit = 10) { order(updated_at: :desc).limit(limit) }
|
|
35
78
|
scope :root_sessions, -> { where(parent_session_id: nil) }
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# Cycles to the next view mode: basic → verbose → debug → basic.
|
|
39
|
-
#
|
|
40
|
-
# @return [String] the next view mode in the cycle
|
|
41
|
-
def next_view_mode
|
|
42
|
-
current_index = VIEW_MODES.index(view_mode) || 0
|
|
43
|
-
VIEW_MODES[(current_index + 1) % VIEW_MODES.size]
|
|
44
|
-
end
|
|
79
|
+
# Sessions currently working on behalf of a human — any non-idle AASM state.
|
|
80
|
+
scope :processing, -> { awaiting.or(executing) }
|
|
45
81
|
|
|
46
82
|
# @return [Boolean] true if this session is a sub-agent (has a parent)
|
|
47
83
|
def sub_agent?
|
|
48
84
|
parent_session_id.present?
|
|
49
85
|
end
|
|
50
86
|
|
|
51
|
-
# Checks whether the Mneme
|
|
52
|
-
#
|
|
53
|
-
#
|
|
87
|
+
# Checks whether the Mneme boundary has left the viewport and enqueues
|
|
88
|
+
# {MnemeJob} when it has. Delegates initial boundary placement to
|
|
89
|
+
# {#initialize_mneme_boundary!} on the first call.
|
|
54
90
|
#
|
|
55
|
-
# The
|
|
56
|
-
#
|
|
91
|
+
# The boundary has "left the viewport" when the cumulative token cost
|
|
92
|
+
# of everything from the boundary to the newest message exceeds the
|
|
93
|
+
# budget — a single SUM aggregate, no window function needed.
|
|
57
94
|
#
|
|
58
95
|
# @return [void]
|
|
59
96
|
def schedule_mneme!
|
|
60
97
|
return if sub_agent?
|
|
61
98
|
|
|
62
|
-
# Initialize boundary on first conversation message
|
|
63
99
|
if mneme_boundary_message_id.nil?
|
|
64
|
-
|
|
65
|
-
.where(message_type: Message::CONVERSATION_TYPES)
|
|
66
|
-
.order(:id).first
|
|
67
|
-
first_conversation ||= messages
|
|
68
|
-
.where(message_type: "tool_call")
|
|
69
|
-
.detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }
|
|
70
|
-
|
|
71
|
-
if first_conversation
|
|
72
|
-
update_column(:mneme_boundary_message_id, first_conversation.id)
|
|
73
|
-
end
|
|
100
|
+
initialize_mneme_boundary!
|
|
74
101
|
return
|
|
75
102
|
end
|
|
76
103
|
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
tokens_since_boundary = messages
|
|
105
|
+
.where("messages.id >= ?", mneme_boundary_message_id)
|
|
106
|
+
.sum(:token_count)
|
|
107
|
+
return if tokens_since_boundary <= effective_token_budget
|
|
79
108
|
|
|
80
109
|
MnemeJob.perform_later(id)
|
|
81
110
|
end
|
|
82
111
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
112
|
+
# Places the initial Mneme boundary at the oldest eligible message in
|
|
113
|
+
# the session — the top of the raw window, from which Mneme will start
|
|
114
|
+
# compressing downward once that message drifts out of the viewport.
|
|
115
|
+
# Eligible messages are conversation messages (user/agent/system) and
|
|
116
|
+
# think tool_calls, considered on equal footing; bare tool_call or
|
|
117
|
+
# tool_response messages are never eligible.
|
|
86
118
|
#
|
|
87
|
-
#
|
|
88
|
-
# evolves, so the name stays relevant to the current topic.
|
|
119
|
+
# No-op when the session has no eligible messages yet.
|
|
89
120
|
#
|
|
90
121
|
# @return [void]
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
count = messages.llm_messages.count
|
|
95
|
-
return if count < 2
|
|
96
|
-
# Already named — only regenerate at interval boundaries (30, 60, 90, …)
|
|
97
|
-
return if name.present? && (count % Anima::Settings.name_generation_interval != 0)
|
|
98
|
-
|
|
99
|
-
AnalyticalBrainJob.perform_later(id)
|
|
122
|
+
def initialize_mneme_boundary!
|
|
123
|
+
first_id = messages.conversation_or_think.order(:id).pick(:id)
|
|
124
|
+
update_column(:mneme_boundary_message_id, first_id) if first_id
|
|
100
125
|
end
|
|
101
126
|
|
|
102
127
|
# Token budget appropriate for this session type.
|
|
@@ -106,157 +131,176 @@ class Session < ApplicationRecord
|
|
|
106
131
|
sub_agent? ? Anima::Settings.subagent_token_budget : Anima::Settings.token_budget
|
|
107
132
|
end
|
|
108
133
|
|
|
109
|
-
# Returns the messages currently visible in the LLM context window
|
|
110
|
-
#
|
|
111
|
-
#
|
|
134
|
+
# Returns the messages currently visible in the LLM context window as a
|
|
135
|
+
# composable AR relation. Selects own messages above the Mneme boundary
|
|
136
|
+
# whose cumulative token count (walked newest-first) fits within the
|
|
137
|
+
# budget. The newest message is always included even when it alone
|
|
138
|
+
# exceeds the budget. Messages are full-size or excluded entirely.
|
|
112
139
|
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
# the
|
|
140
|
+
# The selection runs as a single SQL query using a window function
|
|
141
|
+
# ({+SUM() OVER+}). Older messages have been compressed into snapshots
|
|
142
|
+
# and no longer participate in the viewport. Pending messages live in a
|
|
143
|
+
# separate table ({PendingMessage}) and never appear here — they are
|
|
144
|
+
# promoted to real messages before the agent processes them.
|
|
116
145
|
#
|
|
117
146
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
118
|
-
# @return [
|
|
147
|
+
# @return [ActiveRecord::Relation<Message>] chronologically ordered by id
|
|
119
148
|
def viewport_messages(token_budget: effective_token_budget)
|
|
120
|
-
|
|
121
|
-
|
|
149
|
+
scope = messages
|
|
150
|
+
scope = scope.where("messages.id >= ?", mneme_boundary_message_id) if mneme_boundary_message_id
|
|
122
151
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
#
|
|
128
|
-
# @return [Array<Integer>] IDs of messages no longer in the viewport
|
|
129
|
-
def recalculate_viewport!
|
|
130
|
-
new_ids = viewport_messages.map(&:id)
|
|
131
|
-
old_ids = viewport_message_ids
|
|
152
|
+
windowed = scope.select(
|
|
153
|
+
"messages.*",
|
|
154
|
+
"SUM(token_count) OVER (ORDER BY id DESC) AS running_total"
|
|
155
|
+
)
|
|
132
156
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
157
|
+
Message
|
|
158
|
+
.from(windowed, :messages)
|
|
159
|
+
.where("running_total <= ? OR running_total = token_count", token_budget)
|
|
160
|
+
.order(:id)
|
|
136
161
|
end
|
|
137
162
|
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
163
|
+
# Returns the messages in the Mneme eviction zone — the oldest slice of
|
|
164
|
+
# the conversation starting from the boundary, filling the eviction budget
|
|
165
|
+
# walking newest-ward. These are the messages Mneme will summarize into a
|
|
166
|
+
# snapshot before advancing the boundary past them.
|
|
142
167
|
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
|
|
168
|
+
# Mirror of {#viewport_messages} but walks oldest-first from the boundary
|
|
169
|
+
# instead of newest-first from the tail.
|
|
170
|
+
#
|
|
171
|
+
# @return [ActiveRecord::Relation<Message>] chronologically ordered by id
|
|
172
|
+
def eviction_zone_messages
|
|
173
|
+
return Message.none unless mneme_boundary_message_id
|
|
174
|
+
|
|
175
|
+
budget = (Anima::Settings.token_budget * Anima::Settings.eviction_fraction).to_i
|
|
176
|
+
|
|
177
|
+
scope = messages.where("messages.id >= ?", mneme_boundary_message_id)
|
|
178
|
+
|
|
179
|
+
windowed = scope.select(
|
|
180
|
+
"messages.*",
|
|
181
|
+
"SUM(token_count) OVER (ORDER BY id ASC) AS running_total"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
Message
|
|
185
|
+
.from(windowed, :messages)
|
|
186
|
+
.where("running_total <= ? OR running_total = token_count", budget)
|
|
187
|
+
.order(:id)
|
|
147
188
|
end
|
|
148
189
|
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
# in the viewport are excluded from the activation catalog.
|
|
190
|
+
# Names of skills currently present in the viewport as
|
|
191
|
+
# `from_melete_skill` phantom tool_call messages, in activation order.
|
|
152
192
|
#
|
|
153
|
-
# @return [
|
|
193
|
+
# @return [Array<String>] skill names in the viewport, activation order
|
|
154
194
|
def skills_in_viewport
|
|
155
|
-
|
|
195
|
+
from_melete_messages
|
|
196
|
+
.where("json_extract(payload, '$.tool_name') = ?", PendingMessage::MELETE_SKILL_TOOL)
|
|
197
|
+
.pluck(Arel.sql("json_extract(payload, '$.tool_input.skill')"))
|
|
198
|
+
.compact
|
|
156
199
|
end
|
|
157
200
|
|
|
158
|
-
#
|
|
159
|
-
#
|
|
201
|
+
# Workflow name currently present in the viewport as a
|
|
202
|
+
# `from_melete_workflow` phantom tool_call message, if any. The most
|
|
203
|
+
# recent activation wins when multiple are visible.
|
|
160
204
|
#
|
|
161
|
-
# @return [String, nil] workflow name
|
|
205
|
+
# @return [String, nil] workflow name in the viewport, or nil
|
|
162
206
|
def workflow_in_viewport
|
|
163
|
-
|
|
207
|
+
from_melete_messages
|
|
208
|
+
.where("json_extract(payload, '$.tool_name') = ?", PendingMessage::MELETE_WORKFLOW_TOOL)
|
|
209
|
+
.reorder(id: :desc)
|
|
210
|
+
.pick(Arel.sql("json_extract(payload, '$.tool_input.workflow')"))
|
|
164
211
|
end
|
|
165
212
|
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
|
|
213
|
+
# Active skills — skills Aoide is currently carrying or about to carry.
|
|
214
|
+
# Union of skills already promoted into the viewport and skills pending
|
|
215
|
+
# promotion. A skill is "active" from activation until eviction; there
|
|
216
|
+
# is no deactivation.
|
|
217
|
+
#
|
|
218
|
+
# @return [Array<String>] skill names, deduplicated, activation order first
|
|
219
|
+
def active_skills
|
|
220
|
+
queued = pending_messages.where(source_type: "skill").order(:id).pluck(:source_name)
|
|
221
|
+
(skills_in_viewport + queued).uniq
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Active workflow — the workflow Aoide is currently carrying or about
|
|
225
|
+
# to carry. Pending activations take precedence over viewport contents
|
|
226
|
+
# (the last enqueue wins; the previous phantom pair evicts naturally).
|
|
173
227
|
#
|
|
174
|
-
#
|
|
175
|
-
|
|
228
|
+
# @return [String, nil]
|
|
229
|
+
def active_workflow
|
|
230
|
+
pending = pending_messages.where(source_type: "workflow").order(id: :desc).pick(:source_name)
|
|
231
|
+
pending || workflow_in_viewport
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Returns the system prompt for this session.
|
|
235
|
+
# Sub-agent sessions use their stored prompt plus the pinned task.
|
|
236
|
+
# Main sessions assemble a full system prompt from soul, sisters, and
|
|
237
|
+
# snapshots. Skills, workflows, and goals are injected as phantom
|
|
238
|
+
# tool_use/tool_result pairs in the message stream (not here) to keep
|
|
239
|
+
# the system prompt stable for prompt caching. Environment awareness
|
|
240
|
+
# flows through Bash tool responses.
|
|
176
241
|
#
|
|
177
242
|
# @return [String, nil] the system prompt text, or nil when nothing to inject
|
|
178
243
|
def system_prompt
|
|
179
244
|
if sub_agent?
|
|
180
|
-
[prompt,
|
|
245
|
+
[prompt, assemble_task_section].compact.join("\n\n")
|
|
181
246
|
else
|
|
182
247
|
assemble_system_prompt
|
|
183
248
|
end
|
|
184
249
|
end
|
|
185
250
|
|
|
186
|
-
# Activates a skill on this session
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
251
|
+
# Activates a skill on this session by enqueuing its content as a
|
|
252
|
+
# {PendingMessage} that promotes to a `from_melete_skill` phantom pair.
|
|
253
|
+
# Skips re-activation while the previous phantom pair is still in the
|
|
254
|
+
# viewport — Aoide already has the skill text in front of her.
|
|
190
255
|
#
|
|
191
256
|
# @param skill_name [String] name of the skill to activate
|
|
192
257
|
# @return [Skills::Definition] the activated skill
|
|
193
258
|
# @raise [Skills::InvalidDefinitionError] if skill not found in registry
|
|
194
|
-
# @raise [ActiveRecord::RecordInvalid] if save fails
|
|
195
259
|
def activate_skill(skill_name)
|
|
196
260
|
definition = Skills::Registry.instance.find(skill_name)
|
|
197
261
|
raise Skills::InvalidDefinitionError, "Unknown skill: #{skill_name}" unless definition
|
|
198
|
-
|
|
199
262
|
return definition if active_skills.include?(skill_name)
|
|
200
263
|
|
|
201
|
-
self.active_skills = active_skills + [skill_name]
|
|
202
|
-
save!
|
|
203
264
|
enqueue_recall_message("skill", skill_name, definition.content)
|
|
265
|
+
Events::Bus.emit(Events::SkillActivated.new(session_id: id, skill_name: skill_name))
|
|
204
266
|
definition
|
|
205
267
|
end
|
|
206
268
|
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
def deactivate_skill(skill_name)
|
|
213
|
-
return unless active_skills.include?(skill_name)
|
|
214
|
-
|
|
215
|
-
self.active_skills = active_skills - [skill_name]
|
|
216
|
-
save!
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# Activates a workflow on this session. Validates the workflow exists in the
|
|
220
|
-
# registry, sets it as the active workflow, and enqueues the workflow content
|
|
221
|
-
# as a {PendingMessage}. Only one workflow can be active at a time —
|
|
222
|
-
# activating a new one replaces the previous.
|
|
269
|
+
# Activates a workflow on this session by enqueuing its content as a
|
|
270
|
+
# {PendingMessage} that promotes to a `from_melete_workflow` phantom
|
|
271
|
+
# tool pair. Workflows are main-session only.
|
|
272
|
+
# Skips re-activation while the previous phantom pair is still in the
|
|
273
|
+
# viewport.
|
|
223
274
|
#
|
|
224
275
|
# @param workflow_name [String] name of the workflow to activate
|
|
225
276
|
# @return [Workflows::Definition] the activated workflow
|
|
226
277
|
# @raise [Workflows::InvalidDefinitionError] if workflow not found in registry
|
|
227
|
-
# @raise [ActiveRecord::RecordInvalid] if save fails
|
|
228
278
|
def activate_workflow(workflow_name)
|
|
229
279
|
definition = Workflows::Registry.instance.find(workflow_name)
|
|
230
280
|
raise Workflows::InvalidDefinitionError, "Unknown workflow: #{workflow_name}" unless definition
|
|
231
|
-
|
|
232
281
|
return definition if active_workflow == workflow_name
|
|
233
282
|
|
|
234
|
-
self.active_workflow = workflow_name
|
|
235
|
-
save!
|
|
236
283
|
enqueue_recall_message("workflow", workflow_name, definition.content)
|
|
284
|
+
Events::Bus.emit(Events::WorkflowActivated.new(session_id: id, workflow_name: workflow_name))
|
|
237
285
|
definition
|
|
238
286
|
end
|
|
239
287
|
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
return unless active_workflow.present?
|
|
246
|
-
|
|
247
|
-
self.active_workflow = nil
|
|
248
|
-
save!
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
# Assembles the system prompt: version preamble, soul, and snapshots.
|
|
252
|
-
# Skills, workflows, goals, and environment awareness flow through the
|
|
253
|
-
# message stream and tool responses, keeping the system prompt stable
|
|
254
|
-
# for prompt caching.
|
|
288
|
+
# Assembles the system prompt: version preamble, soul, sisters block,
|
|
289
|
+
# available tools menu, tool guidelines, and snapshots. Skills,
|
|
290
|
+
# workflows, goals, and environment awareness flow through the message
|
|
291
|
+
# stream and tool responses, keeping the system prompt stable for
|
|
292
|
+
# prompt caching.
|
|
255
293
|
#
|
|
256
294
|
# @return [String] composed system prompt
|
|
257
295
|
def assemble_system_prompt
|
|
258
|
-
[
|
|
259
|
-
|
|
296
|
+
[
|
|
297
|
+
assemble_version_preamble,
|
|
298
|
+
assemble_soul_section,
|
|
299
|
+
assemble_sisters_section,
|
|
300
|
+
assemble_available_tools_section,
|
|
301
|
+
assemble_tool_guidelines_section,
|
|
302
|
+
assemble_snapshots_section
|
|
303
|
+
].compact.join("\n\n")
|
|
260
304
|
end
|
|
261
305
|
|
|
262
306
|
# Serializes non-evicted goals as a lightweight summary for ActionCable
|
|
@@ -293,7 +337,7 @@ class Session < ApplicationRecord
|
|
|
293
337
|
pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
|
|
294
338
|
sliding_budget -= pinned_budget
|
|
295
339
|
|
|
296
|
-
window = viewport_messages(token_budget: sliding_budget)
|
|
340
|
+
window = viewport_messages(token_budget: sliding_budget).to_a
|
|
297
341
|
first_message_id = window.first&.id
|
|
298
342
|
|
|
299
343
|
prefix = assemble_context_prefix_messages(first_message_id, budget: pinned_budget)
|
|
@@ -342,72 +386,72 @@ class Session < ApplicationRecord
|
|
|
342
386
|
healed
|
|
343
387
|
end
|
|
344
388
|
|
|
345
|
-
#
|
|
346
|
-
#
|
|
347
|
-
#
|
|
348
|
-
#
|
|
349
|
-
#
|
|
350
|
-
#
|
|
389
|
+
# Enqueues an inbound human-side message (direct user input or a
|
|
390
|
+
# sub-agent reply) as an active {PendingMessage}. The PM's
|
|
391
|
+
# +after_create_commit+ emits the appropriate pipeline event when the
|
|
392
|
+
# session is idle (+StartMelete+ for user input, +StartProcessing+ for
|
|
393
|
+
# sub-agent deliveries). On a busy session the PM queues silently and
|
|
394
|
+
# {#wake_drain_pipeline_if_pending} picks it up on the next transition
|
|
395
|
+
# into +:idle+.
|
|
351
396
|
#
|
|
352
397
|
# @param content [String] message text (raw, without attribution)
|
|
353
398
|
# @param source_type [String] origin type: "user" (default) or "subagent"
|
|
354
399
|
# @param source_name [String, nil] sub-agent nickname (required when source_type is "subagent")
|
|
355
|
-
# @param bounce_back [Boolean] when true,
|
|
356
|
-
#
|
|
357
|
-
#
|
|
358
|
-
# @return [
|
|
400
|
+
# @param bounce_back [Boolean] when true, a failed first LLM call on the
|
|
401
|
+
# promoted message triggers a {Events::BounceBack} so the TUI can
|
|
402
|
+
# restore the text to the input field
|
|
403
|
+
# @return [PendingMessage]
|
|
359
404
|
def enqueue_user_message(content, source_type: "user", source_name: nil, bounce_back: false)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
msg = create_user_message(display)
|
|
369
|
-
job_args = bounce_back ? {message_id: msg.id} : {}
|
|
370
|
-
AgentRequestJob.perform_later(id, **job_args)
|
|
371
|
-
end
|
|
405
|
+
message_type = (source_type == "subagent") ? "subagent" : "user_message"
|
|
406
|
+
pending_messages.create!(
|
|
407
|
+
content: content,
|
|
408
|
+
source_type: source_type,
|
|
409
|
+
source_name: source_name,
|
|
410
|
+
message_type: message_type,
|
|
411
|
+
bounce_back: bounce_back
|
|
412
|
+
)
|
|
372
413
|
end
|
|
373
414
|
|
|
374
|
-
# Promotes a phantom
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
415
|
+
# Promotes a phantom-pair PendingMessage into a synthetic
|
|
416
|
+
# tool_call/tool_response Message pair — the LLM sees "a tool I
|
|
417
|
+
# invoked returned a result" and the pair rides the viewport like
|
|
418
|
+
# any real tool round. Used by {DrainJob} to flush background
|
|
419
|
+
# enrichment PMs (recalled memories, activated skills, workflow
|
|
420
|
+
# triggers, goal events, sub-agent deliveries) into the
|
|
421
|
+
# conversation.
|
|
422
|
+
#
|
|
423
|
+
# Releases a failed drain claim and bounces the promoted user-message
|
|
424
|
+
# back to the client. Called from {DrainJob} when the LLM call raises
|
|
425
|
+
# before {Events::LLMResponded} ships. Destroying the exact message the
|
|
426
|
+
# PM promoted (tracked in {PendingMessage#promoted_message_id}) avoids
|
|
427
|
+
# the "last user_message" guess, which was racy under parallel drains.
|
|
428
|
+
#
|
|
429
|
+
# Idempotent — a nil +promoted_message_id+ skips the destroy and emits
|
|
430
|
+
# the BounceBack with +message_id: nil+ so the TUI still restores input.
|
|
431
|
+
#
|
|
432
|
+
# @param pm [PendingMessage] the user-message PM that failed to round-trip
|
|
433
|
+
# @param error [Exception] the raised error
|
|
378
434
|
# @return [void]
|
|
379
|
-
def
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
timestamp: now,
|
|
392
|
-
token_count: Mneme::PassiveRecall::TOOL_PAIR_OVERHEAD_TOKENS
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
messages.create!(
|
|
396
|
-
message_type: "tool_response",
|
|
397
|
-
tool_use_id: uid,
|
|
398
|
-
payload: {"tool_name" => tool_name, "tool_use_id" => uid,
|
|
399
|
-
"content" => pm.content, "success" => true},
|
|
400
|
-
timestamp: now,
|
|
401
|
-
token_count: Message.estimate_token_count(pm.content.bytesize)
|
|
402
|
-
)
|
|
435
|
+
def release_with_bounce_back(pm, error)
|
|
436
|
+
response_complete! if may_response_complete?
|
|
437
|
+
|
|
438
|
+
bounced = pm.promoted_message_id && messages.find_by(id: pm.promoted_message_id)
|
|
439
|
+
bounced&.destroy!
|
|
440
|
+
|
|
441
|
+
Events::Bus.emit(Events::BounceBack.new(
|
|
442
|
+
content: pm.content,
|
|
443
|
+
error: error.message,
|
|
444
|
+
session_id: id,
|
|
445
|
+
message_id: bounced&.id
|
|
446
|
+
))
|
|
403
447
|
end
|
|
404
448
|
|
|
405
|
-
# Persists a
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
409
|
-
#
|
|
410
|
-
#
|
|
449
|
+
# Persists a user_message Message directly — skipping the PendingMessage
|
|
450
|
+
# mailbox. Used by {DrainJob} to finalize a promoted user_message PM
|
|
451
|
+
# and by the sub-agent spawn tools ({Tools::SpawnSubagent},
|
|
452
|
+
# {Tools::SpawnSpecialist}) to seed the child's conversation with its
|
|
453
|
+
# assigned task. The global {Events::Subscribers::Persister} skips
|
|
454
|
+
# +user_message+ events, so these callers own the persistence.
|
|
411
455
|
#
|
|
412
456
|
# @param content [String] user message text
|
|
413
457
|
# @param source_type [String, nil] origin type (e.g. "skill", "workflow")
|
|
@@ -426,43 +470,11 @@ class Session < ApplicationRecord
|
|
|
426
470
|
)
|
|
427
471
|
end
|
|
428
472
|
|
|
429
|
-
# Promotes all pending messages into the conversation history.
|
|
430
|
-
# Each {PendingMessage} is atomically deleted and replaced with a real
|
|
431
|
-
# {Message} — the new message gets the next auto-increment ID,
|
|
432
|
-
# naturally placing it after any tool_call/tool_response pairs that
|
|
433
|
-
# were persisted while the message was waiting.
|
|
434
|
-
#
|
|
435
|
-
# Returns a hash with two keys:
|
|
436
|
-
# - +:texts+ — plain content strings for user messages (injected as text blocks
|
|
437
|
-
# within the current tool_results turn)
|
|
438
|
-
# - +:pairs+ — synthetic tool_use/tool_result message hashes for phantom pair
|
|
439
|
-
# types (appended as new conversation turns)
|
|
440
|
-
#
|
|
441
|
-
# @return [Hash{Symbol => Array}] promoted messages split by injection strategy
|
|
442
|
-
def promote_pending_messages!
|
|
443
|
-
texts = []
|
|
444
|
-
pairs = []
|
|
445
|
-
pending_messages.find_each do |pm|
|
|
446
|
-
transaction do
|
|
447
|
-
if pm.phantom_pair?
|
|
448
|
-
promote_phantom_pair!(pm)
|
|
449
|
-
else
|
|
450
|
-
create_user_message(pm.display_content, source_type: pm.source_type, source_name: pm.source_name)
|
|
451
|
-
end
|
|
452
|
-
pm.destroy!
|
|
453
|
-
end
|
|
454
|
-
if pm.phantom_pair?
|
|
455
|
-
pairs.concat(pm.to_llm_messages)
|
|
456
|
-
else
|
|
457
|
-
texts << pm.content
|
|
458
|
-
end
|
|
459
|
-
end
|
|
460
|
-
{texts: texts, pairs: pairs}
|
|
461
|
-
end
|
|
462
|
-
|
|
463
473
|
# Broadcasts child session list to all clients subscribed to the parent
|
|
464
|
-
# session. Called when a child session is created or its
|
|
465
|
-
# changes so the HUD sub-agents section updates in real time.
|
|
474
|
+
# session. Called when a child session is created or its AASM state
|
|
475
|
+
# changes so the HUD sub-agents section updates in real time. Evicted
|
|
476
|
+
# sub-agents (+hud_visible: false+) are filtered out — the panel mirrors
|
|
477
|
+
# what Aoide currently carries in her viewport.
|
|
466
478
|
#
|
|
467
479
|
# Queries children via FK directly (avoids loading the parent record) and
|
|
468
480
|
# selects only the columns needed for the HUD payload.
|
|
@@ -471,44 +483,111 @@ class Session < ApplicationRecord
|
|
|
471
483
|
def broadcast_children_update_to_parent
|
|
472
484
|
return unless parent_session_id
|
|
473
485
|
|
|
474
|
-
children = Session.where(parent_session_id: parent_session_id)
|
|
486
|
+
children = Session.where(parent_session_id: parent_session_id, hud_visible: true)
|
|
475
487
|
.order(:created_at)
|
|
476
|
-
.select(:id, :name, :
|
|
488
|
+
.select(:id, :name, :aasm_state)
|
|
477
489
|
ActionCable.server.broadcast("session_#{parent_session_id}", {
|
|
478
490
|
"action" => "children_updated",
|
|
479
491
|
"session_id" => parent_session_id,
|
|
480
492
|
"children" => children.map { |child|
|
|
481
|
-
|
|
482
|
-
{"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
|
|
493
|
+
{"id" => child.id, "name" => child.name, "session_state" => child.aasm_state}
|
|
483
494
|
}
|
|
484
495
|
})
|
|
485
496
|
end
|
|
486
497
|
|
|
487
|
-
#
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
#
|
|
491
|
-
#
|
|
492
|
-
#
|
|
493
|
-
#
|
|
498
|
+
# True when at least one of +child+'s traces (the +spawn_subagent+ tool
|
|
499
|
+
# pair or any +from_{nickname}+ phantom pair) still lives above the
|
|
500
|
+
# Mneme boundary in this session's viewport. Used by {Mneme::Runner}
|
|
501
|
+
# after boundary advancement to decide whether a child should drop out
|
|
502
|
+
# of the HUD panel.
|
|
503
|
+
#
|
|
504
|
+
# Returns +false+ when the given session isn't a direct child, when it
|
|
505
|
+
# has no +spawn_tool_use_id+ (legacy child), or when the boundary has
|
|
506
|
+
# passed every trace.
|
|
507
|
+
#
|
|
508
|
+
# @param child [Session] a sub-agent session to check
|
|
509
|
+
# @return [Boolean]
|
|
510
|
+
def subagent_trace_in_viewport?(child)
|
|
511
|
+
return false unless child.parent_session_id == id
|
|
512
|
+
|
|
513
|
+
boundary_id = mneme_boundary_message_id
|
|
514
|
+
scope = messages
|
|
515
|
+
scope = scope.where("messages.id >= ?", boundary_id) if boundary_id
|
|
516
|
+
|
|
517
|
+
spawn_uid = child.spawn_tool_use_id
|
|
518
|
+
nickname = child.name
|
|
519
|
+
conditions = []
|
|
520
|
+
bindings = {}
|
|
521
|
+
if spawn_uid
|
|
522
|
+
conditions << "messages.tool_use_id = :uid"
|
|
523
|
+
bindings[:uid] = spawn_uid
|
|
524
|
+
end
|
|
525
|
+
if nickname
|
|
526
|
+
conditions << "json_extract(messages.payload, '$.tool_name') = :tool"
|
|
527
|
+
bindings[:tool] = "from_#{nickname}"
|
|
528
|
+
end
|
|
529
|
+
return false if conditions.empty?
|
|
530
|
+
|
|
531
|
+
scope.where(conditions.join(" OR "), **bindings).exists?
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# AASM guard for the +executing → awaiting+ branch of +start_processing+.
|
|
535
|
+
# The round is complete when every orphan +tool_call+ Message (one
|
|
536
|
+
# without a matching +tool_response+ Message) has a corresponding
|
|
537
|
+
# +tool_response+ PendingMessage waiting to be promoted. Until then the
|
|
538
|
+
# drain bails so the LLM never sees a half-assembled tool turn.
|
|
539
|
+
#
|
|
540
|
+
# @return [Boolean]
|
|
541
|
+
def tool_round_complete?
|
|
542
|
+
msg_responses = messages.where(message_type: "tool_response").select(:tool_use_id)
|
|
543
|
+
pm_responses = pending_messages.where(message_type: "tool_response").select(:tool_use_id)
|
|
544
|
+
messages.where(message_type: "tool_call")
|
|
545
|
+
.where.not(tool_use_id: msg_responses)
|
|
546
|
+
.where.not(tool_use_id: pm_responses)
|
|
547
|
+
.none?
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# AASM after_all_events callback — publishes
|
|
551
|
+
# {Events::SessionStateChanged} so the broadcaster subscriber can keep
|
|
552
|
+
# the TUI spinner and parent-session HUD in sync with the state machine.
|
|
553
|
+
# Fires after the state column is updated and persisted, so +aasm_state+
|
|
554
|
+
# reliably holds the post-transition value.
|
|
494
555
|
#
|
|
495
|
-
#
|
|
496
|
-
|
|
556
|
+
# @return [void]
|
|
557
|
+
def emit_state_change
|
|
558
|
+
Events::Bus.emit(Events::SessionStateChanged.new(session_id: id, state: aasm_state))
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# AASM after_all_events callback — clears the +interrupt_requested+
|
|
562
|
+
# flag whenever the session lands in +:idle+. The flag is a
|
|
563
|
+
# one-shot signal that long-running tools ({Tools::Bash}) poll; once
|
|
564
|
+
# the round ends (tool aborted, response synthesized, drain wound
|
|
565
|
+
# down) the signal is spent and must not leak into the next round.
|
|
497
566
|
#
|
|
498
|
-
# @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
|
|
499
|
-
# @param tool [String, nil] tool name when state is "tool_executing"
|
|
500
567
|
# @return [void]
|
|
501
|
-
def
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
ActionCable.server.broadcast("session_#{id}", payload)
|
|
568
|
+
def clear_interrupt_flag_if_idle
|
|
569
|
+
return unless idle?
|
|
570
|
+
return unless interrupt_requested?
|
|
505
571
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
572
|
+
update_column(:interrupt_requested, false)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# AASM after_all_events callback — picks the oldest active
|
|
576
|
+
# PendingMessage and re-runs its pipeline routing whenever the session
|
|
577
|
+
# lands in +:idle+ with work still queued. Covers the race where a PM
|
|
578
|
+
# arrives while the session is +:awaiting+ (LLM in flight): its own
|
|
579
|
+
# +after_create_commit+ saw +may_start_processing?+ return false and
|
|
580
|
+
# emitted nothing, so without this callback the message would sit
|
|
581
|
+
# forever once the LLM call completed.
|
|
582
|
+
#
|
|
583
|
+
# The +:executing → :awaiting+ path (tool round close) does not need
|
|
584
|
+
# this callback — the closing tool_response PM is itself the wake.
|
|
585
|
+
#
|
|
586
|
+
# @return [void]
|
|
587
|
+
def wake_drain_pipeline_if_pending
|
|
588
|
+
return unless idle?
|
|
509
589
|
|
|
510
|
-
|
|
511
|
-
ActionCable.server.broadcast("session_#{parent_session_id}", parent_payload)
|
|
590
|
+
pending_messages.ordered_for_drain.first&.route_to_event_bus
|
|
512
591
|
end
|
|
513
592
|
|
|
514
593
|
# Broadcasts the full LLM debug context to debug-mode TUI clients.
|
|
@@ -524,6 +603,25 @@ class Session < ApplicationRecord
|
|
|
524
603
|
ActionCable.server.broadcast("session_#{id}", self.class.system_prompt_payload(system, tools: tools))
|
|
525
604
|
end
|
|
526
605
|
|
|
606
|
+
# Broadcasts current active skills and workflow to all subscribers.
|
|
607
|
+
# "Active" is viewport-derived — the HUD reflects what Aoide actually
|
|
608
|
+
# has in front of her. Callers invoke this after any operation that
|
|
609
|
+
# changes viewport composition (phantom pair promotion, Mneme eviction).
|
|
610
|
+
#
|
|
611
|
+
# @return [void]
|
|
612
|
+
def broadcast_active_state!
|
|
613
|
+
ActionCable.server.broadcast("session_#{id}", {
|
|
614
|
+
"action" => "active_skills_updated",
|
|
615
|
+
"session_id" => id,
|
|
616
|
+
"active_skills" => active_skills
|
|
617
|
+
})
|
|
618
|
+
ActionCable.server.broadcast("session_#{id}", {
|
|
619
|
+
"action" => "active_workflow_updated",
|
|
620
|
+
"session_id" => id,
|
|
621
|
+
"active_workflow" => active_workflow
|
|
622
|
+
})
|
|
623
|
+
end
|
|
624
|
+
|
|
527
625
|
# Returns the deterministic tool schemas for this session's type and
|
|
528
626
|
# granted_tools configuration. Standard and spawn tools are static
|
|
529
627
|
# class-level definitions — no ShellSession or registry needed.
|
|
@@ -532,22 +630,7 @@ class Session < ApplicationRecord
|
|
|
532
630
|
#
|
|
533
631
|
# @return [Array<Hash>] tool schema hashes matching Anthropic tools API format
|
|
534
632
|
def tool_schemas
|
|
535
|
-
|
|
536
|
-
granted = granted_tools.filter_map { |name| AgentLoop::STANDARD_TOOLS_BY_NAME[name] }
|
|
537
|
-
(AgentLoop::ALWAYS_GRANTED_TOOLS + granted).uniq
|
|
538
|
-
else
|
|
539
|
-
AgentLoop::STANDARD_TOOLS.dup
|
|
540
|
-
end
|
|
541
|
-
|
|
542
|
-
unless sub_agent?
|
|
543
|
-
tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
if sub_agent?
|
|
547
|
-
tools.push(Tools::MarkGoalCompleted)
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
tools.map(&:schema)
|
|
633
|
+
resolved_tool_classes.map(&:schema)
|
|
551
634
|
end
|
|
552
635
|
|
|
553
636
|
# Builds the system prompt payload for debug mode transmission.
|
|
@@ -559,9 +642,8 @@ class Session < ApplicationRecord
|
|
|
559
642
|
# @param tools [Array<Hash>, nil] tool schemas
|
|
560
643
|
# @return [Hash] payload with type, rendered debug content, and token estimate
|
|
561
644
|
def self.system_prompt_payload(prompt, tools: nil)
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
tokens = Message.estimate_token_count(total_bytes)
|
|
645
|
+
tools_json = tools&.any? ? tools.to_json : ""
|
|
646
|
+
tokens = TokenEstimation.estimate_token_count(prompt.to_s + tools_json)
|
|
565
647
|
|
|
566
648
|
debug = {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
|
|
567
649
|
debug[:tools] = tools if tools&.any?
|
|
@@ -575,33 +657,40 @@ class Session < ApplicationRecord
|
|
|
575
657
|
|
|
576
658
|
private
|
|
577
659
|
|
|
578
|
-
#
|
|
579
|
-
#
|
|
580
|
-
#
|
|
581
|
-
#
|
|
582
|
-
#
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
.
|
|
589
|
-
.where("json_extract(payload, '$.source_type') = ?", source_type)
|
|
590
|
-
.pluck(Arel.sql("json_extract(payload, '$.source_name')"))
|
|
591
|
-
.to_set
|
|
660
|
+
# Returns `from_melete_*` tool_call messages currently in the viewport
|
|
661
|
+
# as a composable AR relation. Used by {#skills_in_viewport} and
|
|
662
|
+
# {#workflow_in_viewport} to derive active state with additional
|
|
663
|
+
# `.where` clauses on the tool name suffix.
|
|
664
|
+
#
|
|
665
|
+
# @return [ActiveRecord::Relation<Message>] chronologically ordered
|
|
666
|
+
def from_melete_messages
|
|
667
|
+
viewport_messages
|
|
668
|
+
.where(message_type: "tool_call")
|
|
669
|
+
.where("json_extract(payload, '$.tool_name') GLOB ?", "from_melete_*")
|
|
670
|
+
.order(:id)
|
|
592
671
|
end
|
|
593
672
|
|
|
594
|
-
# Enqueues a recalled skill or workflow as a
|
|
595
|
-
#
|
|
596
|
-
#
|
|
597
|
-
# through the normal promotion flow as a phantom tool_use/tool_result pair.
|
|
673
|
+
# Enqueues a Melete-recalled skill or workflow as a background
|
|
674
|
+
# {PendingMessage}. {DrainJob} flushes it into the conversation as a
|
|
675
|
+
# phantom tool_use/tool_result pair on the next drain cycle.
|
|
598
676
|
#
|
|
599
677
|
# @param source_type [String] "skill" or "workflow"
|
|
600
678
|
# @param source_name [String] skill or workflow name
|
|
601
679
|
# @param content [String] definition content to recall
|
|
602
680
|
# @return [PendingMessage] the created pending message
|
|
603
681
|
def enqueue_recall_message(source_type, source_name, content)
|
|
604
|
-
|
|
682
|
+
message_type = case source_type
|
|
683
|
+
when "skill" then "from_melete_skill"
|
|
684
|
+
when "workflow" then "from_melete_workflow"
|
|
685
|
+
else raise ArgumentError, "unknown recall source_type: #{source_type.inspect}"
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
pending_messages.create!(
|
|
689
|
+
content: content,
|
|
690
|
+
source_type: source_type,
|
|
691
|
+
source_name: source_name,
|
|
692
|
+
message_type: message_type
|
|
693
|
+
)
|
|
605
694
|
end
|
|
606
695
|
|
|
607
696
|
# One-line version preamble so the agent knows its own version.
|
|
@@ -627,25 +716,64 @@ class Session < ApplicationRecord
|
|
|
627
716
|
File.read(path).strip
|
|
628
717
|
end
|
|
629
718
|
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
#
|
|
719
|
+
# Introduces Melete and Mneme so the agent recognizes their
|
|
720
|
+
# contributions as delivered-to-her rather than self-invoked. The
|
|
721
|
+
# `from_` prefix carries the semantics — the section just makes the
|
|
722
|
+
# convention explicit once.
|
|
633
723
|
#
|
|
634
|
-
# @return [String
|
|
635
|
-
def
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
724
|
+
# @return [String] sisters section
|
|
725
|
+
def assemble_sisters_section
|
|
726
|
+
<<~SISTERS.strip
|
|
727
|
+
## Your Sisters
|
|
728
|
+
|
|
729
|
+
You don't work alone. Two muses share the conversation with you, and their work arrives as tool responses prefixed `from_`:
|
|
640
730
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
731
|
+
- **Melete**, the muse of practice, prepares the stage before you speak. Her contributions arrive as `from_melete_skill`, `from_melete_workflow`, and `from_melete_goal`.
|
|
732
|
+
- **Mneme**, the muse of memory, holds what has slipped past your immediate attention. When something from earlier matters again she surfaces it as `from_mneme`.
|
|
733
|
+
|
|
734
|
+
Sub-agents you spawn arrive the same way, named after whoever sent them — `from_sleuth`, `from_scout`, and so on.
|
|
735
|
+
|
|
736
|
+
**How delivery works:** Results from sisters and sub-agents appear automatically as tool responses in your conversation — you don't fetch them. There is no tool to call, no way to poll, and no status to check. When a sub-agent finishes, its output shows up on its own. If you're waiting on multiple agents, just wait — they'll arrive. Do other work in the meantime if you can.
|
|
737
|
+
SISTERS
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Renders a one-line menu of the session's available tools, populated
|
|
741
|
+
# from each tool's {Tools::Base.prompt_snippet}. Tools without a snippet
|
|
742
|
+
# are omitted; the section disappears entirely when no tool contributes.
|
|
743
|
+
#
|
|
744
|
+
# @return [String, nil] available tools section, or nil when empty
|
|
745
|
+
def assemble_available_tools_section
|
|
746
|
+
menu = resolved_tool_classes.filter_map do |tool|
|
|
747
|
+
snippet = tool.prompt_snippet
|
|
748
|
+
"- #{tool.tool_name}: #{snippet}" if snippet
|
|
644
749
|
end
|
|
750
|
+
return if menu.empty?
|
|
645
751
|
|
|
646
|
-
|
|
752
|
+
"## Available Tools\n\n#{menu.join("\n")}"
|
|
753
|
+
end
|
|
647
754
|
|
|
648
|
-
|
|
755
|
+
# Concatenates each available tool's {Tools::Base.prompt_guidelines}
|
|
756
|
+
# into a single behavioral guidance section. Guidelines steer cross-tool
|
|
757
|
+
# selection (e.g. prefer edit_file over `sed`) and reinforce non-obvious
|
|
758
|
+
# behaviour the schema cannot convey at every reasoning token.
|
|
759
|
+
#
|
|
760
|
+
# @return [String, nil] tool guidelines section, or nil when empty
|
|
761
|
+
def assemble_tool_guidelines_section
|
|
762
|
+
bullets = resolved_tool_classes.flat_map(&:prompt_guidelines).map { |line| "- #{line}" }
|
|
763
|
+
return if bullets.empty?
|
|
764
|
+
|
|
765
|
+
"## Tool Guidelines\n\n#{bullets.join("\n")}"
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# Memoizes the active tool classes for this session by delegating to
|
|
769
|
+
# {Tools::Registry.tool_classes_for} — the shared resolver used by
|
|
770
|
+
# {.build}, {#tool_schemas}, and the prompt section assemblers so all
|
|
771
|
+
# views stay in sync. Safe to memoize: +granted_tools+ and +sub_agent?+
|
|
772
|
+
# are immutable post-creation.
|
|
773
|
+
#
|
|
774
|
+
# @return [Array<Class>] tool classes (no MCP tools — those are dynamic)
|
|
775
|
+
def resolved_tool_classes
|
|
776
|
+
@resolved_tool_classes ||= Tools::Registry.tool_classes_for(self)
|
|
649
777
|
end
|
|
650
778
|
|
|
651
779
|
# Assembles the task section for sub-agent system prompts.
|
|
@@ -685,22 +813,6 @@ class Session < ApplicationRecord
|
|
|
685
813
|
lines.join("\n")
|
|
686
814
|
end
|
|
687
815
|
|
|
688
|
-
# Formats a definition (skill or workflow) as a Markdown section for the
|
|
689
|
-
# expertise prompt. Extracts the first Markdown heading from content for
|
|
690
|
-
# the section title; falls back to the definition name when content has
|
|
691
|
-
# no heading.
|
|
692
|
-
#
|
|
693
|
-
# @param definition [Skills::Definition, Workflows::Definition, nil] the definition to format
|
|
694
|
-
# @param fallback_name [String] name to use if content has no heading
|
|
695
|
-
# @return [String, nil] formatted section, or nil if definition is nil
|
|
696
|
-
def format_expertise_section(definition, fallback_name)
|
|
697
|
-
return unless definition
|
|
698
|
-
|
|
699
|
-
content = definition.content
|
|
700
|
-
heading = content.lines.first&.sub(/^#+ /, "")&.strip || fallback_name
|
|
701
|
-
"### #{heading}\n\n#{content}"
|
|
702
|
-
end
|
|
703
|
-
|
|
704
816
|
# Broadcasts a name change to all clients subscribed to this session.
|
|
705
817
|
# Triggered by after_update_commit so clients see name updates in real time.
|
|
706
818
|
#
|
|
@@ -713,66 +825,6 @@ class Session < ApplicationRecord
|
|
|
713
825
|
})
|
|
714
826
|
end
|
|
715
827
|
|
|
716
|
-
# Broadcasts active skill changes to all clients subscribed to this session.
|
|
717
|
-
# Triggered by after_update_commit so the TUI info panel updates reactively.
|
|
718
|
-
#
|
|
719
|
-
# @return [void]
|
|
720
|
-
def broadcast_active_skills_update
|
|
721
|
-
ActionCable.server.broadcast("session_#{id}", {
|
|
722
|
-
"action" => "active_skills_updated",
|
|
723
|
-
"session_id" => id,
|
|
724
|
-
"active_skills" => active_skills
|
|
725
|
-
})
|
|
726
|
-
end
|
|
727
|
-
|
|
728
|
-
# Broadcasts active workflow change to all clients subscribed to this session.
|
|
729
|
-
# Triggered by after_update_commit so the TUI info panel updates reactively.
|
|
730
|
-
#
|
|
731
|
-
# @return [void]
|
|
732
|
-
def broadcast_active_workflow_update
|
|
733
|
-
ActionCable.server.broadcast("session_#{id}", {
|
|
734
|
-
"action" => "active_workflow_updated",
|
|
735
|
-
"session_id" => id,
|
|
736
|
-
"active_workflow" => active_workflow
|
|
737
|
-
})
|
|
738
|
-
end
|
|
739
|
-
|
|
740
|
-
# Scopes own messages for viewport assembly.
|
|
741
|
-
# Starts from the Mneme boundary (inclusive) — older messages have been
|
|
742
|
-
# compressed into snapshots and no longer participate in the viewport.
|
|
743
|
-
# @return [ActiveRecord::Relation]
|
|
744
|
-
def own_message_scope
|
|
745
|
-
scope = messages.context_messages
|
|
746
|
-
scope = scope.where("messages.id >= ?", mneme_boundary_message_id) if mneme_boundary_message_id
|
|
747
|
-
scope
|
|
748
|
-
end
|
|
749
|
-
|
|
750
|
-
# Walks messages newest-first, selecting until the token budget is exhausted.
|
|
751
|
-
# Always includes at least the newest message even if it exceeds budget.
|
|
752
|
-
#
|
|
753
|
-
# @param scope [ActiveRecord::Relation] message scope to select from
|
|
754
|
-
# @param budget [Integer] maximum tokens to include
|
|
755
|
-
# @return [Array<Message>] chronologically ordered
|
|
756
|
-
def select_messages(scope, budget:)
|
|
757
|
-
selected = []
|
|
758
|
-
remaining = budget
|
|
759
|
-
|
|
760
|
-
scope.reorder(id: :desc).each do |msg|
|
|
761
|
-
cost = message_token_cost(msg)
|
|
762
|
-
break if cost > remaining && selected.any?
|
|
763
|
-
|
|
764
|
-
selected << msg
|
|
765
|
-
remaining -= cost
|
|
766
|
-
end
|
|
767
|
-
|
|
768
|
-
selected.reverse
|
|
769
|
-
end
|
|
770
|
-
|
|
771
|
-
# @return [Integer] token cost, using cached count or heuristic estimate
|
|
772
|
-
def message_token_cost(msg)
|
|
773
|
-
(msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
|
|
774
|
-
end
|
|
775
|
-
|
|
776
828
|
# Ensures every tool_call in the message list has a matching tool_response
|
|
777
829
|
# (and vice versa) by removing unpaired messages. The Anthropic API requires
|
|
778
830
|
# every tool_use block to have a tool_result — a missing partner causes
|
|
@@ -800,25 +852,20 @@ class Session < ApplicationRecord
|
|
|
800
852
|
end
|
|
801
853
|
|
|
802
854
|
# Assembles L1/L2 snapshots as a system prompt section.
|
|
803
|
-
# Snapshots
|
|
804
|
-
#
|
|
805
|
-
#
|
|
855
|
+
# Snapshots form a compressed timeline between the system prompt and
|
|
856
|
+
# the live viewport. The budget walk fills chronologically — when the
|
|
857
|
+
# budget overflows, the oldest snapshots drop first so the most recent
|
|
858
|
+
# ones always bridge into the viewport.
|
|
806
859
|
#
|
|
807
860
|
# @return [String, nil] formatted snapshot text for the system prompt, or nil
|
|
808
861
|
def assemble_snapshots_section
|
|
809
|
-
reference_id = mneme_boundary_message_id || viewport_message_ids.first
|
|
810
|
-
return unless reference_id
|
|
811
|
-
|
|
812
|
-
l2_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
|
|
813
|
-
l1_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
|
|
814
|
-
|
|
815
862
|
l2 = select_snapshots_within_budget(
|
|
816
|
-
snapshots.for_level(2)
|
|
817
|
-
budget:
|
|
863
|
+
snapshots.for_level(2),
|
|
864
|
+
budget: (Anima::Settings.token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
|
|
818
865
|
)
|
|
819
866
|
l1 = select_snapshots_within_budget(
|
|
820
|
-
snapshots.for_level(1).not_covered_by_l2
|
|
821
|
-
budget:
|
|
867
|
+
snapshots.for_level(1).not_covered_by_l2,
|
|
868
|
+
budget: (Anima::Settings.token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
|
|
822
869
|
)
|
|
823
870
|
|
|
824
871
|
sections = []
|
|
@@ -827,9 +874,10 @@ class Session < ApplicationRecord
|
|
|
827
874
|
sections.join("\n\n").presence
|
|
828
875
|
end
|
|
829
876
|
|
|
830
|
-
# Walks snapshots
|
|
831
|
-
#
|
|
832
|
-
#
|
|
877
|
+
# Walks snapshots newest-first (by to_message_id), selecting until the
|
|
878
|
+
# token budget is exhausted. Always includes the newest snapshot even
|
|
879
|
+
# if it exceeds the budget. Returns results in chronological order
|
|
880
|
+
# so they read as a timeline in the system prompt.
|
|
833
881
|
#
|
|
834
882
|
# @param scope [ActiveRecord::Relation] snapshot scope to select from
|
|
835
883
|
# @param budget [Integer] maximum tokens to include
|
|
@@ -838,15 +886,15 @@ class Session < ApplicationRecord
|
|
|
838
886
|
selected = []
|
|
839
887
|
remaining = budget
|
|
840
888
|
|
|
841
|
-
scope.each do |snapshot|
|
|
842
|
-
cost = snapshot.
|
|
889
|
+
scope.order(to_message_id: :desc).each do |snapshot|
|
|
890
|
+
cost = snapshot.token_count
|
|
843
891
|
break if cost > remaining && selected.any?
|
|
844
892
|
|
|
845
893
|
selected << snapshot
|
|
846
894
|
remaining -= cost
|
|
847
895
|
end
|
|
848
896
|
|
|
849
|
-
selected
|
|
897
|
+
selected.reverse
|
|
850
898
|
end
|
|
851
899
|
|
|
852
900
|
# Formats a list of snapshots as a labeled section for the system prompt.
|
|
@@ -877,12 +925,9 @@ class Session < ApplicationRecord
|
|
|
877
925
|
root_goals = goals.root.active.includes(:sub_goals).order(:created_at)
|
|
878
926
|
return [] if root_goals.empty?
|
|
879
927
|
|
|
880
|
-
|
|
928
|
+
pins_scope = pinned_messages.where("pinned_messages.message_id < ?", first_message_id)
|
|
929
|
+
selected_pins = select_pins_within_budget(pins_scope, budget: budget)
|
|
881
930
|
.includes(:message, :goals)
|
|
882
|
-
.where("pinned_messages.message_id < ?", first_message_id)
|
|
883
|
-
.order("pinned_messages.message_id")
|
|
884
|
-
|
|
885
|
-
selected_pins = select_pins_within_budget(pins, budget)
|
|
886
931
|
content = render_goal_snapshot_with_pins(root_goals, selected_pins)
|
|
887
932
|
|
|
888
933
|
# Uses session ID (not PendingMessage ID) because this snapshot is
|
|
@@ -890,7 +935,7 @@ class Session < ApplicationRecord
|
|
|
890
935
|
uid = "goal_snapshot_#{id}"
|
|
891
936
|
[
|
|
892
937
|
{role: "assistant", content: [
|
|
893
|
-
{type: "tool_use", id: uid, name: PendingMessage::
|
|
938
|
+
{type: "tool_use", id: uid, name: PendingMessage::MELETE_GOAL_TOOL, input: {}}
|
|
894
939
|
]},
|
|
895
940
|
{role: "user", content: [
|
|
896
941
|
{type: "tool_result", tool_use_id: uid, content: content}
|
|
@@ -898,25 +943,25 @@ class Session < ApplicationRecord
|
|
|
898
943
|
]
|
|
899
944
|
end
|
|
900
945
|
|
|
901
|
-
#
|
|
902
|
-
#
|
|
946
|
+
# Selects pins within a token budget using a cumulative-sum window
|
|
947
|
+
# function — mirror of {#eviction_zone_messages} but keyed by
|
|
948
|
+
# +message_id+. Walks oldest-first and always anchors on the first
|
|
949
|
+
# pin even if it alone exceeds the budget (via
|
|
950
|
+
# +running_total = token_count+).
|
|
903
951
|
#
|
|
904
|
-
# @param
|
|
905
|
-
# @param budget [Integer]
|
|
906
|
-
# @return [
|
|
907
|
-
def select_pins_within_budget(
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
cost = pin.token_cost
|
|
913
|
-
break if cost > remaining && selected.any?
|
|
914
|
-
|
|
915
|
-
selected << pin
|
|
916
|
-
remaining -= cost
|
|
917
|
-
end
|
|
952
|
+
# @param scope [ActiveRecord::Relation] pin scope to select from
|
|
953
|
+
# @param budget [Integer] maximum tokens to include
|
|
954
|
+
# @return [ActiveRecord::Relation<PinnedMessage>] chronologically ordered
|
|
955
|
+
def select_pins_within_budget(scope, budget:)
|
|
956
|
+
windowed = scope.select(
|
|
957
|
+
"pinned_messages.*",
|
|
958
|
+
"SUM(pinned_messages.token_count) OVER (ORDER BY pinned_messages.message_id ASC) AS running_total"
|
|
959
|
+
)
|
|
918
960
|
|
|
919
|
-
|
|
961
|
+
PinnedMessage
|
|
962
|
+
.from(windowed, :pinned_messages)
|
|
963
|
+
.where("running_total <= ? OR running_total = token_count", budget)
|
|
964
|
+
.order(:message_id)
|
|
920
965
|
end
|
|
921
966
|
|
|
922
967
|
# Renders active goals with their associated pinned messages as a
|
|
@@ -1104,13 +1149,4 @@ class Session < ApplicationRecord
|
|
|
1104
1149
|
def now_ns
|
|
1105
1150
|
Time.current.to_ns
|
|
1106
1151
|
end
|
|
1107
|
-
|
|
1108
|
-
# Delegates to {Message#estimate_tokens} for messages not yet counted
|
|
1109
|
-
# by the background job.
|
|
1110
|
-
#
|
|
1111
|
-
# @param msg [Message]
|
|
1112
|
-
# @return [Integer] at least 1
|
|
1113
|
-
def estimate_tokens(msg)
|
|
1114
|
-
msg.estimate_tokens
|
|
1115
|
-
end
|
|
1116
1152
|
end
|