fix(captain): handle GPT-4.1-mini structured output inconsistency

GPT-4.1-mini sometimes returns JSON as a string instead of structured
output when tools are present in the request. This adds a fallback
parser that extracts the first valid JSON object from the string.

- No impact on working cases (Hash returns immediately)
- Handles concatenated JSON responses like `{...}{...}`
- Safe fallback for future "Choose Your Own LLM" feature

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
aakashb95
2026-01-29 16:22:20 +05:30
co-authored by Claude Opus 4.5
parent 14aad67f0a
commit eb0cb23fb1
@@ -73,40 +73,39 @@ class Captain::Assistant::AgentRunnerService
# Response formatting methods
def process_agent_result(result)
# DEBUG LOGGING - remove after investigation
Rails.logger.info "[Captain V2 DEBUG] result.output class: #{result.output.class.name}"
Rails.logger.info "[Captain V2 DEBUG] result.output value: #{result.output.inspect}"
if result.output.is_a?(String)
Rails.logger.warn '[Captain V2 DEBUG] Output is STRING not Hash - schema may not be applied!'
Rails.logger.warn "[Captain V2 DEBUG] String content: #{result.output}"
end
# END DEBUG LOGGING
response = format_response(result.output)
# Extract agent name from context
response['agent_name'] = result.context&.dig(:current_agent)
response
end
def format_response(output)
if output.is_a?(Hash)
Rails.logger.info "[Captain V2 DEBUG] format_response: Hash with keys #{output.keys}"
return output.with_indifferent_access
return output.with_indifferent_access if output.is_a?(Hash)
# GPT-4.1-mini sometimes returns JSON as a string instead of structured output
# when tools are present in the request. Parse the first valid JSON object.
if output.is_a?(String) && output.strip.start_with?('{')
parsed = parse_first_json_object(output)
return parsed.with_indifferent_access if parsed
end
# DEBUG LOGGING - remove after investigation
Rails.logger.warn "[Captain V2 DEBUG] format_response: Non-hash output, type=#{output.class.name}"
Rails.logger.warn "[Captain V2 DEBUG] format_response: content=#{output}"
# END DEBUG LOGGING
# Fallback for backwards compatibility
{
'response' => output.to_s,
'reasoning' => 'Processed by agent'
}
{ 'response' => output.to_s, 'reasoning' => 'Processed by agent' }
end
def parse_first_json_object(str)
depth = 0
start_idx = str.index('{')
return nil unless start_idx
(start_idx...str.length).each do |i|
depth += 1 if str[i] == '{'
depth -= 1 if str[i] == '}'
return JSON.parse(str[start_idx..i]) if depth.zero?
end
nil
rescue JSON::ParserError
nil
end
def error_response(error_message)