anima-core 1.4.0 → 1.5.1
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 +18 -20
- data/README.md +61 -95
- 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 +13 -2
- 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 +21 -10
- data/app/models/message.rb +47 -36
- data/app/models/pending_message.rb +276 -29
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +474 -432
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +17 -4
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +3 -3
- 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 +13 -13
- data/db/structure.sql +44 -31
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/settings.rb +7 -33
- data/lib/anima/version.rb +1 -1
- data/lib/aoide/phantom_call_filter.rb +49 -0
- data/lib/{analytical_brain.rb → aoide.rb} +6 -3
- 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 +145 -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 +6 -8
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +26 -29
- 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 +41 -201
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +63 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
- data/lib/melete.rb +26 -0
- 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 +118 -171
- 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/shell_session.rb +303 -612
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -0
- data/lib/tools/bash.rb +25 -57
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +79 -3
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +20 -10
- data/lib/tools/spawn_subagent.rb +24 -14
- data/lib/tools/subagent_prompts.rb +15 -4
- 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 +5 -4
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/decorators/base_decorator.rb +24 -3
- data/lib/tui/message_store.rb +93 -44
- data/lib/tui/screens/chat.rb +94 -20
- data/lib/tui/settings.rb +9 -2
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +4 -23
- data/workflows/review_pr.md +18 -14
- metadata +88 -28
- 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 -24
- data/app/models/concerns/message/broadcasting.rb +0 -86
- data/lib/agent_loop.rb +0 -215
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
- data/lib/events/agent_message.rb +0 -25
- 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 -204
- data/lib/mneme/passive_recall.rb +0 -138
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Broadcasts eviction cutoff to connected WebSocket clients after Mneme
|
|
6
|
+
# advances the boundary. Clients drop all messages above the cutoff
|
|
7
|
+
# (id <= evict_above_id) — older messages at the top of the chat view.
|
|
8
|
+
#
|
|
9
|
+
# @example Registering at boot
|
|
10
|
+
# Events::Bus.subscribe(Events::Subscribers::EvictionBroadcaster.new) { |event|
|
|
11
|
+
# event[:name] == "anima.eviction.completed"
|
|
12
|
+
# }
|
|
13
|
+
class EvictionBroadcaster
|
|
14
|
+
include Events::Subscriber
|
|
15
|
+
|
|
16
|
+
# @param event [Hash] Rails.event notification hash
|
|
17
|
+
def emit(event)
|
|
18
|
+
payload = event[:payload]
|
|
19
|
+
ActionCable.server.broadcast(
|
|
20
|
+
"session_#{payload[:session_id]}",
|
|
21
|
+
{"action" => "eviction", "evict_above_id" => payload[:evict_above_id]}
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "toon"
|
|
4
|
+
|
|
5
|
+
module Events
|
|
6
|
+
module Subscribers
|
|
7
|
+
# Handles the aftermath of a single LLM round-trip emitted via
|
|
8
|
+
# {Events::LLMResponded}. Persists the assistant's output as Message
|
|
9
|
+
# records, transitions the session state, and — when the response
|
|
10
|
+
# includes a +tool_use+ block — queues {ToolExecutionJob} for each
|
|
11
|
+
# tool.
|
|
12
|
+
#
|
|
13
|
+
# This is where session state moves away from +:awaiting+: either
|
|
14
|
+
# {Session#response_complete!} on a text-only response, or
|
|
15
|
+
# {Session#tool_received!} before dispatching tool work. The drain
|
|
16
|
+
# job itself never transitions state past +:awaiting+ — that is this
|
|
17
|
+
# subscriber's responsibility, per the SOLID rule that event
|
|
18
|
+
# emission is the final act of a piece.
|
|
19
|
+
class LLMResponseHandler
|
|
20
|
+
include Events::Subscriber
|
|
21
|
+
|
|
22
|
+
# @param event [Hash] Rails.event notification hash
|
|
23
|
+
def emit(event)
|
|
24
|
+
payload = event[:payload]
|
|
25
|
+
session = Session.find(payload[:session_id])
|
|
26
|
+
|
|
27
|
+
response = payload[:response] || {}
|
|
28
|
+
api_metrics = payload[:api_metrics]
|
|
29
|
+
|
|
30
|
+
log_raw_response(session, response)
|
|
31
|
+
response = Aoide::PhantomCallFilter.call(response)
|
|
32
|
+
|
|
33
|
+
tool_uses = normalize_tool_uses(response)
|
|
34
|
+
text = extract_text(response)
|
|
35
|
+
|
|
36
|
+
persist_agent_message(session, text, api_metrics) if text.present?
|
|
37
|
+
tool_uses.each { |tool_use| persist_tool_call(session, tool_use) }
|
|
38
|
+
|
|
39
|
+
if tool_uses.any?
|
|
40
|
+
session.tool_received! if session.may_tool_received?
|
|
41
|
+
dispatch_tool_executions(session, tool_uses)
|
|
42
|
+
elsif session.may_response_complete?
|
|
43
|
+
session.response_complete!
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# @return [Logger] dev-only Aoide logger
|
|
50
|
+
def log = Aoide.logger
|
|
51
|
+
|
|
52
|
+
def content_blocks(response)
|
|
53
|
+
response["content"] || response[:content] || []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def block_type(block)
|
|
57
|
+
block["type"] || block[:type]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns tool_use blocks with a guaranteed +id+. Generates a UUID
|
|
61
|
+
# once when the provider omits one so persistence and dispatch see
|
|
62
|
+
# the same id — a missing match breaks tool_use/tool_result
|
|
63
|
+
# pairing in the Anthropic conversation.
|
|
64
|
+
def normalize_tool_uses(response)
|
|
65
|
+
content_blocks(response).filter_map do |block|
|
|
66
|
+
next unless block_type(block) == "tool_use"
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
"id" => block["id"] || block[:id] || SecureRandom.uuid,
|
|
70
|
+
"name" => block["name"] || block[:name],
|
|
71
|
+
"input" => block["input"] || block[:input] || {}
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract_text(response)
|
|
77
|
+
content_blocks(response)
|
|
78
|
+
.select { |block| block_type(block) == "text" }
|
|
79
|
+
.map { |block| block["text"] || block[:text] }
|
|
80
|
+
.join
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def persist_agent_message(session, text, api_metrics)
|
|
84
|
+
session.messages.create!(
|
|
85
|
+
message_type: "agent_message",
|
|
86
|
+
payload: {"type" => "agent_message", "content" => text, "session_id" => session.id},
|
|
87
|
+
timestamp: Time.current.to_ns,
|
|
88
|
+
api_metrics: api_metrics
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def persist_tool_call(session, tool_use)
|
|
93
|
+
tool_use_id = tool_use["id"]
|
|
94
|
+
tool_name = tool_use["name"]
|
|
95
|
+
session.messages.create!(
|
|
96
|
+
message_type: "tool_call",
|
|
97
|
+
tool_use_id: tool_use_id,
|
|
98
|
+
payload: {
|
|
99
|
+
"type" => "tool_call",
|
|
100
|
+
"tool_name" => tool_name,
|
|
101
|
+
"tool_use_id" => tool_use_id,
|
|
102
|
+
"tool_input" => tool_use["input"],
|
|
103
|
+
"content" => "Calling #{tool_name}"
|
|
104
|
+
},
|
|
105
|
+
timestamp: Time.current.to_ns
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def dispatch_tool_executions(session, tool_uses)
|
|
110
|
+
sid = session.id
|
|
111
|
+
tool_uses.each do |tool_use|
|
|
112
|
+
tool_use_id = tool_use["id"]
|
|
113
|
+
tool_name = tool_use["name"]
|
|
114
|
+
log.info("session=#{sid} dispatching tool=#{tool_name} id=#{tool_use_id}")
|
|
115
|
+
ToolExecutionJob.perform_later(
|
|
116
|
+
sid,
|
|
117
|
+
tool_use_id: tool_use_id,
|
|
118
|
+
tool_name: tool_name,
|
|
119
|
+
tool_input: tool_use["input"]
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Diagnostic trace of every Anthropic response that reaches the
|
|
125
|
+
# main loop: a one-line summary at info, the full payload and
|
|
126
|
+
# raw +tool_use+ blocks (pre-normalization) at debug — paired so
|
|
127
|
+
# the inbound API response can be correlated against what got
|
|
128
|
+
# dispatched. Block form on +log.debug+ so +Toon.encode+ never
|
|
129
|
+
# runs unless the level allows it.
|
|
130
|
+
def log_raw_response(session, response)
|
|
131
|
+
sid = session.id
|
|
132
|
+
blocks = content_blocks(response)
|
|
133
|
+
raw_tool_uses = blocks.select { |block| block_type(block) == "tool_use" }
|
|
134
|
+
|
|
135
|
+
log.info(
|
|
136
|
+
"session=#{sid} — response received " \
|
|
137
|
+
"(#{blocks.size} block(s), #{raw_tool_uses.size} tool_use)"
|
|
138
|
+
)
|
|
139
|
+
{"raw response" => response, "raw tool_use blocks" => raw_tool_uses}.each do |label, payload|
|
|
140
|
+
log.debug { "session=#{sid} #{label}:\n#{Toon.encode(payload)}" }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Entry subscriber for the Melete stage of the drain pipeline. On
|
|
6
|
+
# {Events::StartMelete}, enqueues {MeleteEnrichmentJob} to run
|
|
7
|
+
# skill/goal/workflow preparation asynchronously.
|
|
8
|
+
class MeleteKickoff
|
|
9
|
+
include Events::Subscriber
|
|
10
|
+
|
|
11
|
+
# @param event [Hash] Rails.event notification hash
|
|
12
|
+
def emit(event)
|
|
13
|
+
payload = event[:payload]
|
|
14
|
+
session_id = payload[:session_id]
|
|
15
|
+
return unless session_id
|
|
16
|
+
|
|
17
|
+
MeleteEnrichmentJob.perform_later(
|
|
18
|
+
session_id,
|
|
19
|
+
pending_message_id: payload[:pending_message_id]
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Broadcasts message lifecycle events to connected WebSocket clients
|
|
6
|
+
# via ActionCable. Subscribes to {Events::MessageCreated} and
|
|
7
|
+
# {Events::MessageUpdated} events.
|
|
8
|
+
#
|
|
9
|
+
# @example Registering at boot
|
|
10
|
+
# Events::Bus.subscribe(Events::Subscribers::MessageBroadcaster.new) { |event|
|
|
11
|
+
# event[:name].start_with?("anima.message.")
|
|
12
|
+
# }
|
|
13
|
+
class MessageBroadcaster
|
|
14
|
+
include Events::Subscriber
|
|
15
|
+
|
|
16
|
+
ACTION_MAP = {
|
|
17
|
+
Events::MessageCreated::TYPE => "create",
|
|
18
|
+
Events::MessageUpdated::TYPE => "update"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# @param event [Hash] Rails.event notification hash
|
|
22
|
+
def emit(event)
|
|
23
|
+
message = event[:payload][:message]
|
|
24
|
+
action = ACTION_MAP.fetch(event[:payload][:type])
|
|
25
|
+
session = message.session
|
|
26
|
+
broadcast_payload = message.payload.merge("id" => message.id, "action" => action)
|
|
27
|
+
broadcast_payload["api_metrics"] = message.api_metrics if message.api_metrics.present?
|
|
28
|
+
broadcast_payload["rendered"] = {session.view_mode => message.decorate.render(session.view_mode)}
|
|
29
|
+
|
|
30
|
+
ActionCable.server.broadcast("session_#{message.session_id}", broadcast_payload)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Entry subscriber for the Mneme stage of the drain pipeline. On
|
|
6
|
+
# {Events::StartMneme}, enqueues {MnemeEnrichmentJob} to run
|
|
7
|
+
# associative recall asynchronously.
|
|
8
|
+
class MnemeKickoff
|
|
9
|
+
include Events::Subscriber
|
|
10
|
+
|
|
11
|
+
# @param event [Hash] Rails.event notification hash
|
|
12
|
+
def emit(event)
|
|
13
|
+
payload = event[:payload]
|
|
14
|
+
session_id = payload[:session_id]
|
|
15
|
+
return unless session_id
|
|
16
|
+
|
|
17
|
+
MnemeEnrichmentJob.perform_later(
|
|
18
|
+
session_id,
|
|
19
|
+
pending_message_id: payload[:pending_message_id]
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Checks whether Mneme should run after each persisted message.
|
|
6
|
+
# Subscribes to {Events::MessageCreated} events.
|
|
7
|
+
#
|
|
8
|
+
# @example Registering at boot
|
|
9
|
+
# Events::Bus.subscribe(Events::Subscribers::MnemeScheduler.new) { |event|
|
|
10
|
+
# event[:name] == "anima.message.created"
|
|
11
|
+
# }
|
|
12
|
+
class MnemeScheduler
|
|
13
|
+
include Events::Subscriber
|
|
14
|
+
|
|
15
|
+
# @param event [Hash] Rails.event notification hash
|
|
16
|
+
def emit(event)
|
|
17
|
+
event[:payload][:message].session.schedule_mneme!
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -9,10 +9,9 @@ module Events
|
|
|
9
9
|
# session. When initialized without one (global mode), the session is
|
|
10
10
|
# looked up from the event's session_id payload field.
|
|
11
11
|
#
|
|
12
|
-
# User messages are NOT persisted here —
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# messages live in the {PendingMessage} table, outside the event bus.
|
|
12
|
+
# User messages are NOT persisted here — {DrainJob} promotes them
|
|
13
|
+
# from {PendingMessage} into the Message stream as part of the drain
|
|
14
|
+
# cycle so bounce-back semantics stay close to the promotion.
|
|
16
15
|
#
|
|
17
16
|
# @example Session-scoped
|
|
18
17
|
# persister = Events::Subscribers::Persister.new(session)
|
|
@@ -33,10 +32,9 @@ module Events
|
|
|
33
32
|
|
|
34
33
|
# Receives a Rails.event notification hash and persists it.
|
|
35
34
|
#
|
|
36
|
-
# Skips user messages — those are
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
# {Events::BounceBack}).
|
|
35
|
+
# Skips user messages — those are promoted from PendingMessage by
|
|
36
|
+
# {DrainJob}. Also skips event types not in {Message::TYPES}
|
|
37
|
+
# (transient events like {Events::BounceBack}).
|
|
40
38
|
#
|
|
41
39
|
# @param event [Hash] with :payload containing event data
|
|
42
40
|
def emit(event)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Broadcasts session state over ActionCable in response to
|
|
6
|
+
# {Events::SessionStateChanged}. Sends +session_state+ to the session
|
|
7
|
+
# stream and, for sub-agents, +child_state+ to the parent stream so the
|
|
8
|
+
# HUD updates without a full children refresh.
|
|
9
|
+
#
|
|
10
|
+
# @example Registering at boot
|
|
11
|
+
# trigger = ->(event) { event[:name] == "anima.session.state_changed" }
|
|
12
|
+
# Events::Bus.subscribe(Events::Subscribers::SessionStateBroadcaster.new, &trigger)
|
|
13
|
+
class SessionStateBroadcaster
|
|
14
|
+
include Events::Subscriber
|
|
15
|
+
|
|
16
|
+
# @param event [Hash] Rails.event notification hash
|
|
17
|
+
def emit(event)
|
|
18
|
+
payload = event[:payload]
|
|
19
|
+
session_id = payload[:session_id]
|
|
20
|
+
state = payload[:state]
|
|
21
|
+
|
|
22
|
+
action_payload = {"action" => "session_state", "state" => state, "session_id" => session_id}
|
|
23
|
+
ActionCable.server.broadcast("session_#{session_id}", action_payload)
|
|
24
|
+
|
|
25
|
+
parent_id = Session.where(id: session_id).pick(:parent_session_id)
|
|
26
|
+
return unless parent_id
|
|
27
|
+
|
|
28
|
+
parent_payload = action_payload.merge("action" => "child_state", "child_id" => session_id)
|
|
29
|
+
ActionCable.server.broadcast("session_#{parent_id}", parent_payload)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -2,24 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module Events
|
|
4
4
|
module Subscribers
|
|
5
|
-
# Routes text messages between parent and child sessions,
|
|
6
|
-
# bidirectional @mention communication.
|
|
5
|
+
# Routes agent text messages between parent and child sessions,
|
|
6
|
+
# enabling bidirectional @mention communication.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# emits a pending message that is promoted after the current loop
|
|
13
|
-
# completes — same mechanism as {SessionChannel#speak}.
|
|
8
|
+
# Subscribes to {Events::MessageCreated} and filters on
|
|
9
|
+
# +message_type == "agent_message"+ — the Message record is the single
|
|
10
|
+
# source of truth for LLM-produced text, so routing hangs off the
|
|
11
|
+
# persistence lifecycle rather than a parallel domain-event emission.
|
|
14
12
|
#
|
|
15
|
-
# **
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
13
|
+
# **Child → Parent:** When a sub-agent persists an +agent_message+,
|
|
14
|
+
# the router enqueues a {PendingMessage} on the parent with sub-agent
|
|
15
|
+
# attribution. The PM's +after_create_commit+ kicks off the drain
|
|
16
|
+
# pipeline when the parent is idle; otherwise the message queues
|
|
17
|
+
# silently and the idle-wake rule picks it up.
|
|
19
18
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
19
|
+
# **Parent → Child:** When a parent agent persists an +agent_message+
|
|
20
|
+
# containing +@name+ mentions, the router enqueues a PendingMessage
|
|
21
|
+
# in each matching child session with a +[from parent]:+ origin label.
|
|
22
|
+
#
|
|
23
|
+
# Both directions delegate to {Session#enqueue_user_message}.
|
|
23
24
|
#
|
|
24
25
|
# This replaces the +return_result+ tool — sub-agents communicate
|
|
25
26
|
# through natural text messages instead of structured tool calls.
|
|
@@ -35,25 +36,21 @@ module Events
|
|
|
35
36
|
|
|
36
37
|
# Routes agent text messages between parent and child sessions.
|
|
37
38
|
#
|
|
38
|
-
# For sub-agent sessions: forwards to parent with attribution
|
|
39
|
+
# For sub-agent sessions: forwards to parent with attribution.
|
|
39
40
|
# For parent sessions: scans for @mentions and routes to matching children.
|
|
40
41
|
#
|
|
41
|
-
# @param event [Hash] Rails.event notification hash with +:payload+
|
|
42
|
-
#
|
|
42
|
+
# @param event [Hash] Rails.event notification hash with +:payload+
|
|
43
|
+
# carrying the persisted {Message} record under +:message+
|
|
43
44
|
# @return [void]
|
|
44
45
|
def emit(event)
|
|
45
|
-
|
|
46
|
-
return unless
|
|
47
|
-
return unless
|
|
48
|
-
|
|
49
|
-
session_id = payload[:session_id]
|
|
50
|
-
return unless session_id
|
|
46
|
+
message = event.dig(:payload, :message)
|
|
47
|
+
return unless message.is_a?(Message)
|
|
48
|
+
return unless message.message_type == "agent_message"
|
|
51
49
|
|
|
52
|
-
content = payload[
|
|
50
|
+
content = message.payload["content"].to_s
|
|
53
51
|
return if content.empty?
|
|
54
52
|
|
|
55
|
-
session =
|
|
56
|
-
return unless session
|
|
53
|
+
session = message.session
|
|
57
54
|
|
|
58
55
|
if session.sub_agent?
|
|
59
56
|
route_to_parent(session, content)
|
|
@@ -66,8 +63,8 @@ module Events
|
|
|
66
63
|
|
|
67
64
|
# Forwards a sub-agent's text message to its parent session
|
|
68
65
|
# via {Session#enqueue_user_message} with source metadata.
|
|
69
|
-
# The parent's {PendingMessage}
|
|
70
|
-
#
|
|
66
|
+
# The parent's {PendingMessage} owns the attribution formatting —
|
|
67
|
+
# the router passes raw content.
|
|
71
68
|
#
|
|
72
69
|
# @param child [Session] the sub-agent session
|
|
73
70
|
# @param content [String] the sub-agent's message text
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Broadcasts sub-agent eviction to the parent session's stream so the
|
|
6
|
+
# TUI HUD panel removes the entry. Fires in response to
|
|
7
|
+
# {Events::SubagentEvicted}, which {Mneme::Runner} emits after a
|
|
8
|
+
# boundary advance leaves a sub-agent with no remaining traces in the
|
|
9
|
+
# parent viewport.
|
|
10
|
+
#
|
|
11
|
+
# @example Registering at boot
|
|
12
|
+
# Events::Bus.subscribe(Events::Subscribers::SubagentVisibilityBroadcaster.new) { |event|
|
|
13
|
+
# event[:name] == "anima.subagent.evicted"
|
|
14
|
+
# }
|
|
15
|
+
class SubagentVisibilityBroadcaster
|
|
16
|
+
include Events::Subscriber
|
|
17
|
+
|
|
18
|
+
# @param event [Hash] Rails.event notification hash
|
|
19
|
+
def emit(event)
|
|
20
|
+
payload = event[:payload]
|
|
21
|
+
session_id = payload[:session_id]
|
|
22
|
+
ActionCable.server.broadcast(
|
|
23
|
+
"session_#{session_id}",
|
|
24
|
+
{
|
|
25
|
+
"action" => "subagent_evicted",
|
|
26
|
+
"session_id" => session_id,
|
|
27
|
+
"child_id" => payload[:child_id]
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Records a tool's outcome as a +tool_response+ PendingMessage on
|
|
6
|
+
# {Events::ToolExecuted}. One ToolExecuted → one PM. The subscriber
|
|
7
|
+
# owns no state transitions: the session stays in +:executing+ until
|
|
8
|
+
# {DrainJob} claims it via the +executing → awaiting+ branch of
|
|
9
|
+
# +start_processing+ (gated by +Session#tool_round_complete?+).
|
|
10
|
+
#
|
|
11
|
+
# The PM's +after_create_commit+ emits {Events::StartProcessing}
|
|
12
|
+
# whenever the AASM guard says drain may now claim — typically when
|
|
13
|
+
# the last sibling tool_response of the round lands.
|
|
14
|
+
class ToolResponseCreator
|
|
15
|
+
include Events::Subscriber
|
|
16
|
+
|
|
17
|
+
# @param event [Hash] Rails.event notification hash
|
|
18
|
+
def emit(event)
|
|
19
|
+
payload = event[:payload]
|
|
20
|
+
session = Session.find(payload[:session_id])
|
|
21
|
+
|
|
22
|
+
session.pending_messages.create!(
|
|
23
|
+
content: payload[:content].to_s,
|
|
24
|
+
source_type: "tool",
|
|
25
|
+
source_name: payload[:tool_name],
|
|
26
|
+
message_type: "tool_response",
|
|
27
|
+
tool_use_id: payload[:tool_use_id],
|
|
28
|
+
success: payload[:success]
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -4,7 +4,7 @@ module Events
|
|
|
4
4
|
module Subscribers
|
|
5
5
|
# Bridges transient (non-persisted) events to ActionCable so clients
|
|
6
6
|
# receive them over WebSocket. Persisted messages reach clients via
|
|
7
|
-
# {
|
|
7
|
+
# {Events::Subscribers::MessageBroadcaster}; this subscriber handles events
|
|
8
8
|
# that never touch the database.
|
|
9
9
|
#
|
|
10
10
|
# @example Registering at boot
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted by {ToolExecutionJob} after a tool finishes running.
|
|
5
|
+
# Carries the tool result so the response subscriber can create a
|
|
6
|
+
# +tool_response+ PendingMessage and release the session back to idle
|
|
7
|
+
# — which in turn wakes the drain loop for the next LLM round.
|
|
8
|
+
class ToolExecuted
|
|
9
|
+
TYPE = "session.tool_executed"
|
|
10
|
+
|
|
11
|
+
attr_reader :session_id, :tool_use_id, :tool_name, :content, :success
|
|
12
|
+
|
|
13
|
+
# @param session_id [Integer] session the tool ran on behalf of
|
|
14
|
+
# @param tool_use_id [String] pairing ID for the originating +tool_use+ block
|
|
15
|
+
# @param tool_name [String] name of the tool that executed
|
|
16
|
+
# @param content [String] tool output (already formatted and truncated)
|
|
17
|
+
# @param success [Boolean] +true+ on normal completion, +false+ on error or interrupt
|
|
18
|
+
def initialize(session_id:, tool_use_id:, tool_name:, content:, success:)
|
|
19
|
+
@session_id = session_id
|
|
20
|
+
@tool_use_id = tool_use_id
|
|
21
|
+
@tool_name = tool_name
|
|
22
|
+
@content = content
|
|
23
|
+
@success = success
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def event_name
|
|
27
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{type: TYPE, session_id:, tool_use_id:, tool_name:, content:, success:}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted after {Session#activate_workflow} enqueues a workflow's
|
|
5
|
+
# phantom pair. Subscribers rebroadcast the session's active
|
|
6
|
+
# skills/workflow so the HUD reflects the new activation.
|
|
7
|
+
class WorkflowActivated
|
|
8
|
+
TYPE = "workflow.activated"
|
|
9
|
+
|
|
10
|
+
attr_reader :session_id, :workflow_name
|
|
11
|
+
|
|
12
|
+
# @param session_id [Integer] the session the workflow was activated on
|
|
13
|
+
# @param workflow_name [String] canonical workflow name
|
|
14
|
+
def initialize(session_id:, workflow_name:)
|
|
15
|
+
@session_id = session_id
|
|
16
|
+
@workflow_name = workflow_name
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def event_name
|
|
20
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{type: TYPE, session_id:, workflow_name:}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|