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
|
@@ -1,35 +1,420 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# A
|
|
3
|
+
# A message waiting to enter a session's conversation history.
|
|
4
4
|
# Pending messages live in their own table — they are NOT part of the
|
|
5
5
|
# message stream and have no database ID that could interleave with
|
|
6
6
|
# tool_call/tool_response pairs.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# Entry point of the event-driven drain pipeline. Every inbound
|
|
9
|
+
# message destined for the LLM — user input, tool responses,
|
|
10
|
+
# sub-agent replies, Mneme recalls, Melete skills/goals — lands here
|
|
11
|
+
# first, then gets promoted into a real {Message} by {DrainJob}.
|
|
12
|
+
#
|
|
13
|
+
# Each pending message knows its source (+source_type+, +source_name+)
|
|
14
|
+
# and how to serialize itself for the LLM conversation via {#to_llm_messages}.
|
|
15
|
+
# Non-user messages (sub-agent results, recalled skills, workflows, recall,
|
|
16
|
+
# goal events) become synthetic tool_use/tool_result pairs so the LLM sees
|
|
17
|
+
# "a tool I invoked returned a result" rather than "a user wrote me."
|
|
18
|
+
#
|
|
19
|
+
# Classifies itself for the pipeline via +kind+ (+active+ triggers the
|
|
20
|
+
# drain loop, +background+ enriches context silently) and +message_type+
|
|
21
|
+
# (selects which pipeline event to emit on create).
|
|
12
22
|
#
|
|
13
23
|
# @see Session#enqueue_user_message
|
|
14
|
-
# @see
|
|
24
|
+
# @see DrainJob — promotes PMs into Messages
|
|
25
|
+
# @see Events::StartMelete
|
|
26
|
+
# @see Events::StartProcessing
|
|
15
27
|
class PendingMessage < ApplicationRecord
|
|
28
|
+
# Phantom tool names follow the `from_<sender>` convention: the prefix
|
|
29
|
+
# tells the LLM these are messages delivered to it by its sisters or
|
|
30
|
+
# sub-agents, not tools it invoked. Melete's contributions carry the
|
|
31
|
+
# type in the suffix so the viewport query can filter by kind.
|
|
32
|
+
MELETE_SKILL_TOOL = "from_melete_skill"
|
|
33
|
+
MELETE_WORKFLOW_TOOL = "from_melete_workflow"
|
|
34
|
+
MELETE_GOAL_TOOL = "from_melete_goal"
|
|
35
|
+
MNEME_TOOL = "from_mneme"
|
|
36
|
+
|
|
37
|
+
# Source types that produce phantom tool_use/tool_result pairs on promotion.
|
|
38
|
+
# User messages produce plain text blocks instead.
|
|
39
|
+
PHANTOM_PAIR_TYPES = %w[subagent skill workflow recall goal].freeze
|
|
40
|
+
|
|
41
|
+
# Maps each phantom pair source type to a lambda that builds its
|
|
42
|
+
# synthetic tool name. Each Melete contribution carries the type in
|
|
43
|
+
# its suffix; recalled memories come from Mneme; sub-agents encode
|
|
44
|
+
# their nickname directly (e.g. `from_sleuth`).
|
|
45
|
+
PHANTOM_TOOL_NAMES = {
|
|
46
|
+
"subagent" => ->(name) { "from_#{name}" },
|
|
47
|
+
"skill" => ->(_) { MELETE_SKILL_TOOL },
|
|
48
|
+
"workflow" => ->(_) { MELETE_WORKFLOW_TOOL },
|
|
49
|
+
"recall" => ->(_) { MNEME_TOOL },
|
|
50
|
+
"goal" => ->(_) { MELETE_GOAL_TOOL }
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Maps each phantom pair source type to a lambda building its tool input.
|
|
54
|
+
PHANTOM_TOOL_INPUTS = {
|
|
55
|
+
"subagent" => ->(name) { {from: name} },
|
|
56
|
+
"skill" => ->(name) { {skill: name} },
|
|
57
|
+
"workflow" => ->(name) { {workflow: name} },
|
|
58
|
+
"recall" => ->(name) { {message_id: name.to_i} },
|
|
59
|
+
"goal" => ->(name) { {goal_id: name.to_i} }
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# Every message_type has a defined drain-pipeline role. +active+ types
|
|
63
|
+
# trigger the drain loop when the session is idle; +background+ types
|
|
64
|
+
# enrich context silently and ride the next active turn into the LLM.
|
|
65
|
+
# {#kind} is derived from this map in {#derive_kind} — callers only
|
|
66
|
+
# supply +message_type+.
|
|
67
|
+
MESSAGE_TYPE_KINDS = {
|
|
68
|
+
"user_message" => "active",
|
|
69
|
+
"tool_response" => "active",
|
|
70
|
+
"subagent" => "active",
|
|
71
|
+
"from_mneme" => "background",
|
|
72
|
+
"from_melete_skill" => "background",
|
|
73
|
+
"from_melete_workflow" => "background",
|
|
74
|
+
"from_melete_goal" => "background"
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
MESSAGE_TYPES = MESSAGE_TYPE_KINDS.keys.freeze
|
|
78
|
+
|
|
79
|
+
# Routes active message types to the event that begins the drain pipeline.
|
|
80
|
+
# User messages enter through Melete (skill/workflow/goal preparation);
|
|
81
|
+
# Mneme then runs conditionally only when Melete actually mutates goals
|
|
82
|
+
# (set_goal / update_goal), so recall always fires against fresh goals.
|
|
83
|
+
# Tool responses and sub-agent deliveries bypass enrichment and go
|
|
84
|
+
# straight to the drain loop. Background message types route to nothing
|
|
85
|
+
# — they wait in the mailbox until an active turn drains them.
|
|
86
|
+
MESSAGE_TYPE_ROUTES = {
|
|
87
|
+
"user_message" => Events::StartMelete,
|
|
88
|
+
"tool_response" => Events::StartProcessing,
|
|
89
|
+
"subagent" => Events::StartProcessing
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
16
92
|
belongs_to :session
|
|
17
93
|
|
|
94
|
+
# In-memory id of the +Message+ this PM becomes on {#promote!}. Not
|
|
95
|
+
# persisted — the PM row is destroyed as part of the promotion transaction.
|
|
96
|
+
# Used by {Session#release_with_bounce_back} to destroy the exact message
|
|
97
|
+
# that should bounce, instead of guessing from +messages.last+ (which could
|
|
98
|
+
# race under parallel drains).
|
|
99
|
+
attr_accessor :promoted_message_id
|
|
100
|
+
|
|
101
|
+
enum :kind, {background: "background", active: "active"}
|
|
102
|
+
|
|
103
|
+
before_validation :derive_kind, if: :message_type
|
|
104
|
+
|
|
18
105
|
validates :content, presence: true
|
|
106
|
+
validates :source_name, presence: true, unless: :user?
|
|
107
|
+
validates :message_type, presence: true, inclusion: {in: MESSAGE_TYPES}
|
|
108
|
+
validates :tool_use_id, presence: true, if: -> { message_type == "tool_response" }
|
|
109
|
+
|
|
110
|
+
# Tool responses take priority over other active messages: they complete
|
|
111
|
+
# a tool round the LLM is waiting on, so promoting them first preserves
|
|
112
|
+
# the tool_use/tool_result pairing in the conversation. Other actives
|
|
113
|
+
# (user messages, sub-agent replies) wait their FIFO turn behind the
|
|
114
|
+
# completion.
|
|
115
|
+
scope :ordered_for_drain, -> {
|
|
116
|
+
active.order(Arel.sql("message_type = 'tool_response' DESC")).order(:created_at)
|
|
117
|
+
}
|
|
19
118
|
|
|
20
119
|
after_create_commit :broadcast_created
|
|
120
|
+
after_create_commit :route_to_event_bus
|
|
21
121
|
after_destroy_commit :broadcast_removed
|
|
22
122
|
|
|
123
|
+
# @return [Boolean] true when this is a plain user message
|
|
124
|
+
def user?
|
|
125
|
+
source_type == "user"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Boolean] true when this message originated from a sub-agent
|
|
129
|
+
def subagent?
|
|
130
|
+
source_type == "subagent"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Boolean] true when this message carries recalled skill content
|
|
134
|
+
def skill?
|
|
135
|
+
source_type == "skill"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Boolean] true when this message carries recalled workflow content
|
|
139
|
+
def workflow?
|
|
140
|
+
source_type == "workflow"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Boolean] true when this message is an associative recall phantom pair
|
|
144
|
+
def recall?
|
|
145
|
+
source_type == "recall"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [Boolean] true when this message carries a goal event
|
|
149
|
+
def goal?
|
|
150
|
+
source_type == "goal"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @return [Boolean] true when promotion produces phantom tool_use/tool_result pairs
|
|
154
|
+
def phantom_pair?
|
|
155
|
+
source_type.in?(PHANTOM_PAIR_TYPES)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Draper hook: picks the concrete decorator subclass based on
|
|
159
|
+
# {#message_type}. Mirrors {Message#decorator_class} so each PM type
|
|
160
|
+
# renders with the same visual treatment as its promoted counterpart,
|
|
161
|
+
# marked dimmed via +status: "pending"+.
|
|
162
|
+
#
|
|
163
|
+
# PMs are the universal intake queue — every new message_type added
|
|
164
|
+
# under #427 lands here first. Raises on unmapped types so a missing
|
|
165
|
+
# decorator surfaces immediately as a hard failure instead of a
|
|
166
|
+
# silent nil that breaks downstream rendering.
|
|
167
|
+
#
|
|
168
|
+
# @return [Class] a {PendingMessageDecorator} subclass
|
|
169
|
+
# @raise [ArgumentError] if no decorator is registered for the message_type
|
|
170
|
+
def decorator_class
|
|
171
|
+
case message_type
|
|
172
|
+
when "user_message" then PendingUserMessageDecorator
|
|
173
|
+
when "tool_response" then PendingToolResponseDecorator
|
|
174
|
+
when "subagent" then PendingSubagentDecorator
|
|
175
|
+
when "from_mneme" then PendingFromMnemeDecorator
|
|
176
|
+
when "from_melete_skill" then PendingFromMeleteSkillDecorator
|
|
177
|
+
when "from_melete_workflow" then PendingFromMeleteWorkflowDecorator
|
|
178
|
+
when "from_melete_goal" then PendingFromMeleteGoalDecorator
|
|
179
|
+
else raise ArgumentError, "No decorator for PendingMessage message_type: #{message_type.inspect}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Promotes this PendingMessage into the session's conversation history.
|
|
184
|
+
# Dispatches on +message_type+: tool responses become +tool_response+
|
|
185
|
+
# Messages, user messages become +user_message+ Messages, phantom pair
|
|
186
|
+
# types (sub-agent, skill, workflow, recall, goal) become synthetic
|
|
187
|
+
# tool_use/tool_result pairs. The PM row is destroyed in the same
|
|
188
|
+
# transaction so partial promotion can never leave a stray mailbox entry.
|
|
189
|
+
#
|
|
190
|
+
# For promotions that yield a single Message, {#promoted_message_id}
|
|
191
|
+
# captures the new record's id — callers can then act on that specific
|
|
192
|
+
# message (e.g. {Session#release_with_bounce_back}) without guessing.
|
|
193
|
+
#
|
|
194
|
+
# @return [void]
|
|
195
|
+
def promote!
|
|
196
|
+
session.transaction do
|
|
197
|
+
if message_type == "tool_response"
|
|
198
|
+
self.promoted_message_id = promote_as_tool_response!.id
|
|
199
|
+
elsif message_type == "user_message"
|
|
200
|
+
self.promoted_message_id = session.create_user_message(
|
|
201
|
+
display_content,
|
|
202
|
+
source_type: source_type,
|
|
203
|
+
source_name: source_name
|
|
204
|
+
).id
|
|
205
|
+
elsif phantom_pair?
|
|
206
|
+
promote_as_phantom_pair!
|
|
207
|
+
else
|
|
208
|
+
raise "PendingMessage ##{id} cannot promote: message_type=#{message_type.inspect}"
|
|
209
|
+
end
|
|
210
|
+
destroy!
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Phantom tool name for DB persistence and LLM injection.
|
|
215
|
+
# Each phantom pair source type maps to a synthetic tool name via
|
|
216
|
+
# {PHANTOM_TOOL_NAMES} — a lambda so sub-agent names can flow through.
|
|
217
|
+
#
|
|
218
|
+
# @return [String] phantom tool name
|
|
219
|
+
def phantom_tool_name
|
|
220
|
+
PHANTOM_TOOL_NAMES.fetch(source_type).call(source_name)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Phantom tool input hash for DB persistence and LLM injection.
|
|
224
|
+
#
|
|
225
|
+
# @return [Hash] tool input hash
|
|
226
|
+
def phantom_tool_input
|
|
227
|
+
PHANTOM_TOOL_INPUTS.fetch(source_type).call(source_name)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Content formatted for display and history persistence.
|
|
231
|
+
# Sub-agent messages include an attribution prefix. Skill/workflow
|
|
232
|
+
# messages include a recall label. User messages pass through unchanged.
|
|
233
|
+
#
|
|
234
|
+
# @return [String]
|
|
235
|
+
def display_content
|
|
236
|
+
case source_type
|
|
237
|
+
when "subagent"
|
|
238
|
+
format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
|
|
239
|
+
when "skill"
|
|
240
|
+
"[recalled skill: #{source_name}]\n#{content}"
|
|
241
|
+
when "workflow"
|
|
242
|
+
"[recalled workflow: #{source_name}]\n#{content}"
|
|
243
|
+
when "goal"
|
|
244
|
+
"[goal #{source_name}]\n#{content}"
|
|
245
|
+
else
|
|
246
|
+
content
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Builds LLM message hashes for this pending message.
|
|
251
|
+
#
|
|
252
|
+
# Phantom pair types become synthetic tool_use/tool_result pairs so the
|
|
253
|
+
# LLM sees them as its own past invocations. User messages return plain
|
|
254
|
+
# content for injection as text blocks within the current tool_results turn.
|
|
255
|
+
#
|
|
256
|
+
# @return [Array<Hash>] synthetic tool pair for phantom pair types
|
|
257
|
+
# @return [String] raw content for user messages
|
|
258
|
+
def to_llm_messages
|
|
259
|
+
return content unless phantom_pair?
|
|
260
|
+
|
|
261
|
+
build_phantom_pair(phantom_tool_name, phantom_tool_input)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Emits the event that kicks off the drain pipeline for active messages
|
|
265
|
+
# whenever the session is currently claimable. Claimability is delegated
|
|
266
|
+
# to the AASM via +may_start_processing?+ — true from +:idle+ always,
|
|
267
|
+
# true from +:executing+ only once +tool_round_complete?+ holds. This
|
|
268
|
+
# lets a tool_response PM landing mid-round wake the drain only when
|
|
269
|
+
# its sibling responses are all present.
|
|
270
|
+
#
|
|
271
|
+
# Background messages never trigger; active messages landing while the
|
|
272
|
+
# session is unclaimable queue silently —
|
|
273
|
+
# {Session#wake_drain_pipeline_if_pending} re-invokes this on the next
|
|
274
|
+
# transition into +:idle+.
|
|
275
|
+
#
|
|
276
|
+
# Also fires from +after_create_commit+ so freshly enqueued PMs route
|
|
277
|
+
# themselves on persistence.
|
|
278
|
+
def route_to_event_bus
|
|
279
|
+
return unless active?
|
|
280
|
+
return unless session.may_start_processing?
|
|
281
|
+
|
|
282
|
+
event_class = MESSAGE_TYPE_ROUTES.fetch(message_type)
|
|
283
|
+
Events::Bus.emit(event_class.new(session_id: session_id, pending_message_id: id))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Builds the structured +pending_message_created+ payload for transmit/
|
|
287
|
+
# broadcast paths. Wraps the per-mode decorator output in the +rendered+
|
|
288
|
+
# key so the TUI's existing +extract_rendered+ pipeline applies.
|
|
289
|
+
#
|
|
290
|
+
# Required arg — callers always know the session view_mode. A default
|
|
291
|
+
# of +session.view_mode+ would trigger a SELECT per +after_create_commit+
|
|
292
|
+
# when the association isn't preloaded.
|
|
293
|
+
#
|
|
294
|
+
# The raw +content+ field is intentionally absent: decorators decide
|
|
295
|
+
# what crosses the wire per view_mode (e.g. background PMs return nil
|
|
296
|
+
# in basic so the user doesn't see internal pipeline noise). Sending
|
|
297
|
+
# raw content alongside +rendered+ would undercut that boundary.
|
|
298
|
+
#
|
|
299
|
+
# @param mode [String] view mode for decoration
|
|
300
|
+
# @return [Hash] payload ready for ActionCable transmission
|
|
301
|
+
def broadcast_payload(mode)
|
|
302
|
+
{
|
|
303
|
+
"action" => "pending_message_created",
|
|
304
|
+
"pending_message_id" => id,
|
|
305
|
+
"message_type" => message_type,
|
|
306
|
+
"rendered" => {mode => decorate.render(mode)}
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
|
|
23
310
|
private
|
|
24
311
|
|
|
312
|
+
# Persists a +tool_response+ Message for this PM and returns it.
|
|
313
|
+
# Mirrors the tool_use_id / payload shape emitted by
|
|
314
|
+
# {Events::Subscribers::LLMResponseHandler} for the paired +tool_call+.
|
|
315
|
+
#
|
|
316
|
+
# @return [Message]
|
|
317
|
+
def promote_as_tool_response!
|
|
318
|
+
session.messages.create!(
|
|
319
|
+
message_type: "tool_response",
|
|
320
|
+
tool_use_id: tool_use_id,
|
|
321
|
+
payload: {
|
|
322
|
+
"tool_name" => source_name,
|
|
323
|
+
"tool_use_id" => tool_use_id,
|
|
324
|
+
"content" => content,
|
|
325
|
+
"success" => success
|
|
326
|
+
},
|
|
327
|
+
timestamp: Time.current.to_ns,
|
|
328
|
+
token_count: TokenEstimation.estimate_token_count(content)
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Persists a synthetic +tool_call+/+tool_response+ Message pair so the
|
|
333
|
+
# LLM sees this contribution (sub-agent delivery, Melete skill/workflow/
|
|
334
|
+
# goal, Mneme recall) as a past tool invocation of its own. Keeps the
|
|
335
|
+
# system prompt stable for prompt caching — phantom pairs flow through
|
|
336
|
+
# the sliding-window conversation instead.
|
|
337
|
+
#
|
|
338
|
+
# @return [void]
|
|
339
|
+
def promote_as_phantom_pair!
|
|
340
|
+
tool_name = phantom_tool_name
|
|
341
|
+
uid = "#{tool_name}_#{id}"
|
|
342
|
+
now = Time.current.to_ns
|
|
343
|
+
|
|
344
|
+
session.messages.create!(
|
|
345
|
+
message_type: "tool_call",
|
|
346
|
+
tool_use_id: uid,
|
|
347
|
+
payload: {
|
|
348
|
+
"tool_name" => tool_name,
|
|
349
|
+
"tool_use_id" => uid,
|
|
350
|
+
"tool_input" => phantom_tool_input.stringify_keys,
|
|
351
|
+
"content" => display_content.lines.first.chomp
|
|
352
|
+
},
|
|
353
|
+
timestamp: now,
|
|
354
|
+
token_count: Mneme::TOOL_PAIR_OVERHEAD_TOKENS
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
session.messages.create!(
|
|
358
|
+
message_type: "tool_response",
|
|
359
|
+
tool_use_id: uid,
|
|
360
|
+
payload: {
|
|
361
|
+
"tool_name" => tool_name,
|
|
362
|
+
"tool_use_id" => uid,
|
|
363
|
+
"content" => content,
|
|
364
|
+
"success" => true
|
|
365
|
+
},
|
|
366
|
+
timestamp: now,
|
|
367
|
+
token_count: TokenEstimation.estimate_token_count(content)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
restore_subagent_hud_visibility! if subagent?
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Flips the delivering sub-agent back to +hud_visible: true+ when the
|
|
374
|
+
# phantom pair we just persisted reintroduces a trace. A subsequent
|
|
375
|
+
# eviction that passes every trace will hide the sub-agent again via
|
|
376
|
+
# {Mneme::Runner#refresh_subagent_visibility}.
|
|
377
|
+
#
|
|
378
|
+
# Re-broadcasts the parent's children list on flip so the TUI adds the
|
|
379
|
+
# HUD entry back without waiting for the next AASM state change.
|
|
380
|
+
def restore_subagent_hud_visibility!
|
|
381
|
+
child = session.child_sessions.find_by(name: source_name)
|
|
382
|
+
return unless child && !child.hud_visible
|
|
383
|
+
|
|
384
|
+
child.update_column(:hud_visible, true)
|
|
385
|
+
child.broadcast_children_update_to_parent
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Builds a phantom tool_use/tool_result message pair.
|
|
389
|
+
# Follows the same format for all non-user source types — the only
|
|
390
|
+
# difference is the tool name and input hash.
|
|
391
|
+
#
|
|
392
|
+
# Phantom pairs keep the system prompt stable for prompt caching (#395).
|
|
393
|
+
# Instead of injecting skills/workflows into the system prompt (which
|
|
394
|
+
# busts the cache on every change), they flow through the sliding window
|
|
395
|
+
# as messages the LLM "recalls" via phantom tool invocations.
|
|
396
|
+
#
|
|
397
|
+
# @param tool_name [String] phantom tool name (not in the agent's registry)
|
|
398
|
+
# @param input [Hash] tool input hash
|
|
399
|
+
# @return [Array<Hash>] two-element array: assistant tool_use + user tool_result
|
|
400
|
+
def build_phantom_pair(tool_name, input)
|
|
401
|
+
tool_use_id = "#{tool_name}_#{id}"
|
|
402
|
+
[
|
|
403
|
+
{role: "assistant", content: [
|
|
404
|
+
{type: "tool_use", id: tool_use_id, name: tool_name, input: input}
|
|
405
|
+
]},
|
|
406
|
+
{role: "user", content: [
|
|
407
|
+
{type: "tool_result", tool_use_id: tool_use_id, content: content}
|
|
408
|
+
]}
|
|
409
|
+
]
|
|
410
|
+
end
|
|
411
|
+
|
|
25
412
|
# Broadcasts a pending message appearance so TUI clients render the
|
|
26
|
-
# dimmed indicator immediately.
|
|
413
|
+
# type-specific dimmed indicator immediately. Includes the decorated
|
|
414
|
+
# payload for the session's current view mode so the TUI can dispatch
|
|
415
|
+
# by message type without a second round-trip.
|
|
27
416
|
def broadcast_created
|
|
28
|
-
ActionCable.server.broadcast("session_#{session_id}",
|
|
29
|
-
"action" => "pending_message_created",
|
|
30
|
-
"pending_message_id" => id,
|
|
31
|
-
"content" => content
|
|
32
|
-
})
|
|
417
|
+
ActionCable.server.broadcast("session_#{session_id}", broadcast_payload(session.view_mode))
|
|
33
418
|
end
|
|
34
419
|
|
|
35
420
|
# Broadcasts pending message removal so TUI clients clear the entry.
|
|
@@ -40,4 +425,14 @@ class PendingMessage < ApplicationRecord
|
|
|
40
425
|
"pending_message_id" => id
|
|
41
426
|
})
|
|
42
427
|
end
|
|
428
|
+
|
|
429
|
+
# Populates +kind+ from {MESSAGE_TYPE_KINDS} so callers only need to
|
|
430
|
+
# supply +message_type+. The mapping is the single source of truth for
|
|
431
|
+
# whether a message type triggers the drain loop or rides along as
|
|
432
|
+
# enrichment. Guarded by +if: :message_type+ on the callback — rows
|
|
433
|
+
# without a +message_type+ fail validation explicitly rather than
|
|
434
|
+
# crashing here.
|
|
435
|
+
def derive_kind
|
|
436
|
+
self.kind = MESSAGE_TYPE_KINDS.fetch(message_type)
|
|
437
|
+
end
|
|
43
438
|
end
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
#
|
|
11
11
|
# @!attribute display_text
|
|
12
12
|
# @return [String] truncated message content (~200 chars) shown in the Goals section
|
|
13
|
+
# @!attribute token_count
|
|
14
|
+
# @return [Integer] token count of {#display_text}. Seeded with a local
|
|
15
|
+
# estimate on create and later refined by {CountTokensJob}.
|
|
13
16
|
class PinnedMessage < ApplicationRecord
|
|
17
|
+
include TokenEstimation
|
|
18
|
+
|
|
14
19
|
# Display text limit — enough to recognize content, cheap on tokens.
|
|
15
20
|
MAX_DISPLAY_TEXT_LENGTH = 200
|
|
16
21
|
|
|
@@ -34,8 +39,8 @@ class PinnedMessage < ApplicationRecord
|
|
|
34
39
|
)
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
# @return [
|
|
38
|
-
def
|
|
39
|
-
|
|
42
|
+
# @return [String] truncated display text used for token estimation and counting
|
|
43
|
+
def tokenization_text
|
|
44
|
+
display_text.to_s
|
|
40
45
|
end
|
|
41
46
|
end
|