anima-core 1.1.1 → 1.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 161b7dfde73fa61f7656427c0af3c5919e1a1cc50d6b1a2d201f5a45ce79c561
4
- data.tar.gz: 880538d54fbc682b61bbeb6d63b38aa066e86327c778fefce8e60091c6ad816d
3
+ metadata.gz: 19dbc9d1eaa160cc21e98ad347f86968ad4ce5cb31f2bc3024844a1e0cd3c8e4
4
+ data.tar.gz: 280f6848271d0c97958dddc467543bb4b747c5a1e8a2e417f0c88e21b9612f4e
5
5
  SHA512:
6
- metadata.gz: f0ee765c1a125e26b9f10b5cc97f41e857ec17562ef4ba116dd53bf97b9b26fb3781d9f292c6296c1c408089d155d9d8a24e4165d58f57ead64e72595f833ae8
7
- data.tar.gz: 86c95c9f103cb0c6c4b37ef3452662274205da97ef04d3f846f7f2d041ff0c94f375208b60d245f454aa73abb2c144113f9d6691cc99a0124f43971fcfb62ae1
6
+ metadata.gz: 9c43ec74eabd07e8ce2be3f8961e67f16858f2de20e828730f10b00dafe9cf2e3e9f3e96ed018718283d2093ae20bd0933931ce9807c29959b95b1f8c2a9a17c
7
+ data.tar.gz: 6bcb6b84581afb310b98899a40d2aa04d87cbcbd7790970fa9053c817886580eab5fe73f39a37944c7421ef9c190e5be26ffd2abac28a0afd7306dae5e2f19da
data/.reek.yml CHANGED
@@ -80,6 +80,10 @@ detectors:
80
80
  TooManyConstants:
81
81
  exclude:
82
82
  - "EventDecorator"
83
+ # encode_utf8 is descriptive — the digit triggers a false positive.
84
+ UncommunicativeMethodName:
85
+ exclude:
86
+ - "ToolDecorator#self.encode_utf8"
83
87
  # Abstract base class methods declare parameters for the subclass contract.
84
88
  UnusedParameters:
85
89
  exclude:
@@ -21,19 +21,74 @@ class ToolDecorator
21
21
  "web_get" => "WebGetToolDecorator"
22
22
  }.freeze
23
23
 
24
- # Factory: dispatches to the tool-specific decorator or passes through.
24
+ # Factory: dispatches to the tool-specific decorator, then sanitizes
25
+ # the result for safe LLM consumption.
26
+ #
27
+ # Sanitization guarantees the final string is UTF-8 encoded, free of
28
+ # ANSI escape codes, and stripped of control characters that carry no
29
+ # meaning for an LLM. This is the single gate — no tool or decorator
30
+ # subclass needs to think about encoding or terminal noise.
25
31
  #
26
32
  # @param tool_name [String] registered tool name
27
33
  # @param result [String, Hash] raw tool execution result
28
- # @return [String, Hash] decorated result (String) or original error Hash
34
+ # @return [String, Hash] sanitized result (String) or original error Hash
29
35
  def self.call(tool_name, result)
30
36
  return result if result.is_a?(Hash) && result.key?(:error)
31
37
 
32
38
  klass_name = DECORATOR_MAP[tool_name]
33
- return result unless klass_name
39
+ result = klass_name.constantize.new.call(result) if klass_name
40
+
41
+ sanitize_for_llm(result)
42
+ end
43
+
44
+ # Ensures a tool result string is safe for LLM consumption by
45
+ # composing {encode_utf8}, {strip_ansi}, and {strip_control_chars}.
46
+ #
47
+ # Non-string results pass through unchanged.
48
+ #
49
+ # @param result [String, Object] tool output to sanitize
50
+ # @return [String, Object] sanitized string or original object
51
+ def self.sanitize_for_llm(result)
52
+ return result unless result.is_a?(String)
53
+
54
+ strip_control_chars(strip_ansi(encode_utf8(result)))
55
+ end
56
+ private_class_method :sanitize_for_llm
34
57
 
35
- klass_name.constantize.new.call(result)
58
+ # Force-encodes a string to UTF-8, replacing invalid or undefined
59
+ # bytes with the Unicode replacement character (U+FFFD).
60
+ #
61
+ # @param str [String] input in any encoding (commonly ASCII-8BIT from PTY)
62
+ # @return [String] valid UTF-8 string
63
+ def self.encode_utf8(str)
64
+ str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\uFFFD")
65
+ end
66
+ private_class_method :encode_utf8
67
+
68
+ # CSI (colors, cursor, DEC private modes), OSC (terminal title),
69
+ # charset designation, single-char commands
70
+ ANSI_ESCAPE = /\e\[[?>=<0-9;]*[A-Za-z]|\e\][^\a\e]*(?:\a|\e\\)|\e[()][0-9A-Za-z]|\e[>=<78NOMDEHcn]/
71
+ private_constant :ANSI_ESCAPE
72
+
73
+ # Strips ANSI escape sequences that are meaningless noise to an LLM
74
+ # but can dominate terminal output payloads.
75
+ #
76
+ # @param str [String] UTF-8 string possibly containing escape codes
77
+ # @return [String] cleaned string
78
+ def self.strip_ansi(str)
79
+ str.gsub(ANSI_ESCAPE, "")
80
+ end
81
+ private_class_method :strip_ansi
82
+
83
+ # Strips C0 control characters (NUL, BEL, BS, CR, etc.) that carry
84
+ # no meaning for an LLM. Preserves newline (\n) and tab (\t).
85
+ #
86
+ # @param str [String] UTF-8 string possibly containing control chars
87
+ # @return [String] cleaned string
88
+ def self.strip_control_chars(str)
89
+ str.gsub(/[\x00-\x08\x0B-\x0D\x0E-\x1F\x7F]/, "")
36
90
  end
91
+ private_class_method :strip_control_chars
37
92
 
38
93
  # Subclasses override to transform the raw tool result.
39
94
  #
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.1.1"
4
+ VERSION = "1.1.2"
5
5
  end
data/lib/llm/client.rb CHANGED
@@ -191,13 +191,7 @@ module LLM
191
191
  session_id: session_id
192
192
  ))
193
193
 
194
- result = begin
195
- registry.execute(name, input)
196
- rescue => error
197
- Rails.logger.error("Tool #{name} raised #{error.class}: #{error.message}")
198
- {error: "#{error.class}: #{error.message}"}
199
- end
200
-
194
+ result = registry.execute(name, input)
201
195
  result = ToolDecorator.call(name, result)
202
196
  result_content = format_tool_result(result)
203
197
  log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
@@ -209,6 +203,23 @@ module LLM
209
203
  ))
210
204
 
211
205
  {type: "tool_result", tool_use_id: id, content: result_content}
206
+ rescue => error
207
+ error_detail = "#{error.class}: #{error.message}"
208
+ Rails.logger.error("Tool #{name} raised #{error_detail}")
209
+ error_content = format_tool_result(error: error_detail)
210
+
211
+ # Emission can fail (e.g. encoding errors in ActionCable/SQLite),
212
+ # but losing the tool_result would permanently corrupt the session.
213
+ begin
214
+ Events::Bus.emit(Events::ToolResponse.new(
215
+ content: error_content, tool_name: name, tool_use_id: id,
216
+ success: false, session_id: session_id
217
+ ))
218
+ rescue => emit_error
219
+ Rails.logger.error("ToolResponse emission failed: #{emit_error.class}: #{emit_error.message}")
220
+ end
221
+
222
+ {type: "tool_result", tool_use_id: id, content: error_content}
212
223
  end
213
224
 
214
225
  # Creates a synthetic "Stopped by user" result for a tool that was not
data/lib/shell_session.rb CHANGED
@@ -390,10 +390,11 @@ class ShellSession
390
390
 
391
391
  def truncate(output)
392
392
  max_bytes = @max_output_bytes
393
+ output = output.dup.force_encoding("UTF-8").scrub
394
+
393
395
  return output if output.bytesize <= max_bytes
394
396
 
395
397
  output.byteslice(0, max_bytes)
396
- .force_encoding("UTF-8")
397
398
  .scrub +
398
399
  "\n\n[Truncated: output exceeded #{max_bytes} bytes]"
399
400
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anima-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin