anima-core 1.1.3 → 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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
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
@@ -14,40 +14,45 @@ module Tools
14
14
  class Bash < Base
15
15
  def self.tool_name = "bash"
16
16
 
17
- def self.description
18
- <<~DESC.squish
19
- Execute a bash command. Working directory and environment persist across calls within a conversation.
20
- Accepts either `command` (string) for a single command, or `commands` (array of strings) to run
21
- multiple commands as a batch — each command gets its own timeout and result. Batch `mode` controls
22
- error handling: "sequential" (default) stops on the first failure, "parallel" runs all regardless.
23
- DESC
24
- end
17
+ def self.description = "Execute shell commands. Working directory and environment persist between calls."
25
18
 
26
19
  def self.input_schema
27
20
  {
28
21
  type: "object",
29
22
  properties: {
30
23
  command: {
31
- type: "string",
32
- description: "The bash command to execute"
24
+ type: "string"
33
25
  },
34
26
  commands: {
35
27
  type: "array",
36
28
  items: {type: "string"},
37
- description: "Array of bash commands to execute as a batch. Each runs independently with its own timeout and result."
29
+ description: "Each command gets its own timeout and result."
38
30
  },
39
31
  mode: {
40
32
  type: "string",
41
33
  enum: ["sequential", "parallel"],
42
- description: 'Batch error handling: "sequential" (default) stops on first non-zero exit; "parallel" runs all commands regardless of failures.'
34
+ description: "sequential (default) stops on first failure."
43
35
  }
44
36
  }
45
37
  }
46
38
  end
47
39
 
48
40
  # @param shell_session [ShellSession] persistent shell backing this tool
49
- def initialize(shell_session:, **)
41
+ # @param session [Session] conversation session for interrupt checking
42
+ def initialize(shell_session:, session:, **)
50
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
51
56
  end
52
57
 
53
58
  # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
@@ -78,13 +83,18 @@ module Tools
78
83
  command = command.to_s
79
84
  return {error: "Command cannot be blank"} if command.strip.empty?
80
85
 
81
- 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]
82
89
  return result if result.key?(:error)
83
90
 
84
91
  format_result(result[:stdout], result[:stderr], result[:exit_code])
85
92
  end
86
93
 
87
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
+ #
88
98
  # @param commands [Array<String>] commands to execute
89
99
  # @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
90
100
  # @param timeout [Integer, nil] per-command timeout override
@@ -93,13 +103,20 @@ module Tools
93
103
  def execute_batch(commands, mode:, timeout: nil)
94
104
  return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
95
105
 
106
+ checker = interrupt_checker
96
107
  total = commands.size
97
108
  results = []
98
109
  failed = false
110
+ interrupted = false
99
111
 
100
112
  commands.each_with_index do |command, index|
101
113
  position = "[#{index + 1}/#{total}]"
102
114
 
115
+ if interrupted
116
+ results << "#{position} $ #{command}\n(skipped — interrupted by user)"
117
+ next
118
+ end
119
+
103
120
  if failed && mode == "sequential"
104
121
  results << "#{position} $ #{command}\n(skipped)"
105
122
  next
@@ -111,9 +128,12 @@ module Tools
111
128
  next
112
129
  end
113
130
 
114
- result = @shell_session.run(command, timeout: timeout)
131
+ result = @shell_session.run(command, timeout: timeout, interrupt_check: checker)
115
132
 
116
- if result.key?(:error)
133
+ if result[:interrupted]
134
+ results << "#{position} $ #{command}\n#{format_interrupted(result)}"
135
+ interrupted = true
136
+ elsif result.key?(:error)
117
137
  results << "#{position} $ #{command}\n#{result[:error]}"
118
138
  failed = true
119
139
  else
@@ -134,5 +154,29 @@ module Tools
134
154
  parts << "exit_code: #{exit_code}"
135
155
  parts.join("\n\n")
136
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
137
181
  end
138
182
  end
data/lib/tools/edit.rb CHANGED
@@ -16,19 +16,17 @@ 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
- def self.description = "Replace exact text in a file. old_text must match exactly one location; " \
22
- "include surrounding lines for uniqueness. Use for surgical edits; " \
23
- "use write for new files or full replacement."
21
+ def self.description = "Replace text in a file."
24
22
 
25
23
  def self.input_schema
26
24
  {
27
25
  type: "object",
28
26
  properties: {
29
- path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
30
- old_text: {type: "string", description: "Exact text to find (must match exactly one location include surrounding context if needed)"},
31
- new_text: {type: "string", description: "Replacement text (empty string to delete)"}
27
+ path: {type: "string", description: "Relative paths resolve against working directory."},
28
+ old_text: {type: "string", description: "Must match exactly one location. Include surrounding lines for uniqueness."},
29
+ new_text: {type: "string", description: "Empty string to delete."}
32
30
  },
33
31
  required: %w[path old_text new_text]
34
32
  }
@@ -134,7 +132,7 @@ module Tools
134
132
 
135
133
  {error: "Could not find old_text in #{path}. " \
136
134
  "Verify the text exists and matches exactly (including whitespace). " \
137
- "Use the read tool to check current file contents."}
135
+ "Use the read_file tool to check current file contents."}
138
136
  end
139
137
 
140
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
@@ -3,32 +3,29 @@
3
3
  require "open3"
4
4
 
5
5
  module Tools
6
- # Creates a GitHub issue via the +gh+ CLI, letting the agent request
7
- # capabilities it discovers are missing during real work. Every issue
8
- # is tagged with the label from +[github] label+ in +config.toml+ so
9
- # the developer can filter agent-originated requests from human ones.
6
+ # Opens a GitHub issue on Anima's repository via the +gh+ CLI,
7
+ # giving the agent a voice to report bugs, pain points, or ideas.
8
+ # Every issue is tagged with the label from +[github] label+ in
9
+ # +config.toml+ so maintainers can filter agent-originated issues.
10
10
  #
11
11
  # The repository is read from +[github] repo+ in +config.toml+; when
12
12
  # unset, the tool falls back to parsing the +origin+ remote URL.
13
13
  #
14
14
  # @see https://github.com/hoblin/anima/issues/103
15
- class RequestFeature < Base
15
+ class OpenIssue < Base
16
16
  # @return [String] tool identifier used in the Anthropic API schema
17
- def self.tool_name = "request_feature"
17
+ def self.tool_name = "open_issue"
18
18
 
19
- # @return [String] motivational description shown to the LLM
20
- def self.description
21
- "Don't have the right tool for this task? Request it! " \
22
- "Creates a GitHub issue so the developer knows what you need."
23
- end
19
+ # @return [String] description shown to the LLM
20
+ def self.description = "Something broken, missing, or could be better in Anima? Say it here."
24
21
 
25
22
  # @return [Hash] JSON Schema for the tool's input parameters
26
23
  def self.input_schema
27
24
  {
28
25
  type: "object",
29
26
  properties: {
30
- title: {type: "string", description: "Short, descriptive title for the feature request"},
31
- description: {type: "string", description: "What you need and why — what were you trying to do, and what's missing?"}
27
+ title: {type: "string"},
28
+ description: {type: "string", description: "Use gh-issue skill for guidance."}
32
29
  },
33
30
  required: %w[title description]
34
31
  }
data/lib/tools/read.rb CHANGED
@@ -16,17 +16,18 @@ 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
- def self.description = "Read file contents. Returns plain text with smart truncation. Use offset/limit to page through large files."
22
+ def self.description = "Read file. Relative paths resolve against working directory."
22
23
 
23
24
  def self.input_schema
24
25
  {
25
26
  type: "object",
26
27
  properties: {
27
- path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
28
- offset: {type: "integer", description: "1-indexed line number to start from (default: 1)"},
29
- limit: {type: "integer", description: "Maximum lines to read (subject to line and byte caps from config)"}
28
+ path: {type: "string"},
29
+ offset: {type: "integer", description: "1-indexed line number (default: 1)."},
30
+ limit: {type: "integer", description: "Max lines to return."}
30
31
  },
31
32
  required: ["path"]
32
33
  }
@@ -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
- description: "Max execution seconds (default: #{default}). Increase for long-running operations."
105
+ description: "Seconds (default: #{default})."
77
106
  }
78
- s
107
+ result
79
108
  end
80
109
  end
81
110
  end