anima-core 1.1.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +10 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +2 -2
- data/agents/codebase-pattern-finder.md +2 -2
- data/agents/documentation-researcher.md +2 -2
- data/agents/thoughts-analyzer.md +2 -2
- data/agents/web-search-researcher.md +3 -3
- data/app/channels/session_channel.rb +83 -64
- 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 +6 -6
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +5 -19
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +33 -24
- 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 +17 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/message.rb +127 -0
- data/app/models/pending_message.rb +43 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +385 -226
- data/app/models/snapshot.rb +25 -25
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -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/lib/agent_loop.rb +14 -41
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +40 -37
- 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/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +46 -6
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +15 -22
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- 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/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +60 -16
- data/lib/tools/edit.rb +6 -8
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +6 -5
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +37 -8
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +15 -25
- data/lib/tools/spawn_subagent.rb +14 -22
- data/lib/tools/subagent_prompts.rb +42 -6
- data/lib/tools/think.rb +26 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +4 -4
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +103 -59
- data/lib/tui/screens/chat.rb +293 -78
- 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 +42 -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 +20 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/event.rb +0 -129
- 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,11 @@ 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
|
+
has_many :pending_messages, dependent: :destroy
|
|
19
20
|
has_many :goals, dependent: :destroy
|
|
20
21
|
has_many :snapshots, dependent: :destroy
|
|
21
|
-
has_many :
|
|
22
|
+
has_many :pinned_messages, through: :messages
|
|
22
23
|
|
|
23
24
|
belongs_to :parent_session, class_name: "Session", optional: true
|
|
24
25
|
has_many :child_sessions, class_name: "Session", foreign_key: :parent_session_id, dependent: :destroy
|
|
@@ -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
|
#
|
|
@@ -46,34 +48,34 @@ class Session < ApplicationRecord
|
|
|
46
48
|
parent_session_id.present?
|
|
47
49
|
end
|
|
48
50
|
|
|
49
|
-
# Checks whether the Mneme terminal
|
|
50
|
-
# enqueues {MnemeJob} when it has. On the first
|
|
51
|
+
# Checks whether the Mneme terminal message has left the viewport and
|
|
52
|
+
# enqueues {MnemeJob} when it has. On the first message of a new session,
|
|
51
53
|
# initializes the boundary pointer.
|
|
52
54
|
#
|
|
53
|
-
# The terminal
|
|
55
|
+
# The terminal message is always a conversation message (user/agent message
|
|
54
56
|
# or think tool_call), never a bare tool_call/tool_response.
|
|
55
57
|
#
|
|
56
58
|
# @return [void]
|
|
57
59
|
def schedule_mneme!
|
|
58
60
|
return if sub_agent?
|
|
59
61
|
|
|
60
|
-
# Initialize boundary on first conversation
|
|
61
|
-
if
|
|
62
|
-
first_conversation =
|
|
63
|
-
.where(
|
|
62
|
+
# Initialize boundary on first conversation message
|
|
63
|
+
if mneme_boundary_message_id.nil?
|
|
64
|
+
first_conversation = messages
|
|
65
|
+
.where(message_type: Message::CONVERSATION_TYPES)
|
|
64
66
|
.order(:id).first
|
|
65
|
-
first_conversation ||=
|
|
66
|
-
.where(
|
|
67
|
-
.detect { |
|
|
67
|
+
first_conversation ||= messages
|
|
68
|
+
.where(message_type: "tool_call")
|
|
69
|
+
.detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }
|
|
68
70
|
|
|
69
71
|
if first_conversation
|
|
70
|
-
update_column(:
|
|
72
|
+
update_column(:mneme_boundary_message_id, first_conversation.id)
|
|
71
73
|
end
|
|
72
74
|
return
|
|
73
75
|
end
|
|
74
76
|
|
|
75
|
-
# Check if boundary
|
|
76
|
-
return if
|
|
77
|
+
# Check if boundary message has left the viewport
|
|
78
|
+
return if viewport_message_ids.include?(mneme_boundary_message_id)
|
|
77
79
|
|
|
78
80
|
MnemeJob.perform_later(id)
|
|
79
81
|
end
|
|
@@ -89,7 +91,7 @@ class Session < ApplicationRecord
|
|
|
89
91
|
def schedule_analytical_brain!
|
|
90
92
|
return if sub_agent?
|
|
91
93
|
|
|
92
|
-
count =
|
|
94
|
+
count = messages.llm_messages.count
|
|
93
95
|
return if count < 2
|
|
94
96
|
# Already named — only regenerate at interval boundaries (30, 60, 90, …)
|
|
95
97
|
return if name.present? && (count % Anima::Settings.name_generation_interval != 0)
|
|
@@ -97,43 +99,45 @@ class Session < ApplicationRecord
|
|
|
97
99
|
AnalyticalBrainJob.perform_later(id)
|
|
98
100
|
end
|
|
99
101
|
|
|
100
|
-
# Returns the
|
|
101
|
-
# Walks
|
|
102
|
-
# is exhausted.
|
|
102
|
+
# Returns the messages currently visible in the LLM context window.
|
|
103
|
+
# Walks messages newest-first and includes them until the token budget
|
|
104
|
+
# is exhausted. Messages are full-size or excluded entirely.
|
|
103
105
|
#
|
|
104
106
|
# Sub-agent sessions inherit parent context via virtual viewport:
|
|
105
|
-
# child
|
|
106
|
-
# then parent
|
|
107
|
-
# The final array is chronological: parent
|
|
107
|
+
# child messages are prioritized and fill the budget first (newest-first),
|
|
108
|
+
# then parent messages from before the fork point fill the remaining budget.
|
|
109
|
+
# The final array is chronological: parent messages first, then child messages.
|
|
110
|
+
#
|
|
111
|
+
# Pending messages live in a separate table ({PendingMessage}) and never
|
|
112
|
+
# appear in this viewport — they are promoted to real messages before
|
|
113
|
+
# the agent processes them.
|
|
108
114
|
#
|
|
109
115
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
110
|
-
# @
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
own_events = select_events(own_event_scope(include_pending), budget: token_budget)
|
|
115
|
-
remaining = token_budget - own_events.sum { |e| event_token_cost(e) }
|
|
116
|
+
# @return [Array<Message>] chronologically ordered
|
|
117
|
+
def viewport_messages(token_budget: Anima::Settings.token_budget)
|
|
118
|
+
own = select_messages(own_message_scope, budget: token_budget)
|
|
119
|
+
remaining = token_budget - own.sum { |msg| message_token_cost(msg) }
|
|
116
120
|
|
|
117
121
|
if sub_agent? && remaining > 0
|
|
118
|
-
|
|
119
|
-
trim_trailing_tool_calls(
|
|
122
|
+
parent = select_messages(parent_message_scope, budget: remaining)
|
|
123
|
+
trim_trailing_tool_calls(parent) + own
|
|
120
124
|
else
|
|
121
|
-
|
|
125
|
+
own
|
|
122
126
|
end
|
|
123
127
|
end
|
|
124
128
|
|
|
125
|
-
# Recalculates the viewport and returns IDs of
|
|
126
|
-
# last snapshot. Updates the stored
|
|
127
|
-
# Piggybacks on
|
|
129
|
+
# Recalculates the viewport and returns IDs of messages evicted since the
|
|
130
|
+
# last snapshot. Updates the stored viewport_message_ids atomically.
|
|
131
|
+
# Piggybacks on message broadcasts to notify clients which messages left
|
|
128
132
|
# the LLM's context window.
|
|
129
133
|
#
|
|
130
|
-
# @return [Array<Integer>] IDs of
|
|
134
|
+
# @return [Array<Integer>] IDs of messages no longer in the viewport
|
|
131
135
|
def recalculate_viewport!
|
|
132
|
-
new_ids =
|
|
133
|
-
old_ids =
|
|
136
|
+
new_ids = viewport_messages.map(&:id)
|
|
137
|
+
old_ids = viewport_message_ids
|
|
134
138
|
|
|
135
139
|
evicted = old_ids - new_ids
|
|
136
|
-
update_column(:
|
|
140
|
+
update_column(:viewport_message_ids, new_ids) if old_ids != new_ids
|
|
137
141
|
evicted
|
|
138
142
|
end
|
|
139
143
|
|
|
@@ -142,21 +146,26 @@ class Session < ApplicationRecord
|
|
|
142
146
|
# where eviction notifications are unnecessary (clients clear their
|
|
143
147
|
# store first).
|
|
144
148
|
#
|
|
145
|
-
# @param ids [Array<Integer>]
|
|
149
|
+
# @param ids [Array<Integer>] message IDs now in the viewport
|
|
146
150
|
# @return [void]
|
|
147
151
|
def snapshot_viewport!(ids)
|
|
148
|
-
update_column(:
|
|
152
|
+
update_column(:viewport_message_ids, ids)
|
|
149
153
|
end
|
|
150
154
|
|
|
151
155
|
# Returns the system prompt for this session.
|
|
152
|
-
# Sub-agent sessions use their stored prompt
|
|
153
|
-
#
|
|
156
|
+
# Sub-agent sessions use their stored prompt plus active skills and
|
|
157
|
+
# the pinned task. Main sessions assemble a full system prompt from
|
|
158
|
+
# soul, environment, skills/workflow, and goals.
|
|
154
159
|
#
|
|
155
160
|
# @param environment_context [String, nil] pre-assembled environment block
|
|
156
161
|
# from {EnvironmentProbe}; injected between soul and expertise sections
|
|
157
162
|
# @return [String, nil] the system prompt text, or nil when nothing to inject
|
|
158
163
|
def system_prompt(environment_context: nil)
|
|
159
|
-
sub_agent?
|
|
164
|
+
if sub_agent?
|
|
165
|
+
[prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n")
|
|
166
|
+
else
|
|
167
|
+
assemble_system_prompt(environment_context: environment_context)
|
|
168
|
+
end
|
|
160
169
|
end
|
|
161
170
|
|
|
162
171
|
# Activates a skill on this session. Validates the skill exists in the
|
|
@@ -217,38 +226,43 @@ class Session < ApplicationRecord
|
|
|
217
226
|
save!
|
|
218
227
|
end
|
|
219
228
|
|
|
220
|
-
# Assembles the system prompt:
|
|
221
|
-
#
|
|
229
|
+
# Assembles the system prompt: version preamble, soul, environment context,
|
|
230
|
+
# skills/workflow, then goals.
|
|
222
231
|
# The soul is always present — "who am I" before "what can I do."
|
|
223
232
|
#
|
|
224
233
|
# @param environment_context [String, nil] pre-assembled environment block
|
|
225
234
|
# @return [String] composed system prompt
|
|
226
235
|
def assemble_system_prompt(environment_context: nil)
|
|
227
|
-
[assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
|
|
236
|
+
[assemble_version_preamble, assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
|
|
228
237
|
end
|
|
229
238
|
|
|
230
|
-
# Serializes
|
|
239
|
+
# Serializes non-evicted goals as a lightweight summary for ActionCable
|
|
231
240
|
# broadcasts and TUI display. Returns a nested structure: root goals
|
|
232
|
-
# with their sub-goals inlined.
|
|
241
|
+
# with their sub-goals inlined. Evicted goals and their sub-goals are
|
|
242
|
+
# excluded.
|
|
233
243
|
#
|
|
234
244
|
# @return [Array<Hash>] each with :id, :description, :status, and :sub_goals
|
|
235
245
|
def goals_summary
|
|
236
|
-
goals.root.includes(:sub_goals).order(:created_at).map(&:as_summary)
|
|
246
|
+
goals.root.not_evicted.includes(:sub_goals).order(:created_at).map(&:as_summary)
|
|
237
247
|
end
|
|
238
248
|
|
|
239
249
|
# Builds the message array expected by the Anthropic Messages API.
|
|
240
250
|
# Viewport layout (top to bottom):
|
|
241
|
-
# [L2 snapshots] [L1 snapshots] [pinned
|
|
251
|
+
# [L2 snapshots] [L1 snapshots] [pinned messages] [recalled memories] [sliding window messages]
|
|
242
252
|
#
|
|
243
|
-
# Snapshots appear ONLY after their source
|
|
253
|
+
# Snapshots appear ONLY after their source messages have evicted from
|
|
244
254
|
# the sliding window. L1 snapshots drop once covered by an L2 snapshot.
|
|
245
|
-
# Pinned
|
|
255
|
+
# Pinned messages are critical context attached to active Goals — they
|
|
246
256
|
# survive eviction intact until their Goals complete.
|
|
247
|
-
# Recalled memories surface relevant older
|
|
257
|
+
# Recalled memories surface relevant older messages (passive recall via goals).
|
|
248
258
|
# Each layer has a fixed token budget fraction — snapshots, pins, and recall
|
|
249
259
|
# consume viewport space, reducing the sliding window size.
|
|
250
260
|
#
|
|
251
|
-
#
|
|
261
|
+
# The sliding window is post-processed by {#ensure_atomic_tool_pairs}
|
|
262
|
+
# which removes orphaned tool messages whose partner was cut off by the
|
|
263
|
+
# token budget.
|
|
264
|
+
#
|
|
265
|
+
# Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent messages directly).
|
|
252
266
|
#
|
|
253
267
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
254
268
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
@@ -268,44 +282,44 @@ class Session < ApplicationRecord
|
|
|
268
282
|
sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
|
|
269
283
|
end
|
|
270
284
|
|
|
271
|
-
|
|
285
|
+
window = viewport_messages(token_budget: sliding_budget)
|
|
272
286
|
|
|
273
287
|
unless sub_agent?
|
|
274
|
-
|
|
275
|
-
snapshot_messages = assemble_snapshot_messages(
|
|
276
|
-
pinned_messages =
|
|
288
|
+
first_message_id = window.first&.id
|
|
289
|
+
snapshot_messages = assemble_snapshot_messages(first_message_id, l2_budget: l2_budget, l1_budget: l1_budget)
|
|
290
|
+
pinned_messages = assemble_pinned_section_messages(first_message_id, budget: pinned_budget)
|
|
277
291
|
recall_messages = assemble_recall_messages(budget: recall_budget)
|
|
278
292
|
end
|
|
279
293
|
|
|
280
|
-
snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(
|
|
294
|
+
snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(window))
|
|
281
295
|
end
|
|
282
296
|
|
|
283
|
-
# Detects orphaned tool_call
|
|
297
|
+
# Detects orphaned tool_call messages (those without a matching tool_response
|
|
284
298
|
# and whose timeout has expired) and creates synthetic error responses.
|
|
285
299
|
# An orphaned tool_call permanently breaks the session because the
|
|
286
300
|
# Anthropic API rejects conversations where a tool_use block has no
|
|
287
301
|
# matching tool_result.
|
|
288
302
|
#
|
|
289
|
-
# Respects the per-call timeout stored in the tool_call
|
|
303
|
+
# Respects the per-call timeout stored in the tool_call message payload —
|
|
290
304
|
# a tool_call is only healed after its deadline has passed. This avoids
|
|
291
305
|
# prematurely healing long-running tools that the agent intentionally
|
|
292
306
|
# gave an extended timeout.
|
|
293
307
|
#
|
|
294
308
|
# @return [Integer] number of synthetic responses created
|
|
295
309
|
def heal_orphaned_tool_calls!
|
|
296
|
-
|
|
297
|
-
responded_ids =
|
|
298
|
-
unresponded =
|
|
310
|
+
current_ns = now_ns
|
|
311
|
+
responded_ids = messages.where(message_type: "tool_response").select(:tool_use_id)
|
|
312
|
+
unresponded = messages.where(message_type: "tool_call")
|
|
299
313
|
.where.not(tool_use_id: responded_ids)
|
|
300
314
|
|
|
301
315
|
healed = 0
|
|
302
316
|
unresponded.find_each do |orphan|
|
|
303
317
|
timeout = orphan.payload["timeout"] || Anima::Settings.tool_timeout
|
|
304
318
|
deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
|
|
305
|
-
next if
|
|
319
|
+
next if current_ns < deadline_ns
|
|
306
320
|
|
|
307
|
-
|
|
308
|
-
|
|
321
|
+
messages.create!(
|
|
322
|
+
message_type: "tool_response",
|
|
309
323
|
payload: {
|
|
310
324
|
"type" => "tool_response",
|
|
311
325
|
"content" => "Tool execution timed out after #{timeout} seconds — no result was returned.",
|
|
@@ -314,7 +328,7 @@ class Session < ApplicationRecord
|
|
|
314
328
|
"success" => false
|
|
315
329
|
},
|
|
316
330
|
tool_use_id: orphan.tool_use_id,
|
|
317
|
-
timestamp:
|
|
331
|
+
timestamp: current_ns
|
|
318
332
|
)
|
|
319
333
|
healed += 1
|
|
320
334
|
end
|
|
@@ -323,57 +337,58 @@ class Session < ApplicationRecord
|
|
|
323
337
|
|
|
324
338
|
# Delivers a user message respecting the session's processing state.
|
|
325
339
|
#
|
|
326
|
-
# When idle, persists the
|
|
327
|
-
# to process it. When mid-turn ({#processing?}),
|
|
328
|
-
# {
|
|
329
|
-
#
|
|
330
|
-
# tool_use/tool_result pairs.
|
|
340
|
+
# When idle, persists the message directly and enqueues {AgentRequestJob}
|
|
341
|
+
# to process it. When mid-turn ({#processing?}), stages the message as
|
|
342
|
+
# a {PendingMessage} in a separate table — it gets no message ID until
|
|
343
|
+
# promoted, so it can never interleave with tool_call/tool_response pairs.
|
|
331
344
|
#
|
|
332
345
|
# @param content [String] user message text
|
|
333
|
-
# @param bounce_back [Boolean] when true, passes +
|
|
346
|
+
# @param bounce_back [Boolean] when true, passes +message_id+ to the job
|
|
334
347
|
# so failed LLM delivery triggers a {Events::BounceBack} (used by
|
|
335
348
|
# {SessionChannel#speak} for immediate-display messages)
|
|
336
349
|
# @return [void]
|
|
337
350
|
def enqueue_user_message(content, bounce_back: false)
|
|
338
351
|
if processing?
|
|
339
|
-
|
|
340
|
-
content: content, session_id: id,
|
|
341
|
-
status: Event::PENDING_STATUS
|
|
342
|
-
))
|
|
352
|
+
pending_messages.create!(content: content)
|
|
343
353
|
else
|
|
344
|
-
|
|
345
|
-
job_args = bounce_back ? {
|
|
354
|
+
msg = create_user_message(content)
|
|
355
|
+
job_args = bounce_back ? {message_id: msg.id} : {}
|
|
346
356
|
AgentRequestJob.perform_later(id, **job_args)
|
|
347
357
|
end
|
|
348
358
|
end
|
|
349
359
|
|
|
350
|
-
# Persists a user message
|
|
360
|
+
# Persists a user message directly, bypassing the pending queue.
|
|
351
361
|
#
|
|
352
|
-
# Used by {#enqueue_user_message} (idle path), {AgentLoop#
|
|
362
|
+
# Used by {#enqueue_user_message} (idle path), {AgentLoop#run},
|
|
353
363
|
# and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
|
|
354
364
|
# because the global {Events::Subscribers::Persister} skips non-pending user
|
|
355
365
|
# messages — these callers own the persistence lifecycle.
|
|
356
366
|
#
|
|
357
367
|
# @param content [String] user message text
|
|
358
|
-
# @return [
|
|
359
|
-
def
|
|
360
|
-
now =
|
|
361
|
-
|
|
362
|
-
|
|
368
|
+
# @return [Message] the persisted message record
|
|
369
|
+
def create_user_message(content)
|
|
370
|
+
now = now_ns
|
|
371
|
+
messages.create!(
|
|
372
|
+
message_type: "user_message",
|
|
363
373
|
payload: {type: "user_message", content: content, session_id: id, timestamp: now},
|
|
364
374
|
timestamp: now
|
|
365
375
|
)
|
|
366
376
|
end
|
|
367
377
|
|
|
368
|
-
# Promotes all pending
|
|
369
|
-
#
|
|
370
|
-
#
|
|
378
|
+
# Promotes all pending messages into the conversation history.
|
|
379
|
+
# Each {PendingMessage} is atomically deleted and replaced with a real
|
|
380
|
+
# {Message} — the new message gets the next auto-increment ID,
|
|
381
|
+
# naturally placing it after any tool_call/tool_response pairs that
|
|
382
|
+
# were persisted while the message was waiting.
|
|
371
383
|
#
|
|
372
384
|
# @return [Integer] number of promoted messages
|
|
373
385
|
def promote_pending_messages!
|
|
374
386
|
promoted = 0
|
|
375
|
-
|
|
376
|
-
|
|
387
|
+
pending_messages.find_each do |pm|
|
|
388
|
+
transaction do
|
|
389
|
+
create_user_message(pm.content)
|
|
390
|
+
pm.destroy!
|
|
391
|
+
end
|
|
377
392
|
promoted += 1
|
|
378
393
|
end
|
|
379
394
|
promoted
|
|
@@ -396,12 +411,112 @@ class Session < ApplicationRecord
|
|
|
396
411
|
ActionCable.server.broadcast("session_#{parent_session_id}", {
|
|
397
412
|
"action" => "children_updated",
|
|
398
413
|
"session_id" => parent_session_id,
|
|
399
|
-
"children" => children.map { |child|
|
|
414
|
+
"children" => children.map { |child|
|
|
415
|
+
state = child.processing? ? "llm_generating" : "idle"
|
|
416
|
+
{"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
|
|
417
|
+
}
|
|
400
418
|
})
|
|
401
419
|
end
|
|
402
420
|
|
|
421
|
+
# Broadcasts the session's current processing state to all subscribed
|
|
422
|
+
# clients. Stateless — no storage, pure broadcast. The TUI uses this to
|
|
423
|
+
# drive the braille spinner animation and sub-agent HUD icons.
|
|
424
|
+
#
|
|
425
|
+
# Payload broadcast to +session_{id}+:
|
|
426
|
+
# {"action" => "session_state", "state" => state, "session_id" => id}
|
|
427
|
+
# # plus "tool" key when state is "tool_executing"
|
|
428
|
+
#
|
|
429
|
+
# For sub-agents, also broadcasts +child_state+ to the parent stream:
|
|
430
|
+
# {"action" => "child_state", "state" => state, "session_id" => id, "child_id" => id}
|
|
431
|
+
#
|
|
432
|
+
# @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
|
|
433
|
+
# @param tool [String, nil] tool name when state is "tool_executing"
|
|
434
|
+
# @return [void]
|
|
435
|
+
def broadcast_session_state(state, tool: nil)
|
|
436
|
+
payload = {"action" => "session_state", "state" => state, "session_id" => id}
|
|
437
|
+
payload["tool"] = tool if tool
|
|
438
|
+
ActionCable.server.broadcast("session_#{id}", payload)
|
|
439
|
+
|
|
440
|
+
# Notify the parent's stream so the HUD updates child state icons
|
|
441
|
+
# without requiring a full children_updated query.
|
|
442
|
+
return unless parent_session_id
|
|
443
|
+
|
|
444
|
+
parent_payload = payload.merge("action" => "child_state", "child_id" => id)
|
|
445
|
+
ActionCable.server.broadcast("session_#{parent_session_id}", parent_payload)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Broadcasts the full LLM debug context to debug-mode TUI clients.
|
|
449
|
+
# Called on every LLM request so the TUI shows exactly what the LLM
|
|
450
|
+
# receives — system prompt and tool schemas. No-op outside debug mode.
|
|
451
|
+
#
|
|
452
|
+
# @param system [String, nil] the final system prompt sent to the LLM
|
|
453
|
+
# @param tools [Array<Hash>, nil] tool schemas sent to the LLM
|
|
454
|
+
# @return [void]
|
|
455
|
+
def broadcast_debug_context(system:, tools: nil)
|
|
456
|
+
return unless view_mode == "debug" && system
|
|
457
|
+
|
|
458
|
+
ActionCable.server.broadcast("session_#{id}", self.class.system_prompt_payload(system, tools: tools))
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Returns the deterministic tool schemas for this session's type and
|
|
462
|
+
# granted_tools configuration. Standard and spawn tools are static
|
|
463
|
+
# class-level definitions — no ShellSession or registry needed.
|
|
464
|
+
# MCP tools are excluded (they require live server queries and appear
|
|
465
|
+
# after the first LLM request via {#broadcast_debug_context}).
|
|
466
|
+
#
|
|
467
|
+
# @return [Array<Hash>] tool schema hashes matching Anthropic tools API format
|
|
468
|
+
def tool_schemas
|
|
469
|
+
tools = if granted_tools
|
|
470
|
+
granted = granted_tools.filter_map { |name| AgentLoop::STANDARD_TOOLS_BY_NAME[name] }
|
|
471
|
+
(AgentLoop::ALWAYS_GRANTED_TOOLS + granted).uniq
|
|
472
|
+
else
|
|
473
|
+
AgentLoop::STANDARD_TOOLS.dup
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
unless sub_agent?
|
|
477
|
+
tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
if sub_agent?
|
|
481
|
+
tools.push(Tools::MarkGoalCompleted)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
tools.map(&:schema)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Builds the system prompt payload for debug mode transmission.
|
|
488
|
+
# Token estimate covers both the system prompt and tool schemas
|
|
489
|
+
# since both consume the LLM's context window.
|
|
490
|
+
# Tools are sent as raw schemas; the TUI formats them as TOON for display.
|
|
491
|
+
#
|
|
492
|
+
# @param prompt [String] system prompt text
|
|
493
|
+
# @param tools [Array<Hash>, nil] tool schemas
|
|
494
|
+
# @return [Hash] payload with type, rendered debug content, and token estimate
|
|
495
|
+
def self.system_prompt_payload(prompt, tools: nil)
|
|
496
|
+
total_bytes = prompt.bytesize
|
|
497
|
+
total_bytes += tools.to_json.bytesize if tools&.any?
|
|
498
|
+
tokens = Message.estimate_token_count(total_bytes)
|
|
499
|
+
|
|
500
|
+
debug = {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
|
|
501
|
+
debug[:tools] = tools if tools&.any?
|
|
502
|
+
|
|
503
|
+
{
|
|
504
|
+
"id" => Message::SYSTEM_PROMPT_ID,
|
|
505
|
+
"type" => "system_prompt",
|
|
506
|
+
"rendered" => {"debug" => debug}
|
|
507
|
+
}
|
|
508
|
+
end
|
|
509
|
+
|
|
403
510
|
private
|
|
404
511
|
|
|
512
|
+
# One-line version preamble so the agent knows its own version.
|
|
513
|
+
# Useful for commits, handoffs, and debugging.
|
|
514
|
+
#
|
|
515
|
+
# @return [String] e.g. "You are running on Anima v1.1.3"
|
|
516
|
+
def assemble_version_preamble
|
|
517
|
+
"You are running on Anima v#{Anima::VERSION}"
|
|
518
|
+
end
|
|
519
|
+
|
|
405
520
|
# Reads the soul file — the agent's self-authored identity.
|
|
406
521
|
# Loaded as the first section of every system prompt, before skills,
|
|
407
522
|
# workflows, and goals.
|
|
@@ -438,17 +553,55 @@ class Session < ApplicationRecord
|
|
|
438
553
|
"## Your Expertise\n\nYou know this deeply. Now's your chance to put it to work.\n\n#{sections.join("\n\n")}"
|
|
439
554
|
end
|
|
440
555
|
|
|
556
|
+
# Evicts completed goals that have aged past the configured threshold
|
|
557
|
+
# of meaningful messages (user + agent turns). Pure arithmetic — no LLM
|
|
558
|
+
# involvement. Called before prompt assembly so evicted goals are
|
|
559
|
+
# excluded from the very next context window.
|
|
560
|
+
#
|
|
561
|
+
# @return [void]
|
|
562
|
+
def evict_stale_goals!
|
|
563
|
+
threshold = Anima::Settings.completed_decay_messages
|
|
564
|
+
goals.evictable.each do |goal|
|
|
565
|
+
messages_since = messages.llm_messages.where("created_at > ?", goal.completed_at).count
|
|
566
|
+
goal.update!(evicted_at: Time.current) if messages_since >= threshold
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
441
570
|
# Assembles the goals section of the system prompt.
|
|
571
|
+
# Automatically evicts stale completed goals before filtering.
|
|
442
572
|
# Active root goals render as `###` headings with sub-goal checkboxes.
|
|
443
573
|
# Completed root goals collapse to a single strikethrough line.
|
|
574
|
+
# Evicted goals are excluded entirely to free context budget.
|
|
444
575
|
#
|
|
445
576
|
# @return [String, nil] goals section, or nil when no goals exist
|
|
446
577
|
def assemble_goals_section
|
|
447
|
-
|
|
578
|
+
evict_stale_goals!
|
|
579
|
+
|
|
580
|
+
root_goals = goals.root.not_evicted.includes(:sub_goals).order(:created_at)
|
|
448
581
|
return if root_goals.empty?
|
|
449
582
|
|
|
450
583
|
entries = root_goals.map { |goal| render_goal_markdown(goal) }
|
|
451
|
-
"
|
|
584
|
+
"Current Goals\n=============\n\n#{entries.join("\n\n")}"
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Assembles the task section for sub-agent system prompts.
|
|
588
|
+
# Sub-agents have a single pinned goal — their entire raison d'etre.
|
|
589
|
+
# Rendered as a persistent task block so the LLM always knows what it
|
|
590
|
+
# was spawned to do, regardless of conversation length.
|
|
591
|
+
#
|
|
592
|
+
# @return [String, nil] task section, or nil when no active goal exists
|
|
593
|
+
def assemble_task_section
|
|
594
|
+
goal = goals.active.root.first
|
|
595
|
+
return unless goal
|
|
596
|
+
|
|
597
|
+
<<~SECTION.strip
|
|
598
|
+
Your Task
|
|
599
|
+
=========
|
|
600
|
+
|
|
601
|
+
#{goal.description}
|
|
602
|
+
|
|
603
|
+
Complete this task and call mark_goal_completed when done.
|
|
604
|
+
SECTION
|
|
452
605
|
end
|
|
453
606
|
|
|
454
607
|
# Renders a single root goal with its sub-goals as Markdown.
|
|
@@ -520,40 +673,38 @@ class Session < ApplicationRecord
|
|
|
520
673
|
})
|
|
521
674
|
end
|
|
522
675
|
|
|
523
|
-
# Scopes own
|
|
676
|
+
# Scopes own messages for viewport assembly.
|
|
524
677
|
# @return [ActiveRecord::Relation]
|
|
525
|
-
def
|
|
526
|
-
|
|
527
|
-
include_pending ? scope : scope.deliverable
|
|
678
|
+
def own_message_scope
|
|
679
|
+
messages.context_messages
|
|
528
680
|
end
|
|
529
681
|
|
|
530
|
-
# Scopes parent
|
|
531
|
-
# Excludes spawn tool
|
|
682
|
+
# Scopes parent messages created before this session's fork point.
|
|
683
|
+
# Excludes spawn tool messages — sub-agents don't need to see sibling
|
|
532
684
|
# spawn pairs, which cause role confusion (the sub-agent mistakes
|
|
533
685
|
# itself for the parent when it sees "Specialist @sibling spawned...").
|
|
534
686
|
# @return [ActiveRecord::Relation]
|
|
535
|
-
def
|
|
536
|
-
|
|
537
|
-
.
|
|
687
|
+
def parent_message_scope
|
|
688
|
+
parent_session.messages.context_messages
|
|
689
|
+
.excluding_spawn_messages
|
|
538
690
|
.where(created_at: ...created_at)
|
|
539
|
-
include_pending ? scope : scope.deliverable
|
|
540
691
|
end
|
|
541
692
|
|
|
542
|
-
# Walks
|
|
543
|
-
# Always includes at least the newest
|
|
693
|
+
# Walks messages newest-first, selecting until the token budget is exhausted.
|
|
694
|
+
# Always includes at least the newest message even if it exceeds budget.
|
|
544
695
|
#
|
|
545
|
-
# @param scope [ActiveRecord::Relation]
|
|
696
|
+
# @param scope [ActiveRecord::Relation] message scope to select from
|
|
546
697
|
# @param budget [Integer] maximum tokens to include
|
|
547
|
-
# @return [Array<
|
|
548
|
-
def
|
|
698
|
+
# @return [Array<Message>] chronologically ordered
|
|
699
|
+
def select_messages(scope, budget:)
|
|
549
700
|
selected = []
|
|
550
701
|
remaining = budget
|
|
551
702
|
|
|
552
|
-
scope.reorder(id: :desc).each do |
|
|
553
|
-
cost =
|
|
703
|
+
scope.reorder(id: :desc).each do |msg|
|
|
704
|
+
cost = message_token_cost(msg)
|
|
554
705
|
break if cost > remaining && selected.any?
|
|
555
706
|
|
|
556
|
-
selected <<
|
|
707
|
+
selected << msg
|
|
557
708
|
remaining -= cost
|
|
558
709
|
end
|
|
559
710
|
|
|
@@ -561,59 +712,59 @@ class Session < ApplicationRecord
|
|
|
561
712
|
end
|
|
562
713
|
|
|
563
714
|
# @return [Integer] token cost, using cached count or heuristic estimate
|
|
564
|
-
def
|
|
565
|
-
(
|
|
715
|
+
def message_token_cost(msg)
|
|
716
|
+
(msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
|
|
566
717
|
end
|
|
567
718
|
|
|
568
|
-
# Removes trailing tool_call
|
|
719
|
+
# Removes trailing tool_call messages that lack matching tool_response.
|
|
569
720
|
# Prevents orphaned tool_use blocks at the parent/child viewport boundary
|
|
570
721
|
# (the spawn_subagent/spawn_specialist tool_call is emitted before the child exists,
|
|
571
722
|
# but its tool_response comes after — so the cutoff can split them).
|
|
572
|
-
def trim_trailing_tool_calls(
|
|
573
|
-
|
|
574
|
-
|
|
723
|
+
def trim_trailing_tool_calls(message_list)
|
|
724
|
+
message_list.pop while message_list.last&.message_type == "tool_call"
|
|
725
|
+
message_list
|
|
575
726
|
end
|
|
576
727
|
|
|
577
|
-
# Ensures every tool_call in the
|
|
578
|
-
# (and vice versa) by removing unpaired
|
|
728
|
+
# Ensures every tool_call in the message list has a matching tool_response
|
|
729
|
+
# (and vice versa) by removing unpaired messages. The Anthropic API requires
|
|
579
730
|
# every tool_use block to have a tool_result — a missing partner causes
|
|
580
731
|
# a permanent API error. Token budget cutoffs can split pairs when the
|
|
581
732
|
# boundary falls between a tool_call and its tool_response.
|
|
582
733
|
#
|
|
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 <<
|
|
734
|
+
# @param message_list [Array<Message>] chronologically ordered messages
|
|
735
|
+
# @return [Array<Message>] messages with unpaired tool messages removed
|
|
736
|
+
def ensure_atomic_tool_pairs(message_list)
|
|
737
|
+
tool_msgs = message_list.select { |m| m.tool_use_id.present? }
|
|
738
|
+
return message_list if tool_msgs.empty?
|
|
739
|
+
|
|
740
|
+
paired = tool_msgs.group_by(&:tool_use_id)
|
|
741
|
+
complete_ids = paired.each_with_object(Set.new) do |(uid, msgs), set|
|
|
742
|
+
has_call = msgs.any? { |m| m.message_type == "tool_call" }
|
|
743
|
+
has_response = msgs.any? { |m| m.message_type == "tool_response" }
|
|
744
|
+
set << uid if has_call && has_response
|
|
594
745
|
end
|
|
595
746
|
|
|
596
|
-
|
|
747
|
+
message_list.reject { |m| m.tool_use_id.present? && !complete_ids.include?(m.tool_use_id) }
|
|
597
748
|
end
|
|
598
749
|
|
|
599
750
|
# Selects visible snapshots and formats them as Anthropic messages.
|
|
600
|
-
# Snapshots are visible when their source
|
|
751
|
+
# Snapshots are visible when their source messages have fully evicted.
|
|
601
752
|
# L1 snapshots are excluded when covered by an L2 snapshot.
|
|
602
753
|
#
|
|
603
|
-
# @param
|
|
754
|
+
# @param first_message_id [Integer, nil] first message ID in the sliding window
|
|
604
755
|
# @param l2_budget [Integer] token budget for L2 snapshots
|
|
605
756
|
# @param l1_budget [Integer] token budget for L1 snapshots
|
|
606
757
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
607
|
-
def assemble_snapshot_messages(
|
|
608
|
-
return [] unless
|
|
758
|
+
def assemble_snapshot_messages(first_message_id, l2_budget:, l1_budget:)
|
|
759
|
+
return [] unless first_message_id
|
|
609
760
|
|
|
610
761
|
l2_messages = select_snapshots_within_budget(
|
|
611
|
-
snapshots.for_level(2).
|
|
762
|
+
snapshots.for_level(2).source_messages_evicted(first_message_id).chronological,
|
|
612
763
|
budget: l2_budget
|
|
613
764
|
).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
|
|
614
765
|
|
|
615
766
|
l1_messages = select_snapshots_within_budget(
|
|
616
|
-
snapshots.for_level(1).not_covered_by_l2.
|
|
767
|
+
snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(first_message_id).chronological,
|
|
617
768
|
budget: l1_budget
|
|
618
769
|
).map { |snapshot| format_snapshot_message(snapshot, label: "recent memory") }
|
|
619
770
|
|
|
@@ -651,39 +802,39 @@ class Session < ApplicationRecord
|
|
|
651
802
|
{role: "user", content: "[#{label}]\n#{snapshot.text}"}
|
|
652
803
|
end
|
|
653
804
|
|
|
654
|
-
# Assembles pinned
|
|
655
|
-
# Only includes pinned
|
|
656
|
-
# sliding window (same rule as snapshots — no duplication with live
|
|
805
|
+
# Assembles pinned messages as a Goals section message for the viewport.
|
|
806
|
+
# Only includes pinned messages whose source message has evicted from the
|
|
807
|
+
# sliding window (same rule as snapshots — no duplication with live messages).
|
|
657
808
|
#
|
|
658
|
-
# Deduplication: the first Goal referencing
|
|
659
|
-
# display_text; subsequent Goals show a bare `
|
|
809
|
+
# Deduplication: the first Goal referencing a message shows its truncated
|
|
810
|
+
# display_text; subsequent Goals show a bare `message N` ID to save tokens.
|
|
660
811
|
#
|
|
661
|
-
# @param
|
|
662
|
-
# @param budget [Integer] token budget for pinned
|
|
812
|
+
# @param first_message_id [Integer, nil] first message ID in the sliding window
|
|
813
|
+
# @param budget [Integer] token budget for pinned messages
|
|
663
814
|
# @return [Array<Hash>] Anthropic Messages API format (0 or 1 messages)
|
|
664
|
-
def
|
|
665
|
-
return [] unless
|
|
815
|
+
def assemble_pinned_section_messages(first_message_id, budget:)
|
|
816
|
+
return [] unless first_message_id
|
|
666
817
|
|
|
667
|
-
pins =
|
|
668
|
-
.includes(:
|
|
669
|
-
.where("
|
|
670
|
-
.order("
|
|
818
|
+
pins = pinned_messages
|
|
819
|
+
.includes(:message, :goals)
|
|
820
|
+
.where("pinned_messages.message_id < ?", first_message_id)
|
|
821
|
+
.order("pinned_messages.message_id")
|
|
671
822
|
|
|
672
823
|
return [] if pins.empty?
|
|
673
824
|
|
|
674
825
|
selected = select_pins_within_budget(pins, budget)
|
|
675
826
|
return [] if selected.empty?
|
|
676
827
|
|
|
677
|
-
text =
|
|
678
|
-
[{role: "user", content: "[pinned
|
|
828
|
+
text = render_pinned_messages_section(selected)
|
|
829
|
+
[{role: "user", content: "[pinned messages]\n#{text}"}]
|
|
679
830
|
end
|
|
680
831
|
|
|
681
|
-
# Walks pinned
|
|
832
|
+
# Walks pinned messages chronologically, selecting until the token budget
|
|
682
833
|
# is exhausted. Always includes at least one pin.
|
|
683
834
|
#
|
|
684
|
-
# @param pins [Array<
|
|
835
|
+
# @param pins [Array<PinnedMessage>]
|
|
685
836
|
# @param budget [Integer]
|
|
686
|
-
# @return [Array<
|
|
837
|
+
# @return [Array<PinnedMessage>]
|
|
687
838
|
def select_pins_within_budget(pins, budget)
|
|
688
839
|
selected = []
|
|
689
840
|
remaining = budget
|
|
@@ -699,26 +850,26 @@ class Session < ApplicationRecord
|
|
|
699
850
|
selected
|
|
700
851
|
end
|
|
701
852
|
|
|
702
|
-
# Renders the pinned
|
|
853
|
+
# Renders the pinned messages section grouped by Goal.
|
|
703
854
|
# First Goal referencing a pin shows truncated text; subsequent Goals
|
|
704
|
-
# show bare `
|
|
855
|
+
# show bare `message N` ID to avoid token-expensive repetition.
|
|
705
856
|
#
|
|
706
|
-
# @param pins [Array<
|
|
857
|
+
# @param pins [Array<PinnedMessage>] selected pins with preloaded goals
|
|
707
858
|
# @return [String] formatted section text
|
|
708
|
-
def
|
|
859
|
+
def render_pinned_messages_section(pins)
|
|
709
860
|
goal_pins = group_pins_by_active_goal(pins)
|
|
710
861
|
|
|
711
|
-
|
|
862
|
+
shown_messages = Set.new
|
|
712
863
|
goal_pins.map { |goal, pin_list|
|
|
713
|
-
render_goal_pins(goal, pin_list,
|
|
864
|
+
render_goal_pins(goal, pin_list, shown_messages)
|
|
714
865
|
}.join("\n\n")
|
|
715
866
|
end
|
|
716
867
|
|
|
717
868
|
# Groups pins by their active Goals so the viewport renders
|
|
718
869
|
# one headed section per Goal.
|
|
719
870
|
#
|
|
720
|
-
# @param pins [Array<
|
|
721
|
-
# @return [Hash{Goal => Array<
|
|
871
|
+
# @param pins [Array<PinnedMessage>] pins with preloaded goals
|
|
872
|
+
# @return [Hash{Goal => Array<PinnedMessage>}]
|
|
722
873
|
def group_pins_by_active_goal(pins)
|
|
723
874
|
pairs = pins.flat_map { |pin| active_goal_pin_pairs(pin) }
|
|
724
875
|
pairs.group_by(&:first).transform_values { |group| group.map(&:last) }
|
|
@@ -727,61 +878,61 @@ class Session < ApplicationRecord
|
|
|
727
878
|
# Expands a single pin into [goal, pin] pairs for each active Goal
|
|
728
879
|
# referencing it. Uses in-memory filter on preloaded goals.
|
|
729
880
|
#
|
|
730
|
-
# @param pin [
|
|
731
|
-
# @return [Array<Array(Goal,
|
|
881
|
+
# @param pin [PinnedMessage]
|
|
882
|
+
# @return [Array<Array(Goal, PinnedMessage)>]
|
|
732
883
|
def active_goal_pin_pairs(pin)
|
|
733
884
|
pin.goals.select(&:active?).map { |goal| [goal, pin] }
|
|
734
885
|
end
|
|
735
886
|
|
|
736
|
-
# Renders one Goal's pinned
|
|
887
|
+
# Renders one Goal's pinned messages as a headed list.
|
|
737
888
|
#
|
|
738
889
|
# @param goal [Goal]
|
|
739
|
-
# @param pin_list [Array<
|
|
740
|
-
# @param
|
|
890
|
+
# @param pin_list [Array<PinnedMessage>]
|
|
891
|
+
# @param shown_messages [Set<Integer>] tracks already-rendered message IDs for dedup
|
|
741
892
|
# @return [String]
|
|
742
|
-
def render_goal_pins(goal, pin_list,
|
|
893
|
+
def render_goal_pins(goal, pin_list, shown_messages)
|
|
743
894
|
lines = ["📌 #{goal.description} (id: #{goal.id})"]
|
|
744
|
-
pin_list.each { |pin| lines << format_pin_line(pin,
|
|
895
|
+
pin_list.each { |pin| lines << format_pin_line(pin, shown_messages) }
|
|
745
896
|
lines.join("\n")
|
|
746
897
|
end
|
|
747
898
|
|
|
748
899
|
# Formats a single pin line with deduplication: first occurrence shows
|
|
749
|
-
# truncated text, subsequent occurrences show bare
|
|
900
|
+
# truncated text, subsequent occurrences show bare message ID only.
|
|
750
901
|
#
|
|
751
|
-
# @param pin [
|
|
752
|
-
# @param
|
|
902
|
+
# @param pin [PinnedMessage]
|
|
903
|
+
# @param shown_messages [Set<Integer>]
|
|
753
904
|
# @return [String]
|
|
754
|
-
def format_pin_line(pin,
|
|
755
|
-
|
|
756
|
-
if
|
|
757
|
-
"
|
|
905
|
+
def format_pin_line(pin, shown_messages)
|
|
906
|
+
mid = pin.message_id
|
|
907
|
+
if shown_messages.add?(mid)
|
|
908
|
+
" message #{mid}: #{pin.display_text}"
|
|
758
909
|
else
|
|
759
|
-
"
|
|
910
|
+
" message #{mid}"
|
|
760
911
|
end
|
|
761
912
|
end
|
|
762
913
|
|
|
763
914
|
# Assembles recalled memory messages from passive recall results.
|
|
764
|
-
# Recalled
|
|
765
|
-
# with session and
|
|
915
|
+
# Recalled messages are fetched by ID and formatted as compact snippets
|
|
916
|
+
# with session and message context for drill-down via the remember tool.
|
|
766
917
|
#
|
|
767
918
|
# @param budget [Integer] token budget for recall messages
|
|
768
919
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
769
920
|
def assemble_recall_messages(budget:)
|
|
770
|
-
return [] if
|
|
921
|
+
return [] if recalled_message_ids.blank?
|
|
771
922
|
|
|
772
|
-
|
|
923
|
+
recalled = Message.where(id: recalled_message_ids)
|
|
773
924
|
.includes(:session)
|
|
774
925
|
.index_by(&:id)
|
|
775
926
|
|
|
776
927
|
snippets = []
|
|
777
928
|
remaining = budget
|
|
778
929
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
next unless
|
|
930
|
+
recalled_message_ids.each do |mid|
|
|
931
|
+
msg = recalled[mid]
|
|
932
|
+
next unless msg
|
|
782
933
|
|
|
783
|
-
text = format_recall_snippet(
|
|
784
|
-
cost =
|
|
934
|
+
text = format_recall_snippet(msg)
|
|
935
|
+
cost = Message.estimate_token_count(text.bytesize)
|
|
785
936
|
break if cost > remaining && snippets.any?
|
|
786
937
|
|
|
787
938
|
snippets << text
|
|
@@ -793,28 +944,28 @@ class Session < ApplicationRecord
|
|
|
793
944
|
[{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
|
|
794
945
|
end
|
|
795
946
|
|
|
796
|
-
# Formats a recalled
|
|
947
|
+
# Formats a recalled message as a compact snippet with enough context
|
|
797
948
|
# for the agent to decide whether to drill down with the remember tool.
|
|
798
949
|
#
|
|
799
|
-
# @param
|
|
950
|
+
# @param msg [Message] the recalled message
|
|
800
951
|
# @return [String] formatted snippet
|
|
801
|
-
def format_recall_snippet(
|
|
802
|
-
session_label =
|
|
803
|
-
content =
|
|
804
|
-
"
|
|
952
|
+
def format_recall_snippet(msg)
|
|
953
|
+
session_label = msg.session.name || "session ##{msg.session_id}"
|
|
954
|
+
content = extract_message_content(msg).to_s.truncate(Anima::Settings.recall_max_snippet_tokens * Message::BYTES_PER_TOKEN)
|
|
955
|
+
"message #{msg.id} (#{session_label}): #{content}"
|
|
805
956
|
end
|
|
806
957
|
|
|
807
|
-
# Extracts readable content from
|
|
958
|
+
# Extracts readable content from a message's payload.
|
|
808
959
|
#
|
|
809
|
-
# @param
|
|
960
|
+
# @param msg [Message]
|
|
810
961
|
# @return [String]
|
|
811
|
-
def
|
|
812
|
-
data =
|
|
813
|
-
case
|
|
962
|
+
def extract_message_content(msg)
|
|
963
|
+
data = msg.payload
|
|
964
|
+
case msg.message_type
|
|
814
965
|
when "user_message", "agent_message", "system_message"
|
|
815
966
|
data["content"]
|
|
816
967
|
when "tool_call"
|
|
817
|
-
if data["tool_name"] ==
|
|
968
|
+
if data["tool_name"] == Message::THINK_TOOL
|
|
818
969
|
data.dig("tool_input", "thoughts")
|
|
819
970
|
else
|
|
820
971
|
"#{data["tool_name"]}(…)"
|
|
@@ -824,39 +975,39 @@ class Session < ApplicationRecord
|
|
|
824
975
|
end
|
|
825
976
|
end
|
|
826
977
|
|
|
827
|
-
# Converts a chronological list of
|
|
978
|
+
# Converts a chronological list of messages into Anthropic wire-format messages.
|
|
828
979
|
# Prepends a compact timestamp to each user message for LLM time awareness.
|
|
829
|
-
# Groups consecutive tool_call
|
|
830
|
-
# consecutive tool_response
|
|
980
|
+
# Groups consecutive tool_call messages into one assistant message and
|
|
981
|
+
# consecutive tool_response messages into one user message.
|
|
831
982
|
#
|
|
832
|
-
# @param
|
|
983
|
+
# @param msgs [Array<Message>]
|
|
833
984
|
# @return [Array<Hash>]
|
|
834
|
-
def assemble_messages(
|
|
835
|
-
|
|
836
|
-
case
|
|
985
|
+
def assemble_messages(msgs)
|
|
986
|
+
msgs.each_with_object([]) do |msg, api_messages|
|
|
987
|
+
case msg.message_type
|
|
837
988
|
when "user_message"
|
|
838
|
-
content = "#{
|
|
839
|
-
|
|
989
|
+
content = "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"
|
|
990
|
+
api_messages << {role: "user", content: content}
|
|
840
991
|
when "agent_message"
|
|
841
|
-
|
|
992
|
+
api_messages << {role: "assistant", content: msg.payload["content"].to_s}
|
|
842
993
|
when "tool_call"
|
|
843
|
-
append_grouped_block(
|
|
994
|
+
append_grouped_block(api_messages, "assistant", tool_use_block(msg.payload))
|
|
844
995
|
when "tool_response"
|
|
845
|
-
append_grouped_block(
|
|
996
|
+
append_grouped_block(api_messages, "user", tool_result_block(msg.payload))
|
|
846
997
|
when "system_message"
|
|
847
998
|
# Wrapped as user role with prefix — Claude API has no system role in conversation history
|
|
848
|
-
|
|
999
|
+
api_messages << {role: "user", content: "[system] #{msg.payload["content"]}"}
|
|
849
1000
|
end
|
|
850
1001
|
end
|
|
851
1002
|
end
|
|
852
1003
|
|
|
853
1004
|
# Groups consecutive tool blocks into a single message of the given role.
|
|
854
|
-
def append_grouped_block(
|
|
855
|
-
prev =
|
|
1005
|
+
def append_grouped_block(api_messages, role, block)
|
|
1006
|
+
prev = api_messages.last
|
|
856
1007
|
if prev&.dig(:role) == role && prev[:content].is_a?(Array)
|
|
857
1008
|
prev[:content] << block
|
|
858
1009
|
else
|
|
859
|
-
|
|
1010
|
+
api_messages << {role: role, content: [block]}
|
|
860
1011
|
end
|
|
861
1012
|
end
|
|
862
1013
|
|
|
@@ -877,23 +1028,31 @@ class Session < ApplicationRecord
|
|
|
877
1028
|
}
|
|
878
1029
|
end
|
|
879
1030
|
|
|
880
|
-
# Formats
|
|
1031
|
+
# Formats a message's nanosecond timestamp as a compact time prefix for LLM context.
|
|
881
1032
|
# Gives the agent awareness of time of day, day of week, and pauses between messages.
|
|
882
1033
|
#
|
|
883
1034
|
# @param timestamp_ns [Integer] nanoseconds since epoch
|
|
884
1035
|
# @return [String] e.g. "Sat Mar 14 09:51"
|
|
885
1036
|
# @example
|
|
886
|
-
#
|
|
887
|
-
def
|
|
888
|
-
Time.at(timestamp_ns / 1_000_000_000.0).strftime("%a %b %-d %H:%M")
|
|
1037
|
+
# format_message_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
|
|
1038
|
+
def format_message_time(timestamp_ns)
|
|
1039
|
+
Time.at(timestamp_ns / 1_000_000_000.0).utc.strftime("%a %b %-d %H:%M")
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
# Current time as nanoseconds since epoch. Uses Time.current so
|
|
1043
|
+
# ActiveSupport's freeze_time works in tests.
|
|
1044
|
+
#
|
|
1045
|
+
# @return [Integer] nanoseconds since epoch
|
|
1046
|
+
def now_ns
|
|
1047
|
+
Time.current.to_ns
|
|
889
1048
|
end
|
|
890
1049
|
|
|
891
|
-
# Delegates to {
|
|
1050
|
+
# Delegates to {Message#estimate_tokens} for messages not yet counted
|
|
892
1051
|
# by the background job.
|
|
893
1052
|
#
|
|
894
|
-
# @param
|
|
1053
|
+
# @param msg [Message]
|
|
895
1054
|
# @return [Integer] at least 1
|
|
896
|
-
def estimate_tokens(
|
|
897
|
-
|
|
1055
|
+
def estimate_tokens(msg)
|
|
1056
|
+
msg.estimate_tokens
|
|
898
1057
|
end
|
|
899
1058
|
end
|