anima-core 1.2.0 → 1.4.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 +14 -8
- data/README.md +96 -23
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +7 -7
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +15 -6
- data/app/jobs/passive_recall_job.rb +6 -11
- data/app/models/concerns/message/broadcasting.rb +1 -0
- data/app/models/goal.rb +14 -0
- data/app/models/message.rb +13 -31
- data/app/models/pending_message.rb +191 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +480 -271
- data/bin/inspect-cassette +144 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/database.yml +1 -0
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +120 -0
- data/lib/agent_loop.rb +53 -51
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +19 -6
- data/lib/analytical_brain/tools/activate_skill.rb +2 -2
- data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
- data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
- data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
- data/lib/analytical_brain/tools/finish_goal.rb +3 -0
- data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
- data/lib/analytical_brain/tools/read_workflow.rb +2 -2
- data/lib/analytical_brain/tools/set_goal.rb +5 -1
- data/lib/analytical_brain/tools/update_goal.rb +5 -1
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +20 -1
- data/lib/anima/settings.rb +37 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/agent_message.rb +14 -0
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +12 -18
- data/lib/events/subscribers/subagent_message_router.rb +18 -9
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +91 -50
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +9 -5
- data/lib/mneme/passive_recall.rb +85 -16
- data/lib/mneme/runner.rb +15 -4
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +239 -18
- data/lib/tools/base.rb +22 -0
- data/lib/tools/bash.rb +61 -7
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +85 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +41 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +11 -8
- data/lib/tools/spawn_subagent.rb +19 -13
- data/lib/tools/subagent_prompts.rb +41 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +545 -137
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +13 -20
- data/lib/tui/decorators/base_decorator.rb +40 -11
- data/lib/tui/decorators/bash_decorator.rb +3 -3
- data/lib/tui/decorators/edit_decorator.rb +7 -4
- data/lib/tui/decorators/read_decorator.rb +6 -8
- data/lib/tui/decorators/think_decorator.rb +4 -6
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +7 -4
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +33 -0
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +159 -27
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +302 -103
- data/lib/tui/settings.rb +86 -0
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +30 -1
- data/templates/tui.toml +209 -0
- metadata +24 -3
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/environment_probe.rb +0 -232
data/app/models/session.rb
CHANGED
|
@@ -16,6 +16,7 @@ class Session < ApplicationRecord
|
|
|
16
16
|
serialize :granted_tools, coder: JSON
|
|
17
17
|
|
|
18
18
|
has_many :messages, -> { order(:id) }, dependent: :destroy
|
|
19
|
+
has_many :pending_messages, dependent: :destroy
|
|
19
20
|
has_many :goals, dependent: :destroy
|
|
20
21
|
has_many :snapshots, dependent: :destroy
|
|
21
22
|
has_many :pinned_messages, through: :messages
|
|
@@ -32,6 +33,7 @@ class Session < ApplicationRecord
|
|
|
32
33
|
|
|
33
34
|
scope :recent, ->(limit = 10) { order(updated_at: :desc).limit(limit) }
|
|
34
35
|
scope :root_sessions, -> { where(parent_session_id: nil) }
|
|
36
|
+
scope :processing_children_of, ->(parent_id) { where(parent_session_id: parent_id, processing: true) }
|
|
35
37
|
|
|
36
38
|
# Cycles to the next view mode: basic → verbose → debug → basic.
|
|
37
39
|
#
|
|
@@ -59,10 +61,10 @@ class Session < ApplicationRecord
|
|
|
59
61
|
|
|
60
62
|
# Initialize boundary on first conversation message
|
|
61
63
|
if mneme_boundary_message_id.nil?
|
|
62
|
-
first_conversation = messages
|
|
64
|
+
first_conversation = messages
|
|
63
65
|
.where(message_type: Message::CONVERSATION_TYPES)
|
|
64
66
|
.order(:id).first
|
|
65
|
-
first_conversation ||= messages
|
|
67
|
+
first_conversation ||= messages
|
|
66
68
|
.where(message_type: "tool_call")
|
|
67
69
|
.detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }
|
|
68
70
|
|
|
@@ -97,29 +99,25 @@ class Session < ApplicationRecord
|
|
|
97
99
|
AnalyticalBrainJob.perform_later(id)
|
|
98
100
|
end
|
|
99
101
|
|
|
102
|
+
# Token budget appropriate for this session type.
|
|
103
|
+
# Sub-agents use a smaller budget to stay out of the "dumb zone".
|
|
104
|
+
# @return [Integer]
|
|
105
|
+
def effective_token_budget
|
|
106
|
+
sub_agent? ? Anima::Settings.subagent_token_budget : Anima::Settings.token_budget
|
|
107
|
+
end
|
|
108
|
+
|
|
100
109
|
# Returns the messages currently visible in the LLM context window.
|
|
101
110
|
# Walks messages newest-first and includes them until the token budget
|
|
102
111
|
# is exhausted. Messages are full-size or excluded entirely.
|
|
103
112
|
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
# The final array is chronological: parent messages first, then child messages.
|
|
113
|
+
# Pending messages live in a separate table ({PendingMessage}) and never
|
|
114
|
+
# appear in this viewport — they are promoted to real messages before
|
|
115
|
+
# the agent processes them.
|
|
108
116
|
#
|
|
109
117
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
110
|
-
# @param include_pending [Boolean] whether to include pending messages (true for
|
|
111
|
-
# display, false for LLM context assembly)
|
|
112
118
|
# @return [Array<Message>] chronologically ordered
|
|
113
|
-
def viewport_messages(token_budget:
|
|
114
|
-
|
|
115
|
-
remaining = token_budget - own.sum { |msg| message_token_cost(msg) }
|
|
116
|
-
|
|
117
|
-
if sub_agent? && remaining > 0
|
|
118
|
-
parent = select_messages(parent_message_scope(include_pending), budget: remaining)
|
|
119
|
-
trim_trailing_tool_calls(parent) + own
|
|
120
|
-
else
|
|
121
|
-
own
|
|
122
|
-
end
|
|
119
|
+
def viewport_messages(token_budget: effective_token_budget)
|
|
120
|
+
select_messages(own_message_scope, budget: token_budget)
|
|
123
121
|
end
|
|
124
122
|
|
|
125
123
|
# Recalculates the viewport and returns IDs of messages evicted since the
|
|
@@ -148,19 +146,47 @@ class Session < ApplicationRecord
|
|
|
148
146
|
update_column(:viewport_message_ids, ids)
|
|
149
147
|
end
|
|
150
148
|
|
|
149
|
+
# Returns skill names whose recalled content is currently visible in the
|
|
150
|
+
# viewport. Used by the analytical brain for deduplication — skills already
|
|
151
|
+
# in the viewport are excluded from the activation catalog.
|
|
152
|
+
#
|
|
153
|
+
# @return [Set<String>] skill names present in the viewport
|
|
154
|
+
def skills_in_viewport
|
|
155
|
+
recalled_sources_in_viewport("skill")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Returns the workflow name currently visible in the viewport, if any.
|
|
159
|
+
# Only one workflow can be active at a time, so we return the first match.
|
|
160
|
+
#
|
|
161
|
+
# @return [String, nil] workflow name present in the viewport
|
|
162
|
+
def workflow_in_viewport
|
|
163
|
+
recalled_sources_in_viewport("workflow").first
|
|
164
|
+
end
|
|
165
|
+
|
|
151
166
|
# Returns the system prompt for this session.
|
|
152
|
-
# Sub-agent sessions use their stored prompt
|
|
153
|
-
#
|
|
167
|
+
# Sub-agent sessions use their stored prompt plus active skills and
|
|
168
|
+
# the pinned task. Main sessions assemble a full system prompt from
|
|
169
|
+
# soul and snapshots. Skills, workflows, and goals are injected as
|
|
170
|
+
# phantom tool_use/tool_result pairs in the message stream (not here)
|
|
171
|
+
# to keep the system prompt stable for prompt caching. Environment
|
|
172
|
+
# awareness flows through Bash tool responses.
|
|
173
|
+
#
|
|
174
|
+
# Sub-agent sessions still include expertise inline — they're short-lived
|
|
175
|
+
# and don't benefit from prompt caching.
|
|
154
176
|
#
|
|
155
|
-
# @param environment_context [String, nil] pre-assembled environment block
|
|
156
|
-
# from {EnvironmentProbe}; injected between soul and expertise sections
|
|
157
177
|
# @return [String, nil] the system prompt text, or nil when nothing to inject
|
|
158
|
-
def system_prompt
|
|
159
|
-
sub_agent?
|
|
178
|
+
def system_prompt
|
|
179
|
+
if sub_agent?
|
|
180
|
+
[prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n")
|
|
181
|
+
else
|
|
182
|
+
assemble_system_prompt
|
|
183
|
+
end
|
|
160
184
|
end
|
|
161
185
|
|
|
162
186
|
# Activates a skill on this session. Validates the skill exists in the
|
|
163
|
-
# registry,
|
|
187
|
+
# registry, updates active_skills, and enqueues the skill content as a
|
|
188
|
+
# {PendingMessage} so it enters the conversation as a phantom
|
|
189
|
+
# tool_use/tool_result pair through the normal promotion flow.
|
|
164
190
|
#
|
|
165
191
|
# @param skill_name [String] name of the skill to activate
|
|
166
192
|
# @return [Skills::Definition] the activated skill
|
|
@@ -174,10 +200,12 @@ class Session < ApplicationRecord
|
|
|
174
200
|
|
|
175
201
|
self.active_skills = active_skills + [skill_name]
|
|
176
202
|
save!
|
|
203
|
+
enqueue_recall_message("skill", skill_name, definition.content)
|
|
177
204
|
definition
|
|
178
205
|
end
|
|
179
206
|
|
|
180
207
|
# Deactivates a skill on this session. Removes it from active_skills and persists.
|
|
208
|
+
# The skill's recalled message stays in the conversation and evicts naturally.
|
|
181
209
|
#
|
|
182
210
|
# @param skill_name [String] name of the skill to deactivate
|
|
183
211
|
# @return [void]
|
|
@@ -189,8 +217,9 @@ class Session < ApplicationRecord
|
|
|
189
217
|
end
|
|
190
218
|
|
|
191
219
|
# Activates a workflow on this session. Validates the workflow exists in the
|
|
192
|
-
# registry, sets it as the active workflow, and
|
|
193
|
-
# can be active at a time —
|
|
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.
|
|
194
223
|
#
|
|
195
224
|
# @param workflow_name [String] name of the workflow to activate
|
|
196
225
|
# @return [Workflows::Definition] the activated workflow
|
|
@@ -204,10 +233,12 @@ class Session < ApplicationRecord
|
|
|
204
233
|
|
|
205
234
|
self.active_workflow = workflow_name
|
|
206
235
|
save!
|
|
236
|
+
enqueue_recall_message("workflow", workflow_name, definition.content)
|
|
207
237
|
definition
|
|
208
238
|
end
|
|
209
239
|
|
|
210
240
|
# Deactivates the current workflow on this session.
|
|
241
|
+
# The workflow's recalled message stays in the conversation and evicts naturally.
|
|
211
242
|
#
|
|
212
243
|
# @return [void]
|
|
213
244
|
def deactivate_workflow
|
|
@@ -217,67 +248,57 @@ class Session < ApplicationRecord
|
|
|
217
248
|
save!
|
|
218
249
|
end
|
|
219
250
|
|
|
220
|
-
# Assembles the system prompt: version preamble, soul,
|
|
221
|
-
#
|
|
222
|
-
#
|
|
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.
|
|
223
255
|
#
|
|
224
|
-
# @param environment_context [String, nil] pre-assembled environment block
|
|
225
256
|
# @return [String] composed system prompt
|
|
226
|
-
def assemble_system_prompt
|
|
227
|
-
[assemble_version_preamble, assemble_soul_section,
|
|
257
|
+
def assemble_system_prompt
|
|
258
|
+
[assemble_version_preamble, assemble_soul_section, assemble_snapshots_section]
|
|
259
|
+
.compact.join("\n\n")
|
|
228
260
|
end
|
|
229
261
|
|
|
230
|
-
# Serializes
|
|
262
|
+
# Serializes non-evicted goals as a lightweight summary for ActionCable
|
|
231
263
|
# broadcasts and TUI display. Returns a nested structure: root goals
|
|
232
|
-
# with their sub-goals inlined.
|
|
264
|
+
# with their sub-goals inlined. Evicted goals and their sub-goals are
|
|
265
|
+
# excluded.
|
|
233
266
|
#
|
|
234
267
|
# @return [Array<Hash>] each with :id, :description, :status, and :sub_goals
|
|
235
268
|
def goals_summary
|
|
236
|
-
goals.root.includes(:sub_goals).order(:created_at).map(&:as_summary)
|
|
269
|
+
goals.root.not_evicted.includes(:sub_goals).order(:created_at).map(&:as_summary)
|
|
237
270
|
end
|
|
238
271
|
|
|
239
272
|
# Builds the message array expected by the Anthropic Messages API.
|
|
240
273
|
# Viewport layout (top to bottom):
|
|
241
|
-
# [
|
|
274
|
+
# [context prefix: goals + pinned messages] [sliding window messages]
|
|
242
275
|
#
|
|
243
|
-
# Snapshots
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
# Each layer has a fixed token budget fraction — snapshots, pins, and recall
|
|
249
|
-
# consume viewport space, reducing the sliding window size.
|
|
276
|
+
# Snapshots live in the system prompt (stable between Mneme runs).
|
|
277
|
+
# Goal events and recalled memories flow through the message stream as
|
|
278
|
+
# phantom tool pairs — they ride the conveyor belt as regular messages.
|
|
279
|
+
# After eviction, a goal snapshot + pinned messages block is rebuilt
|
|
280
|
+
# from DB state and prepended as a phantom pair.
|
|
250
281
|
#
|
|
251
|
-
#
|
|
282
|
+
# The sliding window is post-processed by {#ensure_atomic_tool_pairs}
|
|
283
|
+
# which removes orphaned tool messages whose partner was cut off by the
|
|
284
|
+
# token budget.
|
|
252
285
|
#
|
|
253
286
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
254
287
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
255
|
-
def messages_for_llm(token_budget:
|
|
288
|
+
def messages_for_llm(token_budget: effective_token_budget)
|
|
256
289
|
heal_orphaned_tool_calls!
|
|
257
290
|
|
|
258
291
|
sliding_budget = token_budget
|
|
259
|
-
snapshot_messages = []
|
|
260
|
-
pinned_messages = []
|
|
261
|
-
recall_messages = []
|
|
262
292
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
l1_budget = (token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
|
|
266
|
-
pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
|
|
267
|
-
recall_budget = (token_budget * Anima::Settings.recall_budget_fraction).to_i
|
|
268
|
-
sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
|
|
269
|
-
end
|
|
293
|
+
pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
|
|
294
|
+
sliding_budget -= pinned_budget
|
|
270
295
|
|
|
271
|
-
window = viewport_messages(token_budget: sliding_budget
|
|
296
|
+
window = viewport_messages(token_budget: sliding_budget)
|
|
297
|
+
first_message_id = window.first&.id
|
|
272
298
|
|
|
273
|
-
|
|
274
|
-
first_message_id = window.first&.id
|
|
275
|
-
snapshot_messages = assemble_snapshot_messages(first_message_id, l2_budget: l2_budget, l1_budget: l1_budget)
|
|
276
|
-
pinned_messages = assemble_pinned_section_messages(first_message_id, budget: pinned_budget)
|
|
277
|
-
recall_messages = assemble_recall_messages(budget: recall_budget)
|
|
278
|
-
end
|
|
299
|
+
prefix = assemble_context_prefix_messages(first_message_id, budget: pinned_budget)
|
|
279
300
|
|
|
280
|
-
|
|
301
|
+
prefix + assemble_messages(ensure_atomic_tool_pairs(window))
|
|
281
302
|
end
|
|
282
303
|
|
|
283
304
|
# Detects orphaned tool_call messages (those without a matching tool_response
|
|
@@ -293,7 +314,7 @@ class Session < ApplicationRecord
|
|
|
293
314
|
#
|
|
294
315
|
# @return [Integer] number of synthetic responses created
|
|
295
316
|
def heal_orphaned_tool_calls!
|
|
296
|
-
|
|
317
|
+
current_ns = now_ns
|
|
297
318
|
responded_ids = messages.where(message_type: "tool_response").select(:tool_use_id)
|
|
298
319
|
unresponded = messages.where(message_type: "tool_call")
|
|
299
320
|
.where.not(tool_use_id: responded_ids)
|
|
@@ -302,7 +323,7 @@ class Session < ApplicationRecord
|
|
|
302
323
|
unresponded.find_each do |orphan|
|
|
303
324
|
timeout = orphan.payload["timeout"] || Anima::Settings.tool_timeout
|
|
304
325
|
deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
|
|
305
|
-
next if
|
|
326
|
+
next if current_ns < deadline_ns
|
|
306
327
|
|
|
307
328
|
messages.create!(
|
|
308
329
|
message_type: "tool_response",
|
|
@@ -314,7 +335,7 @@ class Session < ApplicationRecord
|
|
|
314
335
|
"success" => false
|
|
315
336
|
},
|
|
316
337
|
tool_use_id: orphan.tool_use_id,
|
|
317
|
-
timestamp:
|
|
338
|
+
timestamp: current_ns
|
|
318
339
|
)
|
|
319
340
|
healed += 1
|
|
320
341
|
end
|
|
@@ -324,59 +345,119 @@ class Session < ApplicationRecord
|
|
|
324
345
|
# Delivers a user message respecting the session's processing state.
|
|
325
346
|
#
|
|
326
347
|
# When idle, persists the message directly and enqueues {AgentRequestJob}
|
|
327
|
-
# to process it. When mid-turn ({#processing?}),
|
|
328
|
-
# {
|
|
329
|
-
#
|
|
330
|
-
# tool_use/tool_result pairs.
|
|
348
|
+
# to process it. When mid-turn ({#processing?}), stages the message as
|
|
349
|
+
# a {PendingMessage} in a separate table — it gets no message ID until
|
|
350
|
+
# promoted, so it can never interleave with tool_call/tool_response pairs.
|
|
331
351
|
#
|
|
332
|
-
# @param content [String]
|
|
352
|
+
# @param content [String] message text (raw, without attribution)
|
|
353
|
+
# @param source_type [String] origin type: "user" (default) or "subagent"
|
|
354
|
+
# @param source_name [String, nil] sub-agent nickname (required when source_type is "subagent")
|
|
333
355
|
# @param bounce_back [Boolean] when true, passes +message_id+ to the job
|
|
334
356
|
# so failed LLM delivery triggers a {Events::BounceBack} (used by
|
|
335
357
|
# {SessionChannel#speak} for immediate-display messages)
|
|
336
358
|
# @return [void]
|
|
337
|
-
def enqueue_user_message(content, bounce_back: false)
|
|
359
|
+
def enqueue_user_message(content, source_type: "user", source_name: nil, bounce_back: false)
|
|
338
360
|
if processing?
|
|
339
|
-
|
|
340
|
-
content: content, session_id: id,
|
|
341
|
-
status: Message::PENDING_STATUS
|
|
342
|
-
))
|
|
361
|
+
pending_messages.create!(content: content, source_type: source_type, source_name: source_name)
|
|
343
362
|
else
|
|
344
|
-
|
|
363
|
+
display = if source_type == "subagent"
|
|
364
|
+
format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
|
|
365
|
+
else
|
|
366
|
+
content
|
|
367
|
+
end
|
|
368
|
+
msg = create_user_message(display)
|
|
345
369
|
job_args = bounce_back ? {message_id: msg.id} : {}
|
|
346
370
|
AgentRequestJob.perform_later(id, **job_args)
|
|
347
371
|
end
|
|
348
372
|
end
|
|
349
373
|
|
|
374
|
+
# Promotes a phantom pair pending message into a tool_call/tool_response pair.
|
|
375
|
+
# These persist as real Message records and ride the conveyor belt.
|
|
376
|
+
#
|
|
377
|
+
# @param pm [PendingMessage] phantom pair pending message
|
|
378
|
+
# @return [void]
|
|
379
|
+
def promote_phantom_pair!(pm)
|
|
380
|
+
tool_name = pm.phantom_tool_name
|
|
381
|
+
tool_input = pm.phantom_tool_input
|
|
382
|
+
uid = "#{tool_name}_#{pm.id}"
|
|
383
|
+
now = now_ns
|
|
384
|
+
|
|
385
|
+
messages.create!(
|
|
386
|
+
message_type: "tool_call",
|
|
387
|
+
tool_use_id: uid,
|
|
388
|
+
payload: {"tool_name" => tool_name, "tool_use_id" => uid,
|
|
389
|
+
"tool_input" => tool_input.stringify_keys,
|
|
390
|
+
"content" => pm.display_content.lines.first.chomp},
|
|
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
|
+
)
|
|
403
|
+
end
|
|
404
|
+
|
|
350
405
|
# Persists a user message directly, bypassing the pending queue.
|
|
351
406
|
#
|
|
352
|
-
# Used by {#enqueue_user_message} (idle path), {AgentLoop#
|
|
407
|
+
# Used by {#enqueue_user_message} (idle path), {AgentLoop#run},
|
|
353
408
|
# and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
|
|
354
409
|
# because the global {Events::Subscribers::Persister} skips non-pending user
|
|
355
410
|
# messages — these callers own the persistence lifecycle.
|
|
356
411
|
#
|
|
357
412
|
# @param content [String] user message text
|
|
413
|
+
# @param source_type [String, nil] origin type (e.g. "skill", "workflow")
|
|
414
|
+
# for viewport tracking; omitted for plain user messages
|
|
415
|
+
# @param source_name [String, nil] origin name (e.g. skill name)
|
|
358
416
|
# @return [Message] the persisted message record
|
|
359
|
-
def create_user_message(content)
|
|
360
|
-
now =
|
|
417
|
+
def create_user_message(content, source_type: nil, source_name: nil)
|
|
418
|
+
now = now_ns
|
|
419
|
+
payload = {type: "user_message", content: content, session_id: id, timestamp: now}
|
|
420
|
+
payload["source_type"] = source_type if source_type
|
|
421
|
+
payload["source_name"] = source_name if source_name
|
|
361
422
|
messages.create!(
|
|
362
423
|
message_type: "user_message",
|
|
363
|
-
payload:
|
|
424
|
+
payload: payload,
|
|
364
425
|
timestamp: now
|
|
365
426
|
)
|
|
366
427
|
end
|
|
367
428
|
|
|
368
|
-
# Promotes all pending
|
|
369
|
-
#
|
|
370
|
-
#
|
|
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)
|
|
371
440
|
#
|
|
372
|
-
# @return [
|
|
441
|
+
# @return [Hash{Symbol => Array}] promoted messages split by injection strategy
|
|
373
442
|
def promote_pending_messages!
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
378
459
|
end
|
|
379
|
-
|
|
460
|
+
{texts: texts, pairs: pairs}
|
|
380
461
|
end
|
|
381
462
|
|
|
382
463
|
# Broadcasts child session list to all clients subscribed to the parent
|
|
@@ -396,12 +477,133 @@ class Session < ApplicationRecord
|
|
|
396
477
|
ActionCable.server.broadcast("session_#{parent_session_id}", {
|
|
397
478
|
"action" => "children_updated",
|
|
398
479
|
"session_id" => parent_session_id,
|
|
399
|
-
"children" => children.map { |child|
|
|
480
|
+
"children" => children.map { |child|
|
|
481
|
+
state = child.processing? ? "llm_generating" : "idle"
|
|
482
|
+
{"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
|
|
483
|
+
}
|
|
400
484
|
})
|
|
401
485
|
end
|
|
402
486
|
|
|
487
|
+
# Broadcasts the session's current processing state to all subscribed
|
|
488
|
+
# clients. Stateless — no storage, pure broadcast. The TUI uses this to
|
|
489
|
+
# drive the braille spinner animation and sub-agent HUD icons.
|
|
490
|
+
#
|
|
491
|
+
# Payload broadcast to +session_{id}+:
|
|
492
|
+
# {"action" => "session_state", "state" => state, "session_id" => id}
|
|
493
|
+
# # plus "tool" key when state is "tool_executing"
|
|
494
|
+
#
|
|
495
|
+
# For sub-agents, also broadcasts +child_state+ to the parent stream:
|
|
496
|
+
# {"action" => "child_state", "state" => state, "session_id" => id, "child_id" => id}
|
|
497
|
+
#
|
|
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
|
+
# @return [void]
|
|
501
|
+
def broadcast_session_state(state, tool: nil)
|
|
502
|
+
payload = {"action" => "session_state", "state" => state, "session_id" => id}
|
|
503
|
+
payload["tool"] = tool if tool
|
|
504
|
+
ActionCable.server.broadcast("session_#{id}", payload)
|
|
505
|
+
|
|
506
|
+
# Notify the parent's stream so the HUD updates child state icons
|
|
507
|
+
# without requiring a full children_updated query.
|
|
508
|
+
return unless parent_session_id
|
|
509
|
+
|
|
510
|
+
parent_payload = payload.merge("action" => "child_state", "child_id" => id)
|
|
511
|
+
ActionCable.server.broadcast("session_#{parent_session_id}", parent_payload)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Broadcasts the full LLM debug context to debug-mode TUI clients.
|
|
515
|
+
# Called on every LLM request so the TUI shows exactly what the LLM
|
|
516
|
+
# receives — system prompt and tool schemas. No-op outside debug mode.
|
|
517
|
+
#
|
|
518
|
+
# @param system [String, nil] the final system prompt sent to the LLM
|
|
519
|
+
# @param tools [Array<Hash>, nil] tool schemas sent to the LLM
|
|
520
|
+
# @return [void]
|
|
521
|
+
def broadcast_debug_context(system:, tools: nil)
|
|
522
|
+
return unless view_mode == "debug" && system
|
|
523
|
+
|
|
524
|
+
ActionCable.server.broadcast("session_#{id}", self.class.system_prompt_payload(system, tools: tools))
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Returns the deterministic tool schemas for this session's type and
|
|
528
|
+
# granted_tools configuration. Standard and spawn tools are static
|
|
529
|
+
# class-level definitions — no ShellSession or registry needed.
|
|
530
|
+
# MCP tools are excluded (they require live server queries and appear
|
|
531
|
+
# after the first LLM request via {#broadcast_debug_context}).
|
|
532
|
+
#
|
|
533
|
+
# @return [Array<Hash>] tool schema hashes matching Anthropic tools API format
|
|
534
|
+
def tool_schemas
|
|
535
|
+
tools = if granted_tools
|
|
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)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Builds the system prompt payload for debug mode transmission.
|
|
554
|
+
# Token estimate covers both the system prompt and tool schemas
|
|
555
|
+
# since both consume the LLM's context window.
|
|
556
|
+
# Tools are sent as raw schemas; the TUI formats them as TOON for display.
|
|
557
|
+
#
|
|
558
|
+
# @param prompt [String] system prompt text
|
|
559
|
+
# @param tools [Array<Hash>, nil] tool schemas
|
|
560
|
+
# @return [Hash] payload with type, rendered debug content, and token estimate
|
|
561
|
+
def self.system_prompt_payload(prompt, tools: nil)
|
|
562
|
+
total_bytes = prompt.bytesize
|
|
563
|
+
total_bytes += tools.to_json.bytesize if tools&.any?
|
|
564
|
+
tokens = Message.estimate_token_count(total_bytes)
|
|
565
|
+
|
|
566
|
+
debug = {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
|
|
567
|
+
debug[:tools] = tools if tools&.any?
|
|
568
|
+
|
|
569
|
+
{
|
|
570
|
+
"id" => Message::SYSTEM_PROMPT_ID,
|
|
571
|
+
"type" => "system_prompt",
|
|
572
|
+
"rendered" => {"debug" => debug}
|
|
573
|
+
}
|
|
574
|
+
end
|
|
575
|
+
|
|
403
576
|
private
|
|
404
577
|
|
|
578
|
+
# Finds recalled skill/workflow source names in the current viewport.
|
|
579
|
+
# Scans viewport messages for user_messages tagged with the given source_type.
|
|
580
|
+
#
|
|
581
|
+
# @param source_type [String] "skill" or "workflow"
|
|
582
|
+
# @return [Set<String>] source names present in the viewport
|
|
583
|
+
def recalled_sources_in_viewport(source_type)
|
|
584
|
+
ids = viewport_message_ids
|
|
585
|
+
return Set.new if ids.empty?
|
|
586
|
+
|
|
587
|
+
messages
|
|
588
|
+
.where(id: ids, message_type: "user_message")
|
|
589
|
+
.where("json_extract(payload, '$.source_type') = ?", source_type)
|
|
590
|
+
.pluck(Arel.sql("json_extract(payload, '$.source_name')"))
|
|
591
|
+
.to_set
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Enqueues a recalled skill or workflow as a {PendingMessage}.
|
|
595
|
+
# Always goes through the pending queue because the analytical brain
|
|
596
|
+
# only runs during processing. The message enters the conversation
|
|
597
|
+
# through the normal promotion flow as a phantom tool_use/tool_result pair.
|
|
598
|
+
#
|
|
599
|
+
# @param source_type [String] "skill" or "workflow"
|
|
600
|
+
# @param source_name [String] skill or workflow name
|
|
601
|
+
# @param content [String] definition content to recall
|
|
602
|
+
# @return [PendingMessage] the created pending message
|
|
603
|
+
def enqueue_recall_message(source_type, source_name, content)
|
|
604
|
+
pending_messages.create!(content: content, source_type: source_type, source_name: source_name)
|
|
605
|
+
end
|
|
606
|
+
|
|
405
607
|
# One-line version preamble so the agent knows its own version.
|
|
406
608
|
# Useful for commits, handoffs, and debugging.
|
|
407
609
|
#
|
|
@@ -446,17 +648,24 @@ class Session < ApplicationRecord
|
|
|
446
648
|
"## Your Expertise\n\nYou know this deeply. Now's your chance to put it to work.\n\n#{sections.join("\n\n")}"
|
|
447
649
|
end
|
|
448
650
|
|
|
449
|
-
# Assembles the
|
|
450
|
-
#
|
|
451
|
-
#
|
|
651
|
+
# Assembles the task section for sub-agent system prompts.
|
|
652
|
+
# Sub-agents have a single pinned goal — their entire raison d'etre.
|
|
653
|
+
# Rendered as a persistent task block so the LLM always knows what it
|
|
654
|
+
# was spawned to do, regardless of conversation length.
|
|
452
655
|
#
|
|
453
|
-
# @return [String, nil]
|
|
454
|
-
def
|
|
455
|
-
|
|
456
|
-
return
|
|
656
|
+
# @return [String, nil] task section, or nil when no active goal exists
|
|
657
|
+
def assemble_task_section
|
|
658
|
+
goal = goals.active.root.first
|
|
659
|
+
return unless goal
|
|
660
|
+
|
|
661
|
+
<<~SECTION.strip
|
|
662
|
+
Your Task
|
|
663
|
+
=========
|
|
664
|
+
|
|
665
|
+
#{goal.description}
|
|
457
666
|
|
|
458
|
-
|
|
459
|
-
|
|
667
|
+
Complete this task and call mark_goal_completed when done.
|
|
668
|
+
SECTION
|
|
460
669
|
end
|
|
461
670
|
|
|
462
671
|
# Renders a single root goal with its sub-goals as Markdown.
|
|
@@ -529,22 +738,13 @@ class Session < ApplicationRecord
|
|
|
529
738
|
end
|
|
530
739
|
|
|
531
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.
|
|
532
743
|
# @return [ActiveRecord::Relation]
|
|
533
|
-
def own_message_scope
|
|
744
|
+
def own_message_scope
|
|
534
745
|
scope = messages.context_messages
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
# Scopes parent messages created before this session's fork point.
|
|
539
|
-
# Excludes spawn tool messages — sub-agents don't need to see sibling
|
|
540
|
-
# spawn pairs, which cause role confusion (the sub-agent mistakes
|
|
541
|
-
# itself for the parent when it sees "Specialist @sibling spawned...").
|
|
542
|
-
# @return [ActiveRecord::Relation]
|
|
543
|
-
def parent_message_scope(include_pending)
|
|
544
|
-
scope = parent_session.messages.context_messages
|
|
545
|
-
.excluding_spawn_messages
|
|
546
|
-
.where(created_at: ...created_at)
|
|
547
|
-
include_pending ? scope : scope.deliverable
|
|
746
|
+
scope = scope.where("messages.id >= ?", mneme_boundary_message_id) if mneme_boundary_message_id
|
|
747
|
+
scope
|
|
548
748
|
end
|
|
549
749
|
|
|
550
750
|
# Walks messages newest-first, selecting until the token budget is exhausted.
|
|
@@ -573,21 +773,16 @@ class Session < ApplicationRecord
|
|
|
573
773
|
(msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
|
|
574
774
|
end
|
|
575
775
|
|
|
576
|
-
# Removes trailing tool_call messages that lack matching tool_response.
|
|
577
|
-
# Prevents orphaned tool_use blocks at the parent/child viewport boundary
|
|
578
|
-
# (the spawn_subagent/spawn_specialist tool_call is emitted before the child exists,
|
|
579
|
-
# but its tool_response comes after — so the cutoff can split them).
|
|
580
|
-
def trim_trailing_tool_calls(message_list)
|
|
581
|
-
message_list.pop while message_list.last&.message_type == "tool_call"
|
|
582
|
-
message_list
|
|
583
|
-
end
|
|
584
|
-
|
|
585
776
|
# Ensures every tool_call in the message list has a matching tool_response
|
|
586
777
|
# (and vice versa) by removing unpaired messages. The Anthropic API requires
|
|
587
778
|
# every tool_use block to have a tool_result — a missing partner causes
|
|
588
779
|
# a permanent API error. Token budget cutoffs can split pairs when the
|
|
589
780
|
# boundary falls between a tool_call and its tool_response.
|
|
590
781
|
#
|
|
782
|
+
# Still necessary even though {#assemble_messages} pairs by +tool_use_id+:
|
|
783
|
+
# the assembly assumes every tool_call has a matching response in the window.
|
|
784
|
+
# This guard ensures that assumption holds after viewport truncation.
|
|
785
|
+
#
|
|
591
786
|
# @param message_list [Array<Message>] chronologically ordered messages
|
|
592
787
|
# @return [Array<Message>] messages with unpaired tool messages removed
|
|
593
788
|
def ensure_atomic_tool_pairs(message_list)
|
|
@@ -604,28 +799,32 @@ class Session < ApplicationRecord
|
|
|
604
799
|
message_list.reject { |m| m.tool_use_id.present? && !complete_ids.include?(m.tool_use_id) }
|
|
605
800
|
end
|
|
606
801
|
|
|
607
|
-
#
|
|
608
|
-
# Snapshots are visible when their source messages
|
|
609
|
-
#
|
|
802
|
+
# Assembles L1/L2 snapshots as a system prompt section.
|
|
803
|
+
# Snapshots are visible when their source messages precede the Mneme boundary
|
|
804
|
+
# (compressed in a previous run). Between Mneme runs this section is frozen,
|
|
805
|
+
# making it cache-friendly.
|
|
610
806
|
#
|
|
611
|
-
# @
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
def assemble_snapshot_messages(first_message_id, l2_budget:, l1_budget:)
|
|
616
|
-
return [] unless first_message_id
|
|
807
|
+
# @return [String, nil] formatted snapshot text for the system prompt, or nil
|
|
808
|
+
def assemble_snapshots_section
|
|
809
|
+
reference_id = mneme_boundary_message_id || viewport_message_ids.first
|
|
810
|
+
return unless reference_id
|
|
617
811
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
budget: l2_budget
|
|
621
|
-
).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
|
|
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
|
|
622
814
|
|
|
623
|
-
|
|
624
|
-
snapshots.for_level(
|
|
815
|
+
l2 = select_snapshots_within_budget(
|
|
816
|
+
snapshots.for_level(2).source_messages_evicted(reference_id).chronological,
|
|
817
|
+
budget: l2_budget
|
|
818
|
+
)
|
|
819
|
+
l1 = select_snapshots_within_budget(
|
|
820
|
+
snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(reference_id).chronological,
|
|
625
821
|
budget: l1_budget
|
|
626
|
-
)
|
|
822
|
+
)
|
|
627
823
|
|
|
628
|
-
|
|
824
|
+
sections = []
|
|
825
|
+
sections << format_snapshots_text(l2, label: "Long-term Memory") if l2.any?
|
|
826
|
+
sections << format_snapshots_text(l1, label: "Recent Memory") if l1.any?
|
|
827
|
+
sections.join("\n\n").presence
|
|
629
828
|
end
|
|
630
829
|
|
|
631
830
|
# Walks snapshots chronologically, selecting until the token budget is exhausted.
|
|
@@ -650,40 +849,53 @@ class Session < ApplicationRecord
|
|
|
650
849
|
selected
|
|
651
850
|
end
|
|
652
851
|
|
|
653
|
-
# Formats a
|
|
852
|
+
# Formats a list of snapshots as a labeled section for the system prompt.
|
|
654
853
|
#
|
|
655
|
-
# @param
|
|
656
|
-
# @param label [String]
|
|
657
|
-
# @return [
|
|
658
|
-
def
|
|
659
|
-
|
|
854
|
+
# @param snapshots_list [Array<Snapshot>]
|
|
855
|
+
# @param label [String] section heading
|
|
856
|
+
# @return [String]
|
|
857
|
+
def format_snapshots_text(snapshots_list, label:)
|
|
858
|
+
texts = snapshots_list.map(&:text)
|
|
859
|
+
"## #{label}\n\n#{texts.join("\n\n")}"
|
|
660
860
|
end
|
|
661
861
|
|
|
662
|
-
# Assembles
|
|
663
|
-
# Only
|
|
664
|
-
#
|
|
862
|
+
# Assembles the context prefix: active goals snapshot + pinned messages.
|
|
863
|
+
# Only shown after the first eviction — before that, goal events flow
|
|
864
|
+
# as phantom pairs in the message stream and pinned messages have not
|
|
865
|
+
# yet evicted.
|
|
665
866
|
#
|
|
666
|
-
#
|
|
667
|
-
#
|
|
867
|
+
# Returns a phantom tool_call/tool_result pair so the LLM sees a
|
|
868
|
+
# coherent goals + pins block it "recalled" via a tool invocation.
|
|
668
869
|
#
|
|
669
870
|
# @param first_message_id [Integer, nil] first message ID in the sliding window
|
|
670
|
-
# @param budget [Integer] token budget for
|
|
671
|
-
# @return [Array<Hash>] Anthropic Messages API format (0 or
|
|
672
|
-
def
|
|
871
|
+
# @param budget [Integer] token budget for context prefix
|
|
872
|
+
# @return [Array<Hash>] Anthropic Messages API format (0 or 2 messages)
|
|
873
|
+
def assemble_context_prefix_messages(first_message_id, budget:)
|
|
673
874
|
return [] unless first_message_id
|
|
875
|
+
return [] unless messages.where("id < ?", first_message_id).exists?
|
|
876
|
+
|
|
877
|
+
root_goals = goals.root.active.includes(:sub_goals).order(:created_at)
|
|
878
|
+
return [] if root_goals.empty?
|
|
674
879
|
|
|
675
880
|
pins = pinned_messages
|
|
676
881
|
.includes(:message, :goals)
|
|
677
882
|
.where("pinned_messages.message_id < ?", first_message_id)
|
|
678
883
|
.order("pinned_messages.message_id")
|
|
679
884
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
[
|
|
885
|
+
selected_pins = select_pins_within_budget(pins, budget)
|
|
886
|
+
content = render_goal_snapshot_with_pins(root_goals, selected_pins)
|
|
887
|
+
|
|
888
|
+
# Uses session ID (not PendingMessage ID) because this snapshot is
|
|
889
|
+
# rebuilt from DB state on every eviction — it has no stable PM record.
|
|
890
|
+
uid = "goal_snapshot_#{id}"
|
|
891
|
+
[
|
|
892
|
+
{role: "assistant", content: [
|
|
893
|
+
{type: "tool_use", id: uid, name: PendingMessage::RECALL_GOAL_TOOL, input: {}}
|
|
894
|
+
]},
|
|
895
|
+
{role: "user", content: [
|
|
896
|
+
{type: "tool_result", tool_use_id: uid, content: content}
|
|
897
|
+
]}
|
|
898
|
+
]
|
|
687
899
|
end
|
|
688
900
|
|
|
689
901
|
# Walks pinned messages chronologically, selecting until the token budget
|
|
@@ -707,23 +919,33 @@ class Session < ApplicationRecord
|
|
|
707
919
|
selected
|
|
708
920
|
end
|
|
709
921
|
|
|
710
|
-
# Renders
|
|
711
|
-
#
|
|
712
|
-
#
|
|
922
|
+
# Renders active goals with their associated pinned messages as a
|
|
923
|
+
# combined snapshot. Each goal shows its sub-goals and any pinned
|
|
924
|
+
# messages attached to it.
|
|
713
925
|
#
|
|
926
|
+
# @param root_goals [Array<Goal>] active root goals with preloaded sub_goals
|
|
714
927
|
# @param pins [Array<PinnedMessage>] selected pins with preloaded goals
|
|
715
|
-
# @return [String] formatted
|
|
716
|
-
def
|
|
717
|
-
|
|
718
|
-
|
|
928
|
+
# @return [String] formatted goals + pins block
|
|
929
|
+
def render_goal_snapshot_with_pins(root_goals, pins)
|
|
930
|
+
pin_groups = group_pins_by_active_goal(pins)
|
|
719
931
|
shown_messages = Set.new
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
932
|
+
|
|
933
|
+
sections = root_goals.map { |goal|
|
|
934
|
+
lines = [render_goal_markdown(goal)]
|
|
935
|
+
goal_pins = pin_groups[goal]
|
|
936
|
+
if goal_pins
|
|
937
|
+
lines << ""
|
|
938
|
+
goal_pins.each { |pin| lines << format_pin_line(pin, shown_messages) }
|
|
939
|
+
end
|
|
940
|
+
lines.join("\n")
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
"Current Goals\n=============\n\n#{sections.join("\n\n")}"
|
|
723
944
|
end
|
|
724
945
|
|
|
725
946
|
# Groups pins by their active Goals so the viewport renders
|
|
726
|
-
# one headed section per Goal.
|
|
947
|
+
# one headed section per Goal. Relies on +:goals+ being eager-loaded
|
|
948
|
+
# on each pin — without it, +active_goal_pin_pairs+ triggers N+1.
|
|
727
949
|
#
|
|
728
950
|
# @param pins [Array<PinnedMessage>] pins with preloaded goals
|
|
729
951
|
# @return [Hash{Goal => Array<PinnedMessage>}]
|
|
@@ -741,18 +963,6 @@ class Session < ApplicationRecord
|
|
|
741
963
|
pin.goals.select(&:active?).map { |goal| [goal, pin] }
|
|
742
964
|
end
|
|
743
965
|
|
|
744
|
-
# Renders one Goal's pinned messages as a headed list.
|
|
745
|
-
#
|
|
746
|
-
# @param goal [Goal]
|
|
747
|
-
# @param pin_list [Array<PinnedMessage>]
|
|
748
|
-
# @param shown_messages [Set<Integer>] tracks already-rendered message IDs for dedup
|
|
749
|
-
# @return [String]
|
|
750
|
-
def render_goal_pins(goal, pin_list, shown_messages)
|
|
751
|
-
lines = ["📌 #{goal.description} (id: #{goal.id})"]
|
|
752
|
-
pin_list.each { |pin| lines << format_pin_line(pin, shown_messages) }
|
|
753
|
-
lines.join("\n")
|
|
754
|
-
end
|
|
755
|
-
|
|
756
966
|
# Formats a single pin line with deduplication: first occurrence shows
|
|
757
967
|
# truncated text, subsequent occurrences show bare message ID only.
|
|
758
968
|
#
|
|
@@ -762,109 +972,100 @@ class Session < ApplicationRecord
|
|
|
762
972
|
def format_pin_line(pin, shown_messages)
|
|
763
973
|
mid = pin.message_id
|
|
764
974
|
if shown_messages.add?(mid)
|
|
765
|
-
" message #{mid}: #{pin.display_text}"
|
|
766
|
-
else
|
|
767
|
-
" message #{mid}"
|
|
768
|
-
end
|
|
769
|
-
end
|
|
770
|
-
|
|
771
|
-
# Assembles recalled memory messages from passive recall results.
|
|
772
|
-
# Recalled messages are fetched by ID and formatted as compact snippets
|
|
773
|
-
# with session and message context for drill-down via the remember tool.
|
|
774
|
-
#
|
|
775
|
-
# @param budget [Integer] token budget for recall messages
|
|
776
|
-
# @return [Array<Hash>] Anthropic Messages API format
|
|
777
|
-
def assemble_recall_messages(budget:)
|
|
778
|
-
return [] if recalled_message_ids.blank?
|
|
779
|
-
|
|
780
|
-
recalled = Message.where(id: recalled_message_ids)
|
|
781
|
-
.includes(:session)
|
|
782
|
-
.index_by(&:id)
|
|
783
|
-
|
|
784
|
-
snippets = []
|
|
785
|
-
remaining = budget
|
|
786
|
-
|
|
787
|
-
recalled_message_ids.each do |mid|
|
|
788
|
-
msg = recalled[mid]
|
|
789
|
-
next unless msg
|
|
790
|
-
|
|
791
|
-
text = format_recall_snippet(msg)
|
|
792
|
-
cost = [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
793
|
-
break if cost > remaining && snippets.any?
|
|
794
|
-
|
|
795
|
-
snippets << text
|
|
796
|
-
remaining -= cost
|
|
797
|
-
end
|
|
798
|
-
|
|
799
|
-
return [] if snippets.empty?
|
|
800
|
-
|
|
801
|
-
[{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
|
|
802
|
-
end
|
|
803
|
-
|
|
804
|
-
# Formats a recalled message as a compact snippet with enough context
|
|
805
|
-
# for the agent to decide whether to drill down with the remember tool.
|
|
806
|
-
#
|
|
807
|
-
# @param msg [Message] the recalled message
|
|
808
|
-
# @return [String] formatted snippet
|
|
809
|
-
def format_recall_snippet(msg)
|
|
810
|
-
session_label = msg.session.name || "session ##{msg.session_id}"
|
|
811
|
-
content = extract_message_content(msg).to_s.truncate(Anima::Settings.recall_max_snippet_tokens * Message::BYTES_PER_TOKEN)
|
|
812
|
-
"message #{msg.id} (#{session_label}): #{content}"
|
|
813
|
-
end
|
|
814
|
-
|
|
815
|
-
# Extracts readable content from a message's payload.
|
|
816
|
-
#
|
|
817
|
-
# @param msg [Message]
|
|
818
|
-
# @return [String]
|
|
819
|
-
def extract_message_content(msg)
|
|
820
|
-
data = msg.payload
|
|
821
|
-
case msg.message_type
|
|
822
|
-
when "user_message", "agent_message", "system_message"
|
|
823
|
-
data["content"]
|
|
824
|
-
when "tool_call"
|
|
825
|
-
if data["tool_name"] == Message::THINK_TOOL
|
|
826
|
-
data.dig("tool_input", "thoughts")
|
|
827
|
-
else
|
|
828
|
-
"#{data["tool_name"]}(…)"
|
|
829
|
-
end
|
|
975
|
+
" 📌 message #{mid}: #{pin.display_text}"
|
|
830
976
|
else
|
|
831
|
-
|
|
977
|
+
" 📌 message #{mid}"
|
|
832
978
|
end
|
|
833
979
|
end
|
|
834
980
|
|
|
835
981
|
# Converts a chronological list of messages into Anthropic wire-format messages.
|
|
836
982
|
# Prepends a compact timestamp to each user message for LLM time awareness.
|
|
837
|
-
# Groups consecutive tool_call messages into one assistant message and
|
|
838
|
-
# consecutive tool_response messages into one user message.
|
|
839
983
|
#
|
|
840
|
-
#
|
|
841
|
-
#
|
|
984
|
+
# Tool pairing uses +tool_use_id+ lookup, not message order. When a batch
|
|
985
|
+
# of consecutive +tool_call+ messages is encountered, all matching
|
|
986
|
+
# +tool_response+ messages are found by +tool_use_id+ and emitted as a
|
|
987
|
+
# single user message immediately after the assistant message. This
|
|
988
|
+
# guarantees correct API structure even when responses are persisted
|
|
989
|
+
# out of order (e.g. parallel tool execution, interleaved sub-agent
|
|
990
|
+
# deliveries, or promoted pending messages).
|
|
991
|
+
#
|
|
992
|
+
# Assumes +ensure_atomic_tool_pairs+ has already removed any unpaired
|
|
993
|
+
# tool messages from the window.
|
|
994
|
+
#
|
|
995
|
+
# @param msgs [Array<Message>] chronologically ordered (by id), pre-filtered
|
|
996
|
+
# @return [Array<Hash>] Anthropic API message format
|
|
842
997
|
def assemble_messages(msgs)
|
|
843
|
-
msgs
|
|
998
|
+
response_index = build_tool_response_index(msgs)
|
|
999
|
+
|
|
1000
|
+
result = []
|
|
1001
|
+
i = 0
|
|
1002
|
+
while i < msgs.length
|
|
1003
|
+
msg = msgs[i]
|
|
1004
|
+
|
|
844
1005
|
case msg.message_type
|
|
845
1006
|
when "user_message"
|
|
846
|
-
|
|
847
|
-
|
|
1007
|
+
result << {role: "user", content: "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"}
|
|
1008
|
+
i += 1
|
|
848
1009
|
when "agent_message"
|
|
849
|
-
|
|
1010
|
+
result << {role: "assistant", content: msg.payload["content"].to_s}
|
|
1011
|
+
i += 1
|
|
850
1012
|
when "tool_call"
|
|
851
|
-
|
|
1013
|
+
i = assemble_tool_pair(msgs, i, response_index, result)
|
|
852
1014
|
when "tool_response"
|
|
853
|
-
|
|
1015
|
+
# Already emitted by assemble_tool_pair via tool_use_id lookup.
|
|
1016
|
+
# Any response still here was orphaned by viewport eviction
|
|
1017
|
+
# and should have been stripped by ensure_atomic_tool_pairs.
|
|
1018
|
+
i += 1
|
|
854
1019
|
when "system_message"
|
|
855
|
-
|
|
856
|
-
|
|
1020
|
+
result << {role: "user", content: "[system] #{msg.payload["content"]}"}
|
|
1021
|
+
i += 1
|
|
1022
|
+
else
|
|
1023
|
+
i += 1
|
|
857
1024
|
end
|
|
858
1025
|
end
|
|
1026
|
+
|
|
1027
|
+
result
|
|
859
1028
|
end
|
|
860
1029
|
|
|
861
|
-
#
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1030
|
+
# Collects a batch of consecutive tool_call messages starting at +start+,
|
|
1031
|
+
# emits one assistant message with all tool_use blocks, then emits one
|
|
1032
|
+
# user message with matching tool_result blocks found by tool_use_id.
|
|
1033
|
+
#
|
|
1034
|
+
# @param msgs [Array<Message>] the full message list
|
|
1035
|
+
# @param start [Integer] index of the first tool_call in the batch
|
|
1036
|
+
# @param response_index [Hash{String => Message}] tool_use_id → tool_response
|
|
1037
|
+
# @param result [Array<Hash>] accumulator for assembled API messages
|
|
1038
|
+
# @return [Integer] index of the first message after the batch
|
|
1039
|
+
def assemble_tool_pair(msgs, start, response_index, result)
|
|
1040
|
+
# Collect consecutive tool_calls (same LLM turn)
|
|
1041
|
+
batch = []
|
|
1042
|
+
i = start
|
|
1043
|
+
while i < msgs.length && msgs[i].message_type == "tool_call"
|
|
1044
|
+
batch << msgs[i]
|
|
1045
|
+
i += 1
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
# Assistant message: all tool_use blocks
|
|
1049
|
+
result << {role: "assistant", content: batch.map { |tc| tool_use_block(tc.payload) }}
|
|
1050
|
+
|
|
1051
|
+
# User message: matching tool_result blocks, paired by tool_use_id
|
|
1052
|
+
tool_results = batch.filter_map do |tc|
|
|
1053
|
+
response = response_index[tc.tool_use_id]
|
|
1054
|
+
next unless response
|
|
1055
|
+
tool_result_block(response.payload)
|
|
1056
|
+
end
|
|
1057
|
+
result << {role: "user", content: tool_results} if tool_results.any?
|
|
1058
|
+
|
|
1059
|
+
i
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
# Builds a hash mapping tool_use_id → tool_response Message for O(1) lookup.
|
|
1063
|
+
#
|
|
1064
|
+
# @param msgs [Array<Message>]
|
|
1065
|
+
# @return [Hash{String => Message}]
|
|
1066
|
+
def build_tool_response_index(msgs)
|
|
1067
|
+
msgs.each_with_object({}) do |msg, idx|
|
|
1068
|
+
idx[msg.tool_use_id] = msg if msg.message_type == "tool_response"
|
|
868
1069
|
end
|
|
869
1070
|
end
|
|
870
1071
|
|
|
@@ -893,7 +1094,15 @@ class Session < ApplicationRecord
|
|
|
893
1094
|
# @example
|
|
894
1095
|
# format_message_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
|
|
895
1096
|
def format_message_time(timestamp_ns)
|
|
896
|
-
Time.at(timestamp_ns / 1_000_000_000.0).strftime("%a %b %-d %H:%M")
|
|
1097
|
+
Time.at(timestamp_ns / 1_000_000_000.0).utc.strftime("%a %b %-d %H:%M")
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
# Current time as nanoseconds since epoch. Uses Time.current so
|
|
1101
|
+
# ActiveSupport's freeze_time works in tests.
|
|
1102
|
+
#
|
|
1103
|
+
# @return [Integer] nanoseconds since epoch
|
|
1104
|
+
def now_ns
|
|
1105
|
+
Time.current.to_ns
|
|
897
1106
|
end
|
|
898
1107
|
|
|
899
1108
|
# Delegates to {Message#estimate_tokens} for messages not yet counted
|