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.
Files changed (151) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -20
  3. data/README.md +61 -95
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +13 -2
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +21 -10
  30. data/app/models/message.rb +47 -36
  31. data/app/models/pending_message.rb +276 -29
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +474 -432
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +17 -4
  36. data/config/application.rb +1 -0
  37. data/config/initializers/event_subscribers.rb +71 -4
  38. data/config/initializers/inflections.rb +3 -1
  39. data/db/cable_structure.sql +3 -3
  40. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  41. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  42. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  43. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  44. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  45. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  46. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  47. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  48. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  49. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  50. data/db/queue_structure.sql +13 -13
  51. data/db/structure.sql +44 -31
  52. data/lib/agents/registry.rb +1 -1
  53. data/lib/anima/settings.rb +7 -33
  54. data/lib/anima/version.rb +1 -1
  55. data/lib/aoide/phantom_call_filter.rb +49 -0
  56. data/lib/{analytical_brain.rb → aoide.rb} +6 -3
  57. data/lib/events/authentication_required.rb +24 -0
  58. data/lib/events/bounce_back.rb +4 -4
  59. data/lib/events/eviction_completed.rb +28 -0
  60. data/lib/events/goal_created.rb +28 -0
  61. data/lib/events/goal_updated.rb +32 -0
  62. data/lib/events/llm_responded.rb +35 -0
  63. data/lib/events/message_created.rb +27 -0
  64. data/lib/events/message_updated.rb +25 -0
  65. data/lib/events/session_state_changed.rb +30 -0
  66. data/lib/events/skill_activated.rb +28 -0
  67. data/lib/events/start_melete.rb +36 -0
  68. data/lib/events/start_mneme.rb +33 -0
  69. data/lib/events/start_processing.rb +32 -0
  70. data/lib/events/subagent_evicted.rb +31 -0
  71. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  72. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  73. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  74. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  75. data/lib/events/subscribers/llm_response_handler.rb +145 -0
  76. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  77. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  78. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  79. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  80. data/lib/events/subscribers/persister.rb +6 -8
  81. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  82. data/lib/events/subscribers/subagent_message_router.rb +26 -29
  83. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  84. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  85. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  86. data/lib/events/tool_executed.rb +34 -0
  87. data/lib/events/workflow_activated.rb +27 -0
  88. data/lib/llm/client.rb +41 -201
  89. data/lib/mcp/client_manager.rb +41 -46
  90. data/lib/mcp/stdio_transport.rb +9 -5
  91. data/lib/{analytical_brain → melete}/runner.rb +63 -68
  92. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
  93. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
  94. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  95. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
  96. data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
  97. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
  98. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  99. data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
  100. data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
  101. data/lib/melete.rb +26 -0
  102. data/lib/mneme/base_runner.rb +121 -0
  103. data/lib/mneme/l2_runner.rb +14 -20
  104. data/lib/mneme/recall_runner.rb +132 -0
  105. data/lib/mneme/runner.rb +118 -171
  106. data/lib/mneme/search.rb +104 -62
  107. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  108. data/lib/mneme/tools/save_snapshot.rb +2 -10
  109. data/lib/mneme/tools/surface_memory.rb +89 -0
  110. data/lib/mneme.rb +11 -5
  111. data/lib/shell_session.rb +303 -612
  112. data/lib/skills/definition.rb +2 -2
  113. data/lib/skills/registry.rb +1 -1
  114. data/lib/tools/base.rb +16 -0
  115. data/lib/tools/bash.rb +25 -57
  116. data/lib/tools/edit.rb +2 -0
  117. data/lib/tools/read.rb +2 -0
  118. data/lib/tools/registry.rb +79 -3
  119. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  120. data/lib/tools/spawn_specialist.rb +20 -10
  121. data/lib/tools/spawn_subagent.rb +24 -14
  122. data/lib/tools/subagent_prompts.rb +15 -4
  123. data/lib/tools/think.rb +1 -1
  124. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  125. data/lib/tools/write.rb +2 -0
  126. data/lib/tui/app.rb +5 -4
  127. data/lib/tui/braille_spinner.rb +7 -7
  128. data/lib/tui/decorators/base_decorator.rb +24 -3
  129. data/lib/tui/message_store.rb +93 -44
  130. data/lib/tui/screens/chat.rb +94 -20
  131. data/lib/tui/settings.rb +9 -2
  132. data/lib/workflows/definition.rb +3 -3
  133. data/lib/workflows/registry.rb +1 -1
  134. data/skills/github.md +38 -0
  135. data/templates/config.toml +4 -23
  136. data/workflows/review_pr.md +18 -14
  137. metadata +88 -28
  138. data/app/jobs/agent_request_job.rb +0 -199
  139. data/app/jobs/analytical_brain_job.rb +0 -33
  140. data/app/jobs/count_message_tokens_job.rb +0 -39
  141. data/app/jobs/passive_recall_job.rb +0 -24
  142. data/app/models/concerns/message/broadcasting.rb +0 -86
  143. data/lib/agent_loop.rb +0 -215
  144. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
  145. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
  146. data/lib/events/agent_message.rb +0 -25
  147. data/lib/events/subscribers/message_collector.rb +0 -64
  148. data/lib/events/tool_call.rb +0 -31
  149. data/lib/events/tool_response.rb +0 -33
  150. data/lib/mneme/compressed_viewport.rb +0 -204
  151. 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 — they are created directly by
13
- # their callers ({SessionChannel#speak}, {AgentLoop#run}) so the
14
- # message ID is available for bounce-back cleanup. Pending user
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 persisted by their callers
37
- # ({SessionChannel#speak}, {AgentLoop#run}). Also skips event
38
- # types not in {Message::TYPES} (transient events like
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, enabling
6
- # bidirectional @mention communication.
5
+ # Routes agent text messages between parent and child sessions,
6
+ # enabling bidirectional @mention communication.
7
7
  #
8
- # **Child Parent:** When a sub-agent emits an {Events::AgentMessage},
9
- # the router creates a {Events::UserMessage} in the parent session
10
- # with attribution prefix. If the parent is idle, persists directly
11
- # and wakes it via {AgentRequestJob}. If the parent is mid-turn,
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
- # **ParentChild:** When a parent agent emits an {Events::AgentMessage}
16
- # containing `@name` mentions, the router persists the message in each
17
- # matching child session with a +[from parent]:+ origin label and wakes
18
- # them via {AgentRequestJob}.
13
+ # **ChildParent:** 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
- # Both directions delegate to {Session#enqueue_user_message}, which
21
- # respects the target session's processing state persisting directly
22
- # when idle, deferring via pending queue when mid-turn.
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 prefix.
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+ containing
42
- # an +agent_message+ event (type, session_id, content)
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
- payload = event[:payload]
46
- return unless payload.is_a?(Hash)
47
- return unless payload[:type] == "agent_message"
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[:content].to_s
50
+ content = message.payload["content"].to_s
53
51
  return if content.empty?
54
52
 
55
- session = Session.find_by(id: session_id)
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} (or idle-path message) owns the
70
- # attribution formatting — the router passes raw content.
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
- # {Message::Broadcasting} callbacks; this subscriber handles events
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