anima-core 0.0.1 → 0.2.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -0
  3. data/CHANGELOG.md +36 -0
  4. data/Gemfile +17 -0
  5. data/Procfile +2 -0
  6. data/Procfile.dev +2 -0
  7. data/README.md +167 -22
  8. data/Rakefile +20 -5
  9. data/anima-core.gemspec +40 -0
  10. data/app/channels/application_cable/channel.rb +6 -0
  11. data/app/channels/application_cable/connection.rb +6 -0
  12. data/app/channels/session_channel.rb +126 -0
  13. data/app/controllers/api/sessions_controller.rb +25 -0
  14. data/app/controllers/application_controller.rb +4 -0
  15. data/app/jobs/agent_request_job.rb +59 -0
  16. data/app/jobs/application_job.rb +4 -0
  17. data/app/jobs/count_event_tokens_job.rb +28 -0
  18. data/app/models/application_record.rb +5 -0
  19. data/app/models/event.rb +64 -0
  20. data/app/models/session.rb +114 -0
  21. data/bin/jobs +6 -0
  22. data/bin/rails +6 -0
  23. data/bin/rake +6 -0
  24. data/config/application.rb +35 -0
  25. data/config/boot.rb +8 -0
  26. data/config/cable.yml +14 -0
  27. data/config/database.yml +45 -0
  28. data/config/environment.rb +5 -0
  29. data/config/environments/development.rb +8 -0
  30. data/config/environments/production.rb +8 -0
  31. data/config/environments/test.rb +9 -0
  32. data/config/initializers/event_subscribers.rb +11 -0
  33. data/config/initializers/inflections.rb +9 -0
  34. data/config/puma.rb +13 -0
  35. data/config/queue.yml +18 -0
  36. data/config/recurring.yml +15 -0
  37. data/config/routes.rb +12 -0
  38. data/config.ru +5 -0
  39. data/db/cable_schema.rb +11 -0
  40. data/db/migrate/.keep +0 -0
  41. data/db/migrate/20260308124202_create_sessions.rb +9 -0
  42. data/db/migrate/20260308124203_create_events.rb +18 -0
  43. data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
  44. data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
  45. data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
  46. data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
  47. data/db/queue_schema.rb +141 -0
  48. data/db/seeds.rb +1 -0
  49. data/exe/anima +6 -0
  50. data/lib/agent_loop.rb +97 -0
  51. data/lib/anima/cli.rb +110 -0
  52. data/lib/anima/installer.rb +119 -0
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +5 -0
  55. data/lib/events/agent_message.rb +11 -0
  56. data/lib/events/base.rb +38 -0
  57. data/lib/events/bus.rb +39 -0
  58. data/lib/events/subscriber.rb +26 -0
  59. data/lib/events/subscribers/action_cable_bridge.rb +35 -0
  60. data/lib/events/subscribers/message_collector.rb +64 -0
  61. data/lib/events/subscribers/persister.rb +56 -0
  62. data/lib/events/system_message.rb +11 -0
  63. data/lib/events/tool_call.rb +29 -0
  64. data/lib/events/tool_response.rb +33 -0
  65. data/lib/events/user_message.rb +11 -0
  66. data/lib/llm/client.rb +161 -0
  67. data/lib/providers/anthropic.rb +173 -0
  68. data/lib/shell_session.rb +333 -0
  69. data/lib/tools/base.rb +58 -0
  70. data/lib/tools/bash.rb +53 -0
  71. data/lib/tools/registry.rb +60 -0
  72. data/lib/tools/web_get.rb +62 -0
  73. data/lib/tui/app.rb +239 -0
  74. data/lib/tui/cable_client.rb +377 -0
  75. data/lib/tui/message_store.rb +49 -0
  76. data/lib/tui/screens/anthropic.rb +25 -0
  77. data/lib/tui/screens/chat.rb +321 -0
  78. data/lib/tui/screens/settings.rb +52 -0
  79. metadata +203 -6
  80. data/BRAINSTORM.md +0 -466
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "pty"
5
+ require "securerandom"
6
+ require "timeout"
7
+
8
+ # Persistent shell session backed by a PTY with FIFO-based stderr separation.
9
+ # Commands share working directory, environment variables, and shell history
10
+ # within a conversation. Multiple tools share the same session.
11
+ #
12
+ # @example
13
+ # session = ShellSession.new(session_id: 42)
14
+ # session.run("cd /tmp")
15
+ # session.run("pwd")
16
+ # # => {stdout: "/tmp", stderr: "", exit_code: 0}
17
+ # session.finalize
18
+ class ShellSession
19
+ COMMAND_TIMEOUT = 30
20
+ MAX_OUTPUT_BYTES = 100_000
21
+
22
+ # @return [String, nil] current working directory of the shell process
23
+ attr_reader :pwd
24
+
25
+ # @param session_id [Integer, String] unique identifier for logging/diagnostics
26
+ def initialize(session_id:)
27
+ @session_id = session_id
28
+ @mutex = Mutex.new
29
+ @fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
30
+ @alive = false
31
+ @pwd = nil
32
+ self.class.cleanup_orphans
33
+ start
34
+ self.class.register(self)
35
+ end
36
+
37
+ # Execute a command in the persistent shell.
38
+ #
39
+ # @param command [String] bash command to execute
40
+ # @return [Hash] with :stdout, :stderr, :exit_code keys on success
41
+ # @return [Hash] with :error key on failure
42
+ def run(command)
43
+ @mutex.synchronize do
44
+ return {error: "Shell session is not running"} unless @alive
45
+ execute_in_pty(command)
46
+ end
47
+ end
48
+
49
+ # Clean up PTY, FIFO, and child process.
50
+ def finalize
51
+ @mutex.synchronize { shutdown }
52
+ self.class.unregister(self)
53
+ end
54
+
55
+ # @return [Boolean] whether the shell process is still running
56
+ def alive?
57
+ @mutex.synchronize { @alive }
58
+ end
59
+
60
+ # --- Class-level session tracking for at_exit cleanup ---
61
+
62
+ @sessions = []
63
+ @sessions_mutex = Mutex.new
64
+
65
+ class << self
66
+ # @api private
67
+ def register(session)
68
+ @sessions_mutex.synchronize { @sessions << session }
69
+ end
70
+
71
+ # @api private
72
+ def unregister(session)
73
+ @sessions_mutex.synchronize { @sessions.delete(session) }
74
+ end
75
+
76
+ # Finalize all live sessions. Called automatically via at_exit.
77
+ def cleanup_all
78
+ @sessions_mutex.synchronize do
79
+ @sessions.each { |session| session.send(:shutdown) }
80
+ @sessions.clear
81
+ end
82
+ end
83
+
84
+ # Remove stale FIFO files left by crashed processes.
85
+ # FIFO naming format: anima-stderr-{pid}-{hex}
86
+ def cleanup_orphans
87
+ Dir.glob(File.join(Dir.tmpdir, "anima-stderr-*")).each do |path|
88
+ match = File.basename(path).match(/\Aanima-stderr-(\d+)-/)
89
+ next unless match
90
+
91
+ pid = match[1].to_i
92
+ next if pid <= 0
93
+
94
+ begin
95
+ Process.kill(0, pid)
96
+ rescue Errno::ESRCH
97
+ begin
98
+ File.delete(path)
99
+ rescue SystemCallError
100
+ # Best-effort cleanup
101
+ end
102
+ rescue Errno::EPERM
103
+ # Process exists but we can't signal it — leave it
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ at_exit { ShellSession.cleanup_all }
110
+
111
+ private
112
+
113
+ def start
114
+ create_fifo
115
+ spawn_shell
116
+ start_stderr_reader
117
+ init_shell
118
+ update_pwd
119
+ @alive = true
120
+ end
121
+
122
+ def create_fifo
123
+ File.mkfifo(@fifo_path)
124
+ rescue Errno::EEXIST
125
+ # FIFO already exists — reuse it
126
+ end
127
+
128
+ def spawn_shell
129
+ @pty_stdout, @pty_stdin, @pid = PTY.spawn(
130
+ {"TERM" => "dumb"},
131
+ "bash", "--norc", "--noprofile"
132
+ )
133
+ # Disable terminal echo via termios before bash can echo our commands.
134
+ # This is instant (kernel-level), unlike stty -echo which races with input.
135
+ @pty_stdin.echo = false
136
+ end
137
+
138
+ def start_stderr_reader
139
+ @stderr_mutex = Mutex.new
140
+ @stderr_buffer = []
141
+ @stderr_bytes = 0
142
+ @stderr_truncated = false
143
+ @stderr_thread = Thread.new do
144
+ File.open(@fifo_path, "r") do |fifo|
145
+ while (line = fifo.gets)
146
+ cleaned = line.chomp.delete("\r")
147
+ @stderr_mutex.synchronize do
148
+ if @stderr_bytes < MAX_OUTPUT_BYTES
149
+ @stderr_buffer << cleaned
150
+ @stderr_bytes += cleaned.bytesize
151
+ else
152
+ @stderr_truncated = true
153
+ end
154
+ end
155
+ end
156
+ end
157
+ rescue Errno::ENOENT, IOError
158
+ # FIFO was cleaned up or closed
159
+ end
160
+ end
161
+
162
+ # With echo already off (set in spawn_shell), only command output appears.
163
+ # The initial bash prompt merges with the marker output on one gets line.
164
+ def init_shell
165
+ marker = "__ANIMA_INIT_#{SecureRandom.hex(8)}__"
166
+ @pty_stdin.puts "PS1=''"
167
+ @pty_stdin.puts "exec 2>#{@fifo_path}"
168
+ @pty_stdin.puts "echo '#{marker}'"
169
+ consume_until(marker)
170
+ end
171
+
172
+ def execute_in_pty(command)
173
+ clear_stderr
174
+ marker = "__ANIMA_#{SecureRandom.hex(8)}__"
175
+
176
+ Timeout.timeout(COMMAND_TIMEOUT) do
177
+ # All on one line: run command, capture exit code, ensure newline
178
+ # before marker so output without trailing newline doesn't merge.
179
+ @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
180
+
181
+ stdout, exit_code = read_until_marker(marker)
182
+ update_pwd
183
+ stderr = drain_stderr
184
+
185
+ {
186
+ stdout: truncate(stdout),
187
+ stderr: truncate(stderr),
188
+ exit_code: exit_code
189
+ }
190
+ end
191
+ rescue Timeout::Error
192
+ recover_from_timeout
193
+ {error: "Command timed out after #{COMMAND_TIMEOUT} seconds"}
194
+ rescue Errno::EIO
195
+ @alive = false
196
+ {error: "Shell session terminated unexpectedly"}
197
+ rescue => error
198
+ {error: "#{error.class}: #{error.message}"}
199
+ end
200
+
201
+ def read_until_marker(marker)
202
+ lines = []
203
+ exit_code = nil
204
+
205
+ loop do
206
+ line = @pty_stdout.gets
207
+ break if line.nil?
208
+
209
+ line = line.chomp.delete("\r")
210
+
211
+ if line.include?(marker)
212
+ exit_code = line.split.last.to_i
213
+ break
214
+ end
215
+
216
+ lines << line
217
+ end
218
+
219
+ # Strip trailing empty line added by our separator echo
220
+ lines.pop if lines.last == ""
221
+
222
+ [lines.join("\n"), exit_code || -1]
223
+ end
224
+
225
+ def consume_until(marker)
226
+ loop do
227
+ line = @pty_stdout.gets
228
+ break if line.nil?
229
+ break if line.chomp.delete("\r").include?(marker)
230
+ end
231
+ end
232
+
233
+ # Sends Ctrl+C to interrupt the running command and drains leftover output.
234
+ # If recovery fails, marks the session as dead.
235
+ def recover_from_timeout
236
+ @pty_stdin.write("\x03")
237
+ sleep 0.1
238
+ marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
239
+ @pty_stdin.puts "echo '#{marker}'"
240
+ Timeout.timeout(3) { consume_until(marker) }
241
+ rescue Timeout::Error, Errno::EIO, IOError
242
+ @alive = false
243
+ end
244
+
245
+ def clear_stderr
246
+ @stderr_mutex.synchronize do
247
+ @stderr_buffer.clear
248
+ @stderr_bytes = 0
249
+ @stderr_truncated = false
250
+ end
251
+ end
252
+
253
+ def drain_stderr
254
+ # Allow FIFO reader thread time to flush kernel buffers into @stderr_buffer.
255
+ # Without this, stderr arriving just before the marker may be missed.
256
+ sleep 0.01
257
+ @stderr_mutex.synchronize do
258
+ result = @stderr_buffer.join("\n")
259
+ truncated = @stderr_truncated
260
+ @stderr_buffer.clear
261
+ @stderr_bytes = 0
262
+ @stderr_truncated = false
263
+ truncated ? result + "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]" : result
264
+ end
265
+ end
266
+
267
+ # Reads the shell's current working directory via the /proc filesystem.
268
+ # @note Linux-only. Falls back silently on other platforms or if the
269
+ # process has exited.
270
+ def update_pwd
271
+ @pwd = File.readlink("/proc/#{@pid}/cwd")
272
+ rescue Errno::ENOENT, Errno::EACCES
273
+ # Process exited or no access — @pwd retains its previous value
274
+ end
275
+
276
+ def truncate(output)
277
+ return output if output.bytesize <= MAX_OUTPUT_BYTES
278
+
279
+ output.byteslice(0, MAX_OUTPUT_BYTES)
280
+ .force_encoding("UTF-8")
281
+ .scrub +
282
+ "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]"
283
+ end
284
+
285
+ def shutdown
286
+ return unless @alive
287
+ @alive = false
288
+
289
+ begin
290
+ pgid = Process.getpgid(@pid)
291
+ Process.kill("TERM", -pgid)
292
+ rescue Errno::ESRCH, Errno::EPERM
293
+ # Process group already gone
294
+ end
295
+
296
+ begin
297
+ @pty_stdin&.close
298
+ rescue IOError
299
+ # Already closed
300
+ end
301
+
302
+ begin
303
+ @pty_stdout&.close
304
+ rescue IOError
305
+ # Already closed
306
+ end
307
+
308
+ begin
309
+ @stderr_thread&.kill
310
+ rescue ThreadError
311
+ # Thread already dead
312
+ end
313
+
314
+ File.delete(@fifo_path) if File.exist?(@fifo_path)
315
+
316
+ begin
317
+ # Non-blocking reap with SIGKILL fallback if process doesn't exit in time
318
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
319
+ loop do
320
+ _, status = Process.wait2(@pid, Process::WNOHANG)
321
+ break if status
322
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
323
+ Process.kill("KILL", @pid)
324
+ Process.wait(@pid)
325
+ break
326
+ end
327
+ sleep 0.05
328
+ end
329
+ rescue Errno::ECHILD, Errno::ESRCH
330
+ # Already reaped
331
+ end
332
+ end
333
+ end
data/lib/tools/base.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Abstract base class for all Anima tools. Subclasses must implement
5
+ # the class-level schema methods and the instance-level {#execute} method.
6
+ #
7
+ # @abstract Subclass and implement {.tool_name}, {.description},
8
+ # {.input_schema}, and {#execute}
9
+ #
10
+ # @example Implementing a tool
11
+ # class Tools::Echo < Tools::Base
12
+ # def self.tool_name = "echo"
13
+ # def self.description = "Echoes input back"
14
+ # def self.input_schema
15
+ # {type: "object", properties: {text: {type: "string"}}, required: ["text"]}
16
+ # end
17
+ #
18
+ # def execute(input)
19
+ # input["text"]
20
+ # end
21
+ # end
22
+ class Base
23
+ class << self
24
+ # @return [String] unique tool identifier sent to the LLM
25
+ def tool_name
26
+ raise NotImplementedError, "#{self} must implement .tool_name"
27
+ end
28
+
29
+ # @return [String] human-readable description for the LLM
30
+ def description
31
+ raise NotImplementedError, "#{self} must implement .description"
32
+ end
33
+
34
+ # @return [Hash] JSON Schema describing the tool's input parameters
35
+ def input_schema
36
+ raise NotImplementedError, "#{self} must implement .input_schema"
37
+ end
38
+
39
+ # Builds the schema hash expected by the Anthropic tools API.
40
+ # @return [Hash] with :name, :description, and :input_schema keys
41
+ def schema
42
+ {name: tool_name, description: description, input_schema: input_schema}
43
+ end
44
+ end
45
+
46
+ # Accepts and discards context keywords so that the Registry can pass
47
+ # shared dependencies (e.g. shell_session) to any tool uniformly.
48
+ # Subclasses that need specific context should override with named kwargs.
49
+ def initialize(**) = nil
50
+
51
+ # Execute the tool with the given input.
52
+ # @param input [Hash] parsed input matching {.input_schema}
53
+ # @return [String, Hash] result content; Hash with :error key signals failure
54
+ def execute(input)
55
+ raise NotImplementedError, "#{self.class} must implement #execute"
56
+ end
57
+ end
58
+ end
data/lib/tools/bash.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Executes bash commands in a persistent {ShellSession}. Commands share
5
+ # working directory, environment variables, and shell history within a
6
+ # conversation. Output is truncated and timeouts are enforced by the
7
+ # underlying session.
8
+ #
9
+ # @see ShellSession#run
10
+ class Bash < Base
11
+ def self.tool_name = "bash"
12
+
13
+ def self.description = "Execute a bash command. Working directory and environment persist across calls within a conversation."
14
+
15
+ def self.input_schema
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ command: {type: "string", description: "The bash command to execute"}
20
+ },
21
+ required: ["command"]
22
+ }
23
+ end
24
+
25
+ # @param shell_session [ShellSession] persistent shell backing this tool
26
+ def initialize(shell_session:, **)
27
+ @shell_session = shell_session
28
+ end
29
+
30
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
31
+ # @return [String] formatted output with stdout, stderr, and exit code
32
+ # @return [Hash] with :error key on failure
33
+ def execute(input)
34
+ command = input["command"].to_s
35
+ return {error: "Command cannot be blank"} if command.strip.empty?
36
+
37
+ result = @shell_session.run(command)
38
+ return result if result.key?(:error)
39
+
40
+ format_result(result[:stdout], result[:stderr], result[:exit_code])
41
+ end
42
+
43
+ private
44
+
45
+ def format_result(stdout, stderr, exit_code)
46
+ parts = []
47
+ parts << "stdout:\n#{stdout}" unless stdout.empty?
48
+ parts << "stderr:\n#{stderr}" unless stderr.empty?
49
+ parts << "exit_code: #{exit_code}"
50
+ parts.join("\n\n")
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ class UnknownToolError < StandardError; end
5
+
6
+ # Manages tool registration, schema export, and dispatch.
7
+ # Tools are registered by class and looked up by name at execution time.
8
+ # An optional context hash is passed to each tool's constructor, allowing
9
+ # shared dependencies (e.g. a {ShellSession}) to reach tools that need them.
10
+ #
11
+ # @example
12
+ # registry = Tools::Registry.new(context: {shell_session: my_shell})
13
+ # registry.register(Tools::Bash)
14
+ # registry.execute("bash", {"command" => "ls"})
15
+ class Registry
16
+ # @return [Hash{String => Class}] registered tool classes keyed by name
17
+ attr_reader :tools
18
+
19
+ # @param context [Hash] keyword arguments forwarded to every tool constructor
20
+ def initialize(context: {})
21
+ @tools = {}
22
+ @context = context
23
+ end
24
+
25
+ # Register a tool class. The class must respond to .tool_name.
26
+ # @param tool_class [Class<Tools::Base>] the tool class to register
27
+ # @return [void]
28
+ def register(tool_class)
29
+ @tools[tool_class.tool_name] = tool_class
30
+ end
31
+
32
+ # @return [Array<Hash>] schema array for the Anthropic tools API parameter
33
+ def schemas
34
+ @tools.values.map(&:schema)
35
+ end
36
+
37
+ # Instantiate and execute a tool by name. The registry's context is
38
+ # forwarded to the tool constructor as keyword arguments.
39
+ #
40
+ # @param name [String] registered tool name
41
+ # @param input [Hash] tool input parameters
42
+ # @return [String, Hash] tool execution result
43
+ # @raise [UnknownToolError] if no tool is registered with the given name
44
+ def execute(name, input)
45
+ tool_class = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
46
+ tool_class.new(**@context).execute(input)
47
+ end
48
+
49
+ # @param name [String] tool name to check
50
+ # @return [Boolean] whether a tool with the given name is registered
51
+ def registered?(name)
52
+ @tools.key?(name)
53
+ end
54
+
55
+ # @return [Boolean] whether any tools are registered
56
+ def any?
57
+ @tools.any?
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+
5
+ module Tools
6
+ # Fetches content from a URL via HTTP GET. Returns the response body
7
+ # as plain text, truncated to {MAX_RESPONSE_BYTES} to prevent memory issues.
8
+ #
9
+ # Only http and https schemes are allowed.
10
+ class WebGet < Base
11
+ MAX_RESPONSE_BYTES = 100_000
12
+ REQUEST_TIMEOUT = 10
13
+
14
+ def self.tool_name = "web_get"
15
+
16
+ def self.description = "Fetch content from a URL via HTTP GET and return the response body"
17
+
18
+ def self.input_schema
19
+ {
20
+ type: "object",
21
+ properties: {
22
+ url: {type: "string", description: "The URL to fetch (http or https)"}
23
+ },
24
+ required: ["url"]
25
+ }
26
+ end
27
+
28
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
29
+ # @return [String] response body (possibly truncated)
30
+ # @return [Hash] with :error key on failure
31
+ def execute(input)
32
+ validate_and_fetch(input["url"].to_s)
33
+ end
34
+
35
+ private
36
+
37
+ def validate_and_fetch(url)
38
+ scheme = URI.parse(url).scheme
39
+
40
+ unless %w[http https].include?(scheme)
41
+ return {error: "Only http and https URLs are supported, got: #{scheme.inspect}"}
42
+ end
43
+
44
+ truncate_body(HTTParty.get(url, timeout: REQUEST_TIMEOUT, follow_redirects: false).body.to_s)
45
+ rescue URI::InvalidURIError => error
46
+ {error: "Invalid URL: #{error.message}"}
47
+ rescue Net::OpenTimeout, Net::ReadTimeout
48
+ {error: "Request timed out after #{REQUEST_TIMEOUT} seconds"}
49
+ rescue Errno::ECONNREFUSED
50
+ {error: "Connection refused: #{url}"}
51
+ rescue => error
52
+ {error: "#{error.class}: #{error.message}"}
53
+ end
54
+
55
+ def truncate_body(body)
56
+ return body if body.bytesize <= MAX_RESPONSE_BYTES
57
+
58
+ body.byteslice(0, MAX_RESPONSE_BYTES) +
59
+ "\n\n[Truncated: response exceeded #{MAX_RESPONSE_BYTES} bytes]"
60
+ end
61
+ end
62
+ end