anima-core 1.2.0 → 1.4.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 +14 -8
- data/README.md +96 -23
- 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 +7 -7
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +15 -6
- data/app/jobs/passive_recall_job.rb +6 -11
- data/app/models/concerns/message/broadcasting.rb +1 -0
- data/app/models/goal.rb +14 -0
- data/app/models/message.rb +13 -31
- data/app/models/pending_message.rb +191 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +480 -271
- data/bin/inspect-cassette +144 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/database.yml +1 -0
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/cable_structure.sql +9 -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/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +120 -0
- data/lib/agent_loop.rb +53 -51
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +19 -6
- data/lib/analytical_brain/tools/activate_skill.rb +2 -2
- data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
- data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
- data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
- data/lib/analytical_brain/tools/finish_goal.rb +3 -0
- data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
- data/lib/analytical_brain/tools/read_workflow.rb +2 -2
- data/lib/analytical_brain/tools/set_goal.rb +5 -1
- data/lib/analytical_brain/tools/update_goal.rb +5 -1
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +20 -1
- data/lib/anima/settings.rb +37 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/agent_message.rb +14 -0
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +12 -18
- data/lib/events/subscribers/subagent_message_router.rb +18 -9
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +91 -50
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +9 -5
- data/lib/mneme/passive_recall.rb +85 -16
- data/lib/mneme/runner.rb +15 -4
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +239 -18
- data/lib/tools/base.rb +22 -0
- data/lib/tools/bash.rb +61 -7
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +85 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +41 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +11 -8
- data/lib/tools/spawn_subagent.rb +19 -13
- data/lib/tools/subagent_prompts.rb +41 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +545 -137
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +13 -20
- data/lib/tui/decorators/base_decorator.rb +40 -11
- data/lib/tui/decorators/bash_decorator.rb +3 -3
- data/lib/tui/decorators/edit_decorator.rb +7 -4
- data/lib/tui/decorators/read_decorator.rb +6 -8
- data/lib/tui/decorators/think_decorator.rb +4 -6
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +7 -4
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +33 -0
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +159 -27
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +302 -103
- data/lib/tui/settings.rb +86 -0
- 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 +30 -1
- data/templates/tui.toml +209 -0
- metadata +24 -3
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/environment_probe.rb +0 -232
data/lib/tools/bash.rb
CHANGED
|
@@ -38,8 +38,10 @@ 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
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
|
|
@@ -65,18 +67,24 @@ module Tools
|
|
|
65
67
|
|
|
66
68
|
private
|
|
67
69
|
|
|
68
|
-
# Executes a single command — the original code path
|
|
70
|
+
# Executes a single command — the original code path.
|
|
69
71
|
def execute_single(command, timeout: nil)
|
|
70
72
|
command = command.to_s
|
|
71
73
|
return {error: "Command cannot be blank"} if command.strip.empty?
|
|
72
74
|
|
|
73
|
-
result = @shell_session.run(command, timeout: timeout)
|
|
75
|
+
result = @shell_session.run(command, timeout: timeout, interrupt_check: interrupt_checker)
|
|
76
|
+
|
|
77
|
+
return format_interrupted(result) if result[:interrupted]
|
|
74
78
|
return result if result.key?(:error)
|
|
75
79
|
|
|
76
|
-
format_result(result[:stdout], result[:stderr], result[:exit_code])
|
|
80
|
+
output = format_result(result[:stdout], result[:stderr], result[:exit_code])
|
|
81
|
+
append_env_summary(output, result[:env_summary])
|
|
77
82
|
end
|
|
78
83
|
|
|
79
84
|
# Executes an array of commands, returning a combined result string.
|
|
85
|
+
# Checks for user interrupt between commands and during each command
|
|
86
|
+
# via the {ShellSession} interrupt_check callback.
|
|
87
|
+
#
|
|
80
88
|
# @param commands [Array<String>] commands to execute
|
|
81
89
|
# @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
|
|
82
90
|
# @param timeout [Integer, nil] per-command timeout override
|
|
@@ -85,13 +93,22 @@ module Tools
|
|
|
85
93
|
def execute_batch(commands, mode:, timeout: nil)
|
|
86
94
|
return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
|
|
87
95
|
|
|
96
|
+
checker = interrupt_checker
|
|
88
97
|
total = commands.size
|
|
89
98
|
results = []
|
|
90
99
|
failed = false
|
|
100
|
+
interrupted = false
|
|
101
|
+
|
|
102
|
+
last_env_summary = nil
|
|
91
103
|
|
|
92
104
|
commands.each_with_index do |command, index|
|
|
93
105
|
position = "[#{index + 1}/#{total}]"
|
|
94
106
|
|
|
107
|
+
if interrupted
|
|
108
|
+
results << "#{position} $ #{command}\n(skipped — interrupted by user)"
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
|
|
95
112
|
if failed && mode == "sequential"
|
|
96
113
|
results << "#{position} $ #{command}\n(skipped)"
|
|
97
114
|
next
|
|
@@ -103,20 +120,33 @@ module Tools
|
|
|
103
120
|
next
|
|
104
121
|
end
|
|
105
122
|
|
|
106
|
-
result = @shell_session.run(command, timeout: timeout)
|
|
123
|
+
result = @shell_session.run(command, timeout: timeout, interrupt_check: checker)
|
|
107
124
|
|
|
108
|
-
if result
|
|
125
|
+
if result[:interrupted]
|
|
126
|
+
results << "#{position} $ #{command}\n#{format_interrupted(result)}"
|
|
127
|
+
interrupted = true
|
|
128
|
+
elsif result.key?(:error)
|
|
109
129
|
results << "#{position} $ #{command}\n#{result[:error]}"
|
|
110
130
|
failed = true
|
|
111
131
|
else
|
|
112
132
|
exit_code = result[:exit_code]
|
|
113
133
|
output = format_result(result[:stdout], result[:stderr], exit_code)
|
|
114
134
|
results << "#{position} $ #{command}\n#{output}"
|
|
135
|
+
last_env_summary = result[:env_summary]
|
|
115
136
|
failed = true if exit_code != 0
|
|
116
137
|
end
|
|
117
138
|
end
|
|
118
139
|
|
|
119
|
-
results.join("\n\n")
|
|
140
|
+
append_env_summary(results.join("\n\n"), last_env_summary)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Appends environment summary to tool output when present.
|
|
144
|
+
#
|
|
145
|
+
# @param output [String] formatted tool response
|
|
146
|
+
# @param env_summary [String, nil] natural-language environment change summary
|
|
147
|
+
# @return [String] output with env summary appended
|
|
148
|
+
def append_env_summary(output, env_summary)
|
|
149
|
+
env_summary ? "#{output}\n\n#{env_summary}" : output
|
|
120
150
|
end
|
|
121
151
|
|
|
122
152
|
def format_result(stdout, stderr, exit_code)
|
|
@@ -126,5 +156,29 @@ module Tools
|
|
|
126
156
|
parts << "exit_code: #{exit_code}"
|
|
127
157
|
parts.join("\n\n")
|
|
128
158
|
end
|
|
159
|
+
|
|
160
|
+
# Formats the result of an interrupted command for the LLM.
|
|
161
|
+
# Includes partial output captured before the interrupt.
|
|
162
|
+
#
|
|
163
|
+
# @param result [Hash] ShellSession result with :stdout, :stderr keys
|
|
164
|
+
# @return [String] formatted message for the LLM
|
|
165
|
+
def format_interrupted(result)
|
|
166
|
+
stdout = result[:stdout].to_s
|
|
167
|
+
stderr = result[:stderr].to_s
|
|
168
|
+
parts = [LLM::Client::INTERRUPT_MESSAGE]
|
|
169
|
+
parts << "Partial stdout:\n#{stdout}" unless stdout.empty?
|
|
170
|
+
parts << "stderr:\n#{stderr}" unless stderr.empty?
|
|
171
|
+
parts.join("\n\n")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Builds a lambda that checks the database for a pending interrupt flag.
|
|
175
|
+
# Called every {Anima::Settings.interrupt_check_interval} seconds during
|
|
176
|
+
# command execution inside {ShellSession}.
|
|
177
|
+
#
|
|
178
|
+
# @return [Proc] lambda returning truthy when interrupt is pending
|
|
179
|
+
def interrupt_checker
|
|
180
|
+
session_id = @session.id
|
|
181
|
+
-> { Session.where(id: session_id, interrupt_requested: true).exists? }
|
|
182
|
+
end
|
|
129
183
|
end
|
|
130
184
|
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,85 @@
|
|
|
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 with source
|
|
67
|
+
# metadata. Truncates oversized results to protect the parent's
|
|
68
|
+
# 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
|
+
parent.enqueue_user_message(truncated, source_type: "subagent", source_name: name)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
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,19 @@ 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
|
|
41
|
+
# Returns tool schemas for the Anthropic API. The last schema is
|
|
42
|
+
# annotated with +cache_control+ so the API caches the entire tools
|
|
43
|
+
# prefix (tools are evaluated first in cache prefix order).
|
|
37
44
|
def schemas
|
|
38
45
|
default = Anima::Settings.tool_timeout
|
|
39
|
-
@tools.values.map { |tool| inject_timeout(tool
|
|
46
|
+
result = @tools.values.map { |tool| inject_timeout(resolve_schema(tool), default) }
|
|
47
|
+
result.last[:cache_control] = {type: "ephemeral"}
|
|
48
|
+
result
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
# Execute a tool by name. Classes are instantiated with the registry's
|
|
@@ -53,6 +62,19 @@ module Tools
|
|
|
53
62
|
instance.execute(input)
|
|
54
63
|
end
|
|
55
64
|
|
|
65
|
+
# Returns the truncation threshold for a tool, or +nil+ if the tool
|
|
66
|
+
# opts out of truncation (e.g. read_file tool has its own pagination).
|
|
67
|
+
# MCP tools and other duck-typed instances use the default threshold.
|
|
68
|
+
#
|
|
69
|
+
# @param name [String] registered tool name
|
|
70
|
+
# @return [Integer, nil] character threshold, or nil to skip truncation
|
|
71
|
+
def truncation_threshold(name)
|
|
72
|
+
tool = @tools[name]
|
|
73
|
+
return tool.truncation_threshold if tool&.respond_to?(:truncation_threshold)
|
|
74
|
+
|
|
75
|
+
Anima::Settings.max_tool_response_chars
|
|
76
|
+
end
|
|
77
|
+
|
|
56
78
|
# @param name [String] tool name to check
|
|
57
79
|
# @return [Boolean] whether a tool with the given name is registered
|
|
58
80
|
def registered?(name)
|
|
@@ -66,16 +88,28 @@ module Tools
|
|
|
66
88
|
|
|
67
89
|
private
|
|
68
90
|
|
|
91
|
+
# Returns a tool's schema, preferring the instance-level dynamic
|
|
92
|
+
# variant when available. Only instantiates the tool when needed.
|
|
93
|
+
def resolve_schema(tool)
|
|
94
|
+
return tool.schema unless dynamic_schema?(tool)
|
|
95
|
+
|
|
96
|
+
tool.new(**@context).dynamic_schema
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def dynamic_schema?(tool)
|
|
100
|
+
tool.is_a?(Class) && tool.method_defined?(:dynamic_schema)
|
|
101
|
+
end
|
|
102
|
+
|
|
69
103
|
# Injects an optional `timeout` parameter into the tool's input schema.
|
|
70
104
|
def inject_timeout(schema, default)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
105
|
+
result = schema.deep_dup
|
|
106
|
+
input = result[:input_schema] ||= {type: "object", properties: {}}
|
|
107
|
+
props = input[:properties] ||= {}
|
|
108
|
+
props["timeout"] = {
|
|
75
109
|
type: "integer",
|
|
76
110
|
description: "Seconds (default: #{default})."
|
|
77
111
|
}
|
|
78
|
-
|
|
112
|
+
result
|
|
79
113
|
end
|
|
80
114
|
end
|
|
81
115
|
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
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Tools
|
|
6
|
+
# Truncates oversized tool results to protect the agent's context window.
|
|
7
|
+
#
|
|
8
|
+
# When a tool returns more characters than the configured threshold,
|
|
9
|
+
# saves the full output to a temp file and returns a truncated version:
|
|
10
|
+
# first 10 lines + notice + last 10 lines. The agent can use the
|
|
11
|
+
# +read_file+ tool with offset/limit to inspect the full output.
|
|
12
|
+
#
|
|
13
|
+
# Two thresholds exist:
|
|
14
|
+
# - **Tool threshold** (~3000 chars) — for raw tool output (bash, web, etc.)
|
|
15
|
+
# - **Sub-agent threshold** (~24000 chars) — for curated sub-agent results
|
|
16
|
+
#
|
|
17
|
+
# @example Truncating a tool result
|
|
18
|
+
# ResponseTruncator.truncate(huge_string, threshold: 3000)
|
|
19
|
+
# # => "line 1\nline 2\n...\n---\n⚠️ Response truncated..."
|
|
20
|
+
module ResponseTruncator
|
|
21
|
+
HEAD_LINES = 10
|
|
22
|
+
TAIL_LINES = 10
|
|
23
|
+
|
|
24
|
+
# Attribution prefix for messages routed from sub-agent to parent.
|
|
25
|
+
# Shared by {Events::Subscribers::SubagentMessageRouter} and
|
|
26
|
+
# {Tools::MarkGoalCompleted} to keep formatting consistent.
|
|
27
|
+
ATTRIBUTION_FORMAT = "[sub-agent %s]: %s"
|
|
28
|
+
|
|
29
|
+
NOTICE = <<~NOTICE.strip
|
|
30
|
+
---
|
|
31
|
+
⚠️ Response truncated (%<total>d lines total%<reason>s). Full output saved to: %<path>s
|
|
32
|
+
Use `read_file` tool with offset/limit to inspect specific sections.
|
|
33
|
+
---
|
|
34
|
+
NOTICE
|
|
35
|
+
|
|
36
|
+
# Truncates content that exceeds the character threshold.
|
|
37
|
+
#
|
|
38
|
+
# @param content [Object] the tool result to (maybe) truncate; non-strings pass through unchanged
|
|
39
|
+
# @param threshold [Integer] character limit before truncation kicks in
|
|
40
|
+
# @param reason [String, nil] why truncation occurred (e.g. "bash output displays first/last 10 lines")
|
|
41
|
+
# @return [Object] original value if non-String/under threshold/few lines, truncated String otherwise
|
|
42
|
+
def self.truncate(content, threshold:, reason: nil)
|
|
43
|
+
return content unless content.is_a?(String)
|
|
44
|
+
return content if content.length <= threshold
|
|
45
|
+
|
|
46
|
+
lines = content.lines
|
|
47
|
+
total = lines.size
|
|
48
|
+
return content if total <= HEAD_LINES + TAIL_LINES
|
|
49
|
+
|
|
50
|
+
path = save_full_output(content)
|
|
51
|
+
head = lines.first(HEAD_LINES).join
|
|
52
|
+
tail = lines.last(TAIL_LINES).join
|
|
53
|
+
reason_text = reason ? " — #{reason}" : ""
|
|
54
|
+
notice = format(NOTICE, total: total, path: path, reason: reason_text)
|
|
55
|
+
|
|
56
|
+
"#{head}\n#{notice}\n\n#{tail}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Saves full content to a temp file that persists until system cleanup.
|
|
60
|
+
#
|
|
61
|
+
# @param content [String] the full tool result
|
|
62
|
+
# @return [String] absolute path to the saved file
|
|
63
|
+
def self.save_full_output(content)
|
|
64
|
+
file = Tempfile.create(["tool_result_", ".txt"])
|
|
65
|
+
file.write(content)
|
|
66
|
+
file.close
|
|
67
|
+
file.path
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -21,9 +21,9 @@ module Tools
|
|
|
21
21
|
|
|
22
22
|
# Builds description dynamically to include available specialists.
|
|
23
23
|
def self.description
|
|
24
|
-
base = "
|
|
25
|
-
"Its messages
|
|
26
|
-
"
|
|
24
|
+
base = "Need a specific skill set for the job? Bring in a specialist. " \
|
|
25
|
+
"Its messages appear as tool responses in your conversation. " \
|
|
26
|
+
"Prefix its nickname with @ to send instructions."
|
|
27
27
|
|
|
28
28
|
registry = Agents::Registry.instance
|
|
29
29
|
return base unless registry.any?
|
|
@@ -58,9 +58,11 @@ module Tools
|
|
|
58
58
|
private_class_method :name_property
|
|
59
59
|
|
|
60
60
|
# @param session [Session] the parent session spawning the specialist
|
|
61
|
+
# @param shell_session [ShellSession] the parent's persistent shell (for CWD inheritance)
|
|
61
62
|
# @param agent_registry [Agents::Registry, nil] injectable for testing
|
|
62
|
-
def initialize(session:, agent_registry: nil, **)
|
|
63
|
+
def initialize(session:, shell_session:, agent_registry: nil, **)
|
|
63
64
|
@session = session
|
|
65
|
+
@shell_session = shell_session
|
|
64
66
|
@agent_registry = agent_registry || Agents::Registry.instance
|
|
65
67
|
end
|
|
66
68
|
|
|
@@ -83,9 +85,9 @@ module Tools
|
|
|
83
85
|
|
|
84
86
|
child = spawn_child(definition, task)
|
|
85
87
|
nickname = child.name
|
|
86
|
-
"Specialist
|
|
88
|
+
"Specialist #{nickname} spawned (session #{child.id}). " \
|
|
87
89
|
"Its messages will appear in your conversation. " \
|
|
88
|
-
"
|
|
90
|
+
"To address it, prefix its name with @ in your message."
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
private
|
|
@@ -94,9 +96,10 @@ module Tools
|
|
|
94
96
|
child = Session.create!(
|
|
95
97
|
parent_session_id: @session.id,
|
|
96
98
|
prompt: build_prompt(definition),
|
|
97
|
-
granted_tools: definition.tools
|
|
99
|
+
granted_tools: definition.tools,
|
|
100
|
+
initial_cwd: @shell_session.pwd
|
|
98
101
|
)
|
|
99
|
-
child
|
|
102
|
+
create_goal_with_pinned_task(child, task)
|
|
100
103
|
assign_nickname_via_brain(child)
|
|
101
104
|
child.broadcast_children_update_to_parent
|
|
102
105
|
AgentRequestJob.perform_later(child.id)
|
data/lib/tools/spawn_subagent.rb
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Tools
|
|
4
4
|
# Spawns a generic child session that works on a task autonomously.
|
|
5
|
-
# The sub-agent
|
|
6
|
-
#
|
|
5
|
+
# The sub-agent starts clean — no parent conversation history — with
|
|
6
|
+
# only a system prompt, a Goal, and the task as its first user message.
|
|
7
|
+
# Runs via {AgentRequestJob} and communicates with the parent through
|
|
7
8
|
# natural text messages routed by {Events::Subscribers::SubagentMessageRouter}.
|
|
8
9
|
#
|
|
9
10
|
# Nickname assignment is handled by the {AnalyticalBrain::Runner} which
|
|
@@ -14,14 +15,15 @@ module Tools
|
|
|
14
15
|
class SpawnSubagent < Base
|
|
15
16
|
include SubagentPrompts
|
|
16
17
|
|
|
17
|
-
GENERIC_PROMPT = "
|
|
18
|
+
GENERIC_PROMPT = "#{COMMUNICATION_INSTRUCTION}\n"
|
|
18
19
|
|
|
19
20
|
def self.tool_name = "spawn_subagent"
|
|
20
21
|
|
|
21
22
|
def self.description
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
23
|
+
"Task feels like a sidequest or a context-switch? Hand it off. " \
|
|
24
|
+
"Starts clean with just the task — include all relevant context in the task description. " \
|
|
25
|
+
"Its messages appear as tool responses in your conversation. " \
|
|
26
|
+
"Prefix its nickname with @ to send instructions."
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def self.input_schema
|
|
@@ -42,12 +44,15 @@ module Tools
|
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# @param session [Session] the parent session spawning the sub-agent
|
|
45
|
-
|
|
47
|
+
# @param shell_session [ShellSession] the parent's persistent shell (for CWD inheritance)
|
|
48
|
+
def initialize(session:, shell_session:, **)
|
|
46
49
|
@session = session
|
|
50
|
+
@shell_session = shell_session
|
|
47
51
|
end
|
|
48
52
|
|
|
49
|
-
# Creates a child session
|
|
50
|
-
#
|
|
53
|
+
# Creates a child session with a clean context (no parent history),
|
|
54
|
+
# runs the analytical brain to assign a nickname, persists the task
|
|
55
|
+
# as a pinned user message, and queues background processing.
|
|
51
56
|
# Returns immediately after brain completes (blocking for ~200ms).
|
|
52
57
|
#
|
|
53
58
|
# @param input [Hash<String, Object>] with "task" and optional "tools"
|
|
@@ -65,9 +70,9 @@ module Tools
|
|
|
65
70
|
|
|
66
71
|
child = spawn_child(task, tools)
|
|
67
72
|
nickname = child.name
|
|
68
|
-
"Sub-agent
|
|
73
|
+
"Sub-agent #{nickname} spawned (session #{child.id}). " \
|
|
69
74
|
"Its messages will appear in your conversation. " \
|
|
70
|
-
"
|
|
75
|
+
"To address it, prefix its name with @ in your message."
|
|
71
76
|
end
|
|
72
77
|
|
|
73
78
|
private
|
|
@@ -76,9 +81,10 @@ module Tools
|
|
|
76
81
|
child = Session.create!(
|
|
77
82
|
parent_session_id: @session.id,
|
|
78
83
|
prompt: GENERIC_PROMPT,
|
|
79
|
-
granted_tools: granted_tools
|
|
84
|
+
granted_tools: granted_tools,
|
|
85
|
+
initial_cwd: @shell_session.pwd
|
|
80
86
|
)
|
|
81
|
-
child
|
|
87
|
+
create_goal_with_pinned_task(child, task)
|
|
82
88
|
assign_nickname_via_brain(child)
|
|
83
89
|
child.broadcast_children_update_to_parent
|
|
84
90
|
AgentRequestJob.perform_later(child.id)
|