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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +8 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +4 -4
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +13 -4
  13. data/app/models/goal.rb +13 -0
  14. data/app/models/message.rb +13 -18
  15. data/app/models/pending_message.rb +43 -0
  16. data/app/models/secret.rb +72 -0
  17. data/app/models/session.rb +194 -43
  18. data/config/environments/test.rb +5 -0
  19. data/config/initializers/time_nanoseconds.rb +11 -0
  20. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  21. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  22. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  23. data/lib/agent_loop.rb +13 -40
  24. data/lib/agents/definition.rb +1 -1
  25. data/lib/analytical_brain/runner.rb +7 -4
  26. data/lib/anima/cli/mcp/secrets.rb +4 -4
  27. data/lib/anima/cli/mcp.rb +4 -4
  28. data/lib/anima/installer.rb +7 -1
  29. data/lib/anima/settings.rb +31 -2
  30. data/lib/anima/version.rb +1 -1
  31. data/lib/anima.rb +1 -1
  32. data/lib/credential_store.rb +17 -66
  33. data/lib/events/base.rb +1 -1
  34. data/lib/events/subscribers/persister.rb +11 -18
  35. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  36. data/lib/events/user_message.rb +2 -13
  37. data/lib/llm/client.rb +54 -20
  38. data/lib/mcp/config.rb +2 -2
  39. data/lib/mcp/secrets.rb +7 -8
  40. data/lib/mneme/compressed_viewport.rb +1 -1
  41. data/lib/shell_session.rb +54 -16
  42. data/lib/tools/base.rb +23 -0
  43. data/lib/tools/bash.rb +56 -4
  44. data/lib/tools/edit.rb +2 -2
  45. data/lib/tools/mark_goal_completed.rb +86 -0
  46. data/lib/tools/read.rb +2 -1
  47. data/lib/tools/recall.rb +98 -0
  48. data/lib/tools/registry.rb +36 -7
  49. data/lib/tools/remember.rb +1 -1
  50. data/lib/tools/response_truncator.rb +70 -0
  51. data/lib/tools/spawn_specialist.rb +6 -5
  52. data/lib/tools/spawn_subagent.rb +8 -6
  53. data/lib/tools/subagent_prompts.rb +43 -5
  54. data/lib/tools/think.rb +23 -0
  55. data/lib/tools/write.rb +1 -1
  56. data/lib/tui/app.rb +178 -13
  57. data/lib/tui/braille_spinner.rb +152 -0
  58. data/lib/tui/cable_client.rb +4 -4
  59. data/lib/tui/decorators/base_decorator.rb +17 -8
  60. data/lib/tui/decorators/bash_decorator.rb +2 -2
  61. data/lib/tui/decorators/edit_decorator.rb +5 -4
  62. data/lib/tui/decorators/read_decorator.rb +4 -8
  63. data/lib/tui/decorators/think_decorator.rb +3 -5
  64. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  65. data/lib/tui/decorators/write_decorator.rb +5 -4
  66. data/lib/tui/flash.rb +1 -1
  67. data/lib/tui/formatting.rb +22 -0
  68. data/lib/tui/message_store.rb +70 -26
  69. data/lib/tui/screens/chat.rb +269 -66
  70. data/skills/activerecord/SKILL.md +1 -1
  71. data/skills/dragonruby/SKILL.md +1 -1
  72. data/skills/draper-decorators/SKILL.md +1 -1
  73. data/skills/gh-issue.md +1 -1
  74. data/skills/mcp-server/SKILL.md +1 -1
  75. data/skills/ratatui-ruby/SKILL.md +1 -1
  76. data/skills/rspec/SKILL.md +1 -1
  77. data/templates/config.toml +26 -0
  78. 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/workflow/goal management
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 enters their domain.
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 workflow_management goal_tracking].freeze
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, activate relevant skills, then call everything_is_ready.
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 Rails encrypted
9
- # credentials. Secrets are referenced in mcp.toml via
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 credentials"
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 credentials"
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 credentials"
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 credentials.
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 credentials (repeatable)"
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 credentials.
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
 
@@ -117,7 +117,13 @@ module Anima
117
117
  raise_if_missing_key: true
118
118
  )
119
119
 
120
- config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
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
@@ -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 read tool.
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 read tool.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anima
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
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 credentials. No-op if Rails is already loaded.
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
 
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Low-level read/write operations on Rails encrypted credentials.
4
- # Wraps the merge-and-write pattern used by {SessionChannel#write_anthropic_token}
5
- # in a reusable helper. All namespacing (e.g. +mcp+, +anthropic+) is the
6
- # caller's responsibility this class operates on raw top-level keys.
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
- # Merges into existing credentials, preserving sibling keys.
18
+ # Upserts: existing keys are updated, new keys are created.
17
19
  #
18
- # @param namespace [String] top-level YAML key (e.g. "mcp", "anthropic")
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
- existing = load_credentials
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 YAML key
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
- bust_credentials_cache!
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 YAML key
44
- # @return [Array<String>] credential keys (not values)
38
+ # @param namespace [String] top-level grouping key
39
+ # @return [Array<String>] credential keys
45
40
  def list(namespace)
46
- section = Rails.application.credentials.dig(namespace.to_sym)
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 YAML key
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
- existing = load_credentials
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 = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
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 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 {Message::TYPES} (transient events like {Events::BounceBack}).
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 persisted_by_job?(event_type, payload)
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] || Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
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 and wakes them via {AgentRequestJob}.
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
- # Attribution prefix format for messages routed from child to parent.
29
- # @example "[sub-agent @loop-sleuth]: Here's what I found..."
30
- ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
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
- attributed = format(ATTRIBUTION_FORMAT, name, content)
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(content)
109
+ child.enqueue_user_message(attributed)
98
110
  end
99
111
  end
100
112
  end
@@ -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
- # @param status [String, nil] "pending" when queued during active agent processing
13
- def initialize(content:, session_id: nil, status: nil)
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 message when a tool is skipped due to user interrupt.
19
- INTERRUPT_MESSAGE = "Stopped by user"
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
- # "Stopped by user" results and the loop exits without another LLM call.
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 interrupted?(session_id)
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
- if interrupted?(session_id)
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 "Stopped by user" results for all tools in the list.
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 "Stopped by user" result for a tool that was not
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} (interrupted)", tool_name: 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 database for a pending interrupt flag on the session.
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] whether the session has a pending interrupt request
257
- def interrupted?(session_id)
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
- # Clears the interrupt flag so the agent loop can continue with pending
262
- # messages. Also cleared by {AgentRequestJob#clear_interrupt} as a safety
263
- # net for unexpected exits.
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 clear
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 clear_interrupt!(session_id)
268
- Session.where(id: session_id).update_all(interrupt_requested: false)
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