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
@@ -10,7 +10,7 @@ module Skills
10
10
  # content injected into the main agent's system prompt when active.
11
11
  #
12
12
  # Skills are passive knowledge — they describe WHAT you know, not
13
- # WHAT to do. The analytical brain activates/deactivates them based
13
+ # WHAT to do. Melete activates/deactivates them based
14
14
  # on conversation context.
15
15
  #
16
16
  # @example Skill file format
@@ -25,7 +25,7 @@ module Skills
25
25
  # @return [String] unique skill identifier used in activate_skill(name: "...")
26
26
  attr_reader :name
27
27
 
28
- # @return [String] description shown to the analytical brain for relevance matching
28
+ # @return [String] description shown to Melete for relevance matching
29
29
  attr_reader :description
30
30
 
31
31
  # @return [String] knowledge content (Markdown body) injected into system prompt
@@ -69,7 +69,7 @@ module Skills
69
69
  @skills[name]
70
70
  end
71
71
 
72
- # Skill names and descriptions for inclusion in the analytical brain's context.
72
+ # Skill names and descriptions for inclusion in Melete's context.
73
73
  #
74
74
  # @return [Hash{String => String}] name => description
75
75
  def catalog
data/lib/tools/base.rb CHANGED
@@ -50,6 +50,22 @@ module Tools
50
50
  def truncation_threshold
51
51
  Anima::Settings.max_tool_response_chars
52
52
  end
53
+
54
+ # One-line entry rendered into the system prompt's "## Available Tools"
55
+ # menu. Tools that return +nil+ are omitted from the menu.
56
+ #
57
+ # @return [String, nil] short capability statement, or nil to skip
58
+ def prompt_snippet
59
+ nil
60
+ end
61
+
62
+ # Cross-tool behavioral guidelines merged into the system prompt's
63
+ # "## Tool Guidelines" section. Each entry becomes a Markdown bullet.
64
+ #
65
+ # @return [Array<String>] guideline lines, or empty array to skip
66
+ def prompt_guidelines
67
+ []
68
+ end
53
69
  end
54
70
 
55
71
  # Subclasses whose schema depends on runtime context (e.g. session state,
@@ -64,7 +80,6 @@ module Tools
64
80
  # end
65
81
  #
66
82
  # @see Think#dynamic_schema Budget-based maxLength
67
- # @see Bash#dynamic_schema CWD in description
68
83
 
69
84
  # Accepts and discards context keywords so that the Registry can pass
70
85
  # shared dependencies (e.g. shell_session) to any tool uniformly.
data/lib/tools/bash.rb CHANGED
@@ -3,12 +3,16 @@
3
3
  module Tools
4
4
  # Executes bash commands in a persistent {ShellSession}. Commands share
5
5
  # working directory, environment variables, and shell history within a
6
- # conversation. Output is truncated and timeouts are enforced by the
7
- # underlying session.
6
+ # conversation. Output is the rendered terminal text exactly as a human
7
+ # would see it — including the prompt, which doubles as live cwd/branch
8
+ # telemetry for the agent.
8
9
  #
9
- # Supports two modes:
10
- # - Single command via +command+ (string) — backward compatible
11
- # - Batch via +commands+ (array) with +mode+ controlling error handling
10
+ # Two input shapes:
11
+ # - +command+ (string) — one command, one result.
12
+ # - +commands+ (array) runs each command in order in the same shell;
13
+ # all run regardless of failures (the agent reads merged output and
14
+ # decides what to do). Use shell chaining (+&&+) inside a single
15
+ # command if you need fail-fast.
12
16
  #
13
17
  # @see ShellSession#run
14
18
  class Bash < Base
@@ -16,6 +20,8 @@ module Tools
16
20
 
17
21
  def self.description = "Execute shell commands. Working directory and environment persist between calls."
18
22
 
23
+ def self.prompt_snippet = "Run shell commands."
24
+
19
25
  def self.input_schema
20
26
  {
21
27
  type: "object",
@@ -26,12 +32,7 @@ module Tools
26
32
  commands: {
27
33
  type: "array",
28
34
  items: {type: "string"},
29
- description: "Each command gets its own timeout and result."
30
- },
31
- mode: {
32
- type: "string",
33
- enum: ["sequential", "parallel"],
34
- description: "sequential (default) stops on first failure."
35
+ description: "Each command gets its own timeout and result. All commands run regardless of failures — use a single command with shell chaining if you need fail-fast."
35
36
  }
36
37
  }
37
38
  }
@@ -44,21 +45,10 @@ module Tools
44
45
  @session = session
45
46
  end
46
47
 
47
- # Returns tool schema with the shell's current working directory
48
- # embedded in the description so the agent sees it during tool
49
- # selection — eliminating redundant +cd+ prefixes.
50
- #
51
- # @return [Hash] Anthropic tool schema with dynamic description
52
- def dynamic_schema
53
- schema = self.class.schema.deep_dup
54
- schema[:description] = "Execute shell commands in #{@shell_session.pwd}. Working directory and environment persist between calls."
55
- schema
56
- end
57
-
58
48
  # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
59
49
  # Supports optional "timeout" key (seconds) to override the global
60
50
  # command_timeout setting for long-running operations.
61
- # @return [String] formatted output with stdout, stderr, and exit code
51
+ # @return [String] rendered terminal output
62
52
  # @return [Hash] with :error key on failure
63
53
  def execute(input)
64
54
  timeout = input["timeout"]
@@ -68,7 +58,7 @@ module Tools
68
58
  if has_command && has_commands
69
59
  {error: "Provide either 'command' or 'commands', not both"}
70
60
  elsif has_commands
71
- execute_batch(input["commands"], mode: input.fetch("mode", "sequential"), timeout: timeout)
61
+ execute_batch(input["commands"], timeout: timeout)
72
62
  elsif has_command
73
63
  execute_single(input["command"], timeout: timeout)
74
64
  else
@@ -78,7 +68,7 @@ module Tools
78
68
 
79
69
  private
80
70
 
81
- # Executes a single command — the original code path, preserved for backward compatibility.
71
+ # Executes a single command — the original code path.
82
72
  def execute_single(command, timeout: nil)
83
73
  command = command.to_s
84
74
  return {error: "Command cannot be blank"} if command.strip.empty?
@@ -88,25 +78,24 @@ module Tools
88
78
  return format_interrupted(result) if result[:interrupted]
89
79
  return result if result.key?(:error)
90
80
 
91
- format_result(result[:stdout], result[:stderr], result[:exit_code])
81
+ result[:output]
92
82
  end
93
83
 
94
- # Executes an array of commands, returning a combined result string.
95
- # Checks for user interrupt between commands and during each command
96
- # via the {ShellSession} interrupt_check callback.
84
+ # Executes an array of commands sequentially through the shared
85
+ # shell. Continues past errors the LLM reads the merged output
86
+ # and decides what to do. The only short-circuit is a user interrupt,
87
+ # which skips the remaining commands.
97
88
  #
98
89
  # @param commands [Array<String>] commands to execute
99
- # @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
100
90
  # @param timeout [Integer, nil] per-command timeout override
101
91
  # @return [String] combined results with per-command headers
102
92
  # @return [Hash] with :error key if commands array is invalid
103
- def execute_batch(commands, mode:, timeout: nil)
93
+ def execute_batch(commands, timeout: nil)
104
94
  return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
105
95
 
106
96
  checker = interrupt_checker
107
97
  total = commands.size
108
98
  results = []
109
- failed = false
110
99
  interrupted = false
111
100
 
112
101
  commands.each_with_index do |command, index|
@@ -117,11 +106,6 @@ module Tools
117
106
  next
118
107
  end
119
108
 
120
- if failed && mode == "sequential"
121
- results << "#{position} $ #{command}\n(skipped)"
122
- next
123
- end
124
-
125
109
  command = command.to_s
126
110
  if command.strip.empty?
127
111
  results << "#{position} $ (blank)\n(skipped — blank command)"
@@ -135,37 +119,23 @@ module Tools
135
119
  interrupted = true
136
120
  elsif result.key?(:error)
137
121
  results << "#{position} $ #{command}\n#{result[:error]}"
138
- failed = true
139
122
  else
140
- exit_code = result[:exit_code]
141
- output = format_result(result[:stdout], result[:stderr], exit_code)
142
- results << "#{position} $ #{command}\n#{output}"
143
- failed = true if exit_code != 0
123
+ results << "#{position} $ #{command}\n#{result[:output]}"
144
124
  end
145
125
  end
146
126
 
147
127
  results.join("\n\n")
148
128
  end
149
129
 
150
- def format_result(stdout, stderr, exit_code)
151
- parts = []
152
- parts << "stdout:\n#{stdout}" unless stdout.empty?
153
- parts << "stderr:\n#{stderr}" unless stderr.empty?
154
- parts << "exit_code: #{exit_code}"
155
- parts.join("\n\n")
156
- end
157
-
158
130
  # Formats the result of an interrupted command for the LLM.
159
131
  # Includes partial output captured before the interrupt.
160
132
  #
161
- # @param result [Hash] ShellSession result with :stdout, :stderr keys
133
+ # @param result [Hash] ShellSession result with :output key
162
134
  # @return [String] formatted message for the LLM
163
135
  def format_interrupted(result)
164
- stdout = result[:stdout].to_s
165
- stderr = result[:stderr].to_s
136
+ output = result[:output].to_s
166
137
  parts = [LLM::Client::INTERRUPT_MESSAGE]
167
- parts << "Partial stdout:\n#{stdout}" unless stdout.empty?
168
- parts << "stderr:\n#{stderr}" unless stderr.empty?
138
+ parts << "Partial output:\n#{output}" unless output.empty?
169
139
  parts.join("\n\n")
170
140
  end
171
141
 
data/lib/tools/edit.rb CHANGED
@@ -20,6 +20,8 @@ module Tools
20
20
 
21
21
  def self.description = "Replace text in a file."
22
22
 
23
+ def self.prompt_snippet = "Replace exact text in a file."
24
+
23
25
  def self.input_schema
24
26
  {
25
27
  type: "object",
@@ -63,9 +63,9 @@ module Tools
63
63
  end
64
64
  end
65
65
 
66
- # Delivers the sub-agent's result to the parent session as an
67
- # attributed user message. Truncates oversized results to protect
68
- # the parent's context window. No-op when the parent session is absent.
66
+ # Delivers the sub-agent's result to the parent session with source
67
+ # metadata. Truncates oversized results to protect the parent's
68
+ # context window. No-op when the parent session is absent.
69
69
  #
70
70
  # @param result [String] the sub-agent's findings to forward
71
71
  # @return [void]
@@ -79,8 +79,7 @@ module Tools
79
79
  threshold: Anima::Settings.max_subagent_response_chars,
80
80
  reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
81
81
  )
82
- attributed = format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, name, truncated)
83
- parent.enqueue_user_message(attributed)
82
+ parent.enqueue_user_message(truncated, source_type: "subagent", source_name: name)
84
83
  end
85
84
  end
86
85
  end
data/lib/tools/read.rb CHANGED
@@ -21,6 +21,8 @@ module Tools
21
21
 
22
22
  def self.description = "Read file. Relative paths resolve against working directory."
23
23
 
24
+ def self.prompt_snippet = "Read a file."
25
+
24
26
  def self.input_schema
25
27
  {
26
28
  type: "object",
@@ -14,6 +14,76 @@ module Tools
14
14
  # registry.register(Tools::Bash)
15
15
  # registry.execute("bash", {"command" => "ls"})
16
16
  class Registry
17
+ # Standard tools available to every session unless filtered out by
18
+ # {Session#granted_tools}.
19
+ STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::ViewMessages, Tools::SearchMessages].freeze
20
+
21
+ # Tools that bypass {Session#granted_tools} filtering — the agent's
22
+ # reasoning depends on them regardless of task scope.
23
+ ALWAYS_GRANTED_TOOLS = [Tools::Think].freeze
24
+
25
+ # Name-to-class mapping for granted-tools filtering.
26
+ STANDARD_TOOLS_BY_NAME = STANDARD_TOOLS.index_by(&:tool_name).freeze
27
+
28
+ class << self
29
+ # Builds a registry appropriate for the given session: standard tools
30
+ # filtered through {Session#granted_tools}, plus spawn tools for main
31
+ # sessions or +mark_goal_completed+ for sub-agents, plus any tools
32
+ # exposed by configured MCP servers.
33
+ #
34
+ # MCP registration warnings are emitted as system messages so both the
35
+ # user (in verbose mode) and the LLM see them.
36
+ #
37
+ # @param session [Session] the session requesting tools
38
+ # @param shell_session [ShellSession] persistent PTY for Bash-family tools
39
+ # @return [Registry] configured registry
40
+ def build(session:, shell_session:)
41
+ registry = new(context: {shell_session: shell_session, session: session})
42
+
43
+ tool_classes_for(session).each { |tool| registry.register(tool) }
44
+
45
+ Mcp::ClientManager.shared.register_tools(registry).each do |message|
46
+ Events::Bus.emit(Events::SystemMessage.new(content: message, session_id: session.id))
47
+ end
48
+
49
+ registry
50
+ end
51
+
52
+ # Resolves the ordered tool-class list for a session: granted standard
53
+ # tools (or all of them when +granted_tools+ is nil), plus spawn tools
54
+ # for main sessions or +mark_goal_completed+ for sub-agents. MCP tools
55
+ # are excluded — they are dynamic and registered separately by
56
+ # {.build}. Single source of truth for {.build}, {Session#tool_schemas},
57
+ # and the system-prompt section assemblers.
58
+ #
59
+ # @param session [Session]
60
+ # @return [Array<Class<Tools::Base>>]
61
+ def tool_classes_for(session)
62
+ tools = granted_standard_tools(session).dup
63
+
64
+ if session.sub_agent?
65
+ tools.push(Tools::MarkGoalCompleted)
66
+ else
67
+ tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
68
+ end
69
+
70
+ tools
71
+ end
72
+
73
+ private
74
+
75
+ # Filters {STANDARD_TOOLS} through the session's granted list.
76
+ # Always includes {ALWAYS_GRANTED_TOOLS} so the agent retains core
77
+ # reasoning tools regardless of task scope.
78
+ def granted_standard_tools(session)
79
+ granted = session.granted_tools
80
+ return STANDARD_TOOLS unless granted
81
+
82
+ explicitly_granted = granted.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
83
+ (ALWAYS_GRANTED_TOOLS + explicitly_granted).uniq
84
+ end
85
+ end
86
+
17
87
  # @return [Hash{String => Class, Object}] registered tools keyed by name
18
88
  attr_reader :tools
19
89
 
@@ -38,22 +108,33 @@ module Tools
38
108
  # are instantiated with context to generate their schema:
39
109
  # - {Think}: budget-based maxLength
40
110
  # - {Bash}: CWD embedded in description
111
+ # Returns tool schemas for the Anthropic API. The last schema is
112
+ # annotated with +cache_control+ so the API caches the entire tools
113
+ # prefix (tools are evaluated first in cache prefix order).
41
114
  def schemas
42
115
  default = Anima::Settings.tool_timeout
43
- @tools.values.map { |tool| inject_timeout(resolve_schema(tool), default) }
116
+ result = @tools.values.map { |tool| inject_timeout(resolve_schema(tool), default) }
117
+ result.last[:cache_control] = {type: "ephemeral"}
118
+ result
44
119
  end
45
120
 
46
121
  # Execute a tool by name. Classes are instantiated with the registry's
47
- # context; instances are called directly.
122
+ # context; instances are called directly. The enclosing +tool_use_id+
123
+ # is merged into the context when provided so tools that need to
124
+ # reference their own invoking tool_call (e.g. {Tools::SpawnSubagent}
125
+ # persisting +spawn_tool_use_id+ on the child session) can read it via
126
+ # a named kwarg in their initializer.
48
127
  #
49
128
  # @param name [String] registered tool name
50
129
  # @param input [Hash] tool input parameters (may include "timeout" for
51
130
  # tools that support per-call timeout overrides)
131
+ # @param tool_use_id [String, nil] the invoking tool_call's pairing id
52
132
  # @return [String, Hash] tool execution result
53
133
  # @raise [UnknownToolError] if no tool is registered with the given name
54
- def execute(name, input)
134
+ def execute(name, input, tool_use_id: nil)
55
135
  tool = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
56
- instance = tool.is_a?(Class) ? tool.new(**@context) : tool
136
+ context = tool_use_id ? @context.merge(tool_use_id: tool_use_id) : @context
137
+ instance = tool.is_a?(Class) ? tool.new(**context) : tool
57
138
  instance.execute(input)
58
139
  end
59
140
 
@@ -24,7 +24,7 @@ module Tools
24
24
  # Attribution prefix for messages routed from sub-agent to parent.
25
25
  # Shared by {Events::Subscribers::SubagentMessageRouter} and
26
26
  # {Tools::MarkGoalCompleted} to keep formatting consistent.
27
- ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
27
+ ATTRIBUTION_FORMAT = "[sub-agent %s]: %s"
28
28
 
29
29
  NOTICE = <<~NOTICE.strip
30
30
  ---
@@ -1,51 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tools
4
- # Active memory search keyword lookup across conversation history.
5
- # Returns ranked snippets with message IDs for drill-down via {Remember}.
4
+ # Keyword search across long-term memory — every message Anima has
5
+ # ever seen, across every session, *except* what the agent is already
6
+ # looking at right now. Results sit below her current viewport; anything
7
+ # still in front of her is excluded, so every slot the search returns is
8
+ # new information.
6
9
  #
7
10
  # Two-step memory workflow:
8
- # 1. `recall(query: "auth flow")` → discovers relevant messages
9
- # 2. `remember(message_id: 42)` → fractal zoom into full context
11
+ # 1. `search_messages(query: "auth flow")` → discovers relevant messages
12
+ # 2. `view_messages(message_id: 42)` → fractal zoom into full context
10
13
  #
11
- # Wraps {Mneme::Search} same FTS5 engine used by passive recall,
12
- # but triggered on demand by the agent instead of automatically by goals.
14
+ # Same FTS5 engine Mneme uses for passive recall this variant fires
15
+ # on demand when Aoide reaches for a memory herself.
13
16
  #
14
- # @example Search all sessions
15
- # recall(query: "authentication flow")
16
- #
17
- # @example Search current session only
18
- # recall(query: "OAuth config", session_only: true)
19
- class Recall < Base
20
- def self.tool_name = "recall"
17
+ # @example
18
+ # search_messages(query: "authentication flow")
19
+ class SearchMessages < Base
20
+ def self.tool_name = "search_messages"
21
21
 
22
- def self.description = "Find messages across past conversations by keywords."
22
+ def self.description = "Search long-term memory (past conversations outside your current viewport) by keyword. Returns ranked message snippets with IDs — pass any ID to view_messages to see the full context around it."
23
23
 
24
24
  def self.input_schema
25
25
  {
26
26
  type: "object",
27
27
  properties: {
28
- query: {type: "string"},
29
- session_only: {type: "boolean", description: "Default: all sessions"}
28
+ query: {type: "string"}
30
29
  },
31
30
  required: ["query"]
32
31
  }
33
32
  end
34
33
 
35
- # @param session [Session] the current session (used for session_only scoping)
34
+ # @param session [Session] the calling session drives viewport exclusion
36
35
  def initialize(session:, **)
37
36
  @session = session
38
37
  end
39
38
 
40
- # @param input [Hash] with "query" and optional "session_only"
39
+ # @param input [Hash] with "query"
41
40
  # @return [String] formatted search results with message IDs
42
41
  # @return [Hash] with :error key when query is blank
43
42
  def execute(input)
44
43
  query = input["query"].to_s.strip
45
44
  return {error: "Query cannot be blank"} if query.empty?
46
45
 
47
- session_id = (input["session_only"] == true) ? @session.id : nil
48
- results = Mneme::Search.query(query, session_id: session_id)
46
+ results = Mneme::Search.query(query, caller_session: @session)
49
47
 
50
48
  return "No results found for \"#{query}\"." if results.empty?
51
49
 
@@ -55,7 +53,7 @@ module Tools
55
53
  private
56
54
 
57
55
  # Formats results as token-efficient, LLM-readable output.
58
- # Each result includes message_id for drill-down via remember tool.
56
+ # Each result includes message_id for drill-down via view_messages.
59
57
  #
60
58
  # @param query [String] the original search query
61
59
  # @param results [Array<Mneme::Search::Result>] ranked search results
@@ -5,7 +5,7 @@ module Tools
5
5
  # The specialist has a predefined system prompt and tool set defined
6
6
  # in its Markdown definition file under agents/.
7
7
  #
8
- # Nickname assignment is handled by the {AnalyticalBrain::Runner} which
8
+ # Nickname assignment is handled by the {Melete::Runner} which
9
9
  # runs synchronously at spawn time, generating a unique nickname based
10
10
  # on the task — same as generic sub-agents.
11
11
  #
@@ -22,8 +22,8 @@ module Tools
22
22
  # Builds description dynamically to include available specialists.
23
23
  def self.description
24
24
  base = "Need a specific skill set for the job? Bring in a specialist. " \
25
- "Its messages appear in yours; any message containing " \
26
- "@nickname is forwarded even casual mentions will wake it."
25
+ "Its messages appear as tool responses in your conversation. " \
26
+ "Prefix its nickname with @ to send instructions."
27
27
 
28
28
  registry = Agents::Registry.instance
29
29
  return base unless registry.any?
@@ -59,14 +59,21 @@ module Tools
59
59
 
60
60
  # @param session [Session] the parent session spawning the specialist
61
61
  # @param agent_registry [Agents::Registry, nil] injectable for testing
62
- def initialize(session:, agent_registry: nil, **)
62
+ # @param tool_use_id [String, nil] the invoking +spawn_specialist+ tool_call's
63
+ # pairing id, captured so the spawn pair can later be located by the
64
+ # HUD visibility sweep in {Mneme::Runner}
65
+ def initialize(session:, agent_registry: nil, tool_use_id: nil, **)
63
66
  @session = session
64
67
  @agent_registry = agent_registry || Agents::Registry.instance
68
+ @tool_use_id = tool_use_id
65
69
  end
66
70
 
67
- # Creates a child session with the specialist's predefined prompt and tools,
68
- # persists the task as a user message, and queues background processing.
69
- # Returns immediately (non-blocking).
71
+ # Creates a child session with the specialist's predefined prompt and
72
+ # tools, pins the task as a Goal, and enqueues the task as the
73
+ # child's first user_message PendingMessage — which kicks the
74
+ # standard inbound pipeline (Melete → (Mneme) → StartProcessing →
75
+ # DrainJob) so the specialist self-starts the same way a human-typed
76
+ # message would. Returns immediately after Melete completes.
70
77
  #
71
78
  # @param input [Hash<String, Object>] with "name" and "task"
72
79
  # @return [String] confirmation with child session ID
@@ -83,10 +90,9 @@ module Tools
83
90
 
84
91
  child = spawn_child(definition, task)
85
92
  nickname = child.name
86
- "Specialist @#{nickname} spawned (session #{child.id}). " \
93
+ "Specialist #{nickname} spawned (session #{child.id}). " \
87
94
  "Its messages will appear in your conversation. " \
88
- "Reply with @#{nickname} to send it instructions " \
89
- "any message mentioning @#{nickname} is forwarded, even in narration."
95
+ "To address it, prefix its name with @ in your message."
90
96
  end
91
97
 
92
98
  private
@@ -95,12 +101,14 @@ module Tools
95
101
  child = Session.create!(
96
102
  parent_session_id: @session.id,
97
103
  prompt: build_prompt(definition),
98
- granted_tools: definition.tools
104
+ granted_tools: definition.tools,
105
+ spawn_tool_use_id: @tool_use_id,
106
+ initial_cwd: ShellSession.cwd_via_tmux(@session.id) || @session.initial_cwd
99
107
  )
100
- pin_goal_and_frame(child, task)
101
- assign_nickname_via_brain(child)
108
+ create_goal_with_pinned_task(child, task)
109
+ assign_nickname_via_melete(child)
102
110
  child.broadcast_children_update_to_parent
103
- AgentRequestJob.perform_later(child.id)
111
+ child.enqueue_user_message(task)
104
112
  child
105
113
  end
106
114
 
@@ -2,12 +2,13 @@
2
2
 
3
3
  module Tools
4
4
  # Spawns a generic child session that works on a task autonomously.
5
- # The sub-agent inherits the parent's viewport context at fork time,
6
- # runs via {AgentRequestJob}, and communicates with the parent through
5
+ # The sub-agent starts clean — no parent conversation history with
6
+ # only a system prompt, a Goal, and the task as its first user message.
7
+ # Runs via {DrainJob} and communicates with the parent through
7
8
  # natural text messages routed by {Events::Subscribers::SubagentMessageRouter}.
8
9
  #
9
- # Nickname assignment is handled by the {AnalyticalBrain::Runner} which
10
- # runs synchronously at spawn time — the same brain that manages skills,
10
+ # Nickname assignment is handled by the {Melete::Runner} which
11
+ # runs synchronously at spawn time — the same muse that manages skills,
11
12
  # goals, and workflows for the main session.
12
13
  #
13
14
  # For named specialists with predefined prompts and tools, see {SpawnSpecialist}.
@@ -20,9 +21,9 @@ module Tools
20
21
 
21
22
  def self.description
22
23
  "Task feels like a sidequest or a context-switch? Hand it off. " \
23
- "Inherits your context; its messages appear in yours. " \
24
- "Any message containing @nickname is forwarded " \
25
- "even casual mentions will wake the sub-agent."
24
+ "Starts clean with just the task — include all relevant context in the task description. " \
25
+ "Its messages appear as tool responses in your conversation. " \
26
+ "Prefix its nickname with @ to send instructions."
26
27
  end
27
28
 
28
29
  def self.input_schema
@@ -35,7 +36,7 @@ module Tools
35
36
  items: {type: "string"},
36
37
  description: "Tool names to grant the sub-agent. " \
37
38
  "Omit for all standard tools. Empty array for pure reasoning. " \
38
- "Valid tools: #{AgentLoop::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
39
+ "Valid tools: #{Tools::Registry::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
39
40
  }
40
41
  },
41
42
  required: %w[task]
@@ -43,13 +44,21 @@ module Tools
43
44
  end
44
45
 
45
46
  # @param session [Session] the parent session spawning the sub-agent
46
- def initialize(session:, **)
47
+ # @param tool_use_id [String, nil] the invoking +spawn_subagent+ tool_call's
48
+ # pairing id, captured so the spawn pair can later be located by the
49
+ # HUD visibility sweep in {Mneme::Runner}
50
+ def initialize(session:, tool_use_id: nil, **)
47
51
  @session = session
52
+ @tool_use_id = tool_use_id
48
53
  end
49
54
 
50
- # Creates a child session, runs the analytical brain to assign a nickname,
51
- # persists the task as a user message, and queues background processing.
52
- # Returns immediately after brain completes (blocking for ~200ms).
55
+ # Creates a child session with a clean context (no parent history),
56
+ # runs Melete to assign a nickname, pins the task as a Goal, and
57
+ # enqueues the task as the child's first user_message PendingMessage —
58
+ # which kicks the standard inbound pipeline (Melete → (Mneme) →
59
+ # StartProcessing → DrainJob) so the sub-agent self-starts the same
60
+ # way a human-typed message would. Returns immediately after Melete
61
+ # completes (blocking for ~200ms).
53
62
  #
54
63
  # @param input [Hash<String, Object>] with "task" and optional "tools"
55
64
  # @return [String] confirmation with child session ID and @nickname
@@ -66,10 +75,9 @@ module Tools
66
75
 
67
76
  child = spawn_child(task, tools)
68
77
  nickname = child.name
69
- "Sub-agent @#{nickname} spawned (session #{child.id}). " \
78
+ "Sub-agent #{nickname} spawned (session #{child.id}). " \
70
79
  "Its messages will appear in your conversation. " \
71
- "Reply with @#{nickname} to send it instructions " \
72
- "any message mentioning @#{nickname} is forwarded, even in narration."
80
+ "To address it, prefix its name with @ in your message."
73
81
  end
74
82
 
75
83
  private
@@ -78,12 +86,14 @@ module Tools
78
86
  child = Session.create!(
79
87
  parent_session_id: @session.id,
80
88
  prompt: GENERIC_PROMPT,
81
- granted_tools: granted_tools
89
+ granted_tools: granted_tools,
90
+ spawn_tool_use_id: @tool_use_id,
91
+ initial_cwd: ShellSession.cwd_via_tmux(@session.id) || @session.initial_cwd
82
92
  )
83
- pin_goal_and_frame(child, task)
84
- assign_nickname_via_brain(child)
93
+ create_goal_with_pinned_task(child, task)
94
+ assign_nickname_via_melete(child)
85
95
  child.broadcast_children_update_to_parent
86
- AgentRequestJob.perform_later(child.id)
96
+ child.enqueue_user_message(task)
87
97
  child
88
98
  end
89
99
 
@@ -105,7 +115,7 @@ module Tools
105
115
  return nil unless tools
106
116
  return {error: "tools must be an array"} unless tools.is_a?(Array)
107
117
 
108
- unknown = tools - AgentLoop::STANDARD_TOOLS_BY_NAME.keys
118
+ unknown = tools - Tools::Registry::STANDARD_TOOLS_BY_NAME.keys
109
119
  return {error: "Unknown tool: #{unknown.first}"} if unknown.any?
110
120
 
111
121
  nil