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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  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 +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
@@ -17,6 +17,34 @@ module Providers
17
17
  # subscription tokens on Sonnet/Opus. Without it, /v1/messages returns 400.
18
18
  OAUTH_PASSPHRASE = "You are Claude Code, Anthropic's official CLI for Claude."
19
19
 
20
+ # Rate limit header names for extraction
21
+ RATE_LIMIT_HEADERS = {
22
+ "5h_status" => "Anthropic-Ratelimit-Unified-5h-Status",
23
+ "5h_reset" => "Anthropic-Ratelimit-Unified-5h-Reset",
24
+ "5h_utilization" => "Anthropic-Ratelimit-Unified-5h-Utilization",
25
+ "7d_status" => "Anthropic-Ratelimit-Unified-7d-Status",
26
+ "7d_reset" => "Anthropic-Ratelimit-Unified-7d-Reset",
27
+ "7d_utilization" => "Anthropic-Ratelimit-Unified-7d-Utilization"
28
+ }.freeze
29
+
30
+ # Response wrapper containing both the parsed body and API metrics.
31
+ # Behaves like a Hash for backward compatibility (delegates to body).
32
+ #
33
+ # @!attribute [r] body
34
+ # @return [Hash] parsed API response
35
+ # @!attribute [r] api_metrics
36
+ # @return [Hash, nil] rate limits and usage data
37
+ ApiResponse = Data.define(:body, :api_metrics) do
38
+ # Delegate Hash methods to body for backward compatibility.
39
+ # Callers using response["content"] continue to work unchanged.
40
+ def [](key) = body[key]
41
+ def dig(...) = body.dig(...)
42
+ def fetch(...) = body.fetch(...)
43
+ def key?(key) = body.key?(key)
44
+ def to_h = body
45
+ def to_json(...) = body.to_json(...)
46
+ end
47
+
20
48
  class Error < StandardError; end
21
49
  class AuthenticationError < Error; end
22
50
  class TokenFormatError < Error; end
@@ -76,13 +104,17 @@ module Providers
76
104
  # @param model [String] Anthropic model identifier
77
105
  # @param messages [Array<Hash>] conversation messages
78
106
  # @param max_tokens [Integer] maximum tokens in the response
107
+ # @param include_metrics [Boolean] when true, returns an {ApiResponse}
108
+ # wrapper with both body and api_metrics; when false (default),
109
+ # returns just the parsed body Hash for backward compatibility
79
110
  # @param options [Hash] additional parameters (e.g. +system:+, +tools:+)
80
- # @return [Hash] parsed API response
111
+ # @return [Hash, ApiResponse] parsed API response, or wrapper with metrics
81
112
  # @raise [TransientError] on network failures or server errors (retryable)
82
113
  # @raise [AuthenticationError] on 401/403 (permanent)
83
114
  # @raise [Error] on other API errors
84
- def create_message(model:, messages:, max_tokens:, **options)
115
+ def create_message(model:, messages:, max_tokens:, include_metrics: false, **options)
85
116
  wrap_system_prompt!(options)
117
+ annotate_last_message_for_caching!(messages)
86
118
  body = {model: model, messages: messages, max_tokens: max_tokens}.merge(options)
87
119
 
88
120
  response = self.class.post(
@@ -92,7 +124,7 @@ module Providers
92
124
  timeout: Anima::Settings.api_timeout
93
125
  )
94
126
 
95
- handle_response(response)
127
+ handle_response(response, include_metrics: include_metrics)
96
128
  rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
97
129
  raise TransientError, "#{network_error.class}: #{network_error.message}"
98
130
  end
@@ -106,7 +138,6 @@ module Providers
106
138
  # @return [Integer] estimated input token count
107
139
  # @raise [Error] on API errors
108
140
  def count_tokens(model:, messages:, **options)
109
- wrap_system_prompt!(options)
110
141
  body = {model: model, messages: messages}.merge(options)
111
142
 
112
143
  response = self.class.post(
@@ -159,16 +190,56 @@ module Providers
159
190
  # Wraps the system parameter in the array-of-blocks format required by
160
191
  # Anthropic for OAuth tokens. The passphrase block is always present;
161
192
  # the caller's prompt (if any) is appended as the second block.
193
+ # The last block is annotated with +cache_control+ so the API caches
194
+ # the entire system prefix (tools are evaluated before system).
162
195
  #
163
196
  # @param options [Hash] mutable options hash (modified in place)
164
197
  # @return [void]
165
198
  def wrap_system_prompt!(options)
166
199
  prompt = options[:system]
167
200
  blocks = [{type: "text", text: OAUTH_PASSPHRASE}]
168
- blocks << {type: "text", text: prompt} if prompt
201
+ blocks << {type: "text", text: prompt, cache_control: {type: "ephemeral"}}
169
202
  options[:system] = blocks
170
203
  end
171
204
 
205
+ # Annotates the last message's last content block with +cache_control+
206
+ # so every subsequent API call in a tool-use loop hits the prefix cache.
207
+ # String content is normalized to array-of-blocks format since bare
208
+ # strings cannot carry +cache_control+ metadata.
209
+ #
210
+ # Clears stale breakpoints from earlier messages to stay within the
211
+ # Anthropic 4-breakpoint limit (tools + system consume 2).
212
+ #
213
+ # @param messages [Array<Hash>] mutable messages array (modified in place)
214
+ # @return [void]
215
+ def annotate_last_message_for_caching!(messages)
216
+ return if messages.empty?
217
+
218
+ clear_stale_cache_breakpoints!(messages[0...-1])
219
+
220
+ last_msg = messages.last
221
+ content = last_msg[:content]
222
+
223
+ case content
224
+ when String
225
+ last_msg[:content] = [{type: "text", text: content, cache_control: {type: "ephemeral"}}]
226
+ when Array
227
+ last_block = content.last
228
+ last_block[:cache_control] = {type: "ephemeral"} if last_block
229
+ end
230
+ end
231
+
232
+ # Removes +cache_control+ from content blocks in the given messages.
233
+ # Called before re-annotating the last message to stay within the
234
+ # Anthropic 4-breakpoint limit across tool-loop rounds.
235
+ def clear_stale_cache_breakpoints!(messages)
236
+ messages.each do |msg|
237
+ content = msg[:content]
238
+ next unless content.is_a?(Array)
239
+ content.each { |block| block.delete(:cache_control) if block.is_a?(Hash) }
240
+ end
241
+ end
242
+
172
243
  def request_headers
173
244
  {
174
245
  "Authorization" => "Bearer #{token}",
@@ -178,10 +249,13 @@ module Providers
178
249
  }
179
250
  end
180
251
 
181
- def handle_response(response)
252
+ def handle_response(response, include_metrics: false)
182
253
  case response.code
183
254
  when 200
184
- response.parsed_response
255
+ body = response.parsed_response
256
+ return body unless include_metrics
257
+
258
+ ApiResponse.new(body: body, api_metrics: extract_api_metrics(response))
185
259
  when 400
186
260
  raise Error, "Bad request: #{error_message(response)}"
187
261
  when 401
@@ -199,6 +273,37 @@ module Providers
199
273
  end
200
274
  end
201
275
 
276
+ # Extracts rate limit headers and usage data from an HTTParty response.
277
+ #
278
+ # @param response [HTTParty::Response] raw API response
279
+ # @return [Hash] with "rate_limits" and "usage" string keys
280
+ def extract_api_metrics(response)
281
+ {
282
+ "rate_limits" => extract_rate_limits(response.headers),
283
+ "usage" => response.parsed_response&.dig("usage")
284
+ }
285
+ end
286
+
287
+ # Extracts rate limit values from response headers.
288
+ #
289
+ # @param headers [Hash] HTTParty headers (case-insensitive)
290
+ # @return [Hash] normalized rate limit data
291
+ def extract_rate_limits(headers)
292
+ return {} unless headers
293
+
294
+ RATE_LIMIT_HEADERS.transform_values do |header_name|
295
+ # HTTParty headers are strings; VCR replays them as arrays
296
+ raw = headers[header_name]
297
+ value = raw.is_a?(Array) ? raw.first : raw
298
+ # Parse numeric values (utilization, reset timestamps)
299
+ case value
300
+ when /\A\d+\z/ then value.to_i
301
+ when /\A\d+\.\d+\z/ then value.to_f
302
+ else value
303
+ end
304
+ end
305
+ end
306
+
202
307
  def error_message(response)
203
308
  response.parsed_response&.dig("error", "message") || response.message
204
309
  rescue JSON::ParserError, NoMethodError
data/lib/shell_session.rb CHANGED
@@ -1,9 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/console"
4
+ require "open3"
5
+ require "pathname"
4
6
  require "pty"
5
7
  require "securerandom"
6
8
  require "shellwords"
9
+ require "uri"
10
+
11
+ # Immutable snapshot of the shell's environment for change detection.
12
+ # Compared between commands to produce natural-language summaries of what
13
+ # changed — the agent discovers its environment through Bash tool responses.
14
+ #
15
+ # @!attribute [r] pwd
16
+ # @return [String, nil] current working directory
17
+ # @!attribute [r] branch
18
+ # @return [String, nil] current git branch name
19
+ # @!attribute [r] repo
20
+ # @return [String, nil] "owner/repo" extracted from git origin remote
21
+ # @!attribute [r] project_files
22
+ # @return [Array<String>] sorted relative paths to project instruction files
23
+ EnvironmentSnapshot = Data.define(:pwd, :branch, :repo, :project_files) do
24
+ # Sentinel for "never detected" — diffs against this produce a full snapshot.
25
+ def self.blank = new(pwd: nil, branch: nil, repo: nil, project_files: [])
26
+ end
7
27
 
8
28
  # Persistent shell session backed by a PTY with FIFO-based stderr separation.
9
29
  # Commands share working directory, environment variables, and shell history
@@ -12,6 +32,11 @@ require "shellwords"
12
32
  # Auto-recovers from timeouts and crashes: if the shell dies, the next command
13
33
  # transparently respawns a fresh shell and restores the working directory.
14
34
  #
35
+ # After each successful command, detects environment changes (CWD, git branch,
36
+ # project files) and includes a natural-language summary in the result hash.
37
+ # This replaces the old EnvironmentProbe system-prompt injection, keeping the
38
+ # system prompt static for prompt caching.
39
+ #
15
40
  # Uses IO.select-based deadlines instead of Timeout.timeout for all PTY reads.
16
41
  # Timeout.timeout is unsafe with PTY I/O — it uses Thread.raise which can
17
42
  # corrupt mutex state, leave resources inconsistent, and cause exceptions
@@ -35,6 +60,7 @@ class ShellSession
35
60
  @alive = false
36
61
  @finalized = false
37
62
  @pwd = nil
63
+ @env_snapshot = nil
38
64
  @read_buffer = +""
39
65
  self.class.cleanup_orphans
40
66
  start
@@ -47,13 +73,17 @@ class ShellSession
47
73
  # @param command [String] bash command to execute
48
74
  # @param timeout [Integer, nil] per-call timeout in seconds; overrides
49
75
  # Settings.command_timeout when provided
76
+ # @param interrupt_check [Proc, nil] callable returning truthy when the
77
+ # user has requested an interrupt. Polled every
78
+ # {Anima::Settings.interrupt_check_interval} seconds during command execution.
50
79
  # @return [Hash] with :stdout, :stderr, :exit_code keys on success
80
+ # @return [Hash] with :interrupted, :stdout, :stderr keys on user interrupt
51
81
  # @return [Hash] with :error key on failure
52
- def run(command, timeout: nil)
82
+ def run(command, timeout: nil, interrupt_check: nil)
53
83
  @mutex.synchronize do
54
84
  return {error: "Shell session is not running"} if @finalized
55
85
  restart unless @alive
56
- execute_in_pty(command, timeout: timeout)
86
+ execute_in_pty(command, timeout: timeout, interrupt_check: interrupt_check)
57
87
  end
58
88
  rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
59
89
  {error: "#{error.class}: #{error.message}"}
@@ -133,6 +163,7 @@ class ShellSession
133
163
  start_stderr_reader
134
164
  init_shell
135
165
  update_pwd
166
+ seed_env_snapshot
136
167
  @alive = true
137
168
  end
138
169
 
@@ -229,7 +260,7 @@ class ShellSession
229
260
  end
230
261
  end
231
262
 
232
- def execute_in_pty(command, timeout: nil)
263
+ def execute_in_pty(command, timeout: nil, interrupt_check: nil)
233
264
  clear_stderr
234
265
  marker = "__ANIMA_#{SecureRandom.hex(8)}__"
235
266
  timeout ||= Anima::Settings.command_timeout
@@ -237,10 +268,21 @@ class ShellSession
237
268
 
238
269
  @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
239
270
 
240
- stdout, exit_code = read_until_marker(marker, deadline: deadline)
271
+ stdout, exit_code = read_until_marker(marker, deadline: deadline, interrupt_check: interrupt_check)
272
+
273
+ if exit_code == :interrupted
274
+ recover_shell
275
+ update_pwd
276
+ stderr = drain_stderr
277
+ return {
278
+ interrupted: true,
279
+ stdout: truncate(stdout),
280
+ stderr: truncate(stderr)
281
+ }
282
+ end
241
283
 
242
284
  if exit_code.nil?
243
- recover_from_timeout
285
+ recover_shell
244
286
  stderr = drain_stderr
245
287
  parts = ["Command timed out after #{timeout} seconds."]
246
288
  parts << "Partial stdout:\n#{truncate(stdout)}" unless stdout.empty?
@@ -248,14 +290,16 @@ class ShellSession
248
290
  return {error: parts.join("\n\n")}
249
291
  end
250
292
 
251
- update_pwd
293
+ env_summary = update_environment
252
294
  stderr = drain_stderr
253
295
 
254
- {
296
+ result = {
255
297
  stdout: truncate(stdout),
256
298
  stderr: truncate(stderr),
257
299
  exit_code: exit_code
258
300
  }
301
+ result[:env_summary] = env_summary if env_summary
302
+ result
259
303
  rescue Errno::EIO, IOError
260
304
  @alive = false
261
305
  {error: "Shell session terminated unexpectedly"}
@@ -267,14 +311,23 @@ class ShellSession
267
311
  #
268
312
  # @param marker [String] unique marker to detect command completion
269
313
  # @param deadline [Float] monotonic clock deadline
314
+ # @param interrupt_check [Proc, nil] callable returning truthy on user interrupt
270
315
  # @return [Array(String, Integer)] stdout and exit code on success
316
+ # @return [Array(String, Symbol)] partial stdout and +:interrupted+ on user interrupt
271
317
  # @return [Array(String, nil)] partial stdout and nil exit code on timeout
272
- def read_until_marker(marker, deadline:)
318
+ def read_until_marker(marker, deadline:, interrupt_check: nil)
273
319
  lines = []
274
320
  exit_code = nil
321
+ check_interval = interrupt_check ? [Anima::Settings.interrupt_check_interval, 0.5].max : nil
275
322
 
276
323
  loop do
277
- line = gets_with_deadline(deadline)
324
+ line = gets_with_deadline(deadline, interrupt_check: interrupt_check, check_interval: check_interval)
325
+
326
+ if line == :interrupted
327
+ exit_code = :interrupted
328
+ break
329
+ end
330
+
278
331
  break if line.nil?
279
332
 
280
333
  line = line.chomp.delete("\r")
@@ -315,12 +368,21 @@ class ShellSession
315
368
  # Timeout.timeout (which uses Thread.raise that can corrupt mutex state
316
369
  # and leave resources inconsistent).
317
370
  #
371
+ # When +interrupt_check+ is provided, IO.select uses a shorter timeout
372
+ # (capped at {Anima::Settings.interrupt_check_interval}) and polls the
373
+ # callback between iterations. Returns +:interrupted+ when the callback
374
+ # fires, allowing the caller to send Ctrl+C and return partial output.
375
+ #
318
376
  # @param deadline [Float] monotonic clock deadline
377
+ # @param interrupt_check [Proc, nil] callable returning truthy on user interrupt
378
+ # @param check_interval [Float, nil] resolved interrupt check interval (seconds);
379
+ # pre-computed by the caller to avoid re-reading Settings on every line
319
380
  # @return [String] line including trailing newline
381
+ # @return [:interrupted] when user interrupt detected
320
382
  # @return [nil] if deadline expired
321
383
  # @raise [Errno::EIO] when the PTY child process exits (Linux)
322
384
  # @raise [IOError] when the PTY file descriptor is closed
323
- def gets_with_deadline(deadline)
385
+ def gets_with_deadline(deadline, interrupt_check: nil, check_interval: nil)
324
386
  loop do
325
387
  if (idx = @read_buffer.index("\n"))
326
388
  return @read_buffer.slice!(0..idx)
@@ -329,24 +391,29 @@ class ShellSession
329
391
  remaining = deadline - monotonic_now
330
392
  return nil if remaining <= 0
331
393
 
332
- ready = IO.select([@pty_stdout], nil, nil, remaining)
333
- return nil unless ready
394
+ select_timeout = check_interval ? [remaining, check_interval].min : remaining
334
395
 
335
- begin
336
- @read_buffer << @pty_stdout.read_nonblock(4096)
337
- rescue IO::WaitReadable
338
- # Spurious wakeup from IO.select — retry
396
+ ready = IO.select([@pty_stdout], nil, nil, select_timeout)
397
+
398
+ if ready
399
+ begin
400
+ @read_buffer << @pty_stdout.read_nonblock(4096)
401
+ rescue IO::WaitReadable
402
+ # Spurious wakeup from IO.select — retry
403
+ end
339
404
  end
405
+
406
+ return :interrupted if interrupt_check&.call
340
407
  end
341
408
  end
342
409
 
343
- # Sends Ctrl+C to interrupt the running command and drains leftover output.
410
+ # Sends Ctrl+C and drains leftover output after a timeout or user interrupt.
344
411
  # If recovery fails, marks the session as dead (will be respawned on next run).
345
412
  #
346
413
  # @return [void]
347
414
  # @raise [Errno::EIO] when the PTY child process has exited
348
415
  # @raise [IOError] when the PTY file descriptor is closed
349
- def recover_from_timeout
416
+ def recover_shell
350
417
  @pty_stdin.write("\x03")
351
418
  sleep 0.1
352
419
  marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
@@ -379,6 +446,33 @@ class ShellSession
379
446
  end
380
447
  end
381
448
 
449
+ # Captures the initial environment snapshot so the first real Bash call
450
+ # can diff against the actual shell state rather than a blank sentinel
451
+ # whose nil pwd would always trigger a "location changed" report.
452
+ #
453
+ # Sets {#env_snapshot} to a real snapshot of the current pwd, git branch,
454
+ # repo, and project files. Called within {#start} after {#update_pwd}
455
+ # and before the session is marked alive.
456
+ #
457
+ # @return [void]
458
+ def seed_env_snapshot
459
+ @env_snapshot = take_env_snapshot(EnvironmentSnapshot.blank)
460
+ end
461
+
462
+ # Snapshots the shell's environment and returns a natural-language summary
463
+ # of what changed since the last snapshot. The agent discovers its
464
+ # environment through these summaries in Bash tool responses.
465
+ #
466
+ # Each call only mentions what changed. Returns nil when nothing did.
467
+ #
468
+ # @return [String, nil] human-readable summary of environment changes
469
+ def update_environment
470
+ update_pwd
471
+ previous = @env_snapshot || EnvironmentSnapshot.blank
472
+ @env_snapshot = take_env_snapshot(previous)
473
+ describe_env_changes(previous, @env_snapshot)
474
+ end
475
+
382
476
  # Reads the shell's current working directory via the /proc filesystem.
383
477
  # @note Linux-only. Falls back silently on other platforms or if the
384
478
  # process has exited.
@@ -388,6 +482,133 @@ class ShellSession
388
482
  # Process exited or no access — @pwd retains its previous value
389
483
  end
390
484
 
485
+ # Captures the current environment as an immutable snapshot.
486
+ # Re-detects git state on every call (branch can change without cd).
487
+ # Re-scans project files only when the working directory changed.
488
+ #
489
+ # @param previous [EnvironmentSnapshot] the last known snapshot
490
+ # @return [EnvironmentSnapshot]
491
+ def take_env_snapshot(previous)
492
+ branch, repo = detect_git
493
+ files = (@pwd != previous.pwd) ? scan_project_files : previous.project_files
494
+
495
+ EnvironmentSnapshot.new(pwd: @pwd, branch: branch, repo: repo, project_files: files)
496
+ end
497
+
498
+ # Detects git branch and repo name for the current working directory.
499
+ #
500
+ # @return [Array(String, String)] branch and repo name
501
+ # @return [Array(nil, nil)] when not inside a git repository
502
+ def detect_git
503
+ return [nil, nil] unless @pwd
504
+
505
+ _, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree", err: File::NULL)
506
+ return [nil, nil] unless status.success?
507
+
508
+ branch = detect_git_branch
509
+ repo = detect_git_repo
510
+ [branch, repo]
511
+ rescue Errno::ENOENT
512
+ [nil, nil]
513
+ end
514
+
515
+ # @return [String, nil] current branch name
516
+ def detect_git_branch
517
+ output, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD", err: File::NULL)
518
+ output.strip.presence
519
+ end
520
+
521
+ # @return [String, nil] "owner/repo" extracted from the origin remote
522
+ def detect_git_repo
523
+ output, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin", err: File::NULL)
524
+ remote = output.strip
525
+ return unless remote.present?
526
+
527
+ extract_repo_name(remote)
528
+ end
529
+
530
+ # Scans for well-known project files in the current working directory.
531
+ #
532
+ # @return [Array<String>] sorted relative paths
533
+ def scan_project_files
534
+ return [] unless @pwd
535
+
536
+ base = Pathname.new(@pwd)
537
+ whitelist = Anima::Settings.project_files_whitelist
538
+ max_depth = Anima::Settings.project_files_max_depth
539
+
540
+ patterns = whitelist.product((0..max_depth).to_a).map do |filename, depth|
541
+ File.join(@pwd, Array.new(depth, "*"), filename)
542
+ end
543
+
544
+ patterns.flat_map { |pattern| Dir.glob(pattern) }
545
+ .map { |path| Pathname.new(path).relative_path_from(base).to_s }
546
+ .sort
547
+ .uniq
548
+ end
549
+
550
+ # Extracts owner/repo from a Git remote URL (SSH or HTTPS).
551
+ #
552
+ # @param remote_url [String] SSH or HTTPS remote URL
553
+ # @return [String] "owner/repo" path
554
+ def extract_repo_name(remote_url)
555
+ path = if remote_url.match?(%r{\A\w+://})
556
+ URI.parse(remote_url).path
557
+ else
558
+ remote_url.split(":").last
559
+ end
560
+ path.delete_prefix("/").delete_suffix(".git")
561
+ rescue URI::InvalidURIError
562
+ remote_url
563
+ end
564
+
565
+ # ─── Environment change description ──────────────────────────────
566
+
567
+ # Builds a natural-language summary describing what changed between two
568
+ # environment snapshots. Returns nil when nothing changed.
569
+ #
570
+ # @param old_snap [EnvironmentSnapshot]
571
+ # @param new_snap [EnvironmentSnapshot]
572
+ # @return [String, nil]
573
+ def describe_env_changes(old_snap, new_snap)
574
+ parts = []
575
+ parts << describe_location_change(old_snap, new_snap)
576
+ parts << describe_project_files(old_snap, new_snap)
577
+ parts.compact!
578
+ parts.empty? ? nil : parts.join("\n")
579
+ end
580
+
581
+ # @return [String, nil] location/branch change line
582
+ def describe_location_change(old_snap, new_snap)
583
+ if new_snap.pwd != old_snap.pwd
584
+ format_full_location(new_snap)
585
+ elsif new_snap.branch != old_snap.branch && new_snap.branch
586
+ "Branch changed to #{new_snap.branch}."
587
+ end
588
+ end
589
+
590
+ # @return [String, nil] project files line
591
+ def describe_project_files(old_snap, new_snap)
592
+ return unless new_snap.project_files.any?
593
+ return unless new_snap.pwd != old_snap.pwd || new_snap.project_files != old_snap.project_files
594
+
595
+ "Project has instructions in #{new_snap.project_files.join(", ")}."
596
+ end
597
+
598
+ # Formats the full location line for display in tool responses.
599
+ #
600
+ # @param snap [EnvironmentSnapshot]
601
+ # @return [String]
602
+ def format_full_location(snap)
603
+ parts = ["You are now in #{snap.pwd}"]
604
+ if snap.repo && snap.branch
605
+ parts << ", git repo #{snap.repo} on branch #{snap.branch}"
606
+ elsif snap.branch
607
+ parts << " on branch #{snap.branch}"
608
+ end
609
+ parts.join + "."
610
+ end
611
+
391
612
  def truncate(output)
392
613
  max_bytes = @max_output_bytes
393
614
  output = output.dup.force_encoding("UTF-8").scrub
data/lib/tools/base.rb CHANGED
@@ -41,8 +41,30 @@ module Tools
41
41
  def schema
42
42
  {name: tool_name, description: description, input_schema: input_schema}
43
43
  end
44
+
45
+ # Per-tool character threshold for response truncation.
46
+ # Override in subclasses to use a custom limit, or return +nil+
47
+ # to skip truncation entirely (e.g. read_file tool has its own pagination).
48
+ #
49
+ # @return [Integer, nil] character threshold, or nil to skip truncation
50
+ def truncation_threshold
51
+ Anima::Settings.max_tool_response_chars
52
+ end
44
53
  end
45
54
 
55
+ # Subclasses whose schema depends on runtime context (e.g. session state,
56
+ # shell working directory) can implement +#dynamic_schema+. The registry
57
+ # calls it instead of the class-level {.schema} when present.
58
+ #
59
+ # @example
60
+ # def dynamic_schema
61
+ # schema = self.class.schema.deep_dup
62
+ # schema[:description] = "Dynamic: #{@some_state}"
63
+ # schema
64
+ # end
65
+ #
66
+ # @see Think#dynamic_schema Budget-based maxLength
67
+
46
68
  # Accepts and discards context keywords so that the Registry can pass
47
69
  # shared dependencies (e.g. shell_session) to any tool uniformly.
48
70
  # Subclasses that need specific context should override with named kwargs.