anima-core 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +14 -8
- data/README.md +96 -23
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +7 -7
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +15 -6
- data/app/jobs/passive_recall_job.rb +6 -11
- data/app/models/concerns/message/broadcasting.rb +1 -0
- data/app/models/goal.rb +14 -0
- data/app/models/message.rb +13 -31
- data/app/models/pending_message.rb +191 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +480 -271
- data/bin/inspect-cassette +144 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/database.yml +1 -0
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +120 -0
- data/lib/agent_loop.rb +53 -51
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +19 -6
- data/lib/analytical_brain/tools/activate_skill.rb +2 -2
- data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
- data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
- data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
- data/lib/analytical_brain/tools/finish_goal.rb +3 -0
- data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
- data/lib/analytical_brain/tools/read_workflow.rb +2 -2
- data/lib/analytical_brain/tools/set_goal.rb +5 -1
- data/lib/analytical_brain/tools/update_goal.rb +5 -1
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +20 -1
- data/lib/anima/settings.rb +37 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/agent_message.rb +14 -0
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +12 -18
- data/lib/events/subscribers/subagent_message_router.rb +18 -9
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +91 -50
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +9 -5
- data/lib/mneme/passive_recall.rb +85 -16
- data/lib/mneme/runner.rb +15 -4
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +239 -18
- data/lib/tools/base.rb +22 -0
- data/lib/tools/bash.rb +61 -7
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +85 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +41 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +11 -8
- data/lib/tools/spawn_subagent.rb +19 -13
- data/lib/tools/subagent_prompts.rb +41 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +545 -137
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +13 -20
- data/lib/tui/decorators/base_decorator.rb +40 -11
- data/lib/tui/decorators/bash_decorator.rb +3 -3
- data/lib/tui/decorators/edit_decorator.rb +7 -4
- data/lib/tui/decorators/read_decorator.rb +6 -8
- data/lib/tui/decorators/think_decorator.rb +4 -6
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +7 -4
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +33 -0
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +159 -27
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +302 -103
- data/lib/tui/settings.rb +86 -0
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +30 -1
- data/templates/tui.toml +209 -0
- metadata +24 -3
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/environment_probe.rb +0 -232
data/lib/providers/anthropic.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
return nil unless ready
|
|
394
|
+
select_timeout = check_interval ? [remaining, check_interval].min : remaining
|
|
334
395
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
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
|
|
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.
|