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.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  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 +16 -5
  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 +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -2,33 +2,31 @@
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.
26
27
  class SubagentMessageRouter
27
28
  include Events::Subscriber
28
29
 
29
- # @see Tools::ResponseTruncator::ATTRIBUTION_FORMAT
30
- ATTRIBUTION_FORMAT = Tools::ResponseTruncator::ATTRIBUTION_FORMAT
31
-
32
30
  # Origin label for messages routed from parent agent to sub-agent.
33
31
  # Lets the sub-agent distinguish delegated work from direct user input.
34
32
  PARENT_ATTRIBUTION_FORMAT = "[from parent]: %s"
@@ -38,25 +36,21 @@ module Events
38
36
 
39
37
  # Routes agent text messages between parent and child sessions.
40
38
  #
41
- # For sub-agent sessions: forwards to parent with attribution prefix.
39
+ # For sub-agent sessions: forwards to parent with attribution.
42
40
  # For parent sessions: scans for @mentions and routes to matching children.
43
41
  #
44
- # @param event [Hash] Rails.event notification hash with +:payload+ containing
45
- # 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+
46
44
  # @return [void]
47
45
  def emit(event)
48
- payload = event[:payload]
49
- return unless payload.is_a?(Hash)
50
- return unless payload[:type] == "agent_message"
51
-
52
- session_id = payload[:session_id]
53
- 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"
54
49
 
55
- content = payload[:content].to_s
50
+ content = message.payload["content"].to_s
56
51
  return if content.empty?
57
52
 
58
- session = Session.find_by(id: session_id)
59
- return unless session
53
+ session = message.session
60
54
 
61
55
  if session.sub_agent?
62
56
  route_to_parent(session, content)
@@ -68,8 +62,9 @@ module Events
68
62
  private
69
63
 
70
64
  # Forwards a sub-agent's text message to its parent session
71
- # via {Session#enqueue_user_message}. Truncates oversized messages
72
- # to protect the parent's context window.
65
+ # via {Session#enqueue_user_message} with source metadata.
66
+ # The parent's {PendingMessage} owns the attribution formatting —
67
+ # the router passes raw content.
73
68
  #
74
69
  # @param child [Session] the sub-agent session
75
70
  # @param content [String] the sub-agent's message text
@@ -83,9 +78,8 @@ module Events
83
78
  threshold: Anima::Settings.max_subagent_response_chars,
84
79
  reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
85
80
  )
86
- attributed = format(ATTRIBUTION_FORMAT, name, truncated)
87
81
 
88
- parent.enqueue_user_message(attributed)
82
+ parent.enqueue_user_message(truncated, source_type: "subagent", source_name: name)
89
83
  end
90
84
 
91
85
  # Scans a parent agent's message for @mentions and routes the message
@@ -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
data/lib/llm/client.rb CHANGED
@@ -1,21 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- # Convenience layer over {Providers::Anthropic} for sending messages
5
- # and handling tool execution loops. Supports both simple text chat
6
- # and multi-turn tool calling via the Anthropic tool use protocol.
4
+ # Convenience layer over {Providers::Anthropic} for phantom sessions
5
+ # (Mneme, Melete, Mneme::L2Runner) that need a multi-round tool-use
6
+ # loop driven from plain Ruby objects rather than the main drain
7
+ # pipeline.
7
8
  #
8
- # @example Simple chat (no tools)
9
- # client = LLM::Client.new
10
- # client.chat([{role: "user", content: "Say hello"}])
11
- # # => "Hello! How can I help you today?"
9
+ # The main agent loop does NOT use this class — {DrainJob} talks to
10
+ # the provider directly and emits {Events::LLMResponded} for
11
+ # {Events::Subscribers::LLMResponseHandler} to process. The tool loop
12
+ # here is deliberately minimal: no events, no AASM transitions, no
13
+ # interrupt handling — phantom sessions don't interact with those
14
+ # machineries.
12
15
  #
13
- # @example Chat with tools
16
+ # @example
14
17
  # registry = Tools::Registry.new
15
- # registry.register(Tools::WebGet)
16
- # client.chat_with_tools(messages, registry: registry, session_id: session.id)
18
+ # registry.register(Tools::SaveSnapshot)
19
+ # client.chat_with_tools(messages, registry: registry)
17
20
  class Client
18
- # Synthetic tool_result when a tool is skipped because the human pressed Escape.
21
+ # Synthetic tool_result text shown when a tool run is aborted by the
22
+ # user's Escape press. Mirrored into the interrupt subsystem so both
23
+ # the bash tool and any future interrupt handler share the phrasing.
19
24
  INTERRUPT_MESSAGE = "Your human wants your attention"
20
25
 
21
26
  # @return [Providers::Anthropic] the underlying API provider
@@ -39,86 +44,52 @@ module LLM
39
44
  @logger = logger
40
45
  end
41
46
 
42
- # Send messages to the LLM and return the assistant's text response.
47
+ # Runs a minimal multi-round tool-use cycle: call the LLM, execute
48
+ # any requested tools, feed results back, repeat until the LLM
49
+ # produces a final text response.
43
50
  #
44
- # @param messages [Array<Hash>] conversation messages, each with +:role+ and +:content+
45
- # @param options [Hash] additional API parameters (e.g. +system:+, +temperature:+)
46
- # @return [String] the assistant's response text
47
- # @raise [Providers::Anthropic::Error] on API errors
48
- # @raise [Providers::Anthropic::AuthenticationError] on auth failures
49
- def chat(messages, **options)
50
- response = provider.create_message(
51
- model: model,
52
- messages: messages,
53
- max_tokens: max_tokens,
54
- **options
55
- )
56
-
57
- extract_text(response)
58
- end
59
-
60
- # Send messages with tool support. Runs the full tool execution loop:
61
- # call LLM, execute any requested tools, feed results back, repeat
62
- # until the LLM produces a final text response.
63
- #
64
- # Emits {Events::ToolCall} and {Events::ToolResponse} events for each
65
- # tool interaction so they're persisted and visible in the event stream.
66
- #
67
- # When the user interrupts via Escape, remaining tools receive synthetic
68
- # "Your human wants your attention" results and the loop exits without another LLM call.
51
+ # Intended for phantom sessions (Mneme, Melete). No events are
52
+ # emitted and no persistence happens the caller is responsible
53
+ # for capturing whatever state the tool runs produce.
69
54
  #
70
55
  # @param messages [Array<Hash>] conversation messages in Anthropic format
71
56
  # @param registry [Tools::Registry] registered tools to make available
72
- # @param session_id [Integer, String] session ID for emitted events
73
- # @param first_response [Hash, nil] pre-fetched first API response from
74
- # {AgentLoop#deliver!}. Skips the first API call when provided so
75
- # the Bounce Back transaction doesn't duplicate work.
76
57
  # @param options [Hash] additional API parameters (e.g. +system:+)
77
- # @return [String, nil] the assistant's final text response, or nil when interrupted
58
+ # @return [Hash] +:text+ (String) and +:api_metrics+ (Hash)
78
59
  # @raise [Providers::Anthropic::Error] on API errors
79
- def chat_with_tools(messages, registry:, session_id:, first_response: nil, **options)
60
+ def chat_with_tools(messages, registry:, **options)
80
61
  messages = messages.dup
81
62
  rounds = 0
63
+ last_api_metrics = nil
82
64
 
83
65
  loop do
84
66
  rounds += 1
85
67
  max_rounds = Anima::Settings.max_tool_rounds
86
68
  if rounds > max_rounds
87
- return "[Tool loop exceeded #{max_rounds} rounds — halting]"
69
+ return {text: "[Tool loop exceeded #{max_rounds} rounds — halting]", api_metrics: last_api_metrics}
88
70
  end
89
71
 
90
- response = if first_response && rounds == 1
91
- first_response
92
- else
93
- broadcast_session_state(session_id, "llm_generating")
94
- provider.create_message(
95
- model: model,
96
- messages: messages,
97
- max_tokens: max_tokens,
98
- tools: registry.schemas,
99
- **options
100
- )
101
- end
72
+ response = provider.create_message(
73
+ model: model,
74
+ messages: messages,
75
+ max_tokens: max_tokens,
76
+ tools: registry.schemas,
77
+ include_metrics: true,
78
+ **options
79
+ )
80
+
81
+ last_api_metrics = response.api_metrics if response.respond_to?(:api_metrics)
102
82
 
103
83
  log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
104
84
 
105
85
  if response["stop_reason"] == "tool_use"
106
- tool_results = execute_tools(response, registry, session_id)
107
-
86
+ tool_results = execute_tools(response, registry)
108
87
  messages += [
109
88
  {role: "assistant", content: response["content"]},
110
89
  {role: "user", content: tool_results}
111
90
  ]
112
-
113
- return nil if handle_interrupt!(session_id)
114
91
  else
115
- # Discard the text response if the user pressed Escape while
116
- # the API was generating it. Without this check the interrupt
117
- # flag set during the blocking API call would be silently
118
- # cleared by the ensure block in AgentRequestJob.
119
- return nil if handle_interrupt!(session_id)
120
-
121
- return extract_text(response)
92
+ return {text: extract_text(response), api_metrics: last_api_metrics}
122
93
  end
123
94
  end
124
95
  end
@@ -131,7 +102,6 @@ module LLM
131
102
 
132
103
  def extract_text(response)
133
104
  content = response["content"] || []
134
-
135
105
  content
136
106
  .select { |block| block["type"] == "text" }
137
107
  .map { |block| block["text"] }
@@ -143,157 +113,36 @@ module LLM
143
113
  content.select { |block| block["type"] == "tool_use" }
144
114
  end
145
115
 
146
- # Executes all tool_use blocks from a response, emitting events for each.
147
- # Checks for user interrupt between toolsremaining tools receive
148
- # synthetic results to satisfy the Anthropic API's tool_use/tool_result
149
- # pairing requirement (a missing result permanently breaks the conversation).
150
- #
151
- # @param response [Hash] Anthropic API response with tool_use content blocks
152
- # @param registry [Tools::Registry] tool registry for dispatch
153
- # @param session_id [Integer, String] session ID for events
154
- # @return [Array<Hash>] tool_result content blocks for the next API call
155
- def execute_tools(response, registry, session_id)
156
- tool_uses = extract_tool_uses(response)
157
- results = []
158
- interrupted = false
159
-
160
- tool_uses.each_with_index do |tool_use, index|
161
- # Check-only here; clearing happens in handle_interrupt! after the loop
162
- interrupted ||= interrupt_requested?(session_id)
163
- if interrupted
164
- remaining = tool_uses[index..]
165
- results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
166
- break
167
- end
168
- results << execute_single_tool(tool_use, registry, session_id)
169
- end
170
-
171
- results
172
- end
173
-
174
- # Creates synthetic "Your human wants your attention" results for all tools in the list.
175
- #
176
- # @param tool_uses [Array<Hash>] remaining tool_use content blocks
177
- # @param session_id [Integer, String] session ID for events
178
- # @return [Array<Hash>] tool_result content blocks
179
- def interrupt_remaining_tools(tool_uses, session_id)
180
- tool_uses.map { |tool_use| interrupt_tool(tool_use, session_id) }
116
+ # Executes every +tool_use+ block from the response and returns
117
+ # matching +tool_result+ blocks. Always emits a result a missing
118
+ # result permanently corrupts the Anthropic conversation history.
119
+ def execute_tools(response, registry)
120
+ extract_tool_uses(response).map { |tool_use| execute_single_tool(tool_use, registry) }
181
121
  end
182
122
 
183
- # Executes a single tool and always returns a tool_result — even if the
184
- # tool raises. Per the Anthropic tool-use protocol, every tool_use must
185
- # have a matching tool_result; a missing result permanently corrupts the
186
- # conversation history and breaks the session.
187
- #
188
- # Falls back to SecureRandom.uuid when Anthropic omits the tool_use id,
189
- # ensuring the ToolCall/ToolResponse pair always shares a valid identifier.
190
- def execute_single_tool(tool_use, registry, session_id)
123
+ def execute_single_tool(tool_use, registry)
191
124
  name = tool_use["name"]
192
125
  id = tool_use["id"] || SecureRandom.uuid
193
126
  input = tool_use["input"] || {}
194
- timeout = input["timeout"] || Anima::Settings.tool_timeout
195
127
 
196
128
  log(:debug, "tool_call: #{name}(#{input.to_json})")
197
129
 
198
- broadcast_session_state(session_id, "tool_executing", tool: name)
199
-
200
- Events::Bus.emit(Events::ToolCall.new(
201
- content: "Calling #{name}", tool_name: name,
202
- tool_input: input, tool_use_id: id, timeout: timeout,
203
- session_id: session_id
204
- ))
205
-
206
130
  result = registry.execute(name, input)
207
131
  result = ToolDecorator.call(name, result)
208
132
  result_content = format_tool_result(result)
209
133
  result_content = truncate_tool_result(result_content, registry, name)
210
- log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
211
134
 
212
- Events::Bus.emit(Events::ToolResponse.new(
213
- content: result_content, tool_name: name, tool_use_id: id,
214
- success: !result.is_a?(Hash) || !result.key?(:error),
215
- session_id: session_id
216
- ))
135
+ log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
217
136
 
218
137
  {type: "tool_result", tool_use_id: id, content: result_content}
219
138
  rescue => error
220
139
  error_detail = "#{error.class}: #{error.message}"
221
140
  Rails.logger.error("Tool #{name} raised #{error_detail}")
222
- error_content = format_tool_result(error: error_detail)
223
-
224
- # Emission can fail (e.g. encoding errors in ActionCable/SQLite),
225
- # but losing the tool_result would permanently corrupt the session.
226
- begin
227
- Events::Bus.emit(Events::ToolResponse.new(
228
- content: error_content, tool_name: name, tool_use_id: id,
229
- success: false, session_id: session_id
230
- ))
231
- rescue => emit_error
232
- Rails.logger.error("ToolResponse emission failed: #{emit_error.class}: #{emit_error.message}")
233
- end
234
-
235
- {type: "tool_result", tool_use_id: id, content: error_content}
236
- end
237
-
238
- # Creates a synthetic "Your human wants your attention" result for a tool that was not
239
- # executed due to user interrupt. Emits both ToolCall and ToolResponse
240
- # events so the TUI shows the interrupted tool in the event stream.
241
- #
242
- # @param tool_use [Hash] Anthropic tool_use content block
243
- # @param session_id [Integer, String] session ID for events
244
- # @return [Hash] tool_result content block
245
- def interrupt_tool(tool_use, session_id)
246
- name = tool_use["name"]
247
- id = tool_use["id"] || SecureRandom.uuid
248
- input = tool_use["input"] || {}
249
-
250
- Events::Bus.emit(Events::ToolCall.new(
251
- content: "Skipped #{name} — your human wants your attention", tool_name: name,
252
- tool_input: input, tool_use_id: id, session_id: session_id
253
- ))
254
-
255
- Events::Bus.emit(Events::ToolResponse.new(
256
- content: INTERRUPT_MESSAGE, tool_name: name, tool_use_id: id,
257
- success: false, session_id: session_id
258
- ))
259
-
260
- {type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
261
- end
262
-
263
- # Checks whether the session has a pending interrupt flag.
264
- #
265
- # @param session_id [Integer, String] session to check
266
- # @return [Boolean] true when interrupt is pending
267
- def interrupt_requested?(session_id)
268
- Session.where(id: session_id, interrupt_requested: true).exists?
269
- end
270
-
271
- # Atomically checks for a pending interrupt and clears it in one query.
272
- # Used at loop boundaries (after tools, before LLM text return) to
273
- # short-circuit the agent loop when the user presses Escape.
274
- #
275
- # @param session_id [Integer, String] session to check
276
- # @return [Boolean] true when interrupt was detected and cleared
277
- def handle_interrupt!(session_id)
278
- Session.where(id: session_id, interrupt_requested: true)
279
- .update_all(interrupt_requested: false) > 0
280
- end
281
-
282
- # Broadcasts a session state transition to all subscribed clients.
283
- # Delegates to {Session#broadcast_session_state} which handles both
284
- # the session's own stream and the parent's stream for HUD updates.
285
- #
286
- # @param session_id [Integer, String] session to broadcast for
287
- # @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
288
- # @param tool [String, nil] tool name when state is "tool_executing"
289
- # @return [void]
290
- def broadcast_session_state(session_id, state, tool: nil)
291
- Session.find_by(id: session_id)&.broadcast_session_state(state, tool: tool)
141
+ {type: "tool_result", tool_use_id: id, content: format_tool_result(error: error_detail)}
292
142
  end
293
143
 
294
144
  def log(level, message)
295
145
  return unless @logger
296
-
297
146
  @logger.public_send(level, message)
298
147
  end
299
148
 
@@ -301,8 +150,6 @@ module LLM
301
150
  result.is_a?(Hash) ? result.to_json : result.to_s
302
151
  end
303
152
 
304
- # Applies head+tail truncation when a tool result exceeds the tool's
305
- # configured character threshold. Skips tools that opt out (e.g. read).
306
153
  def truncate_tool_result(content, registry, tool_name)
307
154
  threshold = registry.truncation_threshold(tool_name)
308
155
  return content unless threshold