anima-core 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +23 -26
- data/README.md +118 -104
- data/agents/thoughts-analyzer.md +12 -7
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +38 -58
- data/app/decorators/agent_message_decorator.rb +7 -2
- data/app/decorators/message_decorator.rb +31 -100
- data/app/decorators/pending_from_melete_decorator.rb +36 -0
- data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
- data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
- data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
- data/app/decorators/pending_from_mneme_decorator.rb +44 -0
- data/app/decorators/pending_message_decorator.rb +94 -0
- data/app/decorators/pending_subagent_decorator.rb +46 -0
- data/app/decorators/pending_tool_response_decorator.rb +51 -0
- data/app/decorators/pending_user_message_decorator.rb +22 -0
- data/app/decorators/system_message_decorator.rb +5 -0
- data/app/decorators/tool_call_decorator.rb +16 -5
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +7 -2
- data/app/jobs/count_tokens_job.rb +23 -0
- data/app/jobs/drain_job.rb +169 -0
- data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
- data/app/jobs/melete_enrichment_job.rb +48 -0
- data/app/jobs/mneme_enrichment_job.rb +46 -0
- data/app/jobs/tool_execution_job.rb +87 -0
- data/app/models/concerns/token_estimation.rb +54 -0
- data/app/models/goal.rb +23 -11
- data/app/models/message.rb +46 -48
- data/app/models/pending_message.rb +407 -12
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +660 -566
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +157 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/application.rb +1 -0
- data/config/database.yml +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +9 -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/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
- data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
- data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
- data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
- data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
- data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
- data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
- data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
- data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +133 -0
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +13 -0
- data/lib/anima/settings.rb +16 -36
- data/lib/anima/version.rb +1 -1
- data/lib/events/authentication_required.rb +24 -0
- data/lib/events/bounce_back.rb +4 -4
- data/lib/events/eviction_completed.rb +28 -0
- data/lib/events/goal_created.rb +28 -0
- data/lib/events/goal_updated.rb +32 -0
- data/lib/events/llm_responded.rb +35 -0
- data/lib/events/message_created.rb +27 -0
- data/lib/events/message_updated.rb +25 -0
- data/lib/events/session_state_changed.rb +30 -0
- data/lib/events/skill_activated.rb +28 -0
- data/lib/events/start_melete.rb +36 -0
- data/lib/events/start_mneme.rb +33 -0
- data/lib/events/start_processing.rb +32 -0
- data/lib/events/subagent_evicted.rb +31 -0
- data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
- data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
- data/lib/events/subscribers/drain_kickoff.rb +20 -0
- data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
- data/lib/events/subscribers/llm_response_handler.rb +111 -0
- data/lib/events/subscribers/melete_kickoff.rb +24 -0
- data/lib/events/subscribers/message_broadcaster.rb +34 -0
- data/lib/events/subscribers/mneme_kickoff.rb +24 -0
- data/lib/events/subscribers/mneme_scheduler.rb +21 -0
- data/lib/events/subscribers/persister.rb +8 -9
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +28 -34
- data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
- data/lib/events/subscribers/tool_response_creator.rb +33 -0
- data/lib/events/subscribers/transient_broadcaster.rb +1 -1
- data/lib/events/tool_executed.rb +34 -0
- data/lib/events/workflow_activated.rb +27 -0
- data/lib/llm/client.rb +46 -199
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +73 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
- data/lib/melete/tools/goal_messaging.rb +29 -0
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
- data/lib/{analytical_brain.rb → melete.rb} +6 -3
- data/lib/mneme/base_runner.rb +121 -0
- data/lib/mneme/l2_runner.rb +14 -20
- data/lib/mneme/recall_runner.rb +132 -0
- data/lib/mneme/runner.rb +123 -165
- data/lib/mneme/search.rb +104 -62
- data/lib/mneme/tools/nothing_to_surface.rb +25 -0
- data/lib/mneme/tools/save_snapshot.rb +2 -10
- data/lib/mneme/tools/surface_memory.rb +89 -0
- data/lib/mneme.rb +11 -5
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +290 -432
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -1
- data/lib/tools/bash.rb +25 -55
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/mark_goal_completed.rb +4 -5
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +85 -4
- data/lib/tools/response_truncator.rb +1 -1
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +22 -14
- data/lib/tools/spawn_subagent.rb +30 -20
- data/lib/tools/subagent_prompts.rb +17 -19
- data/lib/tools/think.rb +1 -1
- data/lib/tools/{remember.rb → view_messages.rb} +10 -10
- data/lib/tools/write.rb +2 -0
- data/lib/tui/app.rb +393 -149
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/cable_client.rb +9 -16
- data/lib/tui/decorators/base_decorator.rb +47 -6
- data/lib/tui/decorators/bash_decorator.rb +1 -1
- data/lib/tui/decorators/edit_decorator.rb +4 -2
- data/lib/tui/decorators/read_decorator.rb +4 -2
- data/lib/tui/decorators/think_decorator.rb +2 -2
- data/lib/tui/decorators/web_get_decorator.rb +1 -1
- data/lib/tui/decorators/write_decorator.rb +4 -2
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +20 -9
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +165 -28
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +149 -79
- data/lib/tui/settings.rb +93 -0
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +16 -32
- data/templates/tui.toml +209 -0
- data/workflows/review_pr.md +18 -14
- metadata +98 -29
- data/app/jobs/agent_request_job.rb +0 -199
- data/app/jobs/analytical_brain_job.rb +0 -33
- data/app/jobs/count_message_tokens_job.rb +0 -39
- data/app/jobs/passive_recall_job.rb +0 -29
- data/app/models/concerns/message/broadcasting.rb +0 -85
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/agent_loop.rb +0 -186
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
- data/lib/environment_probe.rb +0 -232
- data/lib/events/agent_message.rb +0 -11
- data/lib/events/subscribers/message_collector.rb +0 -64
- data/lib/events/tool_call.rb +0 -31
- data/lib/events/tool_response.rb +0 -33
- data/lib/mneme/compressed_viewport.rb +0 -200
- data/lib/mneme/passive_recall.rb +0 -69
data/lib/skills/definition.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Skills
|
|
|
10
10
|
# content injected into the main agent's system prompt when active.
|
|
11
11
|
#
|
|
12
12
|
# Skills are passive knowledge — they describe WHAT you know, not
|
|
13
|
-
# WHAT to do.
|
|
13
|
+
# WHAT to do. Melete activates/deactivates them based
|
|
14
14
|
# on conversation context.
|
|
15
15
|
#
|
|
16
16
|
# @example Skill file format
|
|
@@ -25,7 +25,7 @@ module Skills
|
|
|
25
25
|
# @return [String] unique skill identifier used in activate_skill(name: "...")
|
|
26
26
|
attr_reader :name
|
|
27
27
|
|
|
28
|
-
# @return [String] description shown to
|
|
28
|
+
# @return [String] description shown to Melete for relevance matching
|
|
29
29
|
attr_reader :description
|
|
30
30
|
|
|
31
31
|
# @return [String] knowledge content (Markdown body) injected into system prompt
|
data/lib/skills/registry.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Skills
|
|
|
69
69
|
@skills[name]
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
# Skill names and descriptions for inclusion in
|
|
72
|
+
# Skill names and descriptions for inclusion in Melete's context.
|
|
73
73
|
#
|
|
74
74
|
# @return [Hash{String => String}] name => description
|
|
75
75
|
def catalog
|
data/lib/tools/base.rb
CHANGED
|
@@ -50,6 +50,22 @@ module Tools
|
|
|
50
50
|
def truncation_threshold
|
|
51
51
|
Anima::Settings.max_tool_response_chars
|
|
52
52
|
end
|
|
53
|
+
|
|
54
|
+
# One-line entry rendered into the system prompt's "## Available Tools"
|
|
55
|
+
# menu. Tools that return +nil+ are omitted from the menu.
|
|
56
|
+
#
|
|
57
|
+
# @return [String, nil] short capability statement, or nil to skip
|
|
58
|
+
def prompt_snippet
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Cross-tool behavioral guidelines merged into the system prompt's
|
|
63
|
+
# "## Tool Guidelines" section. Each entry becomes a Markdown bullet.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<String>] guideline lines, or empty array to skip
|
|
66
|
+
def prompt_guidelines
|
|
67
|
+
[]
|
|
68
|
+
end
|
|
53
69
|
end
|
|
54
70
|
|
|
55
71
|
# Subclasses whose schema depends on runtime context (e.g. session state,
|
|
@@ -64,7 +80,6 @@ module Tools
|
|
|
64
80
|
# end
|
|
65
81
|
#
|
|
66
82
|
# @see Think#dynamic_schema Budget-based maxLength
|
|
67
|
-
# @see Bash#dynamic_schema CWD in description
|
|
68
83
|
|
|
69
84
|
# Accepts and discards context keywords so that the Registry can pass
|
|
70
85
|
# shared dependencies (e.g. shell_session) to any tool uniformly.
|
data/lib/tools/bash.rb
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
module Tools
|
|
4
4
|
# Executes bash commands in a persistent {ShellSession}. Commands share
|
|
5
5
|
# working directory, environment variables, and shell history within a
|
|
6
|
-
# conversation. Output is
|
|
7
|
-
#
|
|
6
|
+
# conversation. Output is the rendered terminal text exactly as a human
|
|
7
|
+
# would see it — including the prompt, which doubles as live cwd/branch
|
|
8
|
+
# telemetry for the agent.
|
|
8
9
|
#
|
|
9
|
-
#
|
|
10
|
-
# -
|
|
11
|
-
# -
|
|
10
|
+
# Two input shapes:
|
|
11
|
+
# - +command+ (string) — one command, one result.
|
|
12
|
+
# - +commands+ (array) — runs each command in order in the same shell;
|
|
13
|
+
# all run regardless of failures (the agent reads merged output and
|
|
14
|
+
# decides what to do). Use shell chaining (+&&+) inside a single
|
|
15
|
+
# command if you need fail-fast.
|
|
12
16
|
#
|
|
13
17
|
# @see ShellSession#run
|
|
14
18
|
class Bash < Base
|
|
@@ -16,6 +20,8 @@ module Tools
|
|
|
16
20
|
|
|
17
21
|
def self.description = "Execute shell commands. Working directory and environment persist between calls."
|
|
18
22
|
|
|
23
|
+
def self.prompt_snippet = "Run shell commands."
|
|
24
|
+
|
|
19
25
|
def self.input_schema
|
|
20
26
|
{
|
|
21
27
|
type: "object",
|
|
@@ -26,12 +32,7 @@ module Tools
|
|
|
26
32
|
commands: {
|
|
27
33
|
type: "array",
|
|
28
34
|
items: {type: "string"},
|
|
29
|
-
description: "Each command gets its own timeout and result."
|
|
30
|
-
},
|
|
31
|
-
mode: {
|
|
32
|
-
type: "string",
|
|
33
|
-
enum: ["sequential", "parallel"],
|
|
34
|
-
description: "sequential (default) stops on first failure."
|
|
35
|
+
description: "Each command gets its own timeout and result. All commands run regardless of failures — use a single command with shell chaining if you need fail-fast."
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
}
|
|
@@ -44,21 +45,10 @@ module Tools
|
|
|
44
45
|
@session = session
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
# Returns tool schema with the shell's current working directory
|
|
48
|
-
# embedded in the description so the agent sees it during tool
|
|
49
|
-
# selection — eliminating redundant +cd+ prefixes.
|
|
50
|
-
#
|
|
51
|
-
# @return [Hash] Anthropic tool schema with dynamic description
|
|
52
|
-
def dynamic_schema
|
|
53
|
-
schema = self.class.schema.deep_dup
|
|
54
|
-
schema[:description] = "Execute shell commands in #{@shell_session.pwd}. Working directory and environment persist between calls."
|
|
55
|
-
schema
|
|
56
|
-
end
|
|
57
|
-
|
|
58
48
|
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
|
|
59
49
|
# Supports optional "timeout" key (seconds) to override the global
|
|
60
50
|
# command_timeout setting for long-running operations.
|
|
61
|
-
# @return [String]
|
|
51
|
+
# @return [String] rendered terminal output
|
|
62
52
|
# @return [Hash] with :error key on failure
|
|
63
53
|
def execute(input)
|
|
64
54
|
timeout = input["timeout"]
|
|
@@ -68,7 +58,7 @@ module Tools
|
|
|
68
58
|
if has_command && has_commands
|
|
69
59
|
{error: "Provide either 'command' or 'commands', not both"}
|
|
70
60
|
elsif has_commands
|
|
71
|
-
execute_batch(input["commands"],
|
|
61
|
+
execute_batch(input["commands"], timeout: timeout)
|
|
72
62
|
elsif has_command
|
|
73
63
|
execute_single(input["command"], timeout: timeout)
|
|
74
64
|
else
|
|
@@ -78,7 +68,7 @@ module Tools
|
|
|
78
68
|
|
|
79
69
|
private
|
|
80
70
|
|
|
81
|
-
# Executes a single command — the original code path
|
|
71
|
+
# Executes a single command — the original code path.
|
|
82
72
|
def execute_single(command, timeout: nil)
|
|
83
73
|
command = command.to_s
|
|
84
74
|
return {error: "Command cannot be blank"} if command.strip.empty?
|
|
@@ -88,25 +78,24 @@ module Tools
|
|
|
88
78
|
return format_interrupted(result) if result[:interrupted]
|
|
89
79
|
return result if result.key?(:error)
|
|
90
80
|
|
|
91
|
-
|
|
81
|
+
result[:output]
|
|
92
82
|
end
|
|
93
83
|
|
|
94
|
-
# Executes an array of commands
|
|
95
|
-
#
|
|
96
|
-
#
|
|
84
|
+
# Executes an array of commands sequentially through the shared
|
|
85
|
+
# shell. Continues past errors — the LLM reads the merged output
|
|
86
|
+
# and decides what to do. The only short-circuit is a user interrupt,
|
|
87
|
+
# which skips the remaining commands.
|
|
97
88
|
#
|
|
98
89
|
# @param commands [Array<String>] commands to execute
|
|
99
|
-
# @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
|
|
100
90
|
# @param timeout [Integer, nil] per-command timeout override
|
|
101
91
|
# @return [String] combined results with per-command headers
|
|
102
92
|
# @return [Hash] with :error key if commands array is invalid
|
|
103
|
-
def execute_batch(commands,
|
|
93
|
+
def execute_batch(commands, timeout: nil)
|
|
104
94
|
return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
|
|
105
95
|
|
|
106
96
|
checker = interrupt_checker
|
|
107
97
|
total = commands.size
|
|
108
98
|
results = []
|
|
109
|
-
failed = false
|
|
110
99
|
interrupted = false
|
|
111
100
|
|
|
112
101
|
commands.each_with_index do |command, index|
|
|
@@ -117,11 +106,6 @@ module Tools
|
|
|
117
106
|
next
|
|
118
107
|
end
|
|
119
108
|
|
|
120
|
-
if failed && mode == "sequential"
|
|
121
|
-
results << "#{position} $ #{command}\n(skipped)"
|
|
122
|
-
next
|
|
123
|
-
end
|
|
124
|
-
|
|
125
109
|
command = command.to_s
|
|
126
110
|
if command.strip.empty?
|
|
127
111
|
results << "#{position} $ (blank)\n(skipped — blank command)"
|
|
@@ -135,37 +119,23 @@ module Tools
|
|
|
135
119
|
interrupted = true
|
|
136
120
|
elsif result.key?(:error)
|
|
137
121
|
results << "#{position} $ #{command}\n#{result[:error]}"
|
|
138
|
-
failed = true
|
|
139
122
|
else
|
|
140
|
-
|
|
141
|
-
output = format_result(result[:stdout], result[:stderr], exit_code)
|
|
142
|
-
results << "#{position} $ #{command}\n#{output}"
|
|
143
|
-
failed = true if exit_code != 0
|
|
123
|
+
results << "#{position} $ #{command}\n#{result[:output]}"
|
|
144
124
|
end
|
|
145
125
|
end
|
|
146
126
|
|
|
147
127
|
results.join("\n\n")
|
|
148
128
|
end
|
|
149
129
|
|
|
150
|
-
def format_result(stdout, stderr, exit_code)
|
|
151
|
-
parts = []
|
|
152
|
-
parts << "stdout:\n#{stdout}" unless stdout.empty?
|
|
153
|
-
parts << "stderr:\n#{stderr}" unless stderr.empty?
|
|
154
|
-
parts << "exit_code: #{exit_code}"
|
|
155
|
-
parts.join("\n\n")
|
|
156
|
-
end
|
|
157
|
-
|
|
158
130
|
# Formats the result of an interrupted command for the LLM.
|
|
159
131
|
# Includes partial output captured before the interrupt.
|
|
160
132
|
#
|
|
161
|
-
# @param result [Hash] ShellSession result with :
|
|
133
|
+
# @param result [Hash] ShellSession result with :output key
|
|
162
134
|
# @return [String] formatted message for the LLM
|
|
163
135
|
def format_interrupted(result)
|
|
164
|
-
|
|
165
|
-
stderr = result[:stderr].to_s
|
|
136
|
+
output = result[:output].to_s
|
|
166
137
|
parts = [LLM::Client::INTERRUPT_MESSAGE]
|
|
167
|
-
parts << "Partial
|
|
168
|
-
parts << "stderr:\n#{stderr}" unless stderr.empty?
|
|
138
|
+
parts << "Partial output:\n#{output}" unless output.empty?
|
|
169
139
|
parts.join("\n\n")
|
|
170
140
|
end
|
|
171
141
|
|
data/lib/tools/edit.rb
CHANGED
|
@@ -63,9 +63,9 @@ module Tools
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
# Delivers the sub-agent's result to the parent session
|
|
67
|
-
#
|
|
68
|
-
#
|
|
66
|
+
# Delivers the sub-agent's result to the parent session with source
|
|
67
|
+
# metadata. Truncates oversized results to protect the parent's
|
|
68
|
+
# context window. No-op when the parent session is absent.
|
|
69
69
|
#
|
|
70
70
|
# @param result [String] the sub-agent's findings to forward
|
|
71
71
|
# @return [void]
|
|
@@ -79,8 +79,7 @@ module Tools
|
|
|
79
79
|
threshold: Anima::Settings.max_subagent_response_chars,
|
|
80
80
|
reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
|
|
81
81
|
)
|
|
82
|
-
|
|
83
|
-
parent.enqueue_user_message(attributed)
|
|
82
|
+
parent.enqueue_user_message(truncated, source_type: "subagent", source_name: name)
|
|
84
83
|
end
|
|
85
84
|
end
|
|
86
85
|
end
|
data/lib/tools/read.rb
CHANGED
data/lib/tools/registry.rb
CHANGED
|
@@ -14,6 +14,76 @@ module Tools
|
|
|
14
14
|
# registry.register(Tools::Bash)
|
|
15
15
|
# registry.execute("bash", {"command" => "ls"})
|
|
16
16
|
class Registry
|
|
17
|
+
# Standard tools available to every session unless filtered out by
|
|
18
|
+
# {Session#granted_tools}.
|
|
19
|
+
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::ViewMessages, Tools::SearchMessages].freeze
|
|
20
|
+
|
|
21
|
+
# Tools that bypass {Session#granted_tools} filtering — the agent's
|
|
22
|
+
# reasoning depends on them regardless of task scope.
|
|
23
|
+
ALWAYS_GRANTED_TOOLS = [Tools::Think].freeze
|
|
24
|
+
|
|
25
|
+
# Name-to-class mapping for granted-tools filtering.
|
|
26
|
+
STANDARD_TOOLS_BY_NAME = STANDARD_TOOLS.index_by(&:tool_name).freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Builds a registry appropriate for the given session: standard tools
|
|
30
|
+
# filtered through {Session#granted_tools}, plus spawn tools for main
|
|
31
|
+
# sessions or +mark_goal_completed+ for sub-agents, plus any tools
|
|
32
|
+
# exposed by configured MCP servers.
|
|
33
|
+
#
|
|
34
|
+
# MCP registration warnings are emitted as system messages so both the
|
|
35
|
+
# user (in verbose mode) and the LLM see them.
|
|
36
|
+
#
|
|
37
|
+
# @param session [Session] the session requesting tools
|
|
38
|
+
# @param shell_session [ShellSession] persistent PTY for Bash-family tools
|
|
39
|
+
# @return [Registry] configured registry
|
|
40
|
+
def build(session:, shell_session:)
|
|
41
|
+
registry = new(context: {shell_session: shell_session, session: session})
|
|
42
|
+
|
|
43
|
+
tool_classes_for(session).each { |tool| registry.register(tool) }
|
|
44
|
+
|
|
45
|
+
Mcp::ClientManager.shared.register_tools(registry).each do |message|
|
|
46
|
+
Events::Bus.emit(Events::SystemMessage.new(content: message, session_id: session.id))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
registry
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Resolves the ordered tool-class list for a session: granted standard
|
|
53
|
+
# tools (or all of them when +granted_tools+ is nil), plus spawn tools
|
|
54
|
+
# for main sessions or +mark_goal_completed+ for sub-agents. MCP tools
|
|
55
|
+
# are excluded — they are dynamic and registered separately by
|
|
56
|
+
# {.build}. Single source of truth for {.build}, {Session#tool_schemas},
|
|
57
|
+
# and the system-prompt section assemblers.
|
|
58
|
+
#
|
|
59
|
+
# @param session [Session]
|
|
60
|
+
# @return [Array<Class<Tools::Base>>]
|
|
61
|
+
def tool_classes_for(session)
|
|
62
|
+
tools = granted_standard_tools(session).dup
|
|
63
|
+
|
|
64
|
+
if session.sub_agent?
|
|
65
|
+
tools.push(Tools::MarkGoalCompleted)
|
|
66
|
+
else
|
|
67
|
+
tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
tools
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Filters {STANDARD_TOOLS} through the session's granted list.
|
|
76
|
+
# Always includes {ALWAYS_GRANTED_TOOLS} so the agent retains core
|
|
77
|
+
# reasoning tools regardless of task scope.
|
|
78
|
+
def granted_standard_tools(session)
|
|
79
|
+
granted = session.granted_tools
|
|
80
|
+
return STANDARD_TOOLS unless granted
|
|
81
|
+
|
|
82
|
+
explicitly_granted = granted.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
|
|
83
|
+
(ALWAYS_GRANTED_TOOLS + explicitly_granted).uniq
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
17
87
|
# @return [Hash{String => Class, Object}] registered tools keyed by name
|
|
18
88
|
attr_reader :tools
|
|
19
89
|
|
|
@@ -38,22 +108,33 @@ module Tools
|
|
|
38
108
|
# are instantiated with context to generate their schema:
|
|
39
109
|
# - {Think}: budget-based maxLength
|
|
40
110
|
# - {Bash}: CWD embedded in description
|
|
111
|
+
# Returns tool schemas for the Anthropic API. The last schema is
|
|
112
|
+
# annotated with +cache_control+ so the API caches the entire tools
|
|
113
|
+
# prefix (tools are evaluated first in cache prefix order).
|
|
41
114
|
def schemas
|
|
42
115
|
default = Anima::Settings.tool_timeout
|
|
43
|
-
@tools.values.map { |tool| inject_timeout(resolve_schema(tool), default) }
|
|
116
|
+
result = @tools.values.map { |tool| inject_timeout(resolve_schema(tool), default) }
|
|
117
|
+
result.last[:cache_control] = {type: "ephemeral"}
|
|
118
|
+
result
|
|
44
119
|
end
|
|
45
120
|
|
|
46
121
|
# Execute a tool by name. Classes are instantiated with the registry's
|
|
47
|
-
# context; instances are called directly.
|
|
122
|
+
# context; instances are called directly. The enclosing +tool_use_id+
|
|
123
|
+
# is merged into the context when provided so tools that need to
|
|
124
|
+
# reference their own invoking tool_call (e.g. {Tools::SpawnSubagent}
|
|
125
|
+
# persisting +spawn_tool_use_id+ on the child session) can read it via
|
|
126
|
+
# a named kwarg in their initializer.
|
|
48
127
|
#
|
|
49
128
|
# @param name [String] registered tool name
|
|
50
129
|
# @param input [Hash] tool input parameters (may include "timeout" for
|
|
51
130
|
# tools that support per-call timeout overrides)
|
|
131
|
+
# @param tool_use_id [String, nil] the invoking tool_call's pairing id
|
|
52
132
|
# @return [String, Hash] tool execution result
|
|
53
133
|
# @raise [UnknownToolError] if no tool is registered with the given name
|
|
54
|
-
def execute(name, input)
|
|
134
|
+
def execute(name, input, tool_use_id: nil)
|
|
55
135
|
tool = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
|
|
56
|
-
|
|
136
|
+
context = tool_use_id ? @context.merge(tool_use_id: tool_use_id) : @context
|
|
137
|
+
instance = tool.is_a?(Class) ? tool.new(**context) : tool
|
|
57
138
|
instance.execute(input)
|
|
58
139
|
end
|
|
59
140
|
|
|
@@ -24,7 +24,7 @@ module Tools
|
|
|
24
24
|
# Attribution prefix for messages routed from sub-agent to parent.
|
|
25
25
|
# Shared by {Events::Subscribers::SubagentMessageRouter} and
|
|
26
26
|
# {Tools::MarkGoalCompleted} to keep formatting consistent.
|
|
27
|
-
ATTRIBUTION_FORMAT = "[sub-agent
|
|
27
|
+
ATTRIBUTION_FORMAT = "[sub-agent %s]: %s"
|
|
28
28
|
|
|
29
29
|
NOTICE = <<~NOTICE.strip
|
|
30
30
|
---
|
|
@@ -1,51 +1,49 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tools
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# Keyword search across long-term memory — every message Anima has
|
|
5
|
+
# ever seen, across every session, *except* what the agent is already
|
|
6
|
+
# looking at right now. Results sit below her current viewport; anything
|
|
7
|
+
# still in front of her is excluded, so every slot the search returns is
|
|
8
|
+
# new information.
|
|
6
9
|
#
|
|
7
10
|
# Two-step memory workflow:
|
|
8
|
-
# 1. `
|
|
9
|
-
# 2. `
|
|
11
|
+
# 1. `search_messages(query: "auth flow")` → discovers relevant messages
|
|
12
|
+
# 2. `view_messages(message_id: 42)` → fractal zoom into full context
|
|
10
13
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
14
|
+
# Same FTS5 engine Mneme uses for passive recall — this variant fires
|
|
15
|
+
# on demand when Aoide reaches for a memory herself.
|
|
13
16
|
#
|
|
14
|
-
# @example
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
# recall(query: "OAuth config", session_only: true)
|
|
19
|
-
class Recall < Base
|
|
20
|
-
def self.tool_name = "recall"
|
|
17
|
+
# @example
|
|
18
|
+
# search_messages(query: "authentication flow")
|
|
19
|
+
class SearchMessages < Base
|
|
20
|
+
def self.tool_name = "search_messages"
|
|
21
21
|
|
|
22
|
-
def self.description = "
|
|
22
|
+
def self.description = "Search long-term memory (past conversations outside your current viewport) by keyword. Returns ranked message snippets with IDs — pass any ID to view_messages to see the full context around it."
|
|
23
23
|
|
|
24
24
|
def self.input_schema
|
|
25
25
|
{
|
|
26
26
|
type: "object",
|
|
27
27
|
properties: {
|
|
28
|
-
query: {type: "string"}
|
|
29
|
-
session_only: {type: "boolean", description: "Default: all sessions"}
|
|
28
|
+
query: {type: "string"}
|
|
30
29
|
},
|
|
31
30
|
required: ["query"]
|
|
32
31
|
}
|
|
33
32
|
end
|
|
34
33
|
|
|
35
|
-
# @param session [Session] the
|
|
34
|
+
# @param session [Session] the calling session — drives viewport exclusion
|
|
36
35
|
def initialize(session:, **)
|
|
37
36
|
@session = session
|
|
38
37
|
end
|
|
39
38
|
|
|
40
|
-
# @param input [Hash] with "query"
|
|
39
|
+
# @param input [Hash] with "query"
|
|
41
40
|
# @return [String] formatted search results with message IDs
|
|
42
41
|
# @return [Hash] with :error key when query is blank
|
|
43
42
|
def execute(input)
|
|
44
43
|
query = input["query"].to_s.strip
|
|
45
44
|
return {error: "Query cannot be blank"} if query.empty?
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
results = Mneme::Search.query(query, session_id: session_id)
|
|
46
|
+
results = Mneme::Search.query(query, caller_session: @session)
|
|
49
47
|
|
|
50
48
|
return "No results found for \"#{query}\"." if results.empty?
|
|
51
49
|
|
|
@@ -55,7 +53,7 @@ module Tools
|
|
|
55
53
|
private
|
|
56
54
|
|
|
57
55
|
# Formats results as token-efficient, LLM-readable output.
|
|
58
|
-
# Each result includes message_id for drill-down via
|
|
56
|
+
# Each result includes message_id for drill-down via view_messages.
|
|
59
57
|
#
|
|
60
58
|
# @param query [String] the original search query
|
|
61
59
|
# @param results [Array<Mneme::Search::Result>] ranked search results
|
|
@@ -5,7 +5,7 @@ module Tools
|
|
|
5
5
|
# The specialist has a predefined system prompt and tool set defined
|
|
6
6
|
# in its Markdown definition file under agents/.
|
|
7
7
|
#
|
|
8
|
-
# Nickname assignment is handled by the {
|
|
8
|
+
# Nickname assignment is handled by the {Melete::Runner} which
|
|
9
9
|
# runs synchronously at spawn time, generating a unique nickname based
|
|
10
10
|
# on the task — same as generic sub-agents.
|
|
11
11
|
#
|
|
@@ -22,8 +22,8 @@ module Tools
|
|
|
22
22
|
# Builds description dynamically to include available specialists.
|
|
23
23
|
def self.description
|
|
24
24
|
base = "Need a specific skill set for the job? Bring in a specialist. " \
|
|
25
|
-
"Its messages appear
|
|
26
|
-
"
|
|
25
|
+
"Its messages appear as tool responses in your conversation. " \
|
|
26
|
+
"Prefix its nickname with @ to send instructions."
|
|
27
27
|
|
|
28
28
|
registry = Agents::Registry.instance
|
|
29
29
|
return base unless registry.any?
|
|
@@ -59,14 +59,21 @@ module Tools
|
|
|
59
59
|
|
|
60
60
|
# @param session [Session] the parent session spawning the specialist
|
|
61
61
|
# @param agent_registry [Agents::Registry, nil] injectable for testing
|
|
62
|
-
|
|
62
|
+
# @param tool_use_id [String, nil] the invoking +spawn_specialist+ tool_call's
|
|
63
|
+
# pairing id, captured so the spawn pair can later be located by the
|
|
64
|
+
# HUD visibility sweep in {Mneme::Runner}
|
|
65
|
+
def initialize(session:, agent_registry: nil, tool_use_id: nil, **)
|
|
63
66
|
@session = session
|
|
64
67
|
@agent_registry = agent_registry || Agents::Registry.instance
|
|
68
|
+
@tool_use_id = tool_use_id
|
|
65
69
|
end
|
|
66
70
|
|
|
67
|
-
# Creates a child session with the specialist's predefined prompt and
|
|
68
|
-
#
|
|
69
|
-
#
|
|
71
|
+
# Creates a child session with the specialist's predefined prompt and
|
|
72
|
+
# tools, pins the task as a Goal, and enqueues the task as the
|
|
73
|
+
# child's first user_message PendingMessage — which kicks the
|
|
74
|
+
# standard inbound pipeline (Melete → (Mneme) → StartProcessing →
|
|
75
|
+
# DrainJob) so the specialist self-starts the same way a human-typed
|
|
76
|
+
# message would. Returns immediately after Melete completes.
|
|
70
77
|
#
|
|
71
78
|
# @param input [Hash<String, Object>] with "name" and "task"
|
|
72
79
|
# @return [String] confirmation with child session ID
|
|
@@ -83,10 +90,9 @@ module Tools
|
|
|
83
90
|
|
|
84
91
|
child = spawn_child(definition, task)
|
|
85
92
|
nickname = child.name
|
|
86
|
-
"Specialist
|
|
93
|
+
"Specialist #{nickname} spawned (session #{child.id}). " \
|
|
87
94
|
"Its messages will appear in your conversation. " \
|
|
88
|
-
"
|
|
89
|
-
"any message mentioning @#{nickname} is forwarded, even in narration."
|
|
95
|
+
"To address it, prefix its name with @ in your message."
|
|
90
96
|
end
|
|
91
97
|
|
|
92
98
|
private
|
|
@@ -95,12 +101,14 @@ module Tools
|
|
|
95
101
|
child = Session.create!(
|
|
96
102
|
parent_session_id: @session.id,
|
|
97
103
|
prompt: build_prompt(definition),
|
|
98
|
-
granted_tools: definition.tools
|
|
104
|
+
granted_tools: definition.tools,
|
|
105
|
+
spawn_tool_use_id: @tool_use_id,
|
|
106
|
+
initial_cwd: ShellSession.cwd_via_tmux(@session.id) || @session.initial_cwd
|
|
99
107
|
)
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
create_goal_with_pinned_task(child, task)
|
|
109
|
+
assign_nickname_via_melete(child)
|
|
102
110
|
child.broadcast_children_update_to_parent
|
|
103
|
-
|
|
111
|
+
child.enqueue_user_message(task)
|
|
104
112
|
child
|
|
105
113
|
end
|
|
106
114
|
|
data/lib/tools/spawn_subagent.rb
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Tools
|
|
4
4
|
# Spawns a generic child session that works on a task autonomously.
|
|
5
|
-
# The sub-agent
|
|
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 {DrainJob} and communicates with the parent through
|
|
7
8
|
# natural text messages routed by {Events::Subscribers::SubagentMessageRouter}.
|
|
8
9
|
#
|
|
9
|
-
# Nickname assignment is handled by the {
|
|
10
|
-
# runs synchronously at spawn time — the same
|
|
10
|
+
# Nickname assignment is handled by the {Melete::Runner} which
|
|
11
|
+
# runs synchronously at spawn time — the same muse that manages skills,
|
|
11
12
|
# goals, and workflows for the main session.
|
|
12
13
|
#
|
|
13
14
|
# For named specialists with predefined prompts and tools, see {SpawnSpecialist}.
|
|
@@ -20,9 +21,9 @@ module Tools
|
|
|
20
21
|
|
|
21
22
|
def self.description
|
|
22
23
|
"Task feels like a sidequest or a context-switch? Hand it off. " \
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
24
|
+
"Starts clean with just the task — include all relevant context in the task description. " \
|
|
25
|
+
"Its messages appear as tool responses in your conversation. " \
|
|
26
|
+
"Prefix its nickname with @ to send instructions."
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def self.input_schema
|
|
@@ -35,7 +36,7 @@ module Tools
|
|
|
35
36
|
items: {type: "string"},
|
|
36
37
|
description: "Tool names to grant the sub-agent. " \
|
|
37
38
|
"Omit for all standard tools. Empty array for pure reasoning. " \
|
|
38
|
-
"Valid tools: #{
|
|
39
|
+
"Valid tools: #{Tools::Registry::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
|
|
39
40
|
}
|
|
40
41
|
},
|
|
41
42
|
required: %w[task]
|
|
@@ -43,13 +44,21 @@ module Tools
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
# @param session [Session] the parent session spawning the sub-agent
|
|
46
|
-
|
|
47
|
+
# @param tool_use_id [String, nil] the invoking +spawn_subagent+ tool_call's
|
|
48
|
+
# pairing id, captured so the spawn pair can later be located by the
|
|
49
|
+
# HUD visibility sweep in {Mneme::Runner}
|
|
50
|
+
def initialize(session:, tool_use_id: nil, **)
|
|
47
51
|
@session = session
|
|
52
|
+
@tool_use_id = tool_use_id
|
|
48
53
|
end
|
|
49
54
|
|
|
50
|
-
# Creates a child session
|
|
51
|
-
#
|
|
52
|
-
#
|
|
55
|
+
# Creates a child session with a clean context (no parent history),
|
|
56
|
+
# runs Melete to assign a nickname, pins the task as a Goal, and
|
|
57
|
+
# enqueues the task as the child's first user_message PendingMessage —
|
|
58
|
+
# which kicks the standard inbound pipeline (Melete → (Mneme) →
|
|
59
|
+
# StartProcessing → DrainJob) so the sub-agent self-starts the same
|
|
60
|
+
# way a human-typed message would. Returns immediately after Melete
|
|
61
|
+
# completes (blocking for ~200ms).
|
|
53
62
|
#
|
|
54
63
|
# @param input [Hash<String, Object>] with "task" and optional "tools"
|
|
55
64
|
# @return [String] confirmation with child session ID and @nickname
|
|
@@ -66,10 +75,9 @@ module Tools
|
|
|
66
75
|
|
|
67
76
|
child = spawn_child(task, tools)
|
|
68
77
|
nickname = child.name
|
|
69
|
-
"Sub-agent
|
|
78
|
+
"Sub-agent #{nickname} spawned (session #{child.id}). " \
|
|
70
79
|
"Its messages will appear in your conversation. " \
|
|
71
|
-
"
|
|
72
|
-
"any message mentioning @#{nickname} is forwarded, even in narration."
|
|
80
|
+
"To address it, prefix its name with @ in your message."
|
|
73
81
|
end
|
|
74
82
|
|
|
75
83
|
private
|
|
@@ -78,12 +86,14 @@ module Tools
|
|
|
78
86
|
child = Session.create!(
|
|
79
87
|
parent_session_id: @session.id,
|
|
80
88
|
prompt: GENERIC_PROMPT,
|
|
81
|
-
granted_tools: granted_tools
|
|
89
|
+
granted_tools: granted_tools,
|
|
90
|
+
spawn_tool_use_id: @tool_use_id,
|
|
91
|
+
initial_cwd: ShellSession.cwd_via_tmux(@session.id) || @session.initial_cwd
|
|
82
92
|
)
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
create_goal_with_pinned_task(child, task)
|
|
94
|
+
assign_nickname_via_melete(child)
|
|
85
95
|
child.broadcast_children_update_to_parent
|
|
86
|
-
|
|
96
|
+
child.enqueue_user_message(task)
|
|
87
97
|
child
|
|
88
98
|
end
|
|
89
99
|
|
|
@@ -105,7 +115,7 @@ module Tools
|
|
|
105
115
|
return nil unless tools
|
|
106
116
|
return {error: "tools must be an array"} unless tools.is_a?(Array)
|
|
107
117
|
|
|
108
|
-
unknown = tools -
|
|
118
|
+
unknown = tools - Tools::Registry::STANDARD_TOOLS_BY_NAME.keys
|
|
109
119
|
return {error: "Unknown tool: #{unknown.first}"} if unknown.any?
|
|
110
120
|
|
|
111
121
|
nil
|