From 6e2cbb727a9d180fdb0f758dd4dd4662885191fe Mon Sep 17 00:00:00 2001 From: kai392 Date: Sun, 26 Jul 2026 10:47:15 +0800 Subject: [PATCH] fix(ai): prevent Anthropic chat crash on no-argument tool calls (#2755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent Anthropic chat crash on no-argument tool calls When the model streams a tool_use block with no arguments (e.g. get_categories), the accumulated input arrives as an empty string. The Anthropic chat parser passed that empty string straight through as function_args, and Assistant::FunctionToolCaller then called JSON.parse("") — which raises "unexpected end of input", surfaces as a Provider::Anthropic::Error, and kills the whole assistant turn. Fix at the source by normalizing empty/nil tool input to an empty JSON object in the parser, plus a defensive guard in FunctionToolCaller so any provider that emits blank arguments cannot crash a turn. Fixes #2722 Co-Authored-By: Claude Opus 4.8 (1M context) * test: assert nil-args result as Hash to match jsonb function_result Addresses Codex P1 / CodeRabbit review: EchoFunction returns the parsed params Hash and ToolCall::Function stores it in a jsonb column, so the nil-arguments test must assert the Hash directly instead of JSON.parse. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/models/assistant/function_tool_caller.rb | 2 +- app/models/provider/anthropic/chat_parser.rb | 4 +- .../assistant/function_tool_caller_test.rb | 54 +++++++++++++++++++ .../provider/anthropic/chat_parser_test.rb | 31 +++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 test/models/assistant/function_tool_caller_test.rb diff --git a/app/models/assistant/function_tool_caller.rb b/app/models/assistant/function_tool_caller.rb index 4ed081021..bc73b9c50 100644 --- a/app/models/assistant/function_tool_caller.rb +++ b/app/models/assistant/function_tool_caller.rb @@ -23,7 +23,7 @@ class Assistant::FunctionToolCaller private def execute(function_request) fn = find_function(function_request) - fn_args = JSON.parse(function_request.function_args) + fn_args = JSON.parse(function_request.function_args.presence || "{}") fn.call(fn_args) rescue => e raise FunctionExecutionError.new( diff --git a/app/models/provider/anthropic/chat_parser.rb b/app/models/provider/anthropic/chat_parser.rb index 1f22c465d..139df5887 100644 --- a/app/models/provider/anthropic/chat_parser.rb +++ b/app/models/provider/anthropic/chat_parser.rb @@ -50,7 +50,9 @@ class Provider::Anthropic::ChatParser id: block_value(block, :id), call_id: block_value(block, :id), function_name: block_value(block, :name), - function_args: input.is_a?(String) ? input : input.to_json + # A tool_use block with no arguments streams in as an empty string. + # Normalize it to an empty JSON object so downstream JSON.parse succeeds. + function_args: input.is_a?(String) ? input.presence || "{}" : (input || {}).to_json ) end end diff --git a/test/models/assistant/function_tool_caller_test.rb b/test/models/assistant/function_tool_caller_test.rb new file mode 100644 index 000000000..8203e0d37 --- /dev/null +++ b/test/models/assistant/function_tool_caller_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class Assistant::FunctionToolCallerTest < ActiveSupport::TestCase + # Minimal stub function that echoes back whatever args it receives. + class EchoFunction < Assistant::Function + def self.name = "echo" + def self.description = "Echoes the received arguments" + def call(params = {}) = params + end + + FunctionRequest = Provider::LlmConcept::ChatFunctionRequest + + setup do + @caller = Assistant::FunctionToolCaller.new([ EchoFunction.new(nil) ]) + end + + test "parses JSON arguments and forwards them to the function" do + request = FunctionRequest.new( + id: "call_1", call_id: "call_1", function_name: "echo", + function_args: { "foo" => "bar" }.to_json + ) + + result = @caller.fulfill_requests([ request ]).first + + assert_equal({ "foo" => "bar" }, result.function_result) + end + + test "treats empty-string arguments as an empty argument set" do + request = FunctionRequest.new( + id: "call_2", call_id: "call_2", function_name: "echo", + function_args: "" + ) + + # Regression for #2722: JSON.parse("") used to raise, killing the turn. + result = assert_nothing_raised do + @caller.fulfill_requests([ request ]).first + end + + assert_equal({}, result.function_result) + end + + test "treats nil arguments as an empty argument set" do + request = FunctionRequest.new( + id: "call_3", call_id: "call_3", function_name: "echo", + function_args: nil + ) + + result = assert_nothing_raised do + @caller.fulfill_requests([ request ]).first + end + + assert_equal({}, result.function_result) + end +end diff --git a/test/models/provider/anthropic/chat_parser_test.rb b/test/models/provider/anthropic/chat_parser_test.rb index 16085c656..3ce93b505 100644 --- a/test/models/provider/anthropic/chat_parser_test.rb +++ b/test/models/provider/anthropic/chat_parser_test.rb @@ -64,6 +64,37 @@ class Provider::Anthropic::ChatParserTest < ActiveSupport::TestCase assert_equal "toolu_42", parsed.function_requests.first.call_id end + test "normalizes empty-string tool input to an empty JSON object" do + raw = build_message( + id: "msg_5", + model: "claude-sonnet-4-6", + content: [ + OpenStruct.new(type: :tool_use, id: "toolu_noargs", name: "get_categories", input: "") + ] + ) + + parsed = Provider::Anthropic::ChatParser.new(raw).parsed + + req = parsed.function_requests.first + assert_equal "{}", req.function_args + # Must survive the downstream JSON.parse that fulfills the tool call. + assert_equal({}, JSON.parse(req.function_args)) + end + + test "normalizes nil tool input to an empty JSON object" do + raw = build_message( + id: "msg_6", + model: "claude-sonnet-4-6", + content: [ + OpenStruct.new(type: :tool_use, id: "toolu_nil", name: "get_categories", input: nil) + ] + ) + + parsed = Provider::Anthropic::ChatParser.new(raw).parsed + + assert_equal "{}", parsed.function_requests.first.function_args + end + test "accepts hash-shaped content blocks" do raw = OpenStruct.new( id: "msg_4",