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/anima/cli.rb
CHANGED
|
@@ -39,21 +39,29 @@ module Anima
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
require_relative "config_migrator"
|
|
42
|
-
|
|
42
|
+
|
|
43
|
+
result = Spinner.run("Migrating brain configuration...") do
|
|
43
44
|
Anima::ConfigMigrator.new.run
|
|
44
45
|
end
|
|
45
|
-
|
|
46
|
-
case result.status
|
|
47
|
-
when :not_found
|
|
46
|
+
if result.status == :not_found
|
|
48
47
|
say "Config file not found. Run 'anima install' first.", :red
|
|
49
48
|
exit 1
|
|
50
|
-
when :up_to_date
|
|
51
|
-
say " Config is already up to date."
|
|
52
|
-
when :updated
|
|
53
|
-
result.additions.each do |addition|
|
|
54
|
-
say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
|
|
55
|
-
end
|
|
56
49
|
end
|
|
50
|
+
report_migration("Config", result)
|
|
51
|
+
|
|
52
|
+
tui_config_path = File.join(Anima::ConfigMigrator::ANIMA_HOME, "tui.toml")
|
|
53
|
+
tui_template = File.expand_path("../../templates/tui.toml", __dir__)
|
|
54
|
+
unless File.exist?(tui_config_path)
|
|
55
|
+
File.write(tui_config_path, File.read(tui_template))
|
|
56
|
+
say " created #{tui_config_path} (new in this version)"
|
|
57
|
+
end
|
|
58
|
+
tui_result = Spinner.run("Migrating TUI configuration...") do
|
|
59
|
+
Anima::ConfigMigrator.new(
|
|
60
|
+
config_path: tui_config_path,
|
|
61
|
+
template_path: tui_template
|
|
62
|
+
).run
|
|
63
|
+
end
|
|
64
|
+
report_migration("TUI config", tui_result)
|
|
57
65
|
|
|
58
66
|
restart_service_if_active
|
|
59
67
|
end
|
|
@@ -83,13 +91,15 @@ module Anima
|
|
|
83
91
|
end
|
|
84
92
|
|
|
85
93
|
desc "tui", "Launch the Anima terminal interface"
|
|
86
|
-
option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
|
|
87
|
-
option :debug, type: :boolean, default: false, desc: "Enable performance logging
|
|
94
|
+
option :host, desc: "Brain server address (default: from tui.toml or #{DEFAULT_HOST})"
|
|
95
|
+
option :debug, type: :boolean, default: false, desc: "Enable performance logging"
|
|
88
96
|
def tui
|
|
89
97
|
require "ratatui_ruby"
|
|
98
|
+
require_relative "../tui/settings"
|
|
90
99
|
require_relative "../tui/app"
|
|
91
100
|
|
|
92
|
-
|
|
101
|
+
TUI::Settings.load!
|
|
102
|
+
host = options[:host] || TUI::Settings.connection_default_host
|
|
93
103
|
|
|
94
104
|
say "Connecting to brain at #{host}...", :cyan
|
|
95
105
|
|
|
@@ -111,6 +121,24 @@ module Anima
|
|
|
111
121
|
|
|
112
122
|
private
|
|
113
123
|
|
|
124
|
+
# Reports the outcome of a config migration to the user.
|
|
125
|
+
#
|
|
126
|
+
# @param label [String] human-readable config name (e.g. "Config", "TUI config")
|
|
127
|
+
# @param result [Anima::ConfigMigrator::Result] migration outcome
|
|
128
|
+
# @return [void]
|
|
129
|
+
def report_migration(label, result)
|
|
130
|
+
case result.status
|
|
131
|
+
when :not_found
|
|
132
|
+
say "#{label} file not found. Run 'anima install' first.", :red
|
|
133
|
+
when :up_to_date
|
|
134
|
+
say " #{label} is already up to date."
|
|
135
|
+
when :updated
|
|
136
|
+
result.additions.each do |addition|
|
|
137
|
+
say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
114
142
|
# Restarts the systemd user service so updated code takes effect.
|
|
115
143
|
# Without this, the service continues running the old gem version
|
|
116
144
|
# until manually restarted (see #269).
|
data/lib/anima/installer.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Anima
|
|
|
31
31
|
create_directories
|
|
32
32
|
create_soul_file
|
|
33
33
|
create_settings_config
|
|
34
|
+
create_tui_config
|
|
34
35
|
create_mcp_config
|
|
35
36
|
generate_credentials
|
|
36
37
|
create_systemd_service
|
|
@@ -68,6 +69,18 @@ module Anima
|
|
|
68
69
|
say " created #{config_path}"
|
|
69
70
|
end
|
|
70
71
|
|
|
72
|
+
# Creates ~/.anima/tui.toml — TUI-specific presentation settings.
|
|
73
|
+
# Separate from config.toml because the TUI is a standalone client
|
|
74
|
+
# process with no Rails dependency.
|
|
75
|
+
def create_tui_config
|
|
76
|
+
config_path = anima_home.join("tui.toml")
|
|
77
|
+
return if config_path.exist?
|
|
78
|
+
|
|
79
|
+
template = File.read(File.join(TEMPLATE_DIR, "tui.toml"))
|
|
80
|
+
config_path.write(template)
|
|
81
|
+
say " created #{config_path}"
|
|
82
|
+
end
|
|
83
|
+
|
|
71
84
|
def create_mcp_config
|
|
72
85
|
config_path = anima_home.join("mcp.toml")
|
|
73
86
|
return if config_path.exist?
|
|
@@ -117,7 +130,13 @@ module Anima
|
|
|
117
130
|
raise_if_missing_key: true
|
|
118
131
|
)
|
|
119
132
|
|
|
120
|
-
config.write(
|
|
133
|
+
config.write(<<~YAML)
|
|
134
|
+
secret_key_base: #{SecureRandom.hex(64)}
|
|
135
|
+
active_record_encryption:
|
|
136
|
+
primary_key: #{SecureRandom.base64(32)}
|
|
137
|
+
deterministic_key: #{SecureRandom.base64(32)}
|
|
138
|
+
key_derivation_salt: #{SecureRandom.base64(32)}
|
|
139
|
+
YAML
|
|
121
140
|
File.chmod(0o600, content_str)
|
|
122
141
|
say " created credentials for #{env}"
|
|
123
142
|
end
|
data/lib/anima/settings.rb
CHANGED
|
@@ -91,6 +91,20 @@ module Anima
|
|
|
91
91
|
# @return [Integer]
|
|
92
92
|
def token_budget = get("llm", "token_budget")
|
|
93
93
|
|
|
94
|
+
# Maximum character length for the Think tool's thoughts parameter.
|
|
95
|
+
# Sub-agents receive half this budget (their tasks are less complex).
|
|
96
|
+
# @return [Integer]
|
|
97
|
+
def thinking_budget = get("llm", "thinking_budget")
|
|
98
|
+
|
|
99
|
+
# Model for sub-agent sessions. Sonnet is cost-effective for focused tasks.
|
|
100
|
+
# @return [String] Anthropic model identifier
|
|
101
|
+
def subagent_model = get("llm", "subagent_model")
|
|
102
|
+
|
|
103
|
+
# Context window budget for sub-agent sessions.
|
|
104
|
+
# Smaller than main to keep sub-agents out of the "dumb zone".
|
|
105
|
+
# @return [Integer]
|
|
106
|
+
def subagent_token_budget = get("llm", "subagent_token_budget")
|
|
107
|
+
|
|
94
108
|
# ─── Timeouts (seconds) ────────────────────────────────────────
|
|
95
109
|
|
|
96
110
|
# LLM API request timeout.
|
|
@@ -114,6 +128,13 @@ module Anima
|
|
|
114
128
|
# @return [Integer] seconds
|
|
115
129
|
def tool_timeout = get("timeouts", "tool")
|
|
116
130
|
|
|
131
|
+
# Polling interval for user interrupt checks during long-running commands.
|
|
132
|
+
# Enforces a 0.5s floor to prevent busy-polling from misconfiguration.
|
|
133
|
+
# @return [Numeric] seconds (minimum 0.5)
|
|
134
|
+
def interrupt_check_interval
|
|
135
|
+
[get("timeouts", "interrupt_check"), 0.5].max
|
|
136
|
+
end
|
|
137
|
+
|
|
117
138
|
# ─── Shell ──────────────────────────────────────────────────────
|
|
118
139
|
|
|
119
140
|
# Maximum bytes of command output before truncation.
|
|
@@ -126,11 +147,11 @@ module Anima
|
|
|
126
147
|
# @return [Integer]
|
|
127
148
|
def max_file_size = get("tools", "max_file_size")
|
|
128
149
|
|
|
129
|
-
# Maximum lines returned by the
|
|
150
|
+
# Maximum lines returned by the read_file tool.
|
|
130
151
|
# @return [Integer]
|
|
131
152
|
def max_read_lines = get("tools", "max_read_lines")
|
|
132
153
|
|
|
133
|
-
# Maximum bytes returned by the
|
|
154
|
+
# Maximum bytes returned by the read_file tool.
|
|
134
155
|
# @return [Integer]
|
|
135
156
|
def max_read_bytes = get("tools", "max_read_bytes")
|
|
136
157
|
|
|
@@ -142,6 +163,16 @@ module Anima
|
|
|
142
163
|
# @return [Integer]
|
|
143
164
|
def min_web_content_chars = get("tools", "min_web_content_chars")
|
|
144
165
|
|
|
166
|
+
# Maximum characters of tool output before head+tail truncation.
|
|
167
|
+
# Full output saved to a temp file for paginated reading.
|
|
168
|
+
# @return [Integer]
|
|
169
|
+
def max_tool_response_chars = get("tools", "max_tool_response_chars")
|
|
170
|
+
|
|
171
|
+
# Maximum characters of sub-agent result before head+tail truncation.
|
|
172
|
+
# Higher than tool threshold because sub-agent output is already curated.
|
|
173
|
+
# @return [Integer]
|
|
174
|
+
def max_subagent_response_chars = get("tools", "max_subagent_response_chars")
|
|
175
|
+
|
|
145
176
|
# ─── Session ────────────────────────────────────────────────────
|
|
146
177
|
|
|
147
178
|
# View mode applied to new sessions: "basic", "verbose", or "debug".
|
|
@@ -228,6 +259,10 @@ module Anima
|
|
|
228
259
|
# @return [Integer]
|
|
229
260
|
def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
|
|
230
261
|
|
|
262
|
+
# Fraction of the viewport to evict in batch when Mneme runs.
|
|
263
|
+
# @return [Float]
|
|
264
|
+
def mneme_eviction_fraction = get("mneme", "eviction_fraction")
|
|
265
|
+
|
|
231
266
|
# Fraction of the main viewport token budget reserved for pinned messages.
|
|
232
267
|
# Pinned messages appear between snapshots and the sliding window.
|
|
233
268
|
# @return [Float]
|
data/lib/anima/version.rb
CHANGED
data/lib/anima.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Anima
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
# Boots Rails when CLI commands need access to Rails-managed resources
|
|
15
|
-
# like encrypted
|
|
15
|
+
# like the encrypted secrets table. No-op if Rails is already loaded.
|
|
16
16
|
def self.boot_rails!
|
|
17
17
|
return if defined?(Rails)
|
|
18
18
|
|
data/lib/credential_store.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# Read/write operations for runtime secrets (API tokens, MCP credentials).
|
|
4
|
+
# Backed by the {Secret} model with Active Record Encryption — values are
|
|
5
|
+
# encrypted at rest and always fresh (no caching, no file-path issues in
|
|
6
|
+
# forked Solid Queue workers).
|
|
7
|
+
#
|
|
8
|
+
# All namespacing (e.g. +mcp+, +anthropic+) is the caller's responsibility.
|
|
7
9
|
#
|
|
8
10
|
# @example Writing a nested credential
|
|
9
11
|
# CredentialStore.write("mcp", "linear_api_key" => "sk-xxx")
|
|
@@ -13,91 +15,40 @@
|
|
|
13
15
|
class CredentialStore
|
|
14
16
|
class << self
|
|
15
17
|
# Writes one or more key-value pairs under a top-level namespace.
|
|
16
|
-
#
|
|
18
|
+
# Upserts: existing keys are updated, new keys are created.
|
|
17
19
|
#
|
|
18
|
-
# @param namespace [String] top-level
|
|
20
|
+
# @param namespace [String] top-level grouping key (e.g. "mcp", "anthropic")
|
|
19
21
|
# @param pairs [Hash<String, String>] key-value pairs to store
|
|
20
22
|
# @return [void]
|
|
21
23
|
def write(namespace, pairs)
|
|
22
|
-
|
|
23
|
-
section = existing[namespace] ||= {}
|
|
24
|
-
section.merge!(pairs)
|
|
25
|
-
save_credentials(existing)
|
|
24
|
+
Secret.write(namespace, pairs)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
# Reads a single credential value from a namespace.
|
|
29
|
-
# Busts the Rails credentials cache first so cross-process writes
|
|
30
|
-
# (e.g. token saved in the web process, read in the SolidQueue worker)
|
|
31
|
-
# are always visible.
|
|
32
28
|
#
|
|
33
|
-
# @param namespace [String] top-level
|
|
29
|
+
# @param namespace [String] top-level grouping key
|
|
34
30
|
# @param key [String] credential key within the namespace
|
|
35
31
|
# @return [String, nil] credential value or nil if not found
|
|
36
32
|
def read(namespace, key)
|
|
37
|
-
|
|
38
|
-
Rails.application.credentials.dig(namespace.to_sym, key.to_sym)
|
|
33
|
+
Secret.read(namespace, key)
|
|
39
34
|
end
|
|
40
35
|
|
|
41
|
-
# Lists all keys under a namespace.
|
|
36
|
+
# Lists all keys under a namespace (not values).
|
|
42
37
|
#
|
|
43
|
-
# @param namespace [String] top-level
|
|
44
|
-
# @return [Array<String>] credential keys
|
|
38
|
+
# @param namespace [String] top-level grouping key
|
|
39
|
+
# @return [Array<String>] credential keys
|
|
45
40
|
def list(namespace)
|
|
46
|
-
|
|
47
|
-
return [] unless section.is_a?(Hash)
|
|
48
|
-
|
|
49
|
-
section.keys.map(&:to_s)
|
|
41
|
+
Secret.list(namespace)
|
|
50
42
|
end
|
|
51
43
|
|
|
52
44
|
# Removes a single key from a namespace.
|
|
53
45
|
# No-op if the key does not exist.
|
|
54
46
|
#
|
|
55
|
-
# @param namespace [String] top-level
|
|
47
|
+
# @param namespace [String] top-level grouping key
|
|
56
48
|
# @param key [String] credential key to remove
|
|
57
49
|
# @return [void]
|
|
58
50
|
def remove(namespace, key)
|
|
59
|
-
|
|
60
|
-
section = existing[namespace]
|
|
61
|
-
return unless section.is_a?(Hash)
|
|
62
|
-
return unless section.key?(key)
|
|
63
|
-
|
|
64
|
-
section.delete(key)
|
|
65
|
-
existing.delete(namespace) if section.empty?
|
|
66
|
-
save_credentials(existing)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
private
|
|
70
|
-
|
|
71
|
-
# Reads and parses the raw YAML from encrypted credentials.
|
|
72
|
-
# Returns an empty hash when the credentials file does not exist yet.
|
|
73
|
-
#
|
|
74
|
-
# @return [Hash] parsed credentials
|
|
75
|
-
def load_credentials
|
|
76
|
-
creds = Rails.application.credentials
|
|
77
|
-
YAML.safe_load(creds.read) || {}
|
|
78
|
-
rescue ActiveSupport::EncryptedFile::MissingContentError
|
|
79
|
-
{}
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Writes the full credentials hash back to the encrypted file and
|
|
83
|
-
# invalidates the Rails memoization cache so subsequent reads see
|
|
84
|
-
# fresh data.
|
|
85
|
-
#
|
|
86
|
-
# @param data [Hash] complete credentials hash to persist
|
|
87
|
-
# @return [void]
|
|
88
|
-
def save_credentials(data)
|
|
89
|
-
creds = Rails.application.credentials
|
|
90
|
-
creds.write(data.to_yaml)
|
|
91
|
-
bust_credentials_cache!
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Clears the Rails credentials in-memory cache so the next access
|
|
95
|
-
# re-reads and decrypts from disk. Required because Rails memoizes
|
|
96
|
-
# the decrypted config in @config per-process.
|
|
97
|
-
#
|
|
98
|
-
# @return [void]
|
|
99
|
-
def bust_credentials_cache!
|
|
100
|
-
Rails.application.credentials.instance_variable_set(:@config, nil)
|
|
51
|
+
Secret.remove(namespace, key)
|
|
101
52
|
end
|
|
102
53
|
end
|
|
103
54
|
end
|
data/lib/events/agent_message.rb
CHANGED
|
@@ -4,8 +4,22 @@ module Events
|
|
|
4
4
|
class AgentMessage < Base
|
|
5
5
|
TYPE = "agent_message"
|
|
6
6
|
|
|
7
|
+
attr_reader :api_metrics
|
|
8
|
+
|
|
9
|
+
# @param content [String] assistant response text
|
|
10
|
+
# @param session_id [Integer, String] session identifier
|
|
11
|
+
# @param api_metrics [Hash, nil] rate limits and usage from API response
|
|
12
|
+
def initialize(content:, session_id: nil, api_metrics: nil)
|
|
13
|
+
super(content: content, session_id: session_id)
|
|
14
|
+
@api_metrics = api_metrics
|
|
15
|
+
end
|
|
16
|
+
|
|
7
17
|
def type
|
|
8
18
|
TYPE
|
|
9
19
|
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
super.merge(api_metrics: api_metrics)
|
|
23
|
+
end
|
|
10
24
|
end
|
|
11
25
|
end
|
data/lib/events/base.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Events
|
|
|
16
16
|
def initialize(content:, session_id: nil)
|
|
17
17
|
@content = content
|
|
18
18
|
@session_id = session_id
|
|
19
|
-
@timestamp =
|
|
19
|
+
@timestamp = Time.current.to_ns
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# @return [String] event type identifier
|
|
@@ -9,6 +9,11 @@ module Events
|
|
|
9
9
|
# session. When initialized without one (global mode), the session is
|
|
10
10
|
# looked up from the event's session_id payload field.
|
|
11
11
|
#
|
|
12
|
+
# User messages are NOT persisted here — they are created directly by
|
|
13
|
+
# their callers ({SessionChannel#speak}, {AgentLoop#run}) so the
|
|
14
|
+
# message ID is available for bounce-back cleanup. Pending user
|
|
15
|
+
# messages live in the {PendingMessage} table, outside the event bus.
|
|
16
|
+
#
|
|
12
17
|
# @example Session-scoped
|
|
13
18
|
# persister = Events::Subscribers::Persister.new(session)
|
|
14
19
|
# Events::Bus.subscribe(persister)
|
|
@@ -28,10 +33,10 @@ module Events
|
|
|
28
33
|
|
|
29
34
|
# Receives a Rails.event notification hash and persists it.
|
|
30
35
|
#
|
|
31
|
-
# Skips
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
36
|
+
# Skips user messages — those are persisted by their callers
|
|
37
|
+
# ({SessionChannel#speak}, {AgentLoop#run}). Also skips event
|
|
38
|
+
# types not in {Message::TYPES} (transient events like
|
|
39
|
+
# {Events::BounceBack}).
|
|
35
40
|
#
|
|
36
41
|
# @param event [Hash] with :payload containing event data
|
|
37
42
|
def emit(event)
|
|
@@ -41,7 +46,7 @@ module Events
|
|
|
41
46
|
event_type = payload[:type]
|
|
42
47
|
return if event_type.nil?
|
|
43
48
|
return unless Message::TYPES.include?(event_type)
|
|
44
|
-
return if
|
|
49
|
+
return if event_type == "user_message"
|
|
45
50
|
|
|
46
51
|
target_session = @session || Session.find_by(id: payload[:session_id])
|
|
47
52
|
return unless target_session
|
|
@@ -50,9 +55,9 @@ module Events
|
|
|
50
55
|
target_session.messages.create!(
|
|
51
56
|
message_type: event_type,
|
|
52
57
|
payload: payload,
|
|
53
|
-
status: payload[:status],
|
|
54
58
|
tool_use_id: payload[:tool_use_id],
|
|
55
|
-
timestamp: payload[:timestamp] ||
|
|
59
|
+
timestamp: payload[:timestamp] || Time.current.to_ns,
|
|
60
|
+
api_metrics: payload[:api_metrics]
|
|
56
61
|
)
|
|
57
62
|
end
|
|
58
63
|
end
|
|
@@ -60,17 +65,6 @@ module Events
|
|
|
60
65
|
def session=(new_session)
|
|
61
66
|
@mutex.synchronize { @session = new_session }
|
|
62
67
|
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
# Non-pending user messages are persisted by their callers
|
|
67
|
-
# ({SessionChannel#speak}, {AgentLoop#process}) so the message 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] != Message::PENDING_STATUS
|
|
73
|
-
end
|
|
74
68
|
end
|
|
75
69
|
end
|
|
76
70
|
end
|
|
@@ -14,7 +14,8 @@ module Events
|
|
|
14
14
|
#
|
|
15
15
|
# **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
|
|
16
16
|
# containing `@name` mentions, the router persists the message in each
|
|
17
|
-
# matching child session
|
|
17
|
+
# matching child session with a +[from parent]:+ origin label and wakes
|
|
18
|
+
# them via {AgentRequestJob}.
|
|
18
19
|
#
|
|
19
20
|
# Both directions delegate to {Session#enqueue_user_message}, which
|
|
20
21
|
# respects the target session's processing state — persisting directly
|
|
@@ -25,9 +26,9 @@ module Events
|
|
|
25
26
|
class SubagentMessageRouter
|
|
26
27
|
include Events::Subscriber
|
|
27
28
|
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
|
|
29
|
+
# Origin label for messages routed from parent agent to sub-agent.
|
|
30
|
+
# Lets the sub-agent distinguish delegated work from direct user input.
|
|
31
|
+
PARENT_ATTRIBUTION_FORMAT = "[from parent]: %s"
|
|
31
32
|
|
|
32
33
|
# Regex to extract @mention names from parent agent messages.
|
|
33
34
|
MENTION_PATTERN = /@(\w[\w-]*)/
|
|
@@ -64,7 +65,9 @@ module Events
|
|
|
64
65
|
private
|
|
65
66
|
|
|
66
67
|
# Forwards a sub-agent's text message to its parent session
|
|
67
|
-
# via {Session#enqueue_user_message}.
|
|
68
|
+
# via {Session#enqueue_user_message} with source metadata.
|
|
69
|
+
# The parent's {PendingMessage} (or idle-path message) owns the
|
|
70
|
+
# attribution formatting — the router passes raw content.
|
|
68
71
|
#
|
|
69
72
|
# @param child [Session] the sub-agent session
|
|
70
73
|
# @param content [String] the sub-agent's message text
|
|
@@ -73,13 +76,17 @@ module Events
|
|
|
73
76
|
return unless parent
|
|
74
77
|
|
|
75
78
|
name = child.name || "agent-#{child.id}"
|
|
76
|
-
|
|
79
|
+
truncated = Tools::ResponseTruncator.truncate(
|
|
80
|
+
content,
|
|
81
|
+
threshold: Anima::Settings.max_subagent_response_chars,
|
|
82
|
+
reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
|
|
83
|
+
)
|
|
77
84
|
|
|
78
|
-
parent.enqueue_user_message(
|
|
85
|
+
parent.enqueue_user_message(truncated, source_type: "subagent", source_name: name)
|
|
79
86
|
end
|
|
80
87
|
|
|
81
88
|
# Scans a parent agent's message for @mentions and routes the message
|
|
82
|
-
# to each mentioned child session.
|
|
89
|
+
# to each mentioned child session with origin attribution.
|
|
83
90
|
#
|
|
84
91
|
# @param parent [Session] the parent session
|
|
85
92
|
# @param content [String] the parent agent's message text
|
|
@@ -90,11 +97,13 @@ module Events
|
|
|
90
97
|
active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
|
|
91
98
|
return if active_children.empty?
|
|
92
99
|
|
|
100
|
+
attributed = format(PARENT_ATTRIBUTION_FORMAT, content)
|
|
101
|
+
|
|
93
102
|
mentioned_names.each do |name|
|
|
94
103
|
child = active_children[name]
|
|
95
104
|
next unless child
|
|
96
105
|
|
|
97
|
-
child.enqueue_user_message(
|
|
106
|
+
child.enqueue_user_message(attributed)
|
|
98
107
|
end
|
|
99
108
|
end
|
|
100
109
|
end
|
data/lib/events/user_message.rb
CHANGED
|
@@ -4,25 +4,14 @@ module Events
|
|
|
4
4
|
class UserMessage < Base
|
|
5
5
|
TYPE = "user_message"
|
|
6
6
|
|
|
7
|
-
# @return [String, nil] "pending" when queued during active processing, nil otherwise
|
|
8
|
-
attr_reader :status
|
|
9
|
-
|
|
10
7
|
# @param content [String] message text
|
|
11
8
|
# @param session_id [Integer, nil] session identifier
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
super(content: content, session_id: session_id)
|
|
15
|
-
@status = status
|
|
9
|
+
def initialize(content:, session_id: nil)
|
|
10
|
+
super
|
|
16
11
|
end
|
|
17
12
|
|
|
18
13
|
def type
|
|
19
14
|
TYPE
|
|
20
15
|
end
|
|
21
|
-
|
|
22
|
-
def to_h
|
|
23
|
-
h = super
|
|
24
|
-
h[:status] = status if status
|
|
25
|
-
h
|
|
26
|
-
end
|
|
27
16
|
end
|
|
28
17
|
end
|