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