Skip to content

Commit 2408fb9

Browse files
committed
Centralize tool result error handling in format with error_line helper
1 parent dcd44ba commit 2408fb9

2 files changed

Lines changed: 208 additions & 2 deletions

File tree

lib/roast/cogs/agent/providers/claude/tool_result.rb

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ class ToolResult
1010
#: Symbol?
1111
attr_reader :tool_name
1212

13+
#: Hash[Symbol, untyped]
14+
attr_reader :tool_use_input
15+
1316
#: String?
1417
attr_reader :tool_use_description
1518

@@ -22,24 +25,64 @@ class ToolResult
2225
#: (tool_use: Messages::ToolUseMessage?, content: String?, is_error: bool) -> void
2326
def initialize(tool_use:, content:, is_error:)
2427
@tool_name = tool_use&.name || :unknown
25-
@tool_use_description = tool_use&.input&.fetch(:description, nil) #: String?
28+
@tool_use_input = tool_use&.input || {} #: Hash[Symbol, untyped]
29+
@tool_use_description = @tool_use_input[:description] #: String?
2630
@content = content
2731
@is_error = is_error
2832
end
2933

3034
#: () -> String
3135
def format
36+
return error_line if is_error
37+
3238
format_method_name = "format_#{tool_name}".to_sym
3339
return send(format_method_name) if respond_to?(format_method_name, true)
3440

3541
format_unknown
3642
end
3743

44+
TRUNCATE_LIMIT = 45
45+
3846
private
3947

4048
#: () -> String
4149
def format_unknown
42-
"UNKNOWN [#{tool_name}] #{is_error ? " ERROR" : "OK"} #{tool_use_description || ""}\n#{content}"
50+
"UNKNOWN [#{tool_name}] OK #{tool_use_description}\n#{content}"
51+
end
52+
53+
# Renders "<TOOL> OK[ <part> · <part> · ...]"; the success-side twin of
54+
# #error_line. Blank/nil parts are dropped and the rest joined with " · ",
55+
# so callers pass each piece of the summary without minding separators.
56+
#
57+
#: (*String?) -> String
58+
def ok_line(*parts)
59+
summary = parts.select(&:present?).join(" · ")
60+
prefix = "#{tool_name.to_s.upcase} OK"
61+
summary.present? ? "#{prefix} #{summary}" : prefix
62+
end
63+
64+
# Renders "<TOOL> ERROR <message>" with any <tool_use_error> wrapper stripped.
65+
#
66+
# Reads the instance's `content` and `tool_name` to produce a single-line
67+
# error summary. Error messages are intentionally NOT truncated so the full
68+
# diagnostic is preserved for debugging.
69+
#
70+
# Examples:
71+
# BASH ERROR File has not been read yet.
72+
# UNKNOWN ERROR command not found
73+
#
74+
#: () -> String
75+
def error_line
76+
message = content.to_s.gsub(%r{</?tool_use_error>}, "").strip
77+
"#{tool_name.to_s.upcase} ERROR #{message}".strip
78+
end
79+
80+
# Truncates to TRUNCATE_LIMIT chars, appending "..." when cut. nil -> "".
81+
#
82+
#: (String?) -> String
83+
def truncate(str)
84+
s = str.to_s
85+
s.length > TRUNCATE_LIMIT ? "#{s[0...TRUNCATE_LIMIT - 3]}..." : s
4386
end
4487
end
4588
end

test/roast/cogs/agent/providers/claude/tool_result_test.rb

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class Claude::ToolResultTest < ActiveSupport::TestCase
1919

2020
assert_equal :bash, tool_result.tool_name
2121
assert_equal "List files", tool_result.tool_use_description
22+
assert_equal({ description: "List files" }, tool_result.tool_use_input)
2223
assert_equal "file1.txt\nfile2.txt", tool_result.content
2324
refute tool_result.is_error
2425
end
@@ -32,6 +33,7 @@ class Claude::ToolResultTest < ActiveSupport::TestCase
3233

3334
assert_equal :unknown, tool_result.tool_name
3435
assert_nil tool_result.tool_use_description
36+
assert_equal({}, tool_result.tool_use_input)
3537
end
3638

3739
test "initialize with tool_use without description" do
@@ -85,6 +87,64 @@ class Claude::ToolResultTest < ActiveSupport::TestCase
8587
assert_match(/error details/, output)
8688
end
8789

90+
test "error_line strips the tool_use_error wrapper and upcases the tool name" do
91+
tool_use_message = Claude::Messages::ToolUseMessage.new(
92+
type: :tool_use,
93+
hash: { name: "bash", input: {} },
94+
)
95+
tool_result = Claude::ToolResult.new(
96+
tool_use: tool_use_message,
97+
content: "<tool_use_error>File has not been read yet.</tool_use_error>",
98+
is_error: true,
99+
)
100+
101+
output = tool_result.send(:error_line)
102+
103+
assert_equal "BASH ERROR File has not been read yet.", output
104+
end
105+
106+
test "error_line handles nil content gracefully" do
107+
tool_use_message = Claude::Messages::ToolUseMessage.new(
108+
type: :tool_use,
109+
hash: { name: "read", input: {} },
110+
)
111+
tool_result = Claude::ToolResult.new(
112+
tool_use: tool_use_message,
113+
content: nil,
114+
is_error: true,
115+
)
116+
117+
output = tool_result.send(:error_line)
118+
119+
assert_equal "READ ERROR", output
120+
end
121+
122+
test "format error path keeps the full content" do
123+
tool_result = Claude::ToolResult.new(
124+
tool_use: nil,
125+
content: "Error: command failed\n at line 3\n exit status 1",
126+
is_error: true,
127+
)
128+
129+
output = tool_result.format
130+
131+
assert_equal "UNKNOWN ERROR Error: command failed\n at line 3\n exit status 1", output
132+
end
133+
134+
test "format routes errors through the error_line helper" do
135+
tool_result = Claude::ToolResult.new(
136+
tool_use: nil,
137+
content: "error details",
138+
is_error: true,
139+
)
140+
141+
tool_result.expects(:error_line).returns("ERROR LINE")
142+
143+
output = tool_result.format
144+
145+
assert_equal "ERROR LINE", output
146+
end
147+
88148
test "format includes description when present" do
89149
tool_use_message = Claude::Messages::ToolUseMessage.new(
90150
type: :tool_use,
@@ -100,6 +160,109 @@ class Claude::ToolResultTest < ActiveSupport::TestCase
100160

101161
assert_match(/Run command/, output)
102162
end
163+
164+
test "ok_line renders a bare OK line when given no parts" do
165+
tool_use_message = Claude::Messages::ToolUseMessage.new(
166+
type: :tool_use,
167+
hash: { name: "bash", input: {} },
168+
)
169+
tool_result = Claude::ToolResult.new(
170+
tool_use: tool_use_message,
171+
content: nil,
172+
is_error: false,
173+
)
174+
175+
output = tool_result.send(:ok_line)
176+
177+
assert_equal "BASH OK", output
178+
end
179+
180+
test "ok_line appends a single part and upcases the tool name" do
181+
tool_use_message = Claude::Messages::ToolUseMessage.new(
182+
type: :tool_use,
183+
hash: { name: "bash", input: {} },
184+
)
185+
tool_result = Claude::ToolResult.new(
186+
tool_use: tool_use_message,
187+
content: nil,
188+
is_error: false,
189+
)
190+
191+
output = tool_result.send(:ok_line, "3 files")
192+
193+
assert_equal "BASH OK 3 files", output
194+
end
195+
196+
test "ok_line joins multiple parts with a dot separator" do
197+
tool_use_message = Claude::Messages::ToolUseMessage.new(
198+
type: :tool_use,
199+
hash: { name: "bash", input: {} },
200+
)
201+
tool_result = Claude::ToolResult.new(
202+
tool_use: tool_use_message,
203+
content: nil,
204+
is_error: false,
205+
)
206+
207+
output = tool_result.send(:ok_line, "3 lines", "preview text")
208+
209+
assert_equal "BASH OK 3 lines · preview text", output
210+
end
211+
212+
test "ok_line drops blank and nil parts before joining" do
213+
tool_use_message = Claude::Messages::ToolUseMessage.new(
214+
type: :tool_use,
215+
hash: { name: "bash", input: {} },
216+
)
217+
tool_result = Claude::ToolResult.new(
218+
tool_use: tool_use_message,
219+
content: nil,
220+
is_error: false,
221+
)
222+
223+
output = tool_result.send(:ok_line, "3 lines", "", nil)
224+
225+
assert_equal "BASH OK 3 lines", output
226+
end
227+
228+
test "truncate returns strings within the limit unchanged" do
229+
tool_result = Claude::ToolResult.new(
230+
tool_use: nil,
231+
content: nil,
232+
is_error: false,
233+
)
234+
string_at_limit = "a" * Claude::ToolResult::TRUNCATE_LIMIT
235+
236+
output = tool_result.send(:truncate, string_at_limit)
237+
238+
assert_equal string_at_limit, output
239+
end
240+
241+
test "truncate cuts longer strings to the limit with an ellipsis" do
242+
tool_result = Claude::ToolResult.new(
243+
tool_use: nil,
244+
content: nil,
245+
is_error: false,
246+
)
247+
limit = Claude::ToolResult::TRUNCATE_LIMIT
248+
249+
output = tool_result.send(:truncate, "a" * (limit + 10))
250+
251+
assert_equal "#{"a" * (limit - 3)}...", output
252+
assert_equal limit, output.length
253+
end
254+
255+
test "truncate maps nil to an empty string" do
256+
tool_result = Claude::ToolResult.new(
257+
tool_use: nil,
258+
content: nil,
259+
is_error: false,
260+
)
261+
262+
output = tool_result.send(:truncate, nil)
263+
264+
assert_equal "", output
265+
end
103266
end
104267
end
105268
end

0 commit comments

Comments
 (0)