anima-core 1.0.2 → 1.1.1
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/.gitattributes +1 -0
- data/.reek.yml +51 -0
- data/README.md +63 -29
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +30 -11
- data/app/decorators/tool_call_decorator.rb +32 -3
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +12 -4
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +93 -23
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +4 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +402 -6
- data/app/models/snapshot.rb +76 -0
- data/bin/jobs +5 -0
- data/config/initializers/event_subscribers.rb +12 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/config/queue.yml +0 -1
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +63 -20
- data/lib/analytical_brain/runner.rb +158 -65
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +32 -9
- data/lib/anima/installer.rb +11 -24
- data/lib/anima/settings.rb +59 -0
- data/lib/anima/spinner.rb +75 -0
- data/lib/anima/version.rb +1 -1
- data/lib/environment_probe.rb +4 -4
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/persister.rb +19 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/events/tool_call.rb +5 -3
- data/lib/llm/client.rb +19 -9
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +194 -63
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/base.rb +2 -1
- data/lib/tools/bash.rb +4 -2
- data/lib/tools/registry.rb +22 -3
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/request_feature.rb +3 -1
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/web_get.rb +21 -10
- data/lib/tui/app.rb +222 -125
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +97 -8
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +47 -0
- data/templates/soul.md +1 -1
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
data/lib/anima/cli.rb
CHANGED
|
@@ -20,13 +20,17 @@ module Anima
|
|
|
20
20
|
Installer.new.run
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
desc "update", "Upgrade gem and
|
|
23
|
+
desc "update", "Upgrade gem, migrate config, and restart service"
|
|
24
24
|
option :migrate_only, type: :boolean, default: false, desc: "Skip gem upgrade, only migrate config"
|
|
25
25
|
def update
|
|
26
|
+
require_relative "spinner"
|
|
27
|
+
|
|
26
28
|
unless options[:migrate_only]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
success = Spinner.run("Upgrading anima-core gem...") do
|
|
30
|
+
system("gem", "update", "anima-core", out: File::NULL, err: File::NULL)
|
|
31
|
+
end
|
|
32
|
+
unless success
|
|
33
|
+
say "Run manually for details: gem update anima-core", :red
|
|
30
34
|
exit 1
|
|
31
35
|
end
|
|
32
36
|
|
|
@@ -34,22 +38,24 @@ module Anima
|
|
|
34
38
|
exec(File.join(Gem.bindir, "anima"), "update", "--migrate-only")
|
|
35
39
|
end
|
|
36
40
|
|
|
37
|
-
say "Migrating configuration..."
|
|
38
41
|
require_relative "config_migrator"
|
|
39
|
-
result =
|
|
42
|
+
result = Spinner.run("Migrating configuration...") do
|
|
43
|
+
Anima::ConfigMigrator.new.run
|
|
44
|
+
end
|
|
40
45
|
|
|
41
46
|
case result.status
|
|
42
47
|
when :not_found
|
|
43
48
|
say "Config file not found. Run 'anima install' first.", :red
|
|
44
49
|
exit 1
|
|
45
50
|
when :up_to_date
|
|
46
|
-
say "Config is already up to date."
|
|
51
|
+
say " Config is already up to date."
|
|
47
52
|
when :updated
|
|
48
53
|
result.additions.each do |addition|
|
|
49
54
|
say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
|
|
50
55
|
end
|
|
51
|
-
say "Config updated. Changes take effect immediately — no restart needed."
|
|
52
56
|
end
|
|
57
|
+
|
|
58
|
+
restart_service_if_active
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
# Start the Anima brain server (Puma + Solid Queue) via Foreman.
|
|
@@ -78,6 +84,7 @@ module Anima
|
|
|
78
84
|
|
|
79
85
|
desc "tui", "Launch the Anima terminal interface"
|
|
80
86
|
option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
|
|
87
|
+
option :debug, type: :boolean, default: false, desc: "Enable performance logging to log/tui_performance.log"
|
|
81
88
|
def tui
|
|
82
89
|
require "ratatui_ruby"
|
|
83
90
|
require_relative "../tui/app"
|
|
@@ -89,7 +96,7 @@ module Anima
|
|
|
89
96
|
cable_client = TUI::CableClient.new(host: host)
|
|
90
97
|
cable_client.connect
|
|
91
98
|
|
|
92
|
-
TUI::App.new(cable_client: cable_client).run
|
|
99
|
+
TUI::App.new(cable_client: cable_client, debug: options[:debug]).run
|
|
93
100
|
end
|
|
94
101
|
|
|
95
102
|
desc "version", "Show version"
|
|
@@ -103,5 +110,21 @@ module Anima
|
|
|
103
110
|
subcommand "mcp", Mcp
|
|
104
111
|
|
|
105
112
|
private
|
|
113
|
+
|
|
114
|
+
# Restarts the systemd user service so updated code takes effect.
|
|
115
|
+
# Without this, the service continues running the old gem version
|
|
116
|
+
# until manually restarted (see #269).
|
|
117
|
+
#
|
|
118
|
+
# @return [void]
|
|
119
|
+
def restart_service_if_active
|
|
120
|
+
return unless system("systemctl", "--user", "is-active", "--quiet", "anima.service")
|
|
121
|
+
|
|
122
|
+
success = Spinner.run("Restarting anima service...") do
|
|
123
|
+
system("systemctl", "--user", "restart", "anima.service")
|
|
124
|
+
end
|
|
125
|
+
unless success
|
|
126
|
+
say " Run manually: systemctl --user restart anima.service", :red
|
|
127
|
+
end
|
|
128
|
+
end
|
|
106
129
|
end
|
|
107
130
|
end
|
data/lib/anima/installer.rb
CHANGED
|
@@ -30,7 +30,6 @@ module Anima
|
|
|
30
30
|
say "Installing Anima to #{anima_home}..."
|
|
31
31
|
create_directories
|
|
32
32
|
create_soul_file
|
|
33
|
-
create_config_file
|
|
34
33
|
create_settings_config
|
|
35
34
|
create_mcp_config
|
|
36
35
|
generate_credentials
|
|
@@ -60,17 +59,6 @@ module Anima
|
|
|
60
59
|
say " created #{soul_path}"
|
|
61
60
|
end
|
|
62
61
|
|
|
63
|
-
def create_config_file
|
|
64
|
-
config_path = anima_home.join("config", "anima.yml")
|
|
65
|
-
return if config_path.exist?
|
|
66
|
-
|
|
67
|
-
config_path.write(<<~YAML)
|
|
68
|
-
# Anima configuration
|
|
69
|
-
# See https://github.com/hoblin/anima for documentation
|
|
70
|
-
YAML
|
|
71
|
-
say " created #{config_path}"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
62
|
def create_settings_config
|
|
75
63
|
config_path = anima_home.join("config.toml")
|
|
76
64
|
return if config_path.exist?
|
|
@@ -115,19 +103,22 @@ module Anima
|
|
|
115
103
|
|
|
116
104
|
next if key_path.exist? && content_path.exist?
|
|
117
105
|
|
|
106
|
+
content_str = content_path.to_s
|
|
107
|
+
key_str = key_path.to_s
|
|
108
|
+
|
|
118
109
|
key = ActiveSupport::EncryptedFile.generate_key
|
|
119
110
|
key_path.write(key)
|
|
120
|
-
File.chmod(0o600,
|
|
111
|
+
File.chmod(0o600, key_str)
|
|
121
112
|
|
|
122
113
|
config = ActiveSupport::EncryptedConfiguration.new(
|
|
123
|
-
config_path:
|
|
124
|
-
key_path:
|
|
114
|
+
config_path: content_str,
|
|
115
|
+
key_path: key_str,
|
|
125
116
|
env_key: "RAILS_MASTER_KEY",
|
|
126
117
|
raise_if_missing_key: true
|
|
127
118
|
)
|
|
128
119
|
|
|
129
120
|
config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
|
|
130
|
-
File.chmod(0o600,
|
|
121
|
+
File.chmod(0o600, content_str)
|
|
131
122
|
say " created credentials for #{env}"
|
|
132
123
|
end
|
|
133
124
|
end
|
|
@@ -153,16 +144,12 @@ module Anima
|
|
|
153
144
|
WantedBy=default.target
|
|
154
145
|
UNIT
|
|
155
146
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
else
|
|
160
|
-
service_path.write(unit_content)
|
|
161
|
-
say " updated #{service_path}"
|
|
162
|
-
end
|
|
147
|
+
already_exists = service_path.exist?
|
|
148
|
+
if already_exists && service_path.read == unit_content
|
|
149
|
+
say " anima.service unchanged"
|
|
163
150
|
else
|
|
164
151
|
service_path.write(unit_content)
|
|
165
|
-
say " created #{service_path}"
|
|
152
|
+
say " #{already_exists ? "updated" : "created"} #{service_path}"
|
|
166
153
|
end
|
|
167
154
|
|
|
168
155
|
system("systemctl", "--user", "daemon-reload", err: File::NULL, out: File::NULL)
|
data/lib/anima/settings.rb
CHANGED
|
@@ -102,6 +102,11 @@ module Anima
|
|
|
102
102
|
# @return [Integer] seconds
|
|
103
103
|
def web_request_timeout = get("timeouts", "web_request")
|
|
104
104
|
|
|
105
|
+
# Per-tool-call timeout. Used as the default deadline for orphan detection
|
|
106
|
+
# and as the default value for the tool's `timeout` input parameter.
|
|
107
|
+
# @return [Integer] seconds
|
|
108
|
+
def tool_timeout = get("timeouts", "tool")
|
|
109
|
+
|
|
105
110
|
# ─── Shell ──────────────────────────────────────────────────────
|
|
106
111
|
|
|
107
112
|
# Maximum bytes of command output before truncation.
|
|
@@ -128,6 +133,19 @@ module Anima
|
|
|
128
133
|
|
|
129
134
|
# ─── Session ────────────────────────────────────────────────────
|
|
130
135
|
|
|
136
|
+
# View mode applied to new sessions: "basic", "verbose", or "debug".
|
|
137
|
+
# Changing this setting only affects sessions created afterwards.
|
|
138
|
+
# @return [String]
|
|
139
|
+
# @raise [MissingSettingError] if the value is not a valid view mode
|
|
140
|
+
def default_view_mode
|
|
141
|
+
value = get("session", "default_view_mode")
|
|
142
|
+
unless Session::VIEW_MODES.include?(value)
|
|
143
|
+
raise MissingSettingError,
|
|
144
|
+
"[session] default_view_mode must be one of: #{Session::VIEW_MODES.join(", ")} (got #{value.inspect})"
|
|
145
|
+
end
|
|
146
|
+
value
|
|
147
|
+
end
|
|
148
|
+
|
|
131
149
|
# Regenerate session name every N messages.
|
|
132
150
|
# @return [Integer]
|
|
133
151
|
def name_generation_interval = get("session", "name_generation_interval")
|
|
@@ -177,6 +195,47 @@ module Anima
|
|
|
177
195
|
# @return [Integer]
|
|
178
196
|
def analytical_brain_event_window = get("analytical_brain", "event_window")
|
|
179
197
|
|
|
198
|
+
# ─── Mneme (Memory Department) ────────────────────────────────
|
|
199
|
+
|
|
200
|
+
# Maximum tokens per Mneme LLM response.
|
|
201
|
+
# @return [Integer]
|
|
202
|
+
def mneme_max_tokens = get("mneme", "max_tokens")
|
|
203
|
+
|
|
204
|
+
# Fraction of the main viewport token budget allocated to Mneme's viewport.
|
|
205
|
+
# @return [Float]
|
|
206
|
+
def mneme_viewport_fraction = get("mneme", "viewport_fraction")
|
|
207
|
+
|
|
208
|
+
# Fraction of the main viewport token budget reserved for L1 snapshots.
|
|
209
|
+
# @return [Float]
|
|
210
|
+
def mneme_l1_budget_fraction = get("mneme", "l1_budget_fraction")
|
|
211
|
+
|
|
212
|
+
# Fraction of the main viewport token budget reserved for L2 snapshots.
|
|
213
|
+
# @return [Float]
|
|
214
|
+
def mneme_l2_budget_fraction = get("mneme", "l2_budget_fraction")
|
|
215
|
+
|
|
216
|
+
# Number of uncovered L1 snapshots that triggers L2 compression.
|
|
217
|
+
# @return [Integer]
|
|
218
|
+
def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
|
|
219
|
+
|
|
220
|
+
# Fraction of the main viewport token budget reserved for pinned events.
|
|
221
|
+
# Pinned events appear between snapshots and the sliding window.
|
|
222
|
+
# @return [Float]
|
|
223
|
+
def mneme_pinned_budget_fraction = get("mneme", "pinned_budget_fraction")
|
|
224
|
+
|
|
225
|
+
# ─── Recall (Associative Memory) ────────────────────────────
|
|
226
|
+
|
|
227
|
+
# Maximum search results returned per FTS5 query.
|
|
228
|
+
# @return [Integer]
|
|
229
|
+
def recall_max_results = get("recall", "max_results")
|
|
230
|
+
|
|
231
|
+
# Fraction of the main viewport token budget reserved for recalled memories.
|
|
232
|
+
# @return [Float]
|
|
233
|
+
def recall_budget_fraction = get("recall", "budget_fraction")
|
|
234
|
+
|
|
235
|
+
# Maximum tokens per individual recall snippet.
|
|
236
|
+
# @return [Integer]
|
|
237
|
+
def recall_max_snippet_tokens = get("recall", "max_snippet_tokens")
|
|
238
|
+
|
|
180
239
|
private
|
|
181
240
|
|
|
182
241
|
# Reads a setting from the config file.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anima
|
|
4
|
+
# Braille spinner for long-running CLI operations.
|
|
5
|
+
# Animates in a background thread while a block executes in the
|
|
6
|
+
# calling thread, then shows a success/failure indicator.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# result = Spinner.run("Installing...") { system("make install") }
|
|
10
|
+
class Spinner
|
|
11
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
12
|
+
FRAME_DELAY = 0.08
|
|
13
|
+
SUCCESS_ICON = "\u2713"
|
|
14
|
+
FAILURE_ICON = "\u2717"
|
|
15
|
+
JOIN_TIMEOUT = 2
|
|
16
|
+
|
|
17
|
+
# Run a block with an animated spinner beside a status message.
|
|
18
|
+
#
|
|
19
|
+
# @param message [String] status text shown beside the spinner
|
|
20
|
+
# @param output [IO] output stream (defaults to $stdout)
|
|
21
|
+
# @yield the operation to run
|
|
22
|
+
# @return [Object] the block's return value
|
|
23
|
+
def self.run(message, output: $stdout, &block)
|
|
24
|
+
new(message, output: output).run(&block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param message [String] status text shown beside the spinner
|
|
28
|
+
# @param output [IO] output stream
|
|
29
|
+
def initialize(message, output: $stdout)
|
|
30
|
+
@message = message
|
|
31
|
+
@output = output
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@running = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @yield operation to run while the spinner animates
|
|
37
|
+
# @return [Object] the block's return value
|
|
38
|
+
def run
|
|
39
|
+
thread = start_animation
|
|
40
|
+
result = yield
|
|
41
|
+
stop_animation(thread, success: !!result)
|
|
42
|
+
result
|
|
43
|
+
rescue
|
|
44
|
+
stop_animation(thread, success: false)
|
|
45
|
+
raise
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def running?
|
|
51
|
+
@mutex.synchronize { @running }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start_animation
|
|
55
|
+
@mutex.synchronize { @running = true }
|
|
56
|
+
Thread.new do
|
|
57
|
+
idx = 0
|
|
58
|
+
while running?
|
|
59
|
+
@output.print "\r#{FRAMES[idx % FRAMES.size]} #{@message}"
|
|
60
|
+
@output.flush
|
|
61
|
+
idx += 1
|
|
62
|
+
sleep FRAME_DELAY
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stop_animation(thread, success:)
|
|
68
|
+
@mutex.synchronize { @running = false }
|
|
69
|
+
thread.join(JOIN_TIMEOUT)
|
|
70
|
+
icon = success ? SUCCESS_ICON : FAILURE_ICON
|
|
71
|
+
@output.print "\r#{icon} #{@message}\n"
|
|
72
|
+
@output.flush
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/anima/version.rb
CHANGED
data/lib/environment_probe.rb
CHANGED
|
@@ -123,7 +123,7 @@ class EnvironmentProbe
|
|
|
123
123
|
# @return [Hash{Symbol => String}, nil] keys: :remote, :repo_name, :branch,
|
|
124
124
|
# and optionally :pr_number (Integer) and :pr_state (String); nil when not in a repo
|
|
125
125
|
def detect_git
|
|
126
|
-
_, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree")
|
|
126
|
+
_, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree", err: File::NULL)
|
|
127
127
|
return unless status.success?
|
|
128
128
|
|
|
129
129
|
info = {}
|
|
@@ -136,7 +136,7 @@ class EnvironmentProbe
|
|
|
136
136
|
|
|
137
137
|
# Populates :remote and :repo_name on the info hash.
|
|
138
138
|
def detect_git_remote(info)
|
|
139
|
-
remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin")
|
|
139
|
+
remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin", err: File::NULL)
|
|
140
140
|
remote = remote.strip
|
|
141
141
|
return unless remote.present?
|
|
142
142
|
|
|
@@ -146,7 +146,7 @@ class EnvironmentProbe
|
|
|
146
146
|
|
|
147
147
|
# Populates :branch, :pr_number, and :pr_state on the info hash.
|
|
148
148
|
def detect_git_branch(info)
|
|
149
|
-
branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
149
|
+
branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD", err: File::NULL)
|
|
150
150
|
branch = branch.strip
|
|
151
151
|
return unless branch.present?
|
|
152
152
|
|
|
@@ -181,7 +181,7 @@ class EnvironmentProbe
|
|
|
181
181
|
output, status = Open3.capture2(
|
|
182
182
|
"gh", "pr", "list", "--head", branch,
|
|
183
183
|
"--json", "number,state", "--limit", "1",
|
|
184
|
-
chdir: @pwd
|
|
184
|
+
chdir: @pwd, err: File::NULL
|
|
185
185
|
)
|
|
186
186
|
return unless status.success?
|
|
187
187
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Transient failure event emitted when LLM delivery fails inside the
|
|
5
|
+
# Bounce Back transaction. The user event record is rolled back, and
|
|
6
|
+
# this event notifies clients to remove the phantom message and
|
|
7
|
+
# restore the text to the input field.
|
|
8
|
+
#
|
|
9
|
+
# Not persisted — not included in {Event::TYPES}.
|
|
10
|
+
class BounceBack < Base
|
|
11
|
+
TYPE = "bounce_back"
|
|
12
|
+
|
|
13
|
+
# @return [String] human-readable error description
|
|
14
|
+
attr_reader :error
|
|
15
|
+
|
|
16
|
+
# @return [Integer, nil] database ID of the rolled-back event (for client-side removal)
|
|
17
|
+
attr_reader :event_id
|
|
18
|
+
|
|
19
|
+
# @param content [String] original user message text to restore to input
|
|
20
|
+
# @param error [String] error description for the flash message
|
|
21
|
+
# @param session_id [Integer] session the message was intended for
|
|
22
|
+
# @param event_id [Integer, nil] ID of the event that was broadcast optimistically
|
|
23
|
+
def initialize(content:, error:, session_id:, event_id: nil)
|
|
24
|
+
super(content: content, session_id: session_id)
|
|
25
|
+
@error = error
|
|
26
|
+
@event_id = event_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def type
|
|
30
|
+
TYPE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
super.merge(error: error, event_id: event_id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -27,6 +27,12 @@ module Events
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Receives a Rails.event notification hash and persists it.
|
|
30
|
+
#
|
|
31
|
+
# Skips non-pending user messages — those are persisted by their
|
|
32
|
+
# callers ({SessionChannel#speak} for idle sessions,
|
|
33
|
+
# {AgentLoop#process} for direct usage). Also skips event types
|
|
34
|
+
# not in {Event::TYPES} (transient events like {Events::BounceBack}).
|
|
35
|
+
#
|
|
30
36
|
# @param event [Hash] with :payload containing event data
|
|
31
37
|
def emit(event)
|
|
32
38
|
payload = event[:payload]
|
|
@@ -34,6 +40,8 @@ module Events
|
|
|
34
40
|
|
|
35
41
|
event_type = payload[:type]
|
|
36
42
|
return if event_type.nil?
|
|
43
|
+
return unless Event::TYPES.include?(event_type)
|
|
44
|
+
return if persisted_by_job?(event_type, payload)
|
|
37
45
|
|
|
38
46
|
target_session = @session || Session.find_by(id: payload[:session_id])
|
|
39
47
|
return unless target_session
|
|
@@ -52,6 +60,17 @@ module Events
|
|
|
52
60
|
def session=(new_session)
|
|
53
61
|
@mutex.synchronize { @session = new_session }
|
|
54
62
|
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Non-pending user messages are persisted by their callers
|
|
67
|
+
# ({SessionChannel#speak}, {AgentLoop#process}) so the event ID
|
|
68
|
+
# is available for bounce-back cleanup if LLM delivery fails.
|
|
69
|
+
# Pending messages are still auto-persisted here because they
|
|
70
|
+
# queue while the session is busy.
|
|
71
|
+
def persisted_by_job?(event_type, payload)
|
|
72
|
+
event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
|
|
73
|
+
end
|
|
55
74
|
end
|
|
56
75
|
end
|
|
57
76
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Routes text messages between parent and child sessions, enabling
|
|
6
|
+
# bidirectional @mention communication.
|
|
7
|
+
#
|
|
8
|
+
# **Child → Parent:** When a sub-agent emits an {Events::AgentMessage},
|
|
9
|
+
# the router persists a {Events::UserMessage} in the parent session
|
|
10
|
+
# with attribution prefix, then wakes the parent via {AgentRequestJob}.
|
|
11
|
+
#
|
|
12
|
+
# **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
|
|
13
|
+
# containing `@name` mentions, the router persists the message in each
|
|
14
|
+
# matching child session and wakes them via {AgentRequestJob}.
|
|
15
|
+
#
|
|
16
|
+
# Both directions use direct persistence + job enqueue (same pattern as
|
|
17
|
+
# {Tools::SpawnSubagent#spawn_child}) to avoid conflicts with the global
|
|
18
|
+
# {Persister} which skips non-pending user messages.
|
|
19
|
+
#
|
|
20
|
+
# This replaces the +return_result+ tool — sub-agents communicate
|
|
21
|
+
# through natural text messages instead of structured tool calls.
|
|
22
|
+
class SubagentMessageRouter
|
|
23
|
+
include Events::Subscriber
|
|
24
|
+
|
|
25
|
+
# Attribution prefix format for messages routed from child to parent.
|
|
26
|
+
# @example "[sub-agent @loop-sleuth]: Here's what I found..."
|
|
27
|
+
ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
|
|
28
|
+
|
|
29
|
+
# Regex to extract @mention names from parent agent messages.
|
|
30
|
+
MENTION_PATTERN = /@(\w[\w-]*)/
|
|
31
|
+
|
|
32
|
+
# Routes agent text messages between parent and child sessions.
|
|
33
|
+
#
|
|
34
|
+
# For sub-agent sessions: forwards to parent with attribution prefix.
|
|
35
|
+
# For parent sessions: scans for @mentions and routes to matching children.
|
|
36
|
+
#
|
|
37
|
+
# @param event [Hash] Rails.event notification hash with +:payload+ containing
|
|
38
|
+
# an +agent_message+ event (type, session_id, content)
|
|
39
|
+
# @return [void]
|
|
40
|
+
def emit(event)
|
|
41
|
+
payload = event[:payload]
|
|
42
|
+
return unless payload.is_a?(Hash)
|
|
43
|
+
return unless payload[:type] == "agent_message"
|
|
44
|
+
|
|
45
|
+
session_id = payload[:session_id]
|
|
46
|
+
return unless session_id
|
|
47
|
+
|
|
48
|
+
content = payload[:content].to_s
|
|
49
|
+
return if content.empty?
|
|
50
|
+
|
|
51
|
+
session = Session.find_by(id: session_id)
|
|
52
|
+
return unless session
|
|
53
|
+
|
|
54
|
+
if session.sub_agent?
|
|
55
|
+
route_to_parent(session, content)
|
|
56
|
+
else
|
|
57
|
+
route_mentions_to_children(session, content)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Forwards a sub-agent's text message to its parent session.
|
|
64
|
+
# Persists directly and enqueues a job so the parent agent wakes
|
|
65
|
+
# up to process the message.
|
|
66
|
+
#
|
|
67
|
+
# @param child [Session] the sub-agent session
|
|
68
|
+
# @param content [String] the sub-agent's message text
|
|
69
|
+
def route_to_parent(child, content)
|
|
70
|
+
parent = child.parent_session
|
|
71
|
+
return unless parent
|
|
72
|
+
|
|
73
|
+
name = child.name || "agent-#{child.id}"
|
|
74
|
+
attributed = format(ATTRIBUTION_FORMAT, name, content)
|
|
75
|
+
|
|
76
|
+
parent.create_user_event(attributed)
|
|
77
|
+
AgentRequestJob.perform_later(parent.id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Scans a parent agent's message for @mentions and routes the message
|
|
81
|
+
# to each mentioned child session.
|
|
82
|
+
#
|
|
83
|
+
# @param parent [Session] the parent session
|
|
84
|
+
# @param content [String] the parent agent's message text
|
|
85
|
+
def route_mentions_to_children(parent, content)
|
|
86
|
+
mentioned_names = content.scan(MENTION_PATTERN).flatten.uniq
|
|
87
|
+
return if mentioned_names.empty?
|
|
88
|
+
|
|
89
|
+
active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
|
|
90
|
+
return if active_children.empty?
|
|
91
|
+
|
|
92
|
+
mentioned_names.each do |name|
|
|
93
|
+
child = active_children[name]
|
|
94
|
+
next unless child
|
|
95
|
+
|
|
96
|
+
child.create_user_event(content)
|
|
97
|
+
AgentRequestJob.perform_later(child.id)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Bridges transient (non-persisted) events to ActionCable so clients
|
|
6
|
+
# receive them over WebSocket. Persisted events reach clients via
|
|
7
|
+
# {Event::Broadcasting} callbacks; this subscriber handles events
|
|
8
|
+
# that never touch the database.
|
|
9
|
+
#
|
|
10
|
+
# @example Registering at boot
|
|
11
|
+
# Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
|
|
12
|
+
class TransientBroadcaster
|
|
13
|
+
include Events::Subscriber
|
|
14
|
+
|
|
15
|
+
# Event types that are broadcast without persistence.
|
|
16
|
+
TRANSIENT_TYPES = [Events::BounceBack::TYPE].freeze
|
|
17
|
+
|
|
18
|
+
# @param event [Hash] Rails.event notification hash
|
|
19
|
+
def emit(event)
|
|
20
|
+
payload = event[:payload]
|
|
21
|
+
return unless payload.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
event_type = payload[:type]
|
|
24
|
+
return unless TRANSIENT_TYPES.include?(event_type)
|
|
25
|
+
|
|
26
|
+
session_id = payload[:session_id]
|
|
27
|
+
return unless session_id
|
|
28
|
+
|
|
29
|
+
ActionCable.server.broadcast(
|
|
30
|
+
"session_#{session_id}",
|
|
31
|
+
payload.transform_keys(&:to_s)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/events/tool_call.rb
CHANGED
|
@@ -4,18 +4,20 @@ module Events
|
|
|
4
4
|
class ToolCall < Base
|
|
5
5
|
TYPE = "tool_call"
|
|
6
6
|
|
|
7
|
-
attr_reader :tool_name, :tool_input, :tool_use_id
|
|
7
|
+
attr_reader :tool_name, :tool_input, :tool_use_id, :timeout
|
|
8
8
|
|
|
9
9
|
# @param content [String] human-readable description of the tool call
|
|
10
10
|
# @param tool_name [String] registered tool name (e.g. "web_get")
|
|
11
11
|
# @param tool_input [Hash] arguments passed to the tool
|
|
12
12
|
# @param tool_use_id [String] Anthropic-assigned ID for correlating call/result
|
|
13
|
+
# @param timeout [Integer] maximum seconds before the call is considered orphaned
|
|
13
14
|
# @param session_id [String, nil] optional session identifier
|
|
14
|
-
def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, session_id: nil)
|
|
15
|
+
def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, timeout: nil, session_id: nil)
|
|
15
16
|
super(content: content, session_id: session_id)
|
|
16
17
|
@tool_name = tool_name
|
|
17
18
|
@tool_input = tool_input
|
|
18
19
|
@tool_use_id = tool_use_id
|
|
20
|
+
@timeout = timeout
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def type
|
|
@@ -23,7 +25,7 @@ module Events
|
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def to_h
|
|
26
|
-
super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id)
|
|
28
|
+
super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id, timeout: timeout)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
end
|
data/lib/llm/client.rb
CHANGED
|
@@ -70,10 +70,13 @@ module LLM
|
|
|
70
70
|
# @param messages [Array<Hash>] conversation messages in Anthropic format
|
|
71
71
|
# @param registry [Tools::Registry] registered tools to make available
|
|
72
72
|
# @param session_id [Integer, String] session ID for emitted events
|
|
73
|
+
# @param first_response [Hash, nil] pre-fetched first API response from
|
|
74
|
+
# {AgentLoop#deliver!}. Skips the first API call when provided so
|
|
75
|
+
# the Bounce Back transaction doesn't duplicate work.
|
|
73
76
|
# @param options [Hash] additional API parameters (e.g. +system:+)
|
|
74
77
|
# @return [String, nil] the assistant's final text response, or nil when interrupted
|
|
75
78
|
# @raise [Providers::Anthropic::Error] on API errors
|
|
76
|
-
def chat_with_tools(messages, registry:, session_id:, **options)
|
|
79
|
+
def chat_with_tools(messages, registry:, session_id:, first_response: nil, **options)
|
|
77
80
|
messages = messages.dup
|
|
78
81
|
rounds = 0
|
|
79
82
|
|
|
@@ -84,13 +87,17 @@ module LLM
|
|
|
84
87
|
return "[Tool loop exceeded #{max_rounds} rounds — halting]"
|
|
85
88
|
end
|
|
86
89
|
|
|
87
|
-
response =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
response = if first_response && rounds == 1
|
|
91
|
+
first_response
|
|
92
|
+
else
|
|
93
|
+
provider.create_message(
|
|
94
|
+
model: model,
|
|
95
|
+
messages: messages,
|
|
96
|
+
max_tokens: max_tokens,
|
|
97
|
+
tools: registry.schemas,
|
|
98
|
+
**options
|
|
99
|
+
)
|
|
100
|
+
end
|
|
94
101
|
|
|
95
102
|
log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
|
|
96
103
|
|
|
@@ -174,12 +181,14 @@ module LLM
|
|
|
174
181
|
name = tool_use["name"]
|
|
175
182
|
id = tool_use["id"]
|
|
176
183
|
input = tool_use["input"] || {}
|
|
184
|
+
timeout = input["timeout"] || Anima::Settings.tool_timeout
|
|
177
185
|
|
|
178
186
|
log(:debug, "tool_call: #{name}(#{input.to_json})")
|
|
179
187
|
|
|
180
188
|
Events::Bus.emit(Events::ToolCall.new(
|
|
181
189
|
content: "Calling #{name}", tool_name: name,
|
|
182
|
-
tool_input: input, tool_use_id: id,
|
|
190
|
+
tool_input: input, tool_use_id: id, timeout: timeout,
|
|
191
|
+
session_id: session_id
|
|
183
192
|
))
|
|
184
193
|
|
|
185
194
|
result = begin
|
|
@@ -189,6 +198,7 @@ module LLM
|
|
|
189
198
|
{error: "#{error.class}: #{error.message}"}
|
|
190
199
|
end
|
|
191
200
|
|
|
201
|
+
result = ToolDecorator.call(name, result)
|
|
192
202
|
result_content = format_tool_result(result)
|
|
193
203
|
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
194
204
|
|