diff --git a/lib/roast/cogs/agent/providers/claude/tool_result.rb b/lib/roast/cogs/agent/providers/claude/tool_result.rb index 2119612f..4596da44 100644 --- a/lib/roast/cogs/agent/providers/claude/tool_result.rb +++ b/lib/roast/cogs/agent/providers/claude/tool_result.rb @@ -85,6 +85,30 @@ def format_read ok_line("#{count} #{"line".pluralize(count)}") end + # Formats a WebFetch tool-result line. + # + # Input: :url – the URL that was fetched. + # Content: the answer the model produced from the fetched page. A page + # that could not be retrieved (e.g. an HTTP 404) still comes back as + # a non-error result whose content describes what happened. + # + # Output: "WEBFETCH OK · " – the fetched :url followed + # by the first line of that answer, stripped and truncated to + # TRUNCATE_LIMIT chars. Either part is omitted when absent (no url, or + # no content), collapsing to just the other; :url is not truncated, as + # it is the identity of the fetch. + # + # Examples: + # WEBFETCH OK https://example.com · The main heading is "Example Domain". + # WEBFETCH OK https://example.com/missing · The server returned HTTP 404... + # WEBFETCH OK https://example.com + # + #: () -> String + def format_webfetch + preview = truncate(content.to_s.lines.first.to_s.strip) + ok_line(tool_use_input[:url], preview) + end + # Formats a Glob tool-result line. # # Content: newline-separated matches. Lines starting with "/" are diff --git a/lib/roast/cogs/agent/providers/claude/tool_use.rb b/lib/roast/cogs/agent/providers/claude/tool_use.rb index d10648f5..c22699ae 100644 --- a/lib/roast/cogs/agent/providers/claude/tool_use.rb +++ b/lib/roast/cogs/agent/providers/claude/tool_use.rb @@ -108,6 +108,27 @@ def format_read details ? "#{label} (#{details})" : label end + # Formats a WebFetch tool-use line. + # + # Input fields: + # :url (String) – the URL to fetch [required] + # :prompt (String) – what to extract/answer from the page [required] + # + # Output: "WEBFETCH ", with " ()" appended when :prompt is + # present. :prompt is truncated to TRUNCATE_LIMIT chars; :url is not, as + # it is the identity of the fetch (like a file path in a Read). + # + # Examples: + # WEBFETCH https://example.com (What is the main heading?) + # WEBFETCH https://example.com + # + #: () -> String + def format_webfetch + url, prompt = input.values_at(:url, :prompt) + label = url.to_s.empty? ? "WEBFETCH" : "WEBFETCH #{url}" + prompt ? "#{label} (#{truncate(prompt)})" : label + end + # Formats a Glob tool-use line. # # Input fields: diff --git a/test/roast/cogs/agent/providers/claude/tool_result_test.rb b/test/roast/cogs/agent/providers/claude/tool_result_test.rb index fb894070..57050601 100644 --- a/test/roast/cogs/agent/providers/claude/tool_result_test.rb +++ b/test/roast/cogs/agent/providers/claude/tool_result_test.rb @@ -305,6 +305,87 @@ class Claude::ToolResultTest < ActiveSupport::TestCase assert_equal "READ OK 0 lines", output end + test "format_webfetch shows the fetched url and previews the first line of the answer" do + tool_use_message = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { name: "webfetch", input: { url: "https://example.com", prompt: "What is the main heading?" } }, + ) + tool_result = Claude::ToolResult.new( + tool_use: tool_use_message, + content: "The main heading is \"Example Domain\".\n\nIt appears twice on the page.", + is_error: false, + ) + + output = tool_result.format + + assert_equal "WEBFETCH OK https://example.com · The main heading is \"Example Domain\".", output + end + + test "format_webfetch previews a failed fetch that came back as a non-error result" do + tool_use_message = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { name: "webfetch", input: { url: "https://example.com/missing" } }, + ) + tool_result = Claude::ToolResult.new( + tool_use: tool_use_message, + content: "The server returned HTTP 404 Not Found.\n\nThe response body was not retrieved.", + is_error: false, + ) + + output = tool_result.format + + assert_equal "WEBFETCH OK https://example.com/missing · The server returned HTTP 404 Not Found.", output + end + + test "format_webfetch truncates the preview but not the url" do + long_url = "https://example.com/#{"a" * (Claude::ToolResult::TRUNCATE_LIMIT + 10)}" + tool_use_message = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { name: "webfetch", input: { url: long_url } }, + ) + tool_result = Claude::ToolResult.new( + tool_use: tool_use_message, + content: "x" * 60, + is_error: false, + ) + + output = tool_result.format + + assert_equal "WEBFETCH OK #{long_url} · #{"x" * (Claude::ToolResult::TRUNCATE_LIMIT - 3)}...", output + end + + test "format_webfetch shows the url alone when the content is nil" do + tool_use_message = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { name: "webfetch", input: { url: "https://example.com" } }, + ) + tool_result = Claude::ToolResult.new( + tool_use: tool_use_message, + content: nil, + is_error: false, + ) + + output = tool_result.format + + assert_equal "WEBFETCH OK https://example.com", output + end + + test "format_webfetch collapses to a bare line when both url and content are absent" do + tool_use_message = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { name: "webfetch", input: {} }, + ) + tool_result = Claude::ToolResult.new( + tool_use: tool_use_message, + content: "", + is_error: false, + ) + + output = tool_result.format + + assert_equal "WEBFETCH OK", output + end + test "format_glob reports the number of matched files" do tool_use_message = Claude::Messages::ToolUseMessage.new( type: :tool_use, diff --git a/test/roast/cogs/agent/providers/claude/tool_use_test.rb b/test/roast/cogs/agent/providers/claude/tool_use_test.rb index 8fd1cc7e..9b93cee3 100644 --- a/test/roast/cogs/agent/providers/claude/tool_use_test.rb +++ b/test/roast/cogs/agent/providers/claude/tool_use_test.rb @@ -122,6 +122,43 @@ class Claude::ToolUseTest < ActiveSupport::TestCase assert_equal "READ", output end + # format_webfetch + + test "format_webfetch appends the prompt in parentheses" do + tool_use = Claude::ToolUse.new(name: :webfetch, input: { url: "https://example.com", prompt: "What is the main heading?" }) + + output = tool_use.format + + assert_equal "WEBFETCH https://example.com (What is the main heading?)", output + end + + test "format_webfetch renders the url only when no prompt given" do + tool_use = Claude::ToolUse.new(name: :webfetch, input: { url: "https://example.com" }) + + output = tool_use.format + + assert_equal "WEBFETCH https://example.com", output + end + + test "format_webfetch truncates the prompt but not the url" do + long_url = "https://example.com/#{"a" * (Claude::ToolUse::TRUNCATE_LIMIT + 10)}" + long_prompt = "b" * (Claude::ToolUse::TRUNCATE_LIMIT + 10) + truncated = "#{"b" * (Claude::ToolUse::TRUNCATE_LIMIT - 3)}..." + tool_use = Claude::ToolUse.new(name: :webfetch, input: { url: long_url, prompt: long_prompt }) + + output = tool_use.format + + assert_equal "WEBFETCH #{long_url} (#{truncated})", output + end + + test "format_webfetch has no trailing space when the url is absent" do + tool_use = Claude::ToolUse.new(name: :webfetch, input: {}) + + output = tool_use.format + + assert_equal "WEBFETCH", output + end + # format_glob test "format_glob renders the pattern only when no path given" do