anima-core 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +8 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +4 -4
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +13 -4
- data/app/models/goal.rb +13 -0
- data/app/models/message.rb +13 -18
- data/app/models/pending_message.rb +43 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +194 -43
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +13 -40
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +7 -4
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +31 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +11 -18
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +1 -1
- data/lib/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +56 -4
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +36 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +6 -5
- data/lib/tools/spawn_subagent.rb +8 -6
- data/lib/tools/subagent_prompts.rb +43 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +70 -26
- data/lib/tui/screens/chat.rb +269 -66
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +26 -0
- metadata +11 -1
|
@@ -9,7 +9,9 @@ module AnalyticalBrain
|
|
|
9
9
|
# active depends on the session type:
|
|
10
10
|
#
|
|
11
11
|
# * **Parent sessions** — session naming, skill/workflow/goal management
|
|
12
|
-
# * **Child sessions** — sub-agent nickname assignment, skill
|
|
12
|
+
# * **Child sessions** — sub-agent nickname assignment, skill management
|
|
13
|
+
# (goal tracking and workflows disabled — sub-agents manage their sole goal
|
|
14
|
+
# via mark_goal_completed)
|
|
13
15
|
#
|
|
14
16
|
# Tools mutate the observed session directly (e.g. renaming it, activating
|
|
15
17
|
# skills), but no trace of the brain's reasoning is persisted — events are
|
|
@@ -51,7 +53,8 @@ module AnalyticalBrain
|
|
|
51
53
|
──────────────────────────────
|
|
52
54
|
SKILL MANAGEMENT
|
|
53
55
|
──────────────────────────────
|
|
54
|
-
Activate skills when the conversation
|
|
56
|
+
Activate skills when the conversation signals intent — before the agent acts on it.
|
|
57
|
+
Late activation means the agent works without domain knowledge.
|
|
55
58
|
Deactivate when the agent moves to a different domain.
|
|
56
59
|
Multiple skills can be active at once.
|
|
57
60
|
PROMPT
|
|
@@ -104,7 +107,7 @@ module AnalyticalBrain
|
|
|
104
107
|
|
|
105
108
|
# Which responsibilities activate for each session type.
|
|
106
109
|
PARENT_RESPONSIBILITIES = %i[session_naming skill_management workflow_management goal_tracking].freeze
|
|
107
|
-
CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management
|
|
110
|
+
CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management].freeze
|
|
108
111
|
|
|
109
112
|
# @param session [Session] the session to observe and maintain
|
|
110
113
|
# @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
|
|
@@ -201,7 +204,7 @@ module AnalyticalBrain
|
|
|
201
204
|
#{transcript}
|
|
202
205
|
```
|
|
203
206
|
|
|
204
|
-
Assign a nickname
|
|
207
|
+
Assign a nickname and activate relevant skills, then call everything_is_ready.
|
|
205
208
|
MSG
|
|
206
209
|
[{role: "user", content: content}]
|
|
207
210
|
end
|
|
@@ -5,8 +5,8 @@ require "thor"
|
|
|
5
5
|
module Anima
|
|
6
6
|
class CLI < Thor
|
|
7
7
|
class Mcp < Thor
|
|
8
|
-
# CLI commands for managing MCP secrets stored in
|
|
9
|
-
#
|
|
8
|
+
# CLI commands for managing MCP secrets stored in the encrypted
|
|
9
|
+
# secrets table. Secrets are referenced in mcp.toml via
|
|
10
10
|
# +${credential:key_name}+ syntax.
|
|
11
11
|
#
|
|
12
12
|
# @example Store a secret
|
|
@@ -22,7 +22,7 @@ module Anima
|
|
|
22
22
|
true
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
desc "set KEY=VALUE", "Store an MCP secret in encrypted
|
|
25
|
+
desc "set KEY=VALUE", "Store an MCP secret in encrypted secrets"
|
|
26
26
|
def set(pair)
|
|
27
27
|
key, value = pair.split("=", 2)
|
|
28
28
|
unless value
|
|
@@ -50,7 +50,7 @@ module Anima
|
|
|
50
50
|
keys.each { |key| say " #{key}" }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
desc "remove KEY", "Remove an MCP secret from encrypted
|
|
53
|
+
desc "remove KEY", "Remove an MCP secret from encrypted storage"
|
|
54
54
|
def remove(key)
|
|
55
55
|
secrets = require_mcp_secrets
|
|
56
56
|
unless secrets.list.include?(key)
|
data/lib/anima/cli/mcp.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Anima
|
|
|
12
12
|
true
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted
|
|
15
|
+
desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted storage"
|
|
16
16
|
subcommand "secrets", Secrets
|
|
17
17
|
|
|
18
18
|
desc "list", "List configured MCP servers with health status"
|
|
@@ -40,14 +40,14 @@ module Anima
|
|
|
40
40
|
|
|
41
41
|
Use -e KEY=VALUE to set environment variables (stdio servers).
|
|
42
42
|
Use -H "Header: Value" to set HTTP headers (HTTP servers).
|
|
43
|
-
Use -s KEY=VALUE to store a secret in encrypted
|
|
43
|
+
Use -s KEY=VALUE to store a secret in encrypted storage.
|
|
44
44
|
DESC
|
|
45
45
|
option :env, aliases: "-e", type: :string, repeatable: true, banner: "KEY=VALUE",
|
|
46
46
|
desc: "Environment variables (repeatable)"
|
|
47
47
|
option :header, aliases: "-H", type: :string, repeatable: true, banner: "Header: Value",
|
|
48
48
|
desc: "HTTP headers (repeatable)"
|
|
49
49
|
option :secret, aliases: "-s", type: :string, repeatable: true, banner: "KEY=VALUE",
|
|
50
|
-
desc: "Store secret in encrypted
|
|
50
|
+
desc: "Store secret in encrypted storage (repeatable)"
|
|
51
51
|
def add(name, *rest)
|
|
52
52
|
if rest.empty?
|
|
53
53
|
say "Error: missing server URL or command.", :red
|
|
@@ -87,7 +87,7 @@ module Anima
|
|
|
87
87
|
::Mcp::Config.new
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Stores secrets from -s KEY=VALUE flags in encrypted
|
|
90
|
+
# Stores secrets from -s KEY=VALUE flags in the encrypted secrets table.
|
|
91
91
|
def store_secrets(secret_strings)
|
|
92
92
|
return unless secret_strings&.any?
|
|
93
93
|
|
data/lib/anima/installer.rb
CHANGED
|
@@ -117,7 +117,13 @@ module Anima
|
|
|
117
117
|
raise_if_missing_key: true
|
|
118
118
|
)
|
|
119
119
|
|
|
120
|
-
config.write(
|
|
120
|
+
config.write(<<~YAML)
|
|
121
|
+
secret_key_base: #{SecureRandom.hex(64)}
|
|
122
|
+
active_record_encryption:
|
|
123
|
+
primary_key: #{SecureRandom.base64(32)}
|
|
124
|
+
deterministic_key: #{SecureRandom.base64(32)}
|
|
125
|
+
key_derivation_salt: #{SecureRandom.base64(32)}
|
|
126
|
+
YAML
|
|
121
127
|
File.chmod(0o600, content_str)
|
|
122
128
|
say " created credentials for #{env}"
|
|
123
129
|
end
|
data/lib/anima/settings.rb
CHANGED
|
@@ -91,6 +91,11 @@ 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
|
+
|
|
94
99
|
# ─── Timeouts (seconds) ────────────────────────────────────────
|
|
95
100
|
|
|
96
101
|
# LLM API request timeout.
|
|
@@ -114,6 +119,13 @@ module Anima
|
|
|
114
119
|
# @return [Integer] seconds
|
|
115
120
|
def tool_timeout = get("timeouts", "tool")
|
|
116
121
|
|
|
122
|
+
# Polling interval for user interrupt checks during long-running commands.
|
|
123
|
+
# Enforces a 0.5s floor to prevent busy-polling from misconfiguration.
|
|
124
|
+
# @return [Numeric] seconds (minimum 0.5)
|
|
125
|
+
def interrupt_check_interval
|
|
126
|
+
[get("timeouts", "interrupt_check"), 0.5].max
|
|
127
|
+
end
|
|
128
|
+
|
|
117
129
|
# ─── Shell ──────────────────────────────────────────────────────
|
|
118
130
|
|
|
119
131
|
# Maximum bytes of command output before truncation.
|
|
@@ -126,11 +138,11 @@ module Anima
|
|
|
126
138
|
# @return [Integer]
|
|
127
139
|
def max_file_size = get("tools", "max_file_size")
|
|
128
140
|
|
|
129
|
-
# Maximum lines returned by the
|
|
141
|
+
# Maximum lines returned by the read_file tool.
|
|
130
142
|
# @return [Integer]
|
|
131
143
|
def max_read_lines = get("tools", "max_read_lines")
|
|
132
144
|
|
|
133
|
-
# Maximum bytes returned by the
|
|
145
|
+
# Maximum bytes returned by the read_file tool.
|
|
134
146
|
# @return [Integer]
|
|
135
147
|
def max_read_bytes = get("tools", "max_read_bytes")
|
|
136
148
|
|
|
@@ -142,6 +154,16 @@ module Anima
|
|
|
142
154
|
# @return [Integer]
|
|
143
155
|
def min_web_content_chars = get("tools", "min_web_content_chars")
|
|
144
156
|
|
|
157
|
+
# Maximum characters of tool output before head+tail truncation.
|
|
158
|
+
# Full output saved to a temp file for paginated reading.
|
|
159
|
+
# @return [Integer]
|
|
160
|
+
def max_tool_response_chars = get("tools", "max_tool_response_chars")
|
|
161
|
+
|
|
162
|
+
# Maximum characters of sub-agent result before head+tail truncation.
|
|
163
|
+
# Higher than tool threshold because sub-agent output is already curated.
|
|
164
|
+
# @return [Integer]
|
|
165
|
+
def max_subagent_response_chars = get("tools", "max_subagent_response_chars")
|
|
166
|
+
|
|
145
167
|
# ─── Session ────────────────────────────────────────────────────
|
|
146
168
|
|
|
147
169
|
# View mode applied to new sessions: "basic", "verbose", or "debug".
|
|
@@ -206,6 +228,13 @@ module Anima
|
|
|
206
228
|
# @return [Integer]
|
|
207
229
|
def analytical_brain_message_window = get("analytical_brain", "message_window")
|
|
208
230
|
|
|
231
|
+
# ─── Goals ──────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
# Number of meaningful messages (user + agent turns) after completion
|
|
234
|
+
# before a completed goal is automatically evicted from context.
|
|
235
|
+
# @return [Integer]
|
|
236
|
+
def completed_decay_messages = get("goals", "completed_decay_messages")
|
|
237
|
+
|
|
209
238
|
# ─── Mneme (Memory Department) ────────────────────────────────
|
|
210
239
|
|
|
211
240
|
# Maximum tokens per Mneme LLM response.
|
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/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,8 @@ 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
|
|
56
60
|
)
|
|
57
61
|
end
|
|
58
62
|
end
|
|
@@ -60,17 +64,6 @@ module Events
|
|
|
60
64
|
def session=(new_session)
|
|
61
65
|
@mutex.synchronize { @session = new_session }
|
|
62
66
|
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
67
|
end
|
|
75
68
|
end
|
|
76
69
|
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,12 @@ module Events
|
|
|
25
26
|
class SubagentMessageRouter
|
|
26
27
|
include Events::Subscriber
|
|
27
28
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
# @see Tools::ResponseTruncator::ATTRIBUTION_FORMAT
|
|
30
|
+
ATTRIBUTION_FORMAT = Tools::ResponseTruncator::ATTRIBUTION_FORMAT
|
|
31
|
+
|
|
32
|
+
# Origin label for messages routed from parent agent to sub-agent.
|
|
33
|
+
# Lets the sub-agent distinguish delegated work from direct user input.
|
|
34
|
+
PARENT_ATTRIBUTION_FORMAT = "[from parent]: %s"
|
|
31
35
|
|
|
32
36
|
# Regex to extract @mention names from parent agent messages.
|
|
33
37
|
MENTION_PATTERN = /@(\w[\w-]*)/
|
|
@@ -64,7 +68,8 @@ module Events
|
|
|
64
68
|
private
|
|
65
69
|
|
|
66
70
|
# Forwards a sub-agent's text message to its parent session
|
|
67
|
-
# via {Session#enqueue_user_message}.
|
|
71
|
+
# via {Session#enqueue_user_message}. Truncates oversized messages
|
|
72
|
+
# to protect the parent's context window.
|
|
68
73
|
#
|
|
69
74
|
# @param child [Session] the sub-agent session
|
|
70
75
|
# @param content [String] the sub-agent's message text
|
|
@@ -73,13 +78,18 @@ module Events
|
|
|
73
78
|
return unless parent
|
|
74
79
|
|
|
75
80
|
name = child.name || "agent-#{child.id}"
|
|
76
|
-
|
|
81
|
+
truncated = Tools::ResponseTruncator.truncate(
|
|
82
|
+
content,
|
|
83
|
+
threshold: Anima::Settings.max_subagent_response_chars,
|
|
84
|
+
reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
|
|
85
|
+
)
|
|
86
|
+
attributed = format(ATTRIBUTION_FORMAT, name, truncated)
|
|
77
87
|
|
|
78
88
|
parent.enqueue_user_message(attributed)
|
|
79
89
|
end
|
|
80
90
|
|
|
81
91
|
# Scans a parent agent's message for @mentions and routes the message
|
|
82
|
-
# to each mentioned child session.
|
|
92
|
+
# to each mentioned child session with origin attribution.
|
|
83
93
|
#
|
|
84
94
|
# @param parent [Session] the parent session
|
|
85
95
|
# @param content [String] the parent agent's message text
|
|
@@ -90,11 +100,13 @@ module Events
|
|
|
90
100
|
active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
|
|
91
101
|
return if active_children.empty?
|
|
92
102
|
|
|
103
|
+
attributed = format(PARENT_ATTRIBUTION_FORMAT, content)
|
|
104
|
+
|
|
93
105
|
mentioned_names.each do |name|
|
|
94
106
|
child = active_children[name]
|
|
95
107
|
next unless child
|
|
96
108
|
|
|
97
|
-
child.enqueue_user_message(
|
|
109
|
+
child.enqueue_user_message(attributed)
|
|
98
110
|
end
|
|
99
111
|
end
|
|
100
112
|
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
|
data/lib/llm/client.rb
CHANGED
|
@@ -15,8 +15,8 @@ module LLM
|
|
|
15
15
|
# registry.register(Tools::WebGet)
|
|
16
16
|
# client.chat_with_tools(messages, registry: registry, session_id: session.id)
|
|
17
17
|
class Client
|
|
18
|
-
# Synthetic tool_result
|
|
19
|
-
INTERRUPT_MESSAGE = "
|
|
18
|
+
# Synthetic tool_result when a tool is skipped because the human pressed Escape.
|
|
19
|
+
INTERRUPT_MESSAGE = "Your human wants your attention"
|
|
20
20
|
|
|
21
21
|
# @return [Providers::Anthropic] the underlying API provider
|
|
22
22
|
attr_reader :provider
|
|
@@ -65,7 +65,7 @@ module LLM
|
|
|
65
65
|
# tool interaction so they're persisted and visible in the event stream.
|
|
66
66
|
#
|
|
67
67
|
# When the user interrupts via Escape, remaining tools receive synthetic
|
|
68
|
-
# "
|
|
68
|
+
# "Your human wants your attention" results and the loop exits without another LLM call.
|
|
69
69
|
#
|
|
70
70
|
# @param messages [Array<Hash>] conversation messages in Anthropic format
|
|
71
71
|
# @param registry [Tools::Registry] registered tools to make available
|
|
@@ -90,6 +90,7 @@ module LLM
|
|
|
90
90
|
response = if first_response && rounds == 1
|
|
91
91
|
first_response
|
|
92
92
|
else
|
|
93
|
+
broadcast_session_state(session_id, "llm_generating")
|
|
93
94
|
provider.create_message(
|
|
94
95
|
model: model,
|
|
95
96
|
messages: messages,
|
|
@@ -109,11 +110,14 @@ module LLM
|
|
|
109
110
|
{role: "user", content: tool_results}
|
|
110
111
|
]
|
|
111
112
|
|
|
112
|
-
if
|
|
113
|
-
clear_interrupt!(session_id)
|
|
114
|
-
return nil
|
|
115
|
-
end
|
|
113
|
+
return nil if handle_interrupt!(session_id)
|
|
116
114
|
else
|
|
115
|
+
# Discard the text response if the user pressed Escape while
|
|
116
|
+
# the API was generating it. Without this check the interrupt
|
|
117
|
+
# flag set during the blocking API call would be silently
|
|
118
|
+
# cleared by the ensure block in AgentRequestJob.
|
|
119
|
+
return nil if handle_interrupt!(session_id)
|
|
120
|
+
|
|
117
121
|
return extract_text(response)
|
|
118
122
|
end
|
|
119
123
|
end
|
|
@@ -151,9 +155,12 @@ module LLM
|
|
|
151
155
|
def execute_tools(response, registry, session_id)
|
|
152
156
|
tool_uses = extract_tool_uses(response)
|
|
153
157
|
results = []
|
|
158
|
+
interrupted = false
|
|
154
159
|
|
|
155
160
|
tool_uses.each_with_index do |tool_use, index|
|
|
156
|
-
|
|
161
|
+
# Check-only here; clearing happens in handle_interrupt! after the loop
|
|
162
|
+
interrupted ||= interrupt_requested?(session_id)
|
|
163
|
+
if interrupted
|
|
157
164
|
remaining = tool_uses[index..]
|
|
158
165
|
results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
|
|
159
166
|
break
|
|
@@ -164,7 +171,7 @@ module LLM
|
|
|
164
171
|
results
|
|
165
172
|
end
|
|
166
173
|
|
|
167
|
-
# Creates synthetic "
|
|
174
|
+
# Creates synthetic "Your human wants your attention" results for all tools in the list.
|
|
168
175
|
#
|
|
169
176
|
# @param tool_uses [Array<Hash>] remaining tool_use content blocks
|
|
170
177
|
# @param session_id [Integer, String] session ID for events
|
|
@@ -188,6 +195,8 @@ module LLM
|
|
|
188
195
|
|
|
189
196
|
log(:debug, "tool_call: #{name}(#{input.to_json})")
|
|
190
197
|
|
|
198
|
+
broadcast_session_state(session_id, "tool_executing", tool: name)
|
|
199
|
+
|
|
191
200
|
Events::Bus.emit(Events::ToolCall.new(
|
|
192
201
|
content: "Calling #{name}", tool_name: name,
|
|
193
202
|
tool_input: input, tool_use_id: id, timeout: timeout,
|
|
@@ -197,6 +206,7 @@ module LLM
|
|
|
197
206
|
result = registry.execute(name, input)
|
|
198
207
|
result = ToolDecorator.call(name, result)
|
|
199
208
|
result_content = format_tool_result(result)
|
|
209
|
+
result_content = truncate_tool_result(result_content, registry, name)
|
|
200
210
|
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
201
211
|
|
|
202
212
|
Events::Bus.emit(Events::ToolResponse.new(
|
|
@@ -225,7 +235,7 @@ module LLM
|
|
|
225
235
|
{type: "tool_result", tool_use_id: id, content: error_content}
|
|
226
236
|
end
|
|
227
237
|
|
|
228
|
-
# Creates a synthetic "
|
|
238
|
+
# Creates a synthetic "Your human wants your attention" result for a tool that was not
|
|
229
239
|
# executed due to user interrupt. Emits both ToolCall and ToolResponse
|
|
230
240
|
# events so the TUI shows the interrupted tool in the event stream.
|
|
231
241
|
#
|
|
@@ -238,7 +248,7 @@ module LLM
|
|
|
238
248
|
input = tool_use["input"] || {}
|
|
239
249
|
|
|
240
250
|
Events::Bus.emit(Events::ToolCall.new(
|
|
241
|
-
content: "Skipped #{name}
|
|
251
|
+
content: "Skipped #{name} — your human wants your attention", tool_name: name,
|
|
242
252
|
tool_input: input, tool_use_id: id, session_id: session_id
|
|
243
253
|
))
|
|
244
254
|
|
|
@@ -250,22 +260,35 @@ module LLM
|
|
|
250
260
|
{type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
|
|
251
261
|
end
|
|
252
262
|
|
|
253
|
-
# Checks the
|
|
263
|
+
# Checks whether the session has a pending interrupt flag.
|
|
254
264
|
#
|
|
255
265
|
# @param session_id [Integer, String] session to check
|
|
256
|
-
# @return [Boolean]
|
|
257
|
-
def
|
|
266
|
+
# @return [Boolean] true when interrupt is pending
|
|
267
|
+
def interrupt_requested?(session_id)
|
|
258
268
|
Session.where(id: session_id, interrupt_requested: true).exists?
|
|
259
269
|
end
|
|
260
270
|
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
#
|
|
271
|
+
# Atomically checks for a pending interrupt and clears it in one query.
|
|
272
|
+
# Used at loop boundaries (after tools, before LLM text return) to
|
|
273
|
+
# short-circuit the agent loop when the user presses Escape.
|
|
264
274
|
#
|
|
265
|
-
# @param session_id [Integer, String] session to
|
|
275
|
+
# @param session_id [Integer, String] session to check
|
|
276
|
+
# @return [Boolean] true when interrupt was detected and cleared
|
|
277
|
+
def handle_interrupt!(session_id)
|
|
278
|
+
Session.where(id: session_id, interrupt_requested: true)
|
|
279
|
+
.update_all(interrupt_requested: false) > 0
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Broadcasts a session state transition to all subscribed clients.
|
|
283
|
+
# Delegates to {Session#broadcast_session_state} which handles both
|
|
284
|
+
# the session's own stream and the parent's stream for HUD updates.
|
|
285
|
+
#
|
|
286
|
+
# @param session_id [Integer, String] session to broadcast for
|
|
287
|
+
# @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
|
|
288
|
+
# @param tool [String, nil] tool name when state is "tool_executing"
|
|
266
289
|
# @return [void]
|
|
267
|
-
def
|
|
268
|
-
Session.
|
|
290
|
+
def broadcast_session_state(session_id, state, tool: nil)
|
|
291
|
+
Session.find_by(id: session_id)&.broadcast_session_state(state, tool: tool)
|
|
269
292
|
end
|
|
270
293
|
|
|
271
294
|
def log(level, message)
|
|
@@ -277,5 +300,16 @@ module LLM
|
|
|
277
300
|
def format_tool_result(result)
|
|
278
301
|
result.is_a?(Hash) ? result.to_json : result.to_s
|
|
279
302
|
end
|
|
303
|
+
|
|
304
|
+
# Applies head+tail truncation when a tool result exceeds the tool's
|
|
305
|
+
# configured character threshold. Skips tools that opt out (e.g. read).
|
|
306
|
+
def truncate_tool_result(content, registry, tool_name)
|
|
307
|
+
threshold = registry.truncation_threshold(tool_name)
|
|
308
|
+
return content unless threshold
|
|
309
|
+
|
|
310
|
+
lines = Tools::ResponseTruncator::HEAD_LINES
|
|
311
|
+
reason = "#{tool_name} output displays first/last #{lines} lines"
|
|
312
|
+
Tools::ResponseTruncator.truncate(content, threshold: threshold, reason: reason)
|
|
313
|
+
end
|
|
280
314
|
end
|
|
281
315
|
end
|