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 +4 -4
- data/.reek.yml +4 -0
- data/app/decorators/tool_decorator.rb +59 -4
- data/lib/anima/version.rb +1 -1
- data/lib/llm/client.rb +18 -7
- data/lib/shell_session.rb +2 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19dbc9d1eaa160cc21e98ad347f86968ad4ce5cb31f2bc3024844a1e0cd3c8e4
|
|
4
|
+
data.tar.gz: 280f6848271d0c97958dddc467543bb4b747c5a1e8a2e417f0c88e21b9612f4e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
data/lib/llm/client.rb
CHANGED
|
@@ -191,13 +191,7 @@ module LLM
|
|
|
191
191
|
session_id: session_id
|
|
192
192
|
))
|
|
193
193
|
|
|
194
|
-
result =
|
|
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
|