When using with_schema and with_tools together, if the LLM returns both text content and tool calls in the same turn, Chat#complete parses the text content into a Hash before checking tool_call?. On the continuation call, format_text(Hash) wraps it as { type: "text", text: <Hash> }, which Anthropic rejects with:
messages.1.content.0.text.text: Input should be a valid string
Steps to reproduce
chat = RubyLLM.chat(model: 'claude-haiku-4-5')
.with_instructions("Extract data from the document.")
.with_tools(MySearchTool)
.with_schema(my_output_schema)
# When the LLM responds with both text AND tool calls in the same turn,
# the text content is parsed into a Hash, then the tool-call continuation
# sends that Hash where a String is expected.
chat.ask("Extract from this document", with: [pdf_path])
# => RubyLLM::BadRequestError: messages.1.content.0.text.text: Input should be a valid string
Root cause
In chat.rb#complete, the schema JSON parsing runs unconditionally before the tool_call? check:
def complete(&)
response = @provider.complete(...)
# This parses content into a Hash even for intermediate tool-call responses
if @schema && response.content.is_a?(String)
begin
response.content = JSON.parse(response.content)
rescue JSON::ParserError
end
end
add_message response # Hash is now stored in messages
if response.tool_call?
handle_tool_calls(response, &) # continuation call sends Hash where String expected
else
response
end
end
When handle_tool_calls triggers a continuation call, the stored Hash is formatted by format_text as { type: "text", text: <Hash> }, which Anthropic rejects because text must be a String.
Suggested fix
Only parse schema JSON for the final (non-tool-call) response:
if @schema && response.content.is_a?(String) && !response.tool_call?
begin
response.content = JSON.parse(response.content)
rescue JSON::ParserError
end
end
Environment
- ruby_llm version: 1.13.0
- Ruby version: 3.3.5
- Provider: Anthropic (Claude claude-haiku-4-5)
- Using
with_schema + with_tools together
When using
with_schemaandwith_toolstogether, if the LLM returns both text content and tool calls in the same turn,Chat#completeparses the text content into a Hash before checkingtool_call?. On the continuation call,format_text(Hash)wraps it as{ type: "text", text: <Hash> }, which Anthropic rejects with:Steps to reproduce
Root cause
In
chat.rb#complete, the schema JSON parsing runs unconditionally before thetool_call?check:When
handle_tool_callstriggers a continuation call, the stored Hash is formatted byformat_textas{ type: "text", text: <Hash> }, which Anthropic rejects becausetextmust be a String.Suggested fix
Only parse schema JSON for the final (non-tool-call) response:
Environment
with_schema+with_toolstogether