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.
- checksums.yaml +4 -4
- data/.reek.yml +8 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +4 -4
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +13 -4
- data/app/models/goal.rb +13 -0
- data/app/models/message.rb +13 -18
- data/app/models/pending_message.rb +43 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +194 -43
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +13 -40
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +7 -4
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +31 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +11 -18
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +1 -1
- data/lib/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +56 -4
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +36 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +6 -5
- data/lib/tools/spawn_subagent.rb +8 -6
- data/lib/tools/subagent_prompts.rb +43 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +70 -26
- data/lib/tui/screens/chat.rb +269 -66
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +26 -0
- 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
|
|
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
|
-
#
|
|
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
|
|
5
|
-
# Secrets live under the +mcp+ namespace
|
|
4
|
+
# CRUD operations for MCP server secrets stored in the encrypted secrets table.
|
|
5
|
+
# Secrets live under the +mcp+ namespace:
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
|
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
|
|
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
|
|
52
|
+
# Removes a secret from encrypted storage.
|
|
54
53
|
#
|
|
55
54
|
# @param key [String] secret identifier to remove
|
|
56
55
|
# @return [void]
|
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
|
-
|
|
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
|
-
|
|
333
|
-
return nil unless ready
|
|
365
|
+
select_timeout = check_interval ? [remaining, check_interval].min : remaining
|
|
334
366
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 = "
|
|
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
|
|
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 = "
|
|
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
|
|
data/lib/tools/recall.rb
ADDED
|
@@ -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
|
data/lib/tools/registry.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
107
|
+
result
|
|
79
108
|
end
|
|
80
109
|
end
|
|
81
110
|
end
|
data/lib/tools/remember.rb
CHANGED
|
@@ -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
|
|
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
|