anima-core 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +2 -0
- 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 +1 -1
- data/app/channels/session_channel.rb +44 -43
- data/app/decorators/agent_message_decorator.rb +2 -2
- data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
- data/app/decorators/system_message_decorator.rb +2 -2
- data/app/decorators/tool_call_decorator.rb +2 -2
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +3 -3
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +20 -20
- data/app/jobs/count_message_tokens_job.rb +39 -0
- data/app/jobs/passive_recall_job.rb +4 -4
- data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
- data/app/models/goal.rb +4 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/{event.rb → message.rb} +42 -39
- data/app/models/pinned_message.rb +41 -0
- data/app/models/session.rb +206 -198
- data/app/models/snapshot.rb +25 -25
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/lib/agent_loop.rb +6 -6
- data/lib/analytical_brain/runner.rb +35 -35
- data/lib/analytical_brain/tools/activate_skill.rb +5 -9
- data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
- data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
- data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
- data/lib/analytical_brain/tools/finish_goal.rb +5 -8
- data/lib/analytical_brain/tools/read_workflow.rb +5 -9
- data/lib/analytical_brain/tools/rename_session.rb +3 -10
- data/lib/analytical_brain/tools/set_goal.rb +3 -7
- data/lib/analytical_brain/tools/update_goal.rb +3 -7
- data/lib/anima/settings.rb +15 -4
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +7 -7
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/mneme/compressed_viewport.rb +57 -57
- data/lib/mneme/l2_runner.rb +4 -4
- data/lib/mneme/passive_recall.rb +2 -2
- data/lib/mneme/runner.rb +57 -75
- data/lib/mneme/search.rb +38 -38
- data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
- data/lib/mneme/tools/everything_ok.rb +1 -3
- data/lib/mneme/tools/save_snapshot.rb +12 -16
- data/lib/tools/bash.rb +4 -12
- data/lib/tools/edit.rb +4 -6
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +4 -4
- data/lib/tools/registry.rb +1 -1
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/spawn_specialist.rb +12 -23
- data/lib/tools/spawn_subagent.rb +9 -19
- data/lib/tools/subagent_prompts.rb +0 -2
- data/lib/tools/think.rb +3 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +3 -3
- data/lib/tui/cable_client.rb +3 -3
- data/lib/tui/message_store.rb +37 -37
- data/lib/tui/screens/chat.rb +27 -15
- 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 +16 -5
- data/templates/soul.md +7 -19
- data/workflows/create_handoff.md +1 -1
- data/workflows/create_note.md +1 -1
- data/workflows/create_plan.md +1 -1
- data/workflows/implement_plan.md +1 -1
- data/workflows/iterate_plan.md +1 -1
- data/workflows/research_codebase.md +1 -1
- data/workflows/resume_handoff.md +1 -1
- data/workflows/review_pr.md +78 -16
- data/workflows/thoughts_init.md +1 -1
- data/workflows/validate_plan.md +1 -1
- metadata +10 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/goal_pinned_event.rb +0 -11
- data/app/models/pinned_event.rb +0 -41
- data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
data/app/models/session.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# A conversation session — the fundamental unit of agent interaction.
|
|
4
|
-
# Owns an ordered stream of {
|
|
4
|
+
# Owns an ordered stream of {Message} records representing everything
|
|
5
5
|
# that happened: user messages, agent responses, tool calls, etc.
|
|
6
6
|
#
|
|
7
7
|
# Sessions form a hierarchy: a main session can spawn child sessions
|
|
@@ -15,10 +15,10 @@ class Session < ApplicationRecord
|
|
|
15
15
|
|
|
16
16
|
serialize :granted_tools, coder: JSON
|
|
17
17
|
|
|
18
|
-
has_many :
|
|
18
|
+
has_many :messages, -> { order(:id) }, dependent: :destroy
|
|
19
19
|
has_many :goals, dependent: :destroy
|
|
20
20
|
has_many :snapshots, dependent: :destroy
|
|
21
|
-
has_many :
|
|
21
|
+
has_many :pinned_messages, through: :messages
|
|
22
22
|
|
|
23
23
|
belongs_to :parent_session, class_name: "Session", optional: true
|
|
24
24
|
has_many :child_sessions, class_name: "Session", foreign_key: :parent_session_id, dependent: :destroy
|
|
@@ -46,34 +46,34 @@ class Session < ApplicationRecord
|
|
|
46
46
|
parent_session_id.present?
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Checks whether the Mneme terminal
|
|
50
|
-
# enqueues {MnemeJob} when it has. On the first
|
|
49
|
+
# Checks whether the Mneme terminal message has left the viewport and
|
|
50
|
+
# enqueues {MnemeJob} when it has. On the first message of a new session,
|
|
51
51
|
# initializes the boundary pointer.
|
|
52
52
|
#
|
|
53
|
-
# The terminal
|
|
53
|
+
# The terminal message is always a conversation message (user/agent message
|
|
54
54
|
# or think tool_call), never a bare tool_call/tool_response.
|
|
55
55
|
#
|
|
56
56
|
# @return [void]
|
|
57
57
|
def schedule_mneme!
|
|
58
58
|
return if sub_agent?
|
|
59
59
|
|
|
60
|
-
# Initialize boundary on first conversation
|
|
61
|
-
if
|
|
62
|
-
first_conversation =
|
|
63
|
-
.where(
|
|
60
|
+
# Initialize boundary on first conversation message
|
|
61
|
+
if mneme_boundary_message_id.nil?
|
|
62
|
+
first_conversation = messages.deliverable
|
|
63
|
+
.where(message_type: Message::CONVERSATION_TYPES)
|
|
64
64
|
.order(:id).first
|
|
65
|
-
first_conversation ||=
|
|
66
|
-
.where(
|
|
67
|
-
.detect { |
|
|
65
|
+
first_conversation ||= messages.deliverable
|
|
66
|
+
.where(message_type: "tool_call")
|
|
67
|
+
.detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }
|
|
68
68
|
|
|
69
69
|
if first_conversation
|
|
70
|
-
update_column(:
|
|
70
|
+
update_column(:mneme_boundary_message_id, first_conversation.id)
|
|
71
71
|
end
|
|
72
72
|
return
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
# Check if boundary
|
|
76
|
-
return if
|
|
75
|
+
# Check if boundary message has left the viewport
|
|
76
|
+
return if viewport_message_ids.include?(mneme_boundary_message_id)
|
|
77
77
|
|
|
78
78
|
MnemeJob.perform_later(id)
|
|
79
79
|
end
|
|
@@ -89,7 +89,7 @@ class Session < ApplicationRecord
|
|
|
89
89
|
def schedule_analytical_brain!
|
|
90
90
|
return if sub_agent?
|
|
91
91
|
|
|
92
|
-
count =
|
|
92
|
+
count = messages.llm_messages.count
|
|
93
93
|
return if count < 2
|
|
94
94
|
# Already named — only regenerate at interval boundaries (30, 60, 90, …)
|
|
95
95
|
return if name.present? && (count % Anima::Settings.name_generation_interval != 0)
|
|
@@ -97,43 +97,43 @@ class Session < ApplicationRecord
|
|
|
97
97
|
AnalyticalBrainJob.perform_later(id)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
# Returns the
|
|
101
|
-
# Walks
|
|
102
|
-
# is exhausted.
|
|
100
|
+
# Returns the messages currently visible in the LLM context window.
|
|
101
|
+
# Walks messages newest-first and includes them until the token budget
|
|
102
|
+
# is exhausted. Messages are full-size or excluded entirely.
|
|
103
103
|
#
|
|
104
104
|
# Sub-agent sessions inherit parent context via virtual viewport:
|
|
105
|
-
# child
|
|
106
|
-
# then parent
|
|
107
|
-
# The final array is chronological: parent
|
|
105
|
+
# child messages are prioritized and fill the budget first (newest-first),
|
|
106
|
+
# then parent messages from before the fork point fill the remaining budget.
|
|
107
|
+
# The final array is chronological: parent messages first, then child messages.
|
|
108
108
|
#
|
|
109
109
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
110
110
|
# @param include_pending [Boolean] whether to include pending messages (true for
|
|
111
111
|
# display, false for LLM context assembly)
|
|
112
|
-
# @return [Array<
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
remaining = token_budget -
|
|
112
|
+
# @return [Array<Message>] chronologically ordered
|
|
113
|
+
def viewport_messages(token_budget: Anima::Settings.token_budget, include_pending: true)
|
|
114
|
+
own = select_messages(own_message_scope(include_pending), budget: token_budget)
|
|
115
|
+
remaining = token_budget - own.sum { |msg| message_token_cost(msg) }
|
|
116
116
|
|
|
117
117
|
if sub_agent? && remaining > 0
|
|
118
|
-
|
|
119
|
-
trim_trailing_tool_calls(
|
|
118
|
+
parent = select_messages(parent_message_scope(include_pending), budget: remaining)
|
|
119
|
+
trim_trailing_tool_calls(parent) + own
|
|
120
120
|
else
|
|
121
|
-
|
|
121
|
+
own
|
|
122
122
|
end
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
# Recalculates the viewport and returns IDs of
|
|
126
|
-
# last snapshot. Updates the stored
|
|
127
|
-
# Piggybacks on
|
|
125
|
+
# Recalculates the viewport and returns IDs of messages evicted since the
|
|
126
|
+
# last snapshot. Updates the stored viewport_message_ids atomically.
|
|
127
|
+
# Piggybacks on message broadcasts to notify clients which messages left
|
|
128
128
|
# the LLM's context window.
|
|
129
129
|
#
|
|
130
|
-
# @return [Array<Integer>] IDs of
|
|
130
|
+
# @return [Array<Integer>] IDs of messages no longer in the viewport
|
|
131
131
|
def recalculate_viewport!
|
|
132
|
-
new_ids =
|
|
133
|
-
old_ids =
|
|
132
|
+
new_ids = viewport_messages.map(&:id)
|
|
133
|
+
old_ids = viewport_message_ids
|
|
134
134
|
|
|
135
135
|
evicted = old_ids - new_ids
|
|
136
|
-
update_column(:
|
|
136
|
+
update_column(:viewport_message_ids, new_ids) if old_ids != new_ids
|
|
137
137
|
evicted
|
|
138
138
|
end
|
|
139
139
|
|
|
@@ -142,10 +142,10 @@ class Session < ApplicationRecord
|
|
|
142
142
|
# where eviction notifications are unnecessary (clients clear their
|
|
143
143
|
# store first).
|
|
144
144
|
#
|
|
145
|
-
# @param ids [Array<Integer>]
|
|
145
|
+
# @param ids [Array<Integer>] message IDs now in the viewport
|
|
146
146
|
# @return [void]
|
|
147
147
|
def snapshot_viewport!(ids)
|
|
148
|
-
update_column(:
|
|
148
|
+
update_column(:viewport_message_ids, ids)
|
|
149
149
|
end
|
|
150
150
|
|
|
151
151
|
# Returns the system prompt for this session.
|
|
@@ -217,14 +217,14 @@ class Session < ApplicationRecord
|
|
|
217
217
|
save!
|
|
218
218
|
end
|
|
219
219
|
|
|
220
|
-
# Assembles the system prompt:
|
|
221
|
-
#
|
|
220
|
+
# Assembles the system prompt: version preamble, soul, environment context,
|
|
221
|
+
# skills/workflow, then goals.
|
|
222
222
|
# The soul is always present — "who am I" before "what can I do."
|
|
223
223
|
#
|
|
224
224
|
# @param environment_context [String, nil] pre-assembled environment block
|
|
225
225
|
# @return [String] composed system prompt
|
|
226
226
|
def assemble_system_prompt(environment_context: nil)
|
|
227
|
-
[assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
|
|
227
|
+
[assemble_version_preamble, assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
|
|
228
228
|
end
|
|
229
229
|
|
|
230
230
|
# Serializes active goals as a lightweight summary for ActionCable
|
|
@@ -238,17 +238,17 @@ class Session < ApplicationRecord
|
|
|
238
238
|
|
|
239
239
|
# Builds the message array expected by the Anthropic Messages API.
|
|
240
240
|
# Viewport layout (top to bottom):
|
|
241
|
-
# [L2 snapshots] [L1 snapshots] [pinned
|
|
241
|
+
# [L2 snapshots] [L1 snapshots] [pinned messages] [recalled memories] [sliding window messages]
|
|
242
242
|
#
|
|
243
|
-
# Snapshots appear ONLY after their source
|
|
243
|
+
# Snapshots appear ONLY after their source messages have evicted from
|
|
244
244
|
# the sliding window. L1 snapshots drop once covered by an L2 snapshot.
|
|
245
|
-
# Pinned
|
|
245
|
+
# Pinned messages are critical context attached to active Goals — they
|
|
246
246
|
# survive eviction intact until their Goals complete.
|
|
247
|
-
# Recalled memories surface relevant older
|
|
247
|
+
# Recalled memories surface relevant older messages (passive recall via goals).
|
|
248
248
|
# Each layer has a fixed token budget fraction — snapshots, pins, and recall
|
|
249
249
|
# consume viewport space, reducing the sliding window size.
|
|
250
250
|
#
|
|
251
|
-
# Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent
|
|
251
|
+
# Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent messages directly).
|
|
252
252
|
#
|
|
253
253
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
254
254
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
@@ -268,25 +268,25 @@ class Session < ApplicationRecord
|
|
|
268
268
|
sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
|
|
269
269
|
end
|
|
270
270
|
|
|
271
|
-
|
|
271
|
+
window = viewport_messages(token_budget: sliding_budget, include_pending: false)
|
|
272
272
|
|
|
273
273
|
unless sub_agent?
|
|
274
|
-
|
|
275
|
-
snapshot_messages = assemble_snapshot_messages(
|
|
276
|
-
pinned_messages =
|
|
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
277
|
recall_messages = assemble_recall_messages(budget: recall_budget)
|
|
278
278
|
end
|
|
279
279
|
|
|
280
|
-
snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(
|
|
280
|
+
snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(window))
|
|
281
281
|
end
|
|
282
282
|
|
|
283
|
-
# Detects orphaned tool_call
|
|
283
|
+
# Detects orphaned tool_call messages (those without a matching tool_response
|
|
284
284
|
# and whose timeout has expired) and creates synthetic error responses.
|
|
285
285
|
# An orphaned tool_call permanently breaks the session because the
|
|
286
286
|
# Anthropic API rejects conversations where a tool_use block has no
|
|
287
287
|
# matching tool_result.
|
|
288
288
|
#
|
|
289
|
-
# Respects the per-call timeout stored in the tool_call
|
|
289
|
+
# Respects the per-call timeout stored in the tool_call message payload —
|
|
290
290
|
# a tool_call is only healed after its deadline has passed. This avoids
|
|
291
291
|
# prematurely healing long-running tools that the agent intentionally
|
|
292
292
|
# gave an extended timeout.
|
|
@@ -294,8 +294,8 @@ class Session < ApplicationRecord
|
|
|
294
294
|
# @return [Integer] number of synthetic responses created
|
|
295
295
|
def heal_orphaned_tool_calls!
|
|
296
296
|
now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
297
|
-
responded_ids =
|
|
298
|
-
unresponded =
|
|
297
|
+
responded_ids = messages.where(message_type: "tool_response").select(:tool_use_id)
|
|
298
|
+
unresponded = messages.where(message_type: "tool_call")
|
|
299
299
|
.where.not(tool_use_id: responded_ids)
|
|
300
300
|
|
|
301
301
|
healed = 0
|
|
@@ -304,8 +304,8 @@ class Session < ApplicationRecord
|
|
|
304
304
|
deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
|
|
305
305
|
next if now_ns < deadline_ns
|
|
306
306
|
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
messages.create!(
|
|
308
|
+
message_type: "tool_response",
|
|
309
309
|
payload: {
|
|
310
310
|
"type" => "tool_response",
|
|
311
311
|
"content" => "Tool execution timed out after #{timeout} seconds — no result was returned.",
|
|
@@ -323,14 +323,14 @@ class Session < ApplicationRecord
|
|
|
323
323
|
|
|
324
324
|
# Delivers a user message respecting the session's processing state.
|
|
325
325
|
#
|
|
326
|
-
# When idle, persists the
|
|
326
|
+
# When idle, persists the message directly and enqueues {AgentRequestJob}
|
|
327
327
|
# to process it. When mid-turn ({#processing?}), emits a pending
|
|
328
328
|
# {Events::UserMessage} via {Events::Bus} so it queues until the
|
|
329
329
|
# current agent loop completes — preventing interleaving between
|
|
330
330
|
# tool_use/tool_result pairs.
|
|
331
331
|
#
|
|
332
332
|
# @param content [String] user message text
|
|
333
|
-
# @param bounce_back [Boolean] when true, passes +
|
|
333
|
+
# @param bounce_back [Boolean] when true, passes +message_id+ to the job
|
|
334
334
|
# so failed LLM delivery triggers a {Events::BounceBack} (used by
|
|
335
335
|
# {SessionChannel#speak} for immediate-display messages)
|
|
336
336
|
# @return [void]
|
|
@@ -338,16 +338,16 @@ class Session < ApplicationRecord
|
|
|
338
338
|
if processing?
|
|
339
339
|
Events::Bus.emit(Events::UserMessage.new(
|
|
340
340
|
content: content, session_id: id,
|
|
341
|
-
status:
|
|
341
|
+
status: Message::PENDING_STATUS
|
|
342
342
|
))
|
|
343
343
|
else
|
|
344
|
-
|
|
345
|
-
job_args = bounce_back ? {
|
|
344
|
+
msg = create_user_message(content)
|
|
345
|
+
job_args = bounce_back ? {message_id: msg.id} : {}
|
|
346
346
|
AgentRequestJob.perform_later(id, **job_args)
|
|
347
347
|
end
|
|
348
348
|
end
|
|
349
349
|
|
|
350
|
-
# Persists a user message
|
|
350
|
+
# Persists a user message directly, bypassing the pending queue.
|
|
351
351
|
#
|
|
352
352
|
# Used by {#enqueue_user_message} (idle path), {AgentLoop#process},
|
|
353
353
|
# and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
|
|
@@ -355,11 +355,11 @@ class Session < ApplicationRecord
|
|
|
355
355
|
# messages — these callers own the persistence lifecycle.
|
|
356
356
|
#
|
|
357
357
|
# @param content [String] user message text
|
|
358
|
-
# @return [
|
|
359
|
-
def
|
|
358
|
+
# @return [Message] the persisted message record
|
|
359
|
+
def create_user_message(content)
|
|
360
360
|
now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
messages.create!(
|
|
362
|
+
message_type: "user_message",
|
|
363
363
|
payload: {type: "user_message", content: content, session_id: id, timestamp: now},
|
|
364
364
|
timestamp: now
|
|
365
365
|
)
|
|
@@ -367,13 +367,13 @@ class Session < ApplicationRecord
|
|
|
367
367
|
|
|
368
368
|
# Promotes all pending user messages to delivered status so they
|
|
369
369
|
# appear in the next LLM context. Triggers broadcast_update for
|
|
370
|
-
# each
|
|
370
|
+
# each message so connected clients refresh the pending indicator.
|
|
371
371
|
#
|
|
372
372
|
# @return [Integer] number of promoted messages
|
|
373
373
|
def promote_pending_messages!
|
|
374
374
|
promoted = 0
|
|
375
|
-
|
|
376
|
-
|
|
375
|
+
messages.where(message_type: "user_message", status: Message::PENDING_STATUS).find_each do |msg|
|
|
376
|
+
msg.update!(status: nil, payload: msg.payload.except("status"))
|
|
377
377
|
promoted += 1
|
|
378
378
|
end
|
|
379
379
|
promoted
|
|
@@ -402,6 +402,14 @@ class Session < ApplicationRecord
|
|
|
402
402
|
|
|
403
403
|
private
|
|
404
404
|
|
|
405
|
+
# One-line version preamble so the agent knows its own version.
|
|
406
|
+
# Useful for commits, handoffs, and debugging.
|
|
407
|
+
#
|
|
408
|
+
# @return [String] e.g. "You are running on Anima v1.1.3"
|
|
409
|
+
def assemble_version_preamble
|
|
410
|
+
"You are running on Anima v#{Anima::VERSION}"
|
|
411
|
+
end
|
|
412
|
+
|
|
405
413
|
# Reads the soul file — the agent's self-authored identity.
|
|
406
414
|
# Loaded as the first section of every system prompt, before skills,
|
|
407
415
|
# workflows, and goals.
|
|
@@ -520,40 +528,40 @@ class Session < ApplicationRecord
|
|
|
520
528
|
})
|
|
521
529
|
end
|
|
522
530
|
|
|
523
|
-
# Scopes own
|
|
531
|
+
# Scopes own messages for viewport assembly.
|
|
524
532
|
# @return [ActiveRecord::Relation]
|
|
525
|
-
def
|
|
526
|
-
scope =
|
|
533
|
+
def own_message_scope(include_pending)
|
|
534
|
+
scope = messages.context_messages
|
|
527
535
|
include_pending ? scope : scope.deliverable
|
|
528
536
|
end
|
|
529
537
|
|
|
530
|
-
# Scopes parent
|
|
531
|
-
# Excludes spawn tool
|
|
538
|
+
# Scopes parent messages created before this session's fork point.
|
|
539
|
+
# Excludes spawn tool messages — sub-agents don't need to see sibling
|
|
532
540
|
# spawn pairs, which cause role confusion (the sub-agent mistakes
|
|
533
541
|
# itself for the parent when it sees "Specialist @sibling spawned...").
|
|
534
542
|
# @return [ActiveRecord::Relation]
|
|
535
|
-
def
|
|
536
|
-
scope = parent_session.
|
|
537
|
-
.
|
|
543
|
+
def parent_message_scope(include_pending)
|
|
544
|
+
scope = parent_session.messages.context_messages
|
|
545
|
+
.excluding_spawn_messages
|
|
538
546
|
.where(created_at: ...created_at)
|
|
539
547
|
include_pending ? scope : scope.deliverable
|
|
540
548
|
end
|
|
541
549
|
|
|
542
|
-
# Walks
|
|
543
|
-
# Always includes at least the newest
|
|
550
|
+
# Walks messages newest-first, selecting until the token budget is exhausted.
|
|
551
|
+
# Always includes at least the newest message even if it exceeds budget.
|
|
544
552
|
#
|
|
545
|
-
# @param scope [ActiveRecord::Relation]
|
|
553
|
+
# @param scope [ActiveRecord::Relation] message scope to select from
|
|
546
554
|
# @param budget [Integer] maximum tokens to include
|
|
547
|
-
# @return [Array<
|
|
548
|
-
def
|
|
555
|
+
# @return [Array<Message>] chronologically ordered
|
|
556
|
+
def select_messages(scope, budget:)
|
|
549
557
|
selected = []
|
|
550
558
|
remaining = budget
|
|
551
559
|
|
|
552
|
-
scope.reorder(id: :desc).each do |
|
|
553
|
-
cost =
|
|
560
|
+
scope.reorder(id: :desc).each do |msg|
|
|
561
|
+
cost = message_token_cost(msg)
|
|
554
562
|
break if cost > remaining && selected.any?
|
|
555
563
|
|
|
556
|
-
selected <<
|
|
564
|
+
selected << msg
|
|
557
565
|
remaining -= cost
|
|
558
566
|
end
|
|
559
567
|
|
|
@@ -561,59 +569,59 @@ class Session < ApplicationRecord
|
|
|
561
569
|
end
|
|
562
570
|
|
|
563
571
|
# @return [Integer] token cost, using cached count or heuristic estimate
|
|
564
|
-
def
|
|
565
|
-
(
|
|
572
|
+
def message_token_cost(msg)
|
|
573
|
+
(msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
|
|
566
574
|
end
|
|
567
575
|
|
|
568
|
-
# Removes trailing tool_call
|
|
576
|
+
# Removes trailing tool_call messages that lack matching tool_response.
|
|
569
577
|
# Prevents orphaned tool_use blocks at the parent/child viewport boundary
|
|
570
578
|
# (the spawn_subagent/spawn_specialist tool_call is emitted before the child exists,
|
|
571
579
|
# but its tool_response comes after — so the cutoff can split them).
|
|
572
|
-
def trim_trailing_tool_calls(
|
|
573
|
-
|
|
574
|
-
|
|
580
|
+
def trim_trailing_tool_calls(message_list)
|
|
581
|
+
message_list.pop while message_list.last&.message_type == "tool_call"
|
|
582
|
+
message_list
|
|
575
583
|
end
|
|
576
584
|
|
|
577
|
-
# Ensures every tool_call in the
|
|
578
|
-
# (and vice versa) by removing unpaired
|
|
585
|
+
# Ensures every tool_call in the message list has a matching tool_response
|
|
586
|
+
# (and vice versa) by removing unpaired messages. The Anthropic API requires
|
|
579
587
|
# every tool_use block to have a tool_result — a missing partner causes
|
|
580
588
|
# a permanent API error. Token budget cutoffs can split pairs when the
|
|
581
589
|
# boundary falls between a tool_call and its tool_response.
|
|
582
590
|
#
|
|
583
|
-
# @param
|
|
584
|
-
# @return [Array<
|
|
585
|
-
def ensure_atomic_tool_pairs(
|
|
586
|
-
|
|
587
|
-
return
|
|
588
|
-
|
|
589
|
-
paired =
|
|
590
|
-
complete_ids = paired.each_with_object(Set.new) do |(
|
|
591
|
-
has_call =
|
|
592
|
-
has_response =
|
|
593
|
-
set <<
|
|
591
|
+
# @param message_list [Array<Message>] chronologically ordered messages
|
|
592
|
+
# @return [Array<Message>] messages with unpaired tool messages removed
|
|
593
|
+
def ensure_atomic_tool_pairs(message_list)
|
|
594
|
+
tool_msgs = message_list.select { |m| m.tool_use_id.present? }
|
|
595
|
+
return message_list if tool_msgs.empty?
|
|
596
|
+
|
|
597
|
+
paired = tool_msgs.group_by(&:tool_use_id)
|
|
598
|
+
complete_ids = paired.each_with_object(Set.new) do |(uid, msgs), set|
|
|
599
|
+
has_call = msgs.any? { |m| m.message_type == "tool_call" }
|
|
600
|
+
has_response = msgs.any? { |m| m.message_type == "tool_response" }
|
|
601
|
+
set << uid if has_call && has_response
|
|
594
602
|
end
|
|
595
603
|
|
|
596
|
-
|
|
604
|
+
message_list.reject { |m| m.tool_use_id.present? && !complete_ids.include?(m.tool_use_id) }
|
|
597
605
|
end
|
|
598
606
|
|
|
599
607
|
# Selects visible snapshots and formats them as Anthropic messages.
|
|
600
|
-
# Snapshots are visible when their source
|
|
608
|
+
# Snapshots are visible when their source messages have fully evicted.
|
|
601
609
|
# L1 snapshots are excluded when covered by an L2 snapshot.
|
|
602
610
|
#
|
|
603
|
-
# @param
|
|
611
|
+
# @param first_message_id [Integer, nil] first message ID in the sliding window
|
|
604
612
|
# @param l2_budget [Integer] token budget for L2 snapshots
|
|
605
613
|
# @param l1_budget [Integer] token budget for L1 snapshots
|
|
606
614
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
607
|
-
def assemble_snapshot_messages(
|
|
608
|
-
return [] unless
|
|
615
|
+
def assemble_snapshot_messages(first_message_id, l2_budget:, l1_budget:)
|
|
616
|
+
return [] unless first_message_id
|
|
609
617
|
|
|
610
618
|
l2_messages = select_snapshots_within_budget(
|
|
611
|
-
snapshots.for_level(2).
|
|
619
|
+
snapshots.for_level(2).source_messages_evicted(first_message_id).chronological,
|
|
612
620
|
budget: l2_budget
|
|
613
621
|
).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
|
|
614
622
|
|
|
615
623
|
l1_messages = select_snapshots_within_budget(
|
|
616
|
-
snapshots.for_level(1).not_covered_by_l2.
|
|
624
|
+
snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(first_message_id).chronological,
|
|
617
625
|
budget: l1_budget
|
|
618
626
|
).map { |snapshot| format_snapshot_message(snapshot, label: "recent memory") }
|
|
619
627
|
|
|
@@ -651,39 +659,39 @@ class Session < ApplicationRecord
|
|
|
651
659
|
{role: "user", content: "[#{label}]\n#{snapshot.text}"}
|
|
652
660
|
end
|
|
653
661
|
|
|
654
|
-
# Assembles pinned
|
|
655
|
-
# Only includes pinned
|
|
656
|
-
# sliding window (same rule as snapshots — no duplication with live
|
|
662
|
+
# Assembles pinned messages as a Goals section message for the viewport.
|
|
663
|
+
# Only includes pinned messages whose source message has evicted from the
|
|
664
|
+
# sliding window (same rule as snapshots — no duplication with live messages).
|
|
657
665
|
#
|
|
658
|
-
# Deduplication: the first Goal referencing
|
|
659
|
-
# display_text; subsequent Goals show a bare `
|
|
666
|
+
# Deduplication: the first Goal referencing a message shows its truncated
|
|
667
|
+
# display_text; subsequent Goals show a bare `message N` ID to save tokens.
|
|
660
668
|
#
|
|
661
|
-
# @param
|
|
662
|
-
# @param budget [Integer] token budget for pinned
|
|
669
|
+
# @param first_message_id [Integer, nil] first message ID in the sliding window
|
|
670
|
+
# @param budget [Integer] token budget for pinned messages
|
|
663
671
|
# @return [Array<Hash>] Anthropic Messages API format (0 or 1 messages)
|
|
664
|
-
def
|
|
665
|
-
return [] unless
|
|
672
|
+
def assemble_pinned_section_messages(first_message_id, budget:)
|
|
673
|
+
return [] unless first_message_id
|
|
666
674
|
|
|
667
|
-
pins =
|
|
668
|
-
.includes(:
|
|
669
|
-
.where("
|
|
670
|
-
.order("
|
|
675
|
+
pins = pinned_messages
|
|
676
|
+
.includes(:message, :goals)
|
|
677
|
+
.where("pinned_messages.message_id < ?", first_message_id)
|
|
678
|
+
.order("pinned_messages.message_id")
|
|
671
679
|
|
|
672
680
|
return [] if pins.empty?
|
|
673
681
|
|
|
674
682
|
selected = select_pins_within_budget(pins, budget)
|
|
675
683
|
return [] if selected.empty?
|
|
676
684
|
|
|
677
|
-
text =
|
|
678
|
-
[{role: "user", content: "[pinned
|
|
685
|
+
text = render_pinned_messages_section(selected)
|
|
686
|
+
[{role: "user", content: "[pinned messages]\n#{text}"}]
|
|
679
687
|
end
|
|
680
688
|
|
|
681
|
-
# Walks pinned
|
|
689
|
+
# Walks pinned messages chronologically, selecting until the token budget
|
|
682
690
|
# is exhausted. Always includes at least one pin.
|
|
683
691
|
#
|
|
684
|
-
# @param pins [Array<
|
|
692
|
+
# @param pins [Array<PinnedMessage>]
|
|
685
693
|
# @param budget [Integer]
|
|
686
|
-
# @return [Array<
|
|
694
|
+
# @return [Array<PinnedMessage>]
|
|
687
695
|
def select_pins_within_budget(pins, budget)
|
|
688
696
|
selected = []
|
|
689
697
|
remaining = budget
|
|
@@ -699,26 +707,26 @@ class Session < ApplicationRecord
|
|
|
699
707
|
selected
|
|
700
708
|
end
|
|
701
709
|
|
|
702
|
-
# Renders the pinned
|
|
710
|
+
# Renders the pinned messages section grouped by Goal.
|
|
703
711
|
# First Goal referencing a pin shows truncated text; subsequent Goals
|
|
704
|
-
# show bare `
|
|
712
|
+
# show bare `message N` ID to avoid token-expensive repetition.
|
|
705
713
|
#
|
|
706
|
-
# @param pins [Array<
|
|
714
|
+
# @param pins [Array<PinnedMessage>] selected pins with preloaded goals
|
|
707
715
|
# @return [String] formatted section text
|
|
708
|
-
def
|
|
716
|
+
def render_pinned_messages_section(pins)
|
|
709
717
|
goal_pins = group_pins_by_active_goal(pins)
|
|
710
718
|
|
|
711
|
-
|
|
719
|
+
shown_messages = Set.new
|
|
712
720
|
goal_pins.map { |goal, pin_list|
|
|
713
|
-
render_goal_pins(goal, pin_list,
|
|
721
|
+
render_goal_pins(goal, pin_list, shown_messages)
|
|
714
722
|
}.join("\n\n")
|
|
715
723
|
end
|
|
716
724
|
|
|
717
725
|
# Groups pins by their active Goals so the viewport renders
|
|
718
726
|
# one headed section per Goal.
|
|
719
727
|
#
|
|
720
|
-
# @param pins [Array<
|
|
721
|
-
# @return [Hash{Goal => Array<
|
|
728
|
+
# @param pins [Array<PinnedMessage>] pins with preloaded goals
|
|
729
|
+
# @return [Hash{Goal => Array<PinnedMessage>}]
|
|
722
730
|
def group_pins_by_active_goal(pins)
|
|
723
731
|
pairs = pins.flat_map { |pin| active_goal_pin_pairs(pin) }
|
|
724
732
|
pairs.group_by(&:first).transform_values { |group| group.map(&:last) }
|
|
@@ -727,61 +735,61 @@ class Session < ApplicationRecord
|
|
|
727
735
|
# Expands a single pin into [goal, pin] pairs for each active Goal
|
|
728
736
|
# referencing it. Uses in-memory filter on preloaded goals.
|
|
729
737
|
#
|
|
730
|
-
# @param pin [
|
|
731
|
-
# @return [Array<Array(Goal,
|
|
738
|
+
# @param pin [PinnedMessage]
|
|
739
|
+
# @return [Array<Array(Goal, PinnedMessage)>]
|
|
732
740
|
def active_goal_pin_pairs(pin)
|
|
733
741
|
pin.goals.select(&:active?).map { |goal| [goal, pin] }
|
|
734
742
|
end
|
|
735
743
|
|
|
736
|
-
# Renders one Goal's pinned
|
|
744
|
+
# Renders one Goal's pinned messages as a headed list.
|
|
737
745
|
#
|
|
738
746
|
# @param goal [Goal]
|
|
739
|
-
# @param pin_list [Array<
|
|
740
|
-
# @param
|
|
747
|
+
# @param pin_list [Array<PinnedMessage>]
|
|
748
|
+
# @param shown_messages [Set<Integer>] tracks already-rendered message IDs for dedup
|
|
741
749
|
# @return [String]
|
|
742
|
-
def render_goal_pins(goal, pin_list,
|
|
750
|
+
def render_goal_pins(goal, pin_list, shown_messages)
|
|
743
751
|
lines = ["📌 #{goal.description} (id: #{goal.id})"]
|
|
744
|
-
pin_list.each { |pin| lines << format_pin_line(pin,
|
|
752
|
+
pin_list.each { |pin| lines << format_pin_line(pin, shown_messages) }
|
|
745
753
|
lines.join("\n")
|
|
746
754
|
end
|
|
747
755
|
|
|
748
756
|
# Formats a single pin line with deduplication: first occurrence shows
|
|
749
|
-
# truncated text, subsequent occurrences show bare
|
|
757
|
+
# truncated text, subsequent occurrences show bare message ID only.
|
|
750
758
|
#
|
|
751
|
-
# @param pin [
|
|
752
|
-
# @param
|
|
759
|
+
# @param pin [PinnedMessage]
|
|
760
|
+
# @param shown_messages [Set<Integer>]
|
|
753
761
|
# @return [String]
|
|
754
|
-
def format_pin_line(pin,
|
|
755
|
-
|
|
756
|
-
if
|
|
757
|
-
"
|
|
762
|
+
def format_pin_line(pin, shown_messages)
|
|
763
|
+
mid = pin.message_id
|
|
764
|
+
if shown_messages.add?(mid)
|
|
765
|
+
" message #{mid}: #{pin.display_text}"
|
|
758
766
|
else
|
|
759
|
-
"
|
|
767
|
+
" message #{mid}"
|
|
760
768
|
end
|
|
761
769
|
end
|
|
762
770
|
|
|
763
771
|
# Assembles recalled memory messages from passive recall results.
|
|
764
|
-
# Recalled
|
|
765
|
-
# with session and
|
|
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.
|
|
766
774
|
#
|
|
767
775
|
# @param budget [Integer] token budget for recall messages
|
|
768
776
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
769
777
|
def assemble_recall_messages(budget:)
|
|
770
|
-
return [] if
|
|
778
|
+
return [] if recalled_message_ids.blank?
|
|
771
779
|
|
|
772
|
-
|
|
780
|
+
recalled = Message.where(id: recalled_message_ids)
|
|
773
781
|
.includes(:session)
|
|
774
782
|
.index_by(&:id)
|
|
775
783
|
|
|
776
784
|
snippets = []
|
|
777
785
|
remaining = budget
|
|
778
786
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
next unless
|
|
787
|
+
recalled_message_ids.each do |mid|
|
|
788
|
+
msg = recalled[mid]
|
|
789
|
+
next unless msg
|
|
782
790
|
|
|
783
|
-
text = format_recall_snippet(
|
|
784
|
-
cost = [(text.bytesize /
|
|
791
|
+
text = format_recall_snippet(msg)
|
|
792
|
+
cost = [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
785
793
|
break if cost > remaining && snippets.any?
|
|
786
794
|
|
|
787
795
|
snippets << text
|
|
@@ -793,28 +801,28 @@ class Session < ApplicationRecord
|
|
|
793
801
|
[{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
|
|
794
802
|
end
|
|
795
803
|
|
|
796
|
-
# Formats a recalled
|
|
804
|
+
# Formats a recalled message as a compact snippet with enough context
|
|
797
805
|
# for the agent to decide whether to drill down with the remember tool.
|
|
798
806
|
#
|
|
799
|
-
# @param
|
|
807
|
+
# @param msg [Message] the recalled message
|
|
800
808
|
# @return [String] formatted snippet
|
|
801
|
-
def format_recall_snippet(
|
|
802
|
-
session_label =
|
|
803
|
-
content =
|
|
804
|
-
"
|
|
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}"
|
|
805
813
|
end
|
|
806
814
|
|
|
807
|
-
# Extracts readable content from
|
|
815
|
+
# Extracts readable content from a message's payload.
|
|
808
816
|
#
|
|
809
|
-
# @param
|
|
817
|
+
# @param msg [Message]
|
|
810
818
|
# @return [String]
|
|
811
|
-
def
|
|
812
|
-
data =
|
|
813
|
-
case
|
|
819
|
+
def extract_message_content(msg)
|
|
820
|
+
data = msg.payload
|
|
821
|
+
case msg.message_type
|
|
814
822
|
when "user_message", "agent_message", "system_message"
|
|
815
823
|
data["content"]
|
|
816
824
|
when "tool_call"
|
|
817
|
-
if data["tool_name"] ==
|
|
825
|
+
if data["tool_name"] == Message::THINK_TOOL
|
|
818
826
|
data.dig("tool_input", "thoughts")
|
|
819
827
|
else
|
|
820
828
|
"#{data["tool_name"]}(…)"
|
|
@@ -824,39 +832,39 @@ class Session < ApplicationRecord
|
|
|
824
832
|
end
|
|
825
833
|
end
|
|
826
834
|
|
|
827
|
-
# Converts a chronological list of
|
|
835
|
+
# Converts a chronological list of messages into Anthropic wire-format messages.
|
|
828
836
|
# Prepends a compact timestamp to each user message for LLM time awareness.
|
|
829
|
-
# Groups consecutive tool_call
|
|
830
|
-
# consecutive tool_response
|
|
837
|
+
# Groups consecutive tool_call messages into one assistant message and
|
|
838
|
+
# consecutive tool_response messages into one user message.
|
|
831
839
|
#
|
|
832
|
-
# @param
|
|
840
|
+
# @param msgs [Array<Message>]
|
|
833
841
|
# @return [Array<Hash>]
|
|
834
|
-
def assemble_messages(
|
|
835
|
-
|
|
836
|
-
case
|
|
842
|
+
def assemble_messages(msgs)
|
|
843
|
+
msgs.each_with_object([]) do |msg, api_messages|
|
|
844
|
+
case msg.message_type
|
|
837
845
|
when "user_message"
|
|
838
|
-
content = "#{
|
|
839
|
-
|
|
846
|
+
content = "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"
|
|
847
|
+
api_messages << {role: "user", content: content}
|
|
840
848
|
when "agent_message"
|
|
841
|
-
|
|
849
|
+
api_messages << {role: "assistant", content: msg.payload["content"].to_s}
|
|
842
850
|
when "tool_call"
|
|
843
|
-
append_grouped_block(
|
|
851
|
+
append_grouped_block(api_messages, "assistant", tool_use_block(msg.payload))
|
|
844
852
|
when "tool_response"
|
|
845
|
-
append_grouped_block(
|
|
853
|
+
append_grouped_block(api_messages, "user", tool_result_block(msg.payload))
|
|
846
854
|
when "system_message"
|
|
847
855
|
# Wrapped as user role with prefix — Claude API has no system role in conversation history
|
|
848
|
-
|
|
856
|
+
api_messages << {role: "user", content: "[system] #{msg.payload["content"]}"}
|
|
849
857
|
end
|
|
850
858
|
end
|
|
851
859
|
end
|
|
852
860
|
|
|
853
861
|
# Groups consecutive tool blocks into a single message of the given role.
|
|
854
|
-
def append_grouped_block(
|
|
855
|
-
prev =
|
|
862
|
+
def append_grouped_block(api_messages, role, block)
|
|
863
|
+
prev = api_messages.last
|
|
856
864
|
if prev&.dig(:role) == role && prev[:content].is_a?(Array)
|
|
857
865
|
prev[:content] << block
|
|
858
866
|
else
|
|
859
|
-
|
|
867
|
+
api_messages << {role: role, content: [block]}
|
|
860
868
|
end
|
|
861
869
|
end
|
|
862
870
|
|
|
@@ -877,23 +885,23 @@ class Session < ApplicationRecord
|
|
|
877
885
|
}
|
|
878
886
|
end
|
|
879
887
|
|
|
880
|
-
# Formats
|
|
888
|
+
# Formats a message's nanosecond timestamp as a compact time prefix for LLM context.
|
|
881
889
|
# Gives the agent awareness of time of day, day of week, and pauses between messages.
|
|
882
890
|
#
|
|
883
891
|
# @param timestamp_ns [Integer] nanoseconds since epoch
|
|
884
892
|
# @return [String] e.g. "Sat Mar 14 09:51"
|
|
885
893
|
# @example
|
|
886
|
-
#
|
|
887
|
-
def
|
|
894
|
+
# format_message_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
|
|
895
|
+
def format_message_time(timestamp_ns)
|
|
888
896
|
Time.at(timestamp_ns / 1_000_000_000.0).strftime("%a %b %-d %H:%M")
|
|
889
897
|
end
|
|
890
898
|
|
|
891
|
-
# Delegates to {
|
|
899
|
+
# Delegates to {Message#estimate_tokens} for messages not yet counted
|
|
892
900
|
# by the background job.
|
|
893
901
|
#
|
|
894
|
-
# @param
|
|
902
|
+
# @param msg [Message]
|
|
895
903
|
# @return [Integer] at least 1
|
|
896
|
-
def estimate_tokens(
|
|
897
|
-
|
|
904
|
+
def estimate_tokens(msg)
|
|
905
|
+
msg.estimate_tokens
|
|
898
906
|
end
|
|
899
907
|
end
|