anima-core 1.2.0 → 1.3.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +8 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +4 -4
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +13 -4
  13. data/app/models/goal.rb +13 -0
  14. data/app/models/message.rb +13 -18
  15. data/app/models/pending_message.rb +43 -0
  16. data/app/models/secret.rb +72 -0
  17. data/app/models/session.rb +194 -43
  18. data/config/environments/test.rb +5 -0
  19. data/config/initializers/time_nanoseconds.rb +11 -0
  20. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  21. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  22. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  23. data/lib/agent_loop.rb +13 -40
  24. data/lib/agents/definition.rb +1 -1
  25. data/lib/analytical_brain/runner.rb +7 -4
  26. data/lib/anima/cli/mcp/secrets.rb +4 -4
  27. data/lib/anima/cli/mcp.rb +4 -4
  28. data/lib/anima/installer.rb +7 -1
  29. data/lib/anima/settings.rb +31 -2
  30. data/lib/anima/version.rb +1 -1
  31. data/lib/anima.rb +1 -1
  32. data/lib/credential_store.rb +17 -66
  33. data/lib/events/base.rb +1 -1
  34. data/lib/events/subscribers/persister.rb +11 -18
  35. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  36. data/lib/events/user_message.rb +2 -13
  37. data/lib/llm/client.rb +54 -20
  38. data/lib/mcp/config.rb +2 -2
  39. data/lib/mcp/secrets.rb +7 -8
  40. data/lib/mneme/compressed_viewport.rb +1 -1
  41. data/lib/shell_session.rb +54 -16
  42. data/lib/tools/base.rb +23 -0
  43. data/lib/tools/bash.rb +56 -4
  44. data/lib/tools/edit.rb +2 -2
  45. data/lib/tools/mark_goal_completed.rb +86 -0
  46. data/lib/tools/read.rb +2 -1
  47. data/lib/tools/recall.rb +98 -0
  48. data/lib/tools/registry.rb +36 -7
  49. data/lib/tools/remember.rb +1 -1
  50. data/lib/tools/response_truncator.rb +70 -0
  51. data/lib/tools/spawn_specialist.rb +6 -5
  52. data/lib/tools/spawn_subagent.rb +8 -6
  53. data/lib/tools/subagent_prompts.rb +43 -5
  54. data/lib/tools/think.rb +23 -0
  55. data/lib/tools/write.rb +1 -1
  56. data/lib/tui/app.rb +178 -13
  57. data/lib/tui/braille_spinner.rb +152 -0
  58. data/lib/tui/cable_client.rb +4 -4
  59. data/lib/tui/decorators/base_decorator.rb +17 -8
  60. data/lib/tui/decorators/bash_decorator.rb +2 -2
  61. data/lib/tui/decorators/edit_decorator.rb +5 -4
  62. data/lib/tui/decorators/read_decorator.rb +4 -8
  63. data/lib/tui/decorators/think_decorator.rb +3 -5
  64. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  65. data/lib/tui/decorators/write_decorator.rb +5 -4
  66. data/lib/tui/flash.rb +1 -1
  67. data/lib/tui/formatting.rb +22 -0
  68. data/lib/tui/message_store.rb +70 -26
  69. data/lib/tui/screens/chat.rb +269 -66
  70. data/skills/activerecord/SKILL.md +1 -1
  71. data/skills/dragonruby/SKILL.md +1 -1
  72. data/skills/draper-decorators/SKILL.md +1 -1
  73. data/skills/gh-issue.md +1 -1
  74. data/skills/mcp-server/SKILL.md +1 -1
  75. data/skills/ratatui-ruby/SKILL.md +1 -1
  76. data/skills/rspec/SKILL.md +1 -1
  77. data/templates/config.toml +26 -0
  78. metadata +11 -1
data/lib/mcp/config.rb CHANGED
@@ -6,7 +6,7 @@ require "toml-rb"
6
6
  module Mcp
7
7
  # Reads and writes MCP server configuration from a TOML file at
8
8
  # {DEFAULT_PATH}. Supports HTTP and stdio transports. Secrets stored
9
- # in Rails encrypted credentials are interpolated via
9
+ # in the encrypted secrets table are interpolated via
10
10
  # +${credential:key_name}+ syntax in any string value.
11
11
  #
12
12
  # @example Config file format (~/.anima/mcp.toml)
@@ -187,7 +187,7 @@ module Mcp
187
187
  end
188
188
 
189
189
  # Replaces +${credential:key_name}+ placeholders with values from
190
- # Rails encrypted credentials via {Mcp::Secrets}.
190
+ # the encrypted secrets table via {Mcp::Secrets}.
191
191
  #
192
192
  # @param value [String] string potentially containing placeholders
193
193
  # @return [String] interpolated string
data/lib/mcp/secrets.rb CHANGED
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mcp
4
- # CRUD operations for MCP server secrets stored in Rails encrypted credentials.
5
- # Secrets live under the +mcp+ namespace in the credentials file:
4
+ # CRUD operations for MCP server secrets stored in the encrypted secrets table.
5
+ # Secrets live under the +mcp+ namespace:
6
6
  #
7
- # mcp:
8
- # linear_api_key: "sk-xxx"
9
- # mythonix_api_key: "Bearer tok-yyy"
7
+ # Mcp::Secrets.set("linear_api_key", "sk-xxx")
8
+ # Mcp::Secrets.get("linear_api_key") #=> "sk-xxx"
10
9
  #
11
10
  # Referenced in mcp.toml via +${credential:key_name}+ syntax, resolved at
12
11
  # runtime by {Mcp::Config#interpolate_credentials}.
@@ -23,7 +22,7 @@ module Mcp
23
22
  VALID_KEY_PATTERN = /\A\w+\z/
24
23
 
25
24
  class << self
26
- # Stores a secret in encrypted credentials.
25
+ # Stores a secret in encrypted storage.
27
26
  #
28
27
  # @param key [String] secret identifier (e.g. "linear_api_key")
29
28
  # @param value [String] secret value
@@ -35,7 +34,7 @@ module Mcp
35
34
  CredentialStore.write(NAMESPACE, key => value)
36
35
  end
37
36
 
38
- # Retrieves a secret from encrypted credentials.
37
+ # Retrieves a secret from encrypted storage.
39
38
  #
40
39
  # @param key [String] secret identifier
41
40
  # @return [String, nil] secret value or nil if not found
@@ -50,7 +49,7 @@ module Mcp
50
49
  CredentialStore.list(NAMESPACE)
51
50
  end
52
51
 
53
- # Removes a secret from encrypted credentials.
52
+ # Removes a secret from encrypted storage.
54
53
  #
55
54
  # @param key [String] secret identifier to remove
56
55
  # @return [void]
@@ -57,7 +57,7 @@ module Mneme
57
57
  #
58
58
  # @return [Array<Message>]
59
59
  def fetch_messages
60
- scope = @session.messages.context_messages.deliverable
60
+ scope = @session.messages.context_messages
61
61
 
62
62
  if @from_message_id
63
63
  scope = scope.where("id >= ?", @from_message_id)
data/lib/shell_session.rb CHANGED
@@ -47,13 +47,17 @@ class ShellSession
47
47
  # @param command [String] bash command to execute
48
48
  # @param timeout [Integer, nil] per-call timeout in seconds; overrides
49
49
  # Settings.command_timeout when provided
50
+ # @param interrupt_check [Proc, nil] callable returning truthy when the
51
+ # user has requested an interrupt. Polled every
52
+ # {Anima::Settings.interrupt_check_interval} seconds during command execution.
50
53
  # @return [Hash] with :stdout, :stderr, :exit_code keys on success
54
+ # @return [Hash] with :interrupted, :stdout, :stderr keys on user interrupt
51
55
  # @return [Hash] with :error key on failure
52
- def run(command, timeout: nil)
56
+ def run(command, timeout: nil, interrupt_check: nil)
53
57
  @mutex.synchronize do
54
58
  return {error: "Shell session is not running"} if @finalized
55
59
  restart unless @alive
56
- execute_in_pty(command, timeout: timeout)
60
+ execute_in_pty(command, timeout: timeout, interrupt_check: interrupt_check)
57
61
  end
58
62
  rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
59
63
  {error: "#{error.class}: #{error.message}"}
@@ -229,7 +233,7 @@ class ShellSession
229
233
  end
230
234
  end
231
235
 
232
- def execute_in_pty(command, timeout: nil)
236
+ def execute_in_pty(command, timeout: nil, interrupt_check: nil)
233
237
  clear_stderr
234
238
  marker = "__ANIMA_#{SecureRandom.hex(8)}__"
235
239
  timeout ||= Anima::Settings.command_timeout
@@ -237,10 +241,21 @@ class ShellSession
237
241
 
238
242
  @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
239
243
 
240
- stdout, exit_code = read_until_marker(marker, deadline: deadline)
244
+ stdout, exit_code = read_until_marker(marker, deadline: deadline, interrupt_check: interrupt_check)
245
+
246
+ if exit_code == :interrupted
247
+ recover_shell
248
+ update_pwd
249
+ stderr = drain_stderr
250
+ return {
251
+ interrupted: true,
252
+ stdout: truncate(stdout),
253
+ stderr: truncate(stderr)
254
+ }
255
+ end
241
256
 
242
257
  if exit_code.nil?
243
- recover_from_timeout
258
+ recover_shell
244
259
  stderr = drain_stderr
245
260
  parts = ["Command timed out after #{timeout} seconds."]
246
261
  parts << "Partial stdout:\n#{truncate(stdout)}" unless stdout.empty?
@@ -267,14 +282,23 @@ class ShellSession
267
282
  #
268
283
  # @param marker [String] unique marker to detect command completion
269
284
  # @param deadline [Float] monotonic clock deadline
285
+ # @param interrupt_check [Proc, nil] callable returning truthy on user interrupt
270
286
  # @return [Array(String, Integer)] stdout and exit code on success
287
+ # @return [Array(String, Symbol)] partial stdout and +:interrupted+ on user interrupt
271
288
  # @return [Array(String, nil)] partial stdout and nil exit code on timeout
272
- def read_until_marker(marker, deadline:)
289
+ def read_until_marker(marker, deadline:, interrupt_check: nil)
273
290
  lines = []
274
291
  exit_code = nil
292
+ check_interval = interrupt_check ? [Anima::Settings.interrupt_check_interval, 0.5].max : nil
275
293
 
276
294
  loop do
277
- line = gets_with_deadline(deadline)
295
+ line = gets_with_deadline(deadline, interrupt_check: interrupt_check, check_interval: check_interval)
296
+
297
+ if line == :interrupted
298
+ exit_code = :interrupted
299
+ break
300
+ end
301
+
278
302
  break if line.nil?
279
303
 
280
304
  line = line.chomp.delete("\r")
@@ -315,12 +339,21 @@ class ShellSession
315
339
  # Timeout.timeout (which uses Thread.raise that can corrupt mutex state
316
340
  # and leave resources inconsistent).
317
341
  #
342
+ # When +interrupt_check+ is provided, IO.select uses a shorter timeout
343
+ # (capped at {Anima::Settings.interrupt_check_interval}) and polls the
344
+ # callback between iterations. Returns +:interrupted+ when the callback
345
+ # fires, allowing the caller to send Ctrl+C and return partial output.
346
+ #
318
347
  # @param deadline [Float] monotonic clock deadline
348
+ # @param interrupt_check [Proc, nil] callable returning truthy on user interrupt
349
+ # @param check_interval [Float, nil] resolved interrupt check interval (seconds);
350
+ # pre-computed by the caller to avoid re-reading Settings on every line
319
351
  # @return [String] line including trailing newline
352
+ # @return [:interrupted] when user interrupt detected
320
353
  # @return [nil] if deadline expired
321
354
  # @raise [Errno::EIO] when the PTY child process exits (Linux)
322
355
  # @raise [IOError] when the PTY file descriptor is closed
323
- def gets_with_deadline(deadline)
356
+ def gets_with_deadline(deadline, interrupt_check: nil, check_interval: nil)
324
357
  loop do
325
358
  if (idx = @read_buffer.index("\n"))
326
359
  return @read_buffer.slice!(0..idx)
@@ -329,24 +362,29 @@ class ShellSession
329
362
  remaining = deadline - monotonic_now
330
363
  return nil if remaining <= 0
331
364
 
332
- ready = IO.select([@pty_stdout], nil, nil, remaining)
333
- return nil unless ready
365
+ select_timeout = check_interval ? [remaining, check_interval].min : remaining
334
366
 
335
- begin
336
- @read_buffer << @pty_stdout.read_nonblock(4096)
337
- rescue IO::WaitReadable
338
- # Spurious wakeup from IO.select — retry
367
+ ready = IO.select([@pty_stdout], nil, nil, select_timeout)
368
+
369
+ if ready
370
+ begin
371
+ @read_buffer << @pty_stdout.read_nonblock(4096)
372
+ rescue IO::WaitReadable
373
+ # Spurious wakeup from IO.select — retry
374
+ end
339
375
  end
376
+
377
+ return :interrupted if interrupt_check&.call
340
378
  end
341
379
  end
342
380
 
343
- # Sends Ctrl+C to interrupt the running command and drains leftover output.
381
+ # Sends Ctrl+C and drains leftover output after a timeout or user interrupt.
344
382
  # If recovery fails, marks the session as dead (will be respawned on next run).
345
383
  #
346
384
  # @return [void]
347
385
  # @raise [Errno::EIO] when the PTY child process has exited
348
386
  # @raise [IOError] when the PTY file descriptor is closed
349
- def recover_from_timeout
387
+ def recover_shell
350
388
  @pty_stdin.write("\x03")
351
389
  sleep 0.1
352
390
  marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
data/lib/tools/base.rb CHANGED
@@ -41,8 +41,31 @@ module Tools
41
41
  def schema
42
42
  {name: tool_name, description: description, input_schema: input_schema}
43
43
  end
44
+
45
+ # Per-tool character threshold for response truncation.
46
+ # Override in subclasses to use a custom limit, or return +nil+
47
+ # to skip truncation entirely (e.g. read_file tool has its own pagination).
48
+ #
49
+ # @return [Integer, nil] character threshold, or nil to skip truncation
50
+ def truncation_threshold
51
+ Anima::Settings.max_tool_response_chars
52
+ end
44
53
  end
45
54
 
55
+ # Subclasses whose schema depends on runtime context (e.g. session state,
56
+ # shell working directory) can implement +#dynamic_schema+. The registry
57
+ # calls it instead of the class-level {.schema} when present.
58
+ #
59
+ # @example
60
+ # def dynamic_schema
61
+ # schema = self.class.schema.deep_dup
62
+ # schema[:description] = "Dynamic: #{@some_state}"
63
+ # schema
64
+ # end
65
+ #
66
+ # @see Think#dynamic_schema Budget-based maxLength
67
+ # @see Bash#dynamic_schema CWD in description
68
+
46
69
  # Accepts and discards context keywords so that the Registry can pass
47
70
  # shared dependencies (e.g. shell_session) to any tool uniformly.
48
71
  # Subclasses that need specific context should override with named kwargs.
data/lib/tools/bash.rb CHANGED
@@ -38,8 +38,21 @@ module Tools
38
38
  end
39
39
 
40
40
  # @param shell_session [ShellSession] persistent shell backing this tool
41
- def initialize(shell_session:, **)
41
+ # @param session [Session] conversation session for interrupt checking
42
+ def initialize(shell_session:, session:, **)
42
43
  @shell_session = shell_session
44
+ @session = session
45
+ end
46
+
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
43
56
  end
44
57
 
45
58
  # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
@@ -70,13 +83,18 @@ module Tools
70
83
  command = command.to_s
71
84
  return {error: "Command cannot be blank"} if command.strip.empty?
72
85
 
73
- result = @shell_session.run(command, timeout: timeout)
86
+ result = @shell_session.run(command, timeout: timeout, interrupt_check: interrupt_checker)
87
+
88
+ return format_interrupted(result) if result[:interrupted]
74
89
  return result if result.key?(:error)
75
90
 
76
91
  format_result(result[:stdout], result[:stderr], result[:exit_code])
77
92
  end
78
93
 
79
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.
97
+ #
80
98
  # @param commands [Array<String>] commands to execute
81
99
  # @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
82
100
  # @param timeout [Integer, nil] per-command timeout override
@@ -85,13 +103,20 @@ module Tools
85
103
  def execute_batch(commands, mode:, timeout: nil)
86
104
  return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
87
105
 
106
+ checker = interrupt_checker
88
107
  total = commands.size
89
108
  results = []
90
109
  failed = false
110
+ interrupted = false
91
111
 
92
112
  commands.each_with_index do |command, index|
93
113
  position = "[#{index + 1}/#{total}]"
94
114
 
115
+ if interrupted
116
+ results << "#{position} $ #{command}\n(skipped — interrupted by user)"
117
+ next
118
+ end
119
+
95
120
  if failed && mode == "sequential"
96
121
  results << "#{position} $ #{command}\n(skipped)"
97
122
  next
@@ -103,9 +128,12 @@ module Tools
103
128
  next
104
129
  end
105
130
 
106
- result = @shell_session.run(command, timeout: timeout)
131
+ result = @shell_session.run(command, timeout: timeout, interrupt_check: checker)
107
132
 
108
- if result.key?(:error)
133
+ if result[:interrupted]
134
+ results << "#{position} $ #{command}\n#{format_interrupted(result)}"
135
+ interrupted = true
136
+ elsif result.key?(:error)
109
137
  results << "#{position} $ #{command}\n#{result[:error]}"
110
138
  failed = true
111
139
  else
@@ -126,5 +154,29 @@ module Tools
126
154
  parts << "exit_code: #{exit_code}"
127
155
  parts.join("\n\n")
128
156
  end
157
+
158
+ # Formats the result of an interrupted command for the LLM.
159
+ # Includes partial output captured before the interrupt.
160
+ #
161
+ # @param result [Hash] ShellSession result with :stdout, :stderr keys
162
+ # @return [String] formatted message for the LLM
163
+ def format_interrupted(result)
164
+ stdout = result[:stdout].to_s
165
+ stderr = result[:stderr].to_s
166
+ parts = [LLM::Client::INTERRUPT_MESSAGE]
167
+ parts << "Partial stdout:\n#{stdout}" unless stdout.empty?
168
+ parts << "stderr:\n#{stderr}" unless stderr.empty?
169
+ parts.join("\n\n")
170
+ end
171
+
172
+ # Builds a lambda that checks the database for a pending interrupt flag.
173
+ # Called every {Anima::Settings.interrupt_check_interval} seconds during
174
+ # command execution inside {ShellSession}.
175
+ #
176
+ # @return [Proc] lambda returning truthy when interrupt is pending
177
+ def interrupt_checker
178
+ session_id = @session.id
179
+ -> { Session.where(id: session_id, interrupt_requested: true).exists? }
180
+ end
129
181
  end
130
182
  end
data/lib/tools/edit.rb CHANGED
@@ -16,7 +16,7 @@ module Tools
16
16
  # "new_text" => "def greet\n 'hello'\nend")
17
17
  # # => "--- app.rb\n+++ app.rb\n@@ -1,3 +1,3 @@\n ..."
18
18
  class Edit < Base
19
- def self.tool_name = "edit"
19
+ def self.tool_name = "edit_file"
20
20
 
21
21
  def self.description = "Replace text in a file."
22
22
 
@@ -132,7 +132,7 @@ module Tools
132
132
 
133
133
  {error: "Could not find old_text in #{path}. " \
134
134
  "Verify the text exists and matches exactly (including whitespace). " \
135
- "Use the read tool to check current file contents."}
135
+ "Use the read_file tool to check current file contents."}
136
136
  end
137
137
 
138
138
  def ambiguity_error(positions, content, path, fuzzy: false)
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Signals sub-agent task completion by marking its assigned Goal as
5
+ # completed and routing the result back to the parent session.
6
+ #
7
+ # Only available to sub-agent sessions (those with a +parent_session+).
8
+ # This is the explicit "finish line" that prevents runaway sub-agents
9
+ # from continuing past their assigned task.
10
+ #
11
+ # The result text is delivered to the parent session as a user message
12
+ # attributed to the sub-agent, identical to how regular sub-agent
13
+ # messages are routed by {Events::Subscribers::SubagentMessageRouter}.
14
+ #
15
+ # @example Sub-agent completing its task
16
+ # mark_goal_completed(result: "Found 3 N+1 queries in the orders controller.")
17
+ class MarkGoalCompleted < Base
18
+ def self.tool_name = "mark_goal_completed"
19
+
20
+ def self.description = "Deliver result to parent. Stop working after this call."
21
+
22
+ def self.input_schema
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ result: {type: "string"}
27
+ },
28
+ required: %w[result]
29
+ }
30
+ end
31
+
32
+ # @param session [Session] the sub-agent session
33
+ def initialize(session:, **)
34
+ @session = session
35
+ end
36
+
37
+ # Completes the sub-agent's assigned goal and routes the result
38
+ # to the parent session.
39
+ #
40
+ # @param input [Hash<String, Object>] with "result"
41
+ # @return [String] confirmation message
42
+ # @return [Hash{Symbol => String}] with :error key on failure
43
+ def execute(input)
44
+ result = input["result"].to_s.strip
45
+ return {error: "Result cannot be blank"} if result.empty?
46
+
47
+ goal = @session.goals.active.root.first
48
+ return {error: "No active goal found"} unless goal
49
+
50
+ complete_goal(goal)
51
+ route_result_to_parent(result)
52
+
53
+ "Done. Result delivered to parent."
54
+ end
55
+
56
+ private
57
+
58
+ def complete_goal(goal)
59
+ Goal.transaction do
60
+ goal.update!(status: "completed", completed_at: Time.current)
61
+ goal.cascade_completion!
62
+ goal.release_orphaned_pins!
63
+ end
64
+ end
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.
69
+ #
70
+ # @param result [String] the sub-agent's findings to forward
71
+ # @return [void]
72
+ def route_result_to_parent(result)
73
+ parent = @session.parent_session
74
+ return unless parent
75
+
76
+ name = @session.name || "agent-#{@session.id}"
77
+ truncated = Tools::ResponseTruncator.truncate(
78
+ result,
79
+ threshold: Anima::Settings.max_subagent_response_chars,
80
+ reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
81
+ )
82
+ attributed = format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, name, truncated)
83
+ parent.enqueue_user_message(attributed)
84
+ end
85
+ end
86
+ end
data/lib/tools/read.rb CHANGED
@@ -16,7 +16,8 @@ module Tools
16
16
  # tool.execute("path" => "large.log", "offset" => 2001, "limit" => 500)
17
17
  # # => "line 2001 content\n..."
18
18
  class Read < Base
19
- def self.tool_name = "read"
19
+ def self.tool_name = "read_file"
20
+ def self.truncation_threshold = nil
20
21
 
21
22
  def self.description = "Read file. Relative paths resolve against working directory."
22
23
 
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Active memory search — keyword lookup across conversation history.
5
+ # Returns ranked snippets with message IDs for drill-down via {Remember}.
6
+ #
7
+ # Two-step memory workflow:
8
+ # 1. `recall(query: "auth flow")` → discovers relevant messages
9
+ # 2. `remember(message_id: 42)` → fractal zoom into full context
10
+ #
11
+ # Wraps {Mneme::Search} — same FTS5 engine used by passive recall,
12
+ # but triggered on demand by the agent instead of automatically by goals.
13
+ #
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"
21
+
22
+ def self.description = "Find messages across past conversations by keywords."
23
+
24
+ def self.input_schema
25
+ {
26
+ type: "object",
27
+ properties: {
28
+ query: {type: "string"},
29
+ session_only: {type: "boolean", description: "Default: all sessions"}
30
+ },
31
+ required: ["query"]
32
+ }
33
+ end
34
+
35
+ # @param session [Session] the current session (used for session_only scoping)
36
+ def initialize(session:, **)
37
+ @session = session
38
+ end
39
+
40
+ # @param input [Hash] with "query" and optional "session_only"
41
+ # @return [String] formatted search results with message IDs
42
+ # @return [Hash] with :error key when query is blank
43
+ def execute(input)
44
+ query = input["query"].to_s.strip
45
+ return {error: "Query cannot be blank"} if query.empty?
46
+
47
+ session_id = (input["session_only"] == true) ? @session.id : nil
48
+ results = Mneme::Search.query(query, session_id: session_id)
49
+
50
+ return "No results found for \"#{query}\"." if results.empty?
51
+
52
+ format_results(query, results)
53
+ end
54
+
55
+ private
56
+
57
+ # Formats results as token-efficient, LLM-readable output.
58
+ # Each result includes message_id for drill-down via remember tool.
59
+ #
60
+ # @param query [String] the original search query
61
+ # @param results [Array<Mneme::Search::Result>] ranked search results
62
+ # @return [String] formatted output
63
+ def format_results(query, results)
64
+ session_names = load_session_names(results)
65
+
66
+ result_word = (results.size == 1) ? "result" : "results"
67
+ lines = ["Found #{results.size} #{result_word} for \"#{query}\":", ""]
68
+ results.each { |result| lines.concat(format_single_result(result, session_names)) }
69
+ lines.join("\n")
70
+ end
71
+
72
+ # Formats a single search result as display lines.
73
+ #
74
+ # @param result [Mneme::Search::Result]
75
+ # @param session_names [Hash{Integer => String}]
76
+ # @return [Array<String>]
77
+ def format_single_result(result, session_names)
78
+ sid = result.session_id
79
+ session_name = session_names[sid] || "Session ##{sid}"
80
+ snippet = result.snippet.to_s.gsub(/\s+/, " ").strip
81
+
82
+ [
83
+ "[message #{result.message_id}, session \"#{session_name}\", #{result.message_type}]",
84
+ " ...#{snippet}...",
85
+ ""
86
+ ]
87
+ end
88
+
89
+ # Batch-loads session names to avoid N+1 queries.
90
+ #
91
+ # @param results [Array<Mneme::Search::Result>]
92
+ # @return [Hash{Integer => String}] session_id => name
93
+ def load_session_names(results)
94
+ session_ids = results.map(&:session_id).uniq
95
+ Session.where(id: session_ids).pluck(:id, :name).to_h
96
+ end
97
+ end
98
+ end
@@ -33,10 +33,14 @@ module Tools
33
33
  # @return [Array<Hash>] schema array for the Anthropic tools API parameter.
34
34
  # Each schema includes an optional `timeout` parameter (seconds) injected
35
35
  # by the registry. The agent can override the default per call for
36
- # long-running operations.
36
+ # long-running operations. Tools with session-dependent schemas (e.g.
37
+ # {Think} with budget-based maxLength, {Bash} with CWD in description)
38
+ # are instantiated with context to generate their schema:
39
+ # - {Think}: budget-based maxLength
40
+ # - {Bash}: CWD embedded in description
37
41
  def schemas
38
42
  default = Anima::Settings.tool_timeout
39
- @tools.values.map { |tool| inject_timeout(tool.schema, default) }
43
+ @tools.values.map { |tool| inject_timeout(resolve_schema(tool), default) }
40
44
  end
41
45
 
42
46
  # Execute a tool by name. Classes are instantiated with the registry's
@@ -53,6 +57,19 @@ module Tools
53
57
  instance.execute(input)
54
58
  end
55
59
 
60
+ # Returns the truncation threshold for a tool, or +nil+ if the tool
61
+ # opts out of truncation (e.g. read_file tool has its own pagination).
62
+ # MCP tools and other duck-typed instances use the default threshold.
63
+ #
64
+ # @param name [String] registered tool name
65
+ # @return [Integer, nil] character threshold, or nil to skip truncation
66
+ def truncation_threshold(name)
67
+ tool = @tools[name]
68
+ return tool.truncation_threshold if tool&.respond_to?(:truncation_threshold)
69
+
70
+ Anima::Settings.max_tool_response_chars
71
+ end
72
+
56
73
  # @param name [String] tool name to check
57
74
  # @return [Boolean] whether a tool with the given name is registered
58
75
  def registered?(name)
@@ -66,16 +83,28 @@ module Tools
66
83
 
67
84
  private
68
85
 
86
+ # Returns a tool's schema, preferring the instance-level dynamic
87
+ # variant when available. Only instantiates the tool when needed.
88
+ def resolve_schema(tool)
89
+ return tool.schema unless dynamic_schema?(tool)
90
+
91
+ tool.new(**@context).dynamic_schema
92
+ end
93
+
94
+ def dynamic_schema?(tool)
95
+ tool.is_a?(Class) && tool.method_defined?(:dynamic_schema)
96
+ end
97
+
69
98
  # Injects an optional `timeout` parameter into the tool's input schema.
70
99
  def inject_timeout(schema, default)
71
- s = schema.deep_dup
72
- s[:input_schema] ||= {type: "object", properties: {}}
73
- s[:input_schema][:properties] ||= {}
74
- s[:input_schema][:properties]["timeout"] = {
100
+ result = schema.deep_dup
101
+ input = result[:input_schema] ||= {type: "object", properties: {}}
102
+ props = input[:properties] ||= {}
103
+ props["timeout"] = {
75
104
  type: "integer",
76
105
  description: "Seconds (default: #{default})."
77
106
  }
78
- s
107
+ result
79
108
  end
80
109
  end
81
110
  end
@@ -112,7 +112,7 @@ module Tools
112
112
  # @return [Array<Message>] chronologically ordered
113
113
  def fetch_center_messages(target, target_session)
114
114
  half = CONTEXT_WINDOW / 2
115
- scope = target_session.messages.context_messages.deliverable
115
+ scope = target_session.messages.context_messages
116
116
  target_id = target.id
117
117
 
118
118
  before = scope.where("id <= ?", target_id).reorder(id: :desc).limit(half + 1).to_a.reverse