anima-core 1.4.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.
Files changed (149) 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 +468 -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/events/authentication_required.rb +24 -0
  56. data/lib/events/bounce_back.rb +4 -4
  57. data/lib/events/eviction_completed.rb +28 -0
  58. data/lib/events/goal_created.rb +28 -0
  59. data/lib/events/goal_updated.rb +32 -0
  60. data/lib/events/llm_responded.rb +35 -0
  61. data/lib/events/message_created.rb +27 -0
  62. data/lib/events/message_updated.rb +25 -0
  63. data/lib/events/session_state_changed.rb +30 -0
  64. data/lib/events/skill_activated.rb +28 -0
  65. data/lib/events/start_melete.rb +36 -0
  66. data/lib/events/start_mneme.rb +33 -0
  67. data/lib/events/start_processing.rb +32 -0
  68. data/lib/events/subagent_evicted.rb +31 -0
  69. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  70. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  71. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  72. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  73. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  74. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  75. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  76. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  77. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  78. data/lib/events/subscribers/persister.rb +6 -8
  79. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  80. data/lib/events/subscribers/subagent_message_router.rb +26 -29
  81. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  82. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  83. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  84. data/lib/events/tool_executed.rb +34 -0
  85. data/lib/events/workflow_activated.rb +27 -0
  86. data/lib/llm/client.rb +41 -201
  87. data/lib/mcp/client_manager.rb +41 -46
  88. data/lib/mcp/stdio_transport.rb +9 -5
  89. data/lib/{analytical_brain → melete}/runner.rb +63 -68
  90. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
  91. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
  92. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  93. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
  94. data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
  95. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
  96. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  97. data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
  98. data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
  99. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  100. data/lib/mneme/base_runner.rb +121 -0
  101. data/lib/mneme/l2_runner.rb +14 -20
  102. data/lib/mneme/recall_runner.rb +132 -0
  103. data/lib/mneme/runner.rb +118 -171
  104. data/lib/mneme/search.rb +104 -62
  105. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  106. data/lib/mneme/tools/save_snapshot.rb +2 -10
  107. data/lib/mneme/tools/surface_memory.rb +89 -0
  108. data/lib/mneme.rb +11 -5
  109. data/lib/shell_session.rb +287 -612
  110. data/lib/skills/definition.rb +2 -2
  111. data/lib/skills/registry.rb +1 -1
  112. data/lib/tools/base.rb +16 -0
  113. data/lib/tools/bash.rb +25 -57
  114. data/lib/tools/edit.rb +2 -0
  115. data/lib/tools/read.rb +2 -0
  116. data/lib/tools/registry.rb +79 -3
  117. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  118. data/lib/tools/spawn_specialist.rb +16 -10
  119. data/lib/tools/spawn_subagent.rb +20 -14
  120. data/lib/tools/subagent_prompts.rb +4 -4
  121. data/lib/tools/think.rb +1 -1
  122. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  123. data/lib/tools/write.rb +2 -0
  124. data/lib/tui/app.rb +5 -4
  125. data/lib/tui/braille_spinner.rb +7 -7
  126. data/lib/tui/decorators/base_decorator.rb +24 -3
  127. data/lib/tui/message_store.rb +93 -44
  128. data/lib/tui/screens/chat.rb +94 -20
  129. data/lib/tui/settings.rb +9 -2
  130. data/lib/workflows/definition.rb +3 -3
  131. data/lib/workflows/registry.rb +1 -1
  132. data/skills/github.md +38 -0
  133. data/templates/config.toml +4 -23
  134. data/workflows/review_pr.md +18 -14
  135. metadata +86 -28
  136. data/app/jobs/agent_request_job.rb +0 -199
  137. data/app/jobs/analytical_brain_job.rb +0 -33
  138. data/app/jobs/count_message_tokens_job.rb +0 -39
  139. data/app/jobs/passive_recall_job.rb +0 -24
  140. data/app/models/concerns/message/broadcasting.rb +0 -86
  141. data/lib/agent_loop.rb +0 -215
  142. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
  143. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
  144. data/lib/events/agent_message.rb +0 -25
  145. data/lib/events/subscribers/message_collector.rb +0 -64
  146. data/lib/events/tool_call.rb +0 -31
  147. data/lib/events/tool_response.rb +0 -33
  148. data/lib/mneme/compressed_viewport.rb +0 -204
  149. data/lib/mneme/passive_recall.rb +0 -138
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +subagent+ {PendingMessage} — a sub-agent's reply that
4
+ # landed on the parent's mailbox via {SubagentMessageRouter}. Promotes
5
+ # into a phantom +from_<nickname>+ tool_call/tool_response pair, but
6
+ # while pending it surfaces as a labeled inbound delivery so the user
7
+ # sees which sub-agent is talking to her.
8
+ #
9
+ # Hidden in basic (matches the promoted tool pair, which is hidden in
10
+ # basic). Visible from verbose with a +[from <nickname>]+ badge.
11
+ class PendingSubagentDecorator < PendingMessageDecorator
12
+ # @return [nil] sub-agent deliveries are hidden in basic mode
13
+ def render_basic
14
+ nil
15
+ end
16
+
17
+ # @return [Hash] dimmed sub-agent delivery payload
18
+ def render_verbose
19
+ {
20
+ role: :pending_subagent,
21
+ source: source_name,
22
+ content: truncate_lines(content, max_lines: 3),
23
+ status: "pending"
24
+ }
25
+ end
26
+
27
+ # @return [Hash] full sub-agent delivery payload
28
+ def render_debug
29
+ {
30
+ role: :pending_subagent,
31
+ source: source_name,
32
+ content: content,
33
+ status: "pending"
34
+ }
35
+ end
36
+
37
+ # @return [String] Melete transcript line
38
+ def render_melete
39
+ "Sub-agent #{source_name} (pending): #{truncate_middle(content)}"
40
+ end
41
+
42
+ # @return [String] Mneme transcript line
43
+ def render_mneme
44
+ "Sub-agent #{source_name} (pending): #{truncate_middle(content)}"
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +tool_response+ {PendingMessage} — a tool result waiting in
4
+ # the mailbox before the drain pairs it with its tool_call and feeds the
5
+ # next LLM turn. Mirrors {ToolResponseDecorator}: hidden in basic
6
+ # (aggregated by the tool counter), structured tool output in verbose,
7
+ # full untruncated content in debug — all dimmed via
8
+ # +status: "pending"+.
9
+ class PendingToolResponseDecorator < PendingMessageDecorator
10
+ # @return [nil] tool responses are hidden in basic mode
11
+ def render_basic
12
+ nil
13
+ end
14
+
15
+ # @return [Hash] truncated tool response payload tagged as pending
16
+ def render_verbose
17
+ {
18
+ role: :tool_response,
19
+ tool: source_name,
20
+ content: truncate_lines(content, max_lines: 3),
21
+ # nil treated as success; only an explicit false flips the indicator
22
+ # — mirrors {ToolResponseDecorator}'s convention so legacy PMs
23
+ # without an explicit success column don't render as failures.
24
+ success: success != false,
25
+ tool_use_id: tool_use_id,
26
+ status: "pending"
27
+ }
28
+ end
29
+
30
+ # @return [Hash] full tool response payload tagged as pending
31
+ def render_debug
32
+ {
33
+ role: :tool_response,
34
+ tool: source_name,
35
+ content: content,
36
+ success: success != false,
37
+ tool_use_id: tool_use_id,
38
+ status: "pending"
39
+ }
40
+ end
41
+
42
+ # @return [String] Melete transcript line
43
+ def render_melete
44
+ "tool_response #{tool_use_id} (pending): #{truncate_middle(content)}"
45
+ end
46
+
47
+ # @return [String] Mneme transcript line
48
+ def render_mneme
49
+ "tool_response #{tool_use_id} (pending): #{truncate_middle(content)}"
50
+ end
51
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +user_message+ {PendingMessage} — the user's input as it
4
+ # sits in the mailbox between submission and promotion. Mirrors
5
+ # {UserMessageDecorator}'s shape, with +status: "pending"+ added so the
6
+ # TUI dims the entry.
7
+ class PendingUserMessageDecorator < PendingMessageDecorator
8
+ # @return [Hash] dimmed user message payload
9
+ def render_basic
10
+ {role: :user, content: content, status: "pending"}
11
+ end
12
+
13
+ # @return [String] Melete transcript line
14
+ def render_melete
15
+ "User (pending): #{truncate_middle(content)}"
16
+ end
17
+
18
+ # @return [String] Mneme transcript line
19
+ def render_mneme
20
+ "User (pending): #{truncate_middle(content)}"
21
+ end
22
+ end
@@ -18,4 +18,9 @@ class SystemMessageDecorator < MessageDecorator
18
18
  def render_debug
19
19
  render_verbose
20
20
  end
21
+
22
+ # @return [String] transcript line for Mneme's eviction/context zones
23
+ def render_mneme
24
+ "message #{id} System: #{content}"
25
+ end
21
26
  end
@@ -50,8 +50,8 @@ class ToolCallDecorator < MessageDecorator
50
50
 
51
51
  # Think calls get full text — the agent's reasoning IS the signal.
52
52
  # Other tool calls show tool name + params (compact JSON).
53
- # @return [String] transcript line for the analytical brain
54
- def render_brain
53
+ # @return [String] transcript line for Melete
54
+ def render_melete
55
55
  if think?
56
56
  "Think: #{thoughts}"
57
57
  else
@@ -59,6 +59,17 @@ class ToolCallDecorator < MessageDecorator
59
59
  end
60
60
  end
61
61
 
62
+ # Think calls render as conversation. Regular tool calls return
63
+ # a +:tool_call+ marker for the counter accumulator.
64
+ # @return [String, Symbol] transcript line or counter marker
65
+ def render_mneme
66
+ if think?
67
+ "message #{id} Think: #{thoughts}"
68
+ else
69
+ :tool_call
70
+ end
71
+ end
72
+
62
73
  private
63
74
 
64
75
  def think?
@@ -46,10 +46,10 @@ class ToolResponseDecorator < MessageDecorator
46
46
  }.merge(token_info)
47
47
  end
48
48
 
49
- # Think responses ("OK") are noise — excluded from the brain's transcript.
49
+ # Think responses ("OK") are noise — excluded from Melete's transcript.
50
50
  # Other tool responses are compressed to success/failure indicators only.
51
51
  # @return [String, nil] ✅ or ❌ indicator, nil for think responses
52
- def render_brain
52
+ def render_melete
53
53
  return if think?
54
54
 
55
55
  (payload["success"] != false) ? "\u2705" : "\u274C"
@@ -19,9 +19,14 @@ class UserMessageDecorator < MessageDecorator
19
19
  render_verbose.merge(token_info)
20
20
  end
21
21
 
22
- # @return [String] user message for the analytical brain, middle-truncated
22
+ # @return [String] user message for Melete, middle-truncated
23
23
  # if very long (preserves intent at start and conclusion at end)
24
- def render_brain
24
+ def render_melete
25
25
  "User: #{truncate_middle(content)}"
26
26
  end
27
+
28
+ # @return [String] transcript line for Mneme's eviction/context zones
29
+ def render_mneme
30
+ "message #{id} User: #{content}"
31
+ end
27
32
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refines a record's +token_count+ with the real Anthropic tokenizer count,
4
+ # replacing the local heuristic seeded during creation. Accepts any record
5
+ # that includes {TokenEstimation} and implements +#tokenization_text+ —
6
+ # Messages, Snapshots, and PinnedMessages share the same pipeline.
7
+ class CountTokensJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
11
+ discard_on ActiveRecord::RecordNotFound
12
+
13
+ # @param record [ActiveRecord::Base] any record responding to
14
+ # +#tokenization_text+ and +token_count=+
15
+ def perform(record)
16
+ count = Providers::Anthropic.new.count_tokens(
17
+ model: Anima::Settings.model,
18
+ messages: [{role: "user", content: record.tokenization_text}]
19
+ )
20
+
21
+ record.update!(token_count: count)
22
+ end
23
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drains the PendingMessage mailbox into a single LLM round-trip.
4
+ #
5
+ # One invocation == one half-step of the event-driven agent loop:
6
+ # 1. Claim the session via {Session#start_processing!} (atomic; bails if
7
+ # another drain already holds the session OR the in-flight tool round
8
+ # is incomplete — the AASM guard +tool_round_complete?+ handles both).
9
+ # 2. Promote pending work into the conversation — every tool_response PM
10
+ # of the freshly completed round flushes in one transaction so the
11
+ # LLM sees a whole assistant turn paired with a whole user turn;
12
+ # background phantom pairs flush; one active FIFO message rides
13
+ # along. Promotion lives on {PendingMessage#promote!} — the job only
14
+ # decides what to pick and in which order.
15
+ # 3. Make one LLM API call and emit {Events::LLMResponded}.
16
+ #
17
+ # On the happy path the job never releases the session — state
18
+ # transitions after the emit belong to
19
+ # {Events::Subscribers::LLMResponseHandler} (on text or tool dispatch).
20
+ # {Events::Subscribers::ToolResponseCreator} no longer touches state;
21
+ # the +executing → awaiting+ branch of +start_processing+ closes the
22
+ # tool round and claims in one atomic, lock-protected step.
23
+ #
24
+ # The job DOES release its own claim when there is no responder to do
25
+ # it: an empty mailbox (spurious kickoff) or an exception raised before
26
+ # the LLM call succeeded. Those are lifecycle edges of the claim itself,
27
+ # not hand-offs to responders.
28
+ #
29
+ # @see Events::Subscribers::DrainKickoff — enqueues this job
30
+ # @see Events::LLMResponded — the event emitted on LLM completion
31
+ class DrainJob < ApplicationJob
32
+ queue_as :default
33
+
34
+ # Transient provider errors retry inline within {#call_llm_and_emit}.
35
+ # A job-level +retry_on+ would be a no-op here: {PendingMessage#promote!}
36
+ # destroys the PM rows *before* the LLM call, so a retried job would find
37
+ # an empty mailbox and exit without ever re-issuing the request.
38
+
39
+ discard_on Providers::Anthropic::AuthenticationError do |job, error|
40
+ Events::Bus.emit(Events::AuthenticationRequired.new(
41
+ session_id: job.arguments.first,
42
+ content: error.message
43
+ ))
44
+ end
45
+
46
+ discard_on ActiveRecord::RecordNotFound
47
+
48
+ # @param session_id [Integer]
49
+ def perform(session_id)
50
+ @session = Session.find(session_id)
51
+ return unless @session.start_processing!
52
+
53
+ drained = drain_mailbox
54
+ return @session.response_complete! if drained.zero?
55
+
56
+ call_llm_and_emit
57
+ rescue Providers::Anthropic::AuthenticationError => error
58
+ release_after_failure(error) if @session
59
+ raise
60
+ rescue => error
61
+ release_after_failure(error) if @session
62
+ raise unless @active_pm&.bounce_back?
63
+ end
64
+
65
+ private
66
+
67
+ # Decides what the upcoming LLM round will carry and promotes those PMs.
68
+ #
69
+ # 1. All +tool_response+ PMs of the just-completed round — the AASM
70
+ # guard guarantees they are all present when we arrive from
71
+ # +:executing+; from +:idle+ this set is empty.
72
+ # 2. Pick one active FIFO message (user_message or subagent) to ride
73
+ # along. If there are no tool_responses AND no active message, the
74
+ # LLM call is a no-op: release the claim and do NOT flush background
75
+ # PMs (they stay in the mailbox for the next turn).
76
+ # 3. Flush background phantom pairs so they sit above the active pick.
77
+ # 4. Promote the active pick.
78
+ #
79
+ # @return [Integer] count of PMs promoted this cycle (0 means "release
80
+ # the claim without calling the LLM")
81
+ def drain_mailbox
82
+ tool_responses = @session.pending_messages.where(message_type: "tool_response").order(:created_at).to_a
83
+ @active_pm = @session.pending_messages.active.where.not(message_type: "tool_response").order(:created_at).first
84
+
85
+ promoted = tool_responses.size + (@active_pm ? 1 : 0)
86
+ return 0 if promoted.zero?
87
+
88
+ tool_responses.each(&:promote!)
89
+ @session.pending_messages.background.find_each(&:promote!)
90
+ @active_pm&.promote!
91
+
92
+ promoted
93
+ end
94
+
95
+ def call_llm_and_emit
96
+ prompt = @session.system_prompt
97
+ @session.broadcast_debug_context(system: prompt, tools: registry.schemas)
98
+
99
+ response = with_transient_retry do
100
+ client.provider.create_message(
101
+ model: client.model,
102
+ messages: @session.messages_for_llm,
103
+ max_tokens: client.max_tokens,
104
+ tools: registry.schemas,
105
+ include_metrics: true,
106
+ **(prompt ? {system: prompt} : {})
107
+ )
108
+ end
109
+
110
+ Events::Bus.emit(Events::LLMResponded.new(
111
+ session_id: @session.id,
112
+ response: response.to_h.stringify_keys,
113
+ api_metrics: response.api_metrics
114
+ ))
115
+ end
116
+
117
+ # Retries the LLM call in-place on transient provider errors. The
118
+ # polynomial-backoff formula mirrors ActiveJob's +:polynomially_longer+.
119
+ # On final exhaustion the subscriber-visible SystemMessage is emitted
120
+ # before the error re-raises into {#perform}'s rescue path for release.
121
+ TRANSIENT_RETRY_ATTEMPTS = 5
122
+
123
+ def with_transient_retry
124
+ tries = 0
125
+ begin
126
+ yield
127
+ rescue Providers::Anthropic::TransientError => error
128
+ tries += 1
129
+ if tries >= TRANSIENT_RETRY_ATTEMPTS
130
+ Events::Bus.emit(Events::SystemMessage.new(
131
+ content: "Failed after multiple retries: #{error.message}",
132
+ session_id: @session.id
133
+ ))
134
+ raise
135
+ end
136
+ sleep(transient_backoff(tries))
137
+ retry
138
+ end
139
+ end
140
+
141
+ def transient_backoff(attempt)
142
+ base = attempt**4
143
+ base + (rand * 0.15 * base)
144
+ end
145
+
146
+ def release_after_failure(error)
147
+ if @active_pm&.bounce_back?
148
+ @session.release_with_bounce_back(@active_pm, error)
149
+ elsif @session.may_response_complete?
150
+ @session.response_complete!
151
+ end
152
+ end
153
+
154
+ def client
155
+ @client ||= if @session.sub_agent?
156
+ LLM::Client.new(model: Anima::Settings.subagent_model)
157
+ else
158
+ LLM::Client.new
159
+ end
160
+ end
161
+
162
+ def registry
163
+ @registry ||= Tools::Registry.build(session: @session, shell_session: shell_session)
164
+ end
165
+
166
+ def shell_session
167
+ @shell_session ||= ShellSession.for_session(@session)
168
+ end
169
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MeleteEnrichmentJob < ApplicationJob
4
+ # Scoped subscriber that watches the event bus for goal mutation events
5
+ # (+anima.goal.created+, +anima.goal.updated+) belonging to one session,
6
+ # for the duration of a block.
7
+ #
8
+ # Returns +true+ from {.observe} when at least one matching event fired
9
+ # during the block, +false+ otherwise. The subscription is registered
10
+ # before the block runs and removed in an +ensure+ so it is cleaned up
11
+ # even if the block raises.
12
+ #
13
+ # @example
14
+ # goal_changed = GoalChangeListener.observe(session_id: 42) do
15
+ # Melete::Runner.new(session).call
16
+ # end
17
+ class GoalChangeListener
18
+ EVENT_NAMES = [
19
+ "#{Events::Bus::NAMESPACE}.#{Events::GoalCreated::TYPE}",
20
+ "#{Events::Bus::NAMESPACE}.#{Events::GoalUpdated::TYPE}"
21
+ ].freeze
22
+
23
+ # @param session_id [Integer] only events whose payload session_id matches count
24
+ # @yield runs while the subscription is active
25
+ # @return [Boolean] whether a matching event fired during the block
26
+ def self.observe(session_id:, &block)
27
+ new(session_id).observe(&block)
28
+ end
29
+
30
+ def initialize(session_id)
31
+ @session_id = session_id
32
+ @triggered = false
33
+ end
34
+
35
+ def observe
36
+ Events::Bus.subscribe(self) do |event|
37
+ EVENT_NAMES.include?(event[:name]) &&
38
+ event[:payload][:session_id] == @session_id
39
+ end
40
+ yield
41
+ @triggered
42
+ ensure
43
+ Events::Bus.unsubscribe(self)
44
+ end
45
+
46
+ # Bus subscriber contract — flips the latch on any matching event.
47
+ # @api private
48
+ def emit(_event)
49
+ @triggered = true
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # First stage of the drain pipeline: runs Melete to activate skills,
4
+ # read workflows, and refine goals. Hands off to either Mneme (when goals
5
+ # changed during this run) or directly to the drain loop.
6
+ #
7
+ # Triggered by {Events::Subscribers::MeleteKickoff} in response to
8
+ # {Events::StartMelete}. Runs the existing synchronous {Melete::Runner}
9
+ # — the event is only the entry/exit plumbing.
10
+ #
11
+ # A {GoalChangeListener} observes {Events::GoalCreated} and
12
+ # {Events::GoalUpdated} for the duration of the runner call. When a goal
13
+ # mutation is heard the job emits {Events::StartMneme} so Mneme recalls
14
+ # against the fresh goal set; otherwise it emits {Events::StartProcessing}
15
+ # and Mneme is skipped — there is no new search seed to recall against.
16
+ #
17
+ # Sub-agents skip Melete entirely (sub-agent nickname assignment is a
18
+ # one-time step, not part of the recurring pipeline). With no runner
19
+ # call, the listener never fires and the job falls through to
20
+ # {Events::StartProcessing}.
21
+ #
22
+ # Exceptions from {Melete::Runner#call} propagate — no defensive rescue.
23
+ # A crashed Melete leaves the session idle with the PM still in the
24
+ # mailbox; the next PM create (or idle-wake re-route) retries the full
25
+ # chain. Swallowing would surface a degraded response to the user without
26
+ # the failure being visible anywhere (anti-pattern per the project's
27
+ # "soft error paths" principle).
28
+ class MeleteEnrichmentJob < ApplicationJob
29
+ queue_as :default
30
+
31
+ discard_on ActiveRecord::RecordNotFound
32
+
33
+ # @param session_id [Integer]
34
+ # @param pending_message_id [Integer, nil] the PM that kicked off the chain
35
+ def perform(session_id, pending_message_id: nil)
36
+ session = Session.find(session_id)
37
+
38
+ goal_changed = GoalChangeListener.observe(session_id: session_id) do
39
+ Melete::Runner.new(session).call unless session.sub_agent?
40
+ end
41
+
42
+ next_event_class = goal_changed ? Events::StartMneme : Events::StartProcessing
43
+ Events::Bus.emit(next_event_class.new(
44
+ session_id: session_id,
45
+ pending_message_id: pending_message_id
46
+ ))
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Second stage of the drain pipeline: runs Mneme's recall loop so any
4
+ # older memory she judges useful lands in the mailbox as background
5
+ # {PendingMessage}s, then hands off to the drain loop via
6
+ # {Events::StartProcessing}.
7
+ #
8
+ # Triggered by {Events::Subscribers::MnemeKickoff} in response to
9
+ # {Events::StartMneme}, which {MeleteEnrichmentJob} only emits when goals
10
+ # changed during the preceding Melete run. Runs the phantom
11
+ # {Mneme::RecallRunner} loop — the event is only the entry/exit plumbing.
12
+ #
13
+ # Mneme recall is *enrichment* — it adds recalled memories as background
14
+ # phantom pairs but is never required for the primary pipeline to make
15
+ # progress. If recall raises (bad FTS5 input, SQL glitch, …) the handoff
16
+ # to the drain loop must still happen, otherwise the session's user
17
+ # message is stranded in the mailbox with no retry trigger. Exceptions
18
+ # are logged loudly so failures stay visible — they just don't gate the drain.
19
+ class MnemeEnrichmentJob < ApplicationJob
20
+ queue_as :default
21
+
22
+ discard_on ActiveRecord::RecordNotFound
23
+
24
+ # @param session_id [Integer]
25
+ # @param pending_message_id [Integer, nil] the PM that kicked off the chain
26
+ def perform(session_id, pending_message_id: nil)
27
+ session = Session.find(session_id)
28
+
29
+ run_recall(session)
30
+
31
+ Events::Bus.emit(Events::StartProcessing.new(
32
+ session_id: session_id,
33
+ pending_message_id: pending_message_id
34
+ ))
35
+ end
36
+
37
+ private
38
+
39
+ def run_recall(session)
40
+ Mneme::RecallRunner.new(session).call
41
+ rescue => error
42
+ msg = "FAILED (recall) session=#{session.id}: #{error.class}: #{error.message}"
43
+ Rails.logger.error("Mneme #{msg}")
44
+ Mneme.logger.error("#{msg}\n#{error.backtrace&.first(10)&.join("\n")}")
45
+ end
46
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runs a single tool on behalf of the session and reports the outcome.
4
+ #
5
+ # Queued by {Events::Subscribers::LLMResponseHandler} when the LLM
6
+ # returns a +tool_use+ block. The session is already in the +:executing+
7
+ # state (transition owned by the response handler). This job:
8
+ #
9
+ # 1. Dispatches the tool via {Tools::Registry}.
10
+ # 2. Truncates and formats the result.
11
+ # 3. Emits {Events::ToolExecuted}.
12
+ #
13
+ # The job does not release the session or create the +tool_response+
14
+ # PendingMessage — that's {Events::Subscribers::ToolResponseCreator}'s
15
+ # job. Event emission is the final act that hands control off.
16
+ class ToolExecutionJob < ApplicationJob
17
+ queue_as :default
18
+
19
+ discard_on ActiveRecord::RecordNotFound
20
+
21
+ # @param session_id [Integer]
22
+ # @param tool_use_id [String] Anthropic-assigned pairing ID
23
+ # @param tool_name [String]
24
+ # @param tool_input [Hash]
25
+ def perform(session_id, tool_use_id:, tool_name:, tool_input:)
26
+ session = Session.find(session_id)
27
+ # ShellSession.for_session returns the conversation's persistent shell
28
+ # — spawned on first use, reused on every subsequent tool call so the
29
+ # agent's cd's and exported env vars survive between calls. We do NOT
30
+ # finalize it here; the shell's lifetime is the Session's lifetime.
31
+ shell_session = ShellSession.for_session(session)
32
+ registry = Tools::Registry.build(session: session, shell_session: shell_session)
33
+
34
+ content, success = execute(registry, tool_name, tool_input, tool_use_id)
35
+
36
+ Events::Bus.emit(Events::ToolExecuted.new(
37
+ session_id: session_id,
38
+ tool_use_id: tool_use_id,
39
+ tool_name: tool_name,
40
+ content: content,
41
+ success: success
42
+ ))
43
+ rescue => error
44
+ # A missing {Events::ToolExecuted} would leave the session in +:executing+
45
+ # forever. Always emit a synthetic failure event so
46
+ # {Events::Subscribers::ToolResponseCreator} runs and releases the claim.
47
+ Rails.logger.error("ToolExecutionJob crashed: #{error.class}: #{error.message}")
48
+ Events::Bus.emit(Events::ToolExecuted.new(
49
+ session_id: session_id,
50
+ tool_use_id: tool_use_id,
51
+ tool_name: tool_name,
52
+ content: "#{error.class}: #{error.message}",
53
+ success: false
54
+ ))
55
+ end
56
+
57
+ private
58
+
59
+ # Always emits something executable back — a missing +tool_result+
60
+ # permanently corrupts the Anthropic conversation history.
61
+ def execute(registry, tool_name, tool_input, tool_use_id)
62
+ result = registry.execute(tool_name, tool_input, tool_use_id: tool_use_id)
63
+ result = ::ToolDecorator.call(tool_name, result)
64
+ content = format_result(result)
65
+ content = truncate(content, registry, tool_name)
66
+ [content, !result.is_a?(Hash) || !result.key?(:error)]
67
+ rescue => error
68
+ Rails.logger.error("Tool #{tool_name} raised #{error.class}: #{error.message}")
69
+ [format_result(error: "#{error.class}: #{error.message}"), false]
70
+ end
71
+
72
+ def format_result(result)
73
+ result.is_a?(Hash) ? result.to_json : result.to_s
74
+ end
75
+
76
+ def truncate(content, registry, tool_name)
77
+ threshold = registry.truncation_threshold(tool_name)
78
+ return content unless threshold
79
+
80
+ lines = ::Tools::ResponseTruncator::HEAD_LINES
81
+ ::Tools::ResponseTruncator.truncate(
82
+ content,
83
+ threshold: threshold,
84
+ reason: "#{tool_name} output displays first/last #{lines} lines"
85
+ )
86
+ end
87
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared token-count lifecycle for records that ride in the LLM context
4
+ # window. Including models seed {#token_count} with a local heuristic on
5
+ # create and schedule {CountTokensJob} to refine it with the real Anthropic
6
+ # tokenizer count.
7
+ #
8
+ # Non-AR callers (TUI debug display, phantom-pair sizing, byte-cap
9
+ # calculations) use {.estimate_token_count} and {BYTES_PER_TOKEN} as
10
+ # module-level helpers without including the concern.
11
+ #
12
+ # Including models must implement +#tokenization_text+ returning the
13
+ # string whose token count should be estimated and later refined.
14
+ module TokenEstimation
15
+ extend ActiveSupport::Concern
16
+
17
+ # Heuristic: average bytes per token for English prose.
18
+ BYTES_PER_TOKEN = 4
19
+
20
+ # Estimates token count from a string using the {BYTES_PER_TOKEN} heuristic.
21
+ #
22
+ # @param text [String, nil]
23
+ # @return [Integer] estimated token count (0 for blank input)
24
+ def self.estimate_token_count(text)
25
+ (text.to_s.bytesize / BYTES_PER_TOKEN.to_f).ceil
26
+ end
27
+
28
+ included do
29
+ before_validation :set_estimated_token_count, on: :create
30
+ after_create :schedule_token_count
31
+ end
32
+
33
+ # Heuristic token estimate for this record's {#tokenization_text}.
34
+ #
35
+ # @return [Integer]
36
+ def estimate_tokens
37
+ TokenEstimation.estimate_token_count(tokenization_text)
38
+ end
39
+
40
+ private
41
+
42
+ # Seeds {#token_count} with a local estimate before the record is saved.
43
+ # Respects an explicit positive value passed by the caller (e.g. tests
44
+ # that want deterministic counts).
45
+ def set_estimated_token_count
46
+ return if token_count.to_i.positive?
47
+
48
+ self.token_count = estimate_tokens
49
+ end
50
+
51
+ def schedule_token_count
52
+ CountTokensJob.perform_later(self)
53
+ end
54
+ end