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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted after a Message record is updated and committed.
|
|
5
|
+
# Used by subscribers that need to react to message changes
|
|
6
|
+
# (e.g. broadcasting updated token counts to WebSocket clients).
|
|
7
|
+
class MessageUpdated
|
|
8
|
+
TYPE = "message.updated"
|
|
9
|
+
|
|
10
|
+
attr_reader :message
|
|
11
|
+
|
|
12
|
+
# @param message [Message] the updated message record
|
|
13
|
+
def initialize(message)
|
|
14
|
+
@message = message
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def event_name
|
|
18
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
{type: TYPE, message:}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted when a session's transport-level state changes. Carries the
|
|
5
|
+
# AASM state after a transition (+"idle"+/+"awaiting"+/+"executing"+)
|
|
6
|
+
# or a transient UI signal (+"interrupting"+).
|
|
7
|
+
#
|
|
8
|
+
# Subscribers broadcast the state over ActionCable so the TUI spinner
|
|
9
|
+
# and sub-agent HUD update in sync.
|
|
10
|
+
class SessionStateChanged
|
|
11
|
+
TYPE = "session.state_changed"
|
|
12
|
+
|
|
13
|
+
attr_reader :session_id, :state
|
|
14
|
+
|
|
15
|
+
# @param session_id [Integer] the session the state change belongs to
|
|
16
|
+
# @param state [String] transport state name
|
|
17
|
+
def initialize(session_id:, state:)
|
|
18
|
+
@session_id = session_id
|
|
19
|
+
@state = state
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def event_name
|
|
23
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{type: TYPE, session_id: session_id, state: state}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted after {Session#activate_skill} enqueues a skill's phantom
|
|
5
|
+
# pair. Subscribers rebroadcast the session's active skills/workflow
|
|
6
|
+
# so the HUD reflects the new activation immediately (before the
|
|
7
|
+
# pending message even promotes).
|
|
8
|
+
class SkillActivated
|
|
9
|
+
TYPE = "skill.activated"
|
|
10
|
+
|
|
11
|
+
attr_reader :session_id, :skill_name
|
|
12
|
+
|
|
13
|
+
# @param session_id [Integer] the session the skill was activated on
|
|
14
|
+
# @param skill_name [String] canonical skill name
|
|
15
|
+
def initialize(session_id:, skill_name:)
|
|
16
|
+
@session_id = session_id
|
|
17
|
+
@skill_name = skill_name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def event_name
|
|
21
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{type: TYPE, session_id:, skill_name:}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted when a +user_message+ PendingMessage lands on an idle session.
|
|
5
|
+
# Melete subscribes via {Events::Subscribers::MeleteKickoff} and runs
|
|
6
|
+
# its enrichment loop — activating skills, reading workflows, refining
|
|
7
|
+
# goals, renaming the session — then either:
|
|
8
|
+
#
|
|
9
|
+
# * emits {Events::StartMneme} when a goal changed during the run, so
|
|
10
|
+
# Mneme can recall against the fresh goal set, or
|
|
11
|
+
# * emits {Events::StartProcessing} when goals were untouched, skipping
|
|
12
|
+
# Mneme entirely (no new search seed to recall against).
|
|
13
|
+
#
|
|
14
|
+
# First stage of the +start_melete → (start_mneme) → start_processing+
|
|
15
|
+
# chain that orchestrates context enrichment before the LLM is called.
|
|
16
|
+
class StartMelete
|
|
17
|
+
TYPE = "session.start_melete"
|
|
18
|
+
|
|
19
|
+
attr_reader :session_id, :pending_message_id
|
|
20
|
+
|
|
21
|
+
# @param session_id [Integer] session whose enrichment chain should continue
|
|
22
|
+
# @param pending_message_id [Integer, nil] the PendingMessage that triggered the chain
|
|
23
|
+
def initialize(session_id:, pending_message_id: nil)
|
|
24
|
+
@session_id = session_id
|
|
25
|
+
@pending_message_id = pending_message_id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def event_name
|
|
29
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
{type: TYPE, session_id:, pending_message_id:}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted by {MeleteEnrichmentJob} when goals changed during the Melete
|
|
5
|
+
# run, signalling that Mneme should recall against the fresh goal set.
|
|
6
|
+
# Mneme subscribes via {Events::Subscribers::MnemeKickoff}, performs
|
|
7
|
+
# associative recall, enqueues its memories as background PendingMessages,
|
|
8
|
+
# and emits {Events::StartProcessing} to continue the drain.
|
|
9
|
+
#
|
|
10
|
+
# Second stage of the +start_melete → (start_mneme) → start_processing+
|
|
11
|
+
# chain. Conditional — when goals are untouched the pipeline jumps
|
|
12
|
+
# straight from {Events::StartMelete} to {Events::StartProcessing}.
|
|
13
|
+
class StartMneme
|
|
14
|
+
TYPE = "session.start_mneme"
|
|
15
|
+
|
|
16
|
+
attr_reader :session_id, :pending_message_id
|
|
17
|
+
|
|
18
|
+
# @param session_id [Integer] session whose drain pipeline should start
|
|
19
|
+
# @param pending_message_id [Integer] the PendingMessage that triggered the chain
|
|
20
|
+
def initialize(session_id:, pending_message_id:)
|
|
21
|
+
@session_id = session_id
|
|
22
|
+
@pending_message_id = pending_message_id
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def event_name
|
|
26
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{type: TYPE, session_id:, pending_message_id:}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted when an active PendingMessage lands on an idle session and does
|
|
5
|
+
# not require the Melete/Mneme enrichment pipeline (tool calls, tool
|
|
6
|
+
# responses, sub-agent replies), or when {MeleteEnrichmentJob} finishes
|
|
7
|
+
# without a goal change, or when {MnemeEnrichmentJob} finishes recall.
|
|
8
|
+
# The drain loop subscribes and begins processing the mailbox.
|
|
9
|
+
#
|
|
10
|
+
# Final stage of the +start_melete → (start_mneme) → start_processing+
|
|
11
|
+
# chain.
|
|
12
|
+
class StartProcessing
|
|
13
|
+
TYPE = "session.start_processing"
|
|
14
|
+
|
|
15
|
+
attr_reader :session_id, :pending_message_id
|
|
16
|
+
|
|
17
|
+
# @param session_id [Integer] session whose drain loop should start
|
|
18
|
+
# @param pending_message_id [Integer, nil] the PendingMessage that triggered the chain, if any
|
|
19
|
+
def initialize(session_id:, pending_message_id: nil)
|
|
20
|
+
@session_id = session_id
|
|
21
|
+
@pending_message_id = pending_message_id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def event_name
|
|
25
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{type: TYPE, session_id:, pending_message_id:}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Emitted when {Mneme::Runner} advances the boundary past every remaining
|
|
5
|
+
# trace of a sub-agent — the spawn pair plus every +from_{nickname}+
|
|
6
|
+
# phantom pair. Subscribers broadcast the removal so clients drop the
|
|
7
|
+
# entry from the HUD panel.
|
|
8
|
+
#
|
|
9
|
+
# +session_id+ is the parent session (HUD owner), +child_id+ is the
|
|
10
|
+
# sub-agent session whose traces just aged out.
|
|
11
|
+
class SubagentEvicted
|
|
12
|
+
TYPE = "subagent.evicted"
|
|
13
|
+
|
|
14
|
+
attr_reader :session_id, :child_id
|
|
15
|
+
|
|
16
|
+
# @param session_id [Integer] parent session whose HUD should drop the entry
|
|
17
|
+
# @param child_id [Integer] sub-agent session whose traces were evicted
|
|
18
|
+
def initialize(session_id:, child_id:)
|
|
19
|
+
@session_id = session_id
|
|
20
|
+
@child_id = child_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def event_name
|
|
24
|
+
"#{Bus::NAMESPACE}.#{TYPE}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{type: TYPE, session_id:, child_id:}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Rebroadcasts the session's active skills and workflow whenever the
|
|
6
|
+
# set can change: skill activation, workflow activation, or Mneme
|
|
7
|
+
# eviction. Same handler, three triggers — each event carries a
|
|
8
|
+
# +session_id+ and the broadcaster reads live state off the session.
|
|
9
|
+
#
|
|
10
|
+
# @example Registering at boot
|
|
11
|
+
# trigger = ->(event) {
|
|
12
|
+
# %w[anima.skill.activated anima.workflow.activated anima.eviction.completed]
|
|
13
|
+
# .include?(event[:name])
|
|
14
|
+
# }
|
|
15
|
+
# Events::Bus.subscribe(Events::Subscribers::ActiveStateBroadcaster.new, &trigger)
|
|
16
|
+
class ActiveStateBroadcaster
|
|
17
|
+
include Events::Subscriber
|
|
18
|
+
|
|
19
|
+
# @param event [Hash] Rails.event notification hash
|
|
20
|
+
def emit(event)
|
|
21
|
+
session_id = event.dig(:payload, :session_id)
|
|
22
|
+
session = Session.find_by(id: session_id)
|
|
23
|
+
session&.broadcast_active_state!
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Reacts to {Events::AuthenticationRequired} by surfacing the provider
|
|
6
|
+
# rejection to the operator. Emits a +system_message+ into the
|
|
7
|
+
# conversation (so the failure lives in history) and broadcasts an
|
|
8
|
+
# +authentication_required+ frame on the session's ActionCable stream
|
|
9
|
+
# (so the TUI can prompt for a new token).
|
|
10
|
+
#
|
|
11
|
+
# Follows the same shape as {SessionStateBroadcaster}: jobs emit
|
|
12
|
+
# typed events, broadcasters own the ActionCable side.
|
|
13
|
+
class AuthenticationBroadcaster
|
|
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
|
+
message = payload[:content]
|
|
21
|
+
|
|
22
|
+
Events::Bus.emit(Events::SystemMessage.new(
|
|
23
|
+
content: "Authentication failed: #{message}",
|
|
24
|
+
session_id: session_id
|
|
25
|
+
))
|
|
26
|
+
|
|
27
|
+
ActionCable.server.broadcast(
|
|
28
|
+
"session_#{session_id}",
|
|
29
|
+
{"action" => "authentication_required", "message" => message}
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Entry subscriber for the drain loop. On {Events::StartProcessing},
|
|
6
|
+
# enqueues {DrainJob} — the actual work (session claim, PM promotion,
|
|
7
|
+
# LLM call) happens in the job so the emitter's thread isn't blocked.
|
|
8
|
+
class DrainKickoff
|
|
9
|
+
include Events::Subscriber
|
|
10
|
+
|
|
11
|
+
# @param event [Hash] Rails.event notification hash
|
|
12
|
+
def emit(event)
|
|
13
|
+
session_id = event[:payload][:session_id]
|
|
14
|
+
return unless session_id
|
|
15
|
+
|
|
16
|
+
DrainJob.perform_later(session_id)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -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,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Handles the aftermath of a single LLM round-trip emitted via
|
|
6
|
+
# {Events::LLMResponded}. Persists the assistant's output as Message
|
|
7
|
+
# records, transitions the session state, and — when the response
|
|
8
|
+
# includes a +tool_use+ block — queues {ToolExecutionJob} for each
|
|
9
|
+
# tool.
|
|
10
|
+
#
|
|
11
|
+
# This is where session state moves away from +:awaiting+: either
|
|
12
|
+
# {Session#response_complete!} on a text-only response, or
|
|
13
|
+
# {Session#tool_received!} before dispatching tool work. The drain
|
|
14
|
+
# job itself never transitions state past +:awaiting+ — that is this
|
|
15
|
+
# subscriber's responsibility, per the SOLID rule that event
|
|
16
|
+
# emission is the final act of a piece.
|
|
17
|
+
class LLMResponseHandler
|
|
18
|
+
include Events::Subscriber
|
|
19
|
+
|
|
20
|
+
# @param event [Hash] Rails.event notification hash
|
|
21
|
+
def emit(event)
|
|
22
|
+
payload = event[:payload]
|
|
23
|
+
session = Session.find(payload[:session_id])
|
|
24
|
+
|
|
25
|
+
response = payload[:response] || {}
|
|
26
|
+
api_metrics = payload[:api_metrics]
|
|
27
|
+
|
|
28
|
+
tool_uses = normalize_tool_uses(response)
|
|
29
|
+
text = extract_text(response)
|
|
30
|
+
|
|
31
|
+
persist_agent_message(session, text, api_metrics) if text.present?
|
|
32
|
+
tool_uses.each { |tool_use| persist_tool_call(session, tool_use) }
|
|
33
|
+
|
|
34
|
+
if tool_uses.any?
|
|
35
|
+
session.tool_received! if session.may_tool_received?
|
|
36
|
+
dispatch_tool_executions(session, tool_uses)
|
|
37
|
+
elsif session.may_response_complete?
|
|
38
|
+
session.response_complete!
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def content_blocks(response)
|
|
45
|
+
response["content"] || response[:content] || []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def block_type(block)
|
|
49
|
+
block["type"] || block[:type]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns tool_use blocks with a guaranteed +id+. Generates a UUID
|
|
53
|
+
# once when the provider omits one so persistence and dispatch see
|
|
54
|
+
# the same id — a missing match breaks tool_use/tool_result
|
|
55
|
+
# pairing in the Anthropic conversation.
|
|
56
|
+
def normalize_tool_uses(response)
|
|
57
|
+
content_blocks(response).filter_map do |block|
|
|
58
|
+
next unless block_type(block) == "tool_use"
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
"id" => block["id"] || block[:id] || SecureRandom.uuid,
|
|
62
|
+
"name" => block["name"] || block[:name],
|
|
63
|
+
"input" => block["input"] || block[:input] || {}
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def extract_text(response)
|
|
69
|
+
content_blocks(response)
|
|
70
|
+
.select { |block| block_type(block) == "text" }
|
|
71
|
+
.map { |block| block["text"] || block[:text] }
|
|
72
|
+
.join
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def persist_agent_message(session, text, api_metrics)
|
|
76
|
+
session.messages.create!(
|
|
77
|
+
message_type: "agent_message",
|
|
78
|
+
payload: {"type" => "agent_message", "content" => text, "session_id" => session.id},
|
|
79
|
+
timestamp: Time.current.to_ns,
|
|
80
|
+
api_metrics: api_metrics
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def persist_tool_call(session, tool_use)
|
|
85
|
+
session.messages.create!(
|
|
86
|
+
message_type: "tool_call",
|
|
87
|
+
tool_use_id: tool_use["id"],
|
|
88
|
+
payload: {
|
|
89
|
+
"type" => "tool_call",
|
|
90
|
+
"tool_name" => tool_use["name"],
|
|
91
|
+
"tool_use_id" => tool_use["id"],
|
|
92
|
+
"tool_input" => tool_use["input"],
|
|
93
|
+
"content" => "Calling #{tool_use["name"]}"
|
|
94
|
+
},
|
|
95
|
+
timestamp: Time.current.to_ns
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def dispatch_tool_executions(session, tool_uses)
|
|
100
|
+
tool_uses.each do |tool_use|
|
|
101
|
+
ToolExecutionJob.perform_later(
|
|
102
|
+
session.id,
|
|
103
|
+
tool_use_id: tool_use["id"],
|
|
104
|
+
tool_name: tool_use["name"],
|
|
105
|
+
tool_input: tool_use["input"]
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
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)
|
|
@@ -56,7 +54,8 @@ module Events
|
|
|
56
54
|
message_type: event_type,
|
|
57
55
|
payload: payload,
|
|
58
56
|
tool_use_id: payload[:tool_use_id],
|
|
59
|
-
timestamp: payload[:timestamp] || Time.current.to_ns
|
|
57
|
+
timestamp: payload[:timestamp] || Time.current.to_ns,
|
|
58
|
+
api_metrics: payload[:api_metrics]
|
|
60
59
|
)
|
|
61
60
|
end
|
|
62
61
|
end
|
|
@@ -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
|