{"record":{"id":"755400658be20754","repo":"we-promise/sure","slug":"external-assistant-returned-an-empty-response","errorCode":null,"errorMessage":"External assistant returned an empty response.","messagePattern":"External assistant returned an empty response\\.","errorType":"exception","errorClass":"Assistant::Error","httpStatus":null,"severity":"error","filePath":"app/models/assistant/external.rb","lineNumber":60,"sourceCode":"        \"External assistant is not configured. Set the URL and token in Settings > Self-Hosting or via environment variables.\"\n    end\n\n    unless self.class.allowed_user?(chat.user)\n      raise Assistant::Error, \"Your account is not authorized to use the external assistant.\"\n    end\n\n    client = build_client\n    messages = build_conversation_messages\n\n    model = client.chat(\n      messages: messages,\n      user: \"sure-family-#{chat.user.family_id}\"\n    ) do |text|\n      assistant_message.append_text!(text)\n    end\n\n    if assistant_message.content.blank?\n      raise Assistant::Error, \"External assistant returned an empty response.\"\n    end\n\n    response_completed = true\n    assistant_message.update!(ai_model: model) if model.present?\n  rescue Assistant::Error, ActiveRecord::ActiveRecordError => e\n    cleanup_partial_response(assistant_message) unless response_completed\n    chat.add_error(e)\n  rescue => e\n    Rails.logger.error(\"[Assistant::External] Unexpected error: #{e.class} - #{e.message}\")\n    cleanup_partial_response(assistant_message) unless response_completed\n    chat.add_error(Assistant::Error.new(\"Something went wrong with the external assistant. Check server logs for details.\"))\n  end\n\n  private\n\n    def cleanup_partial_response(assistant_message)\n      assistant_message&.destroy! if assistant_message&.persisted?\n    rescue ActiveRecord::ActiveRecordError => e","sourceCodeStart":42,"sourceCodeEnd":78,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/assistant/external.rb#L42-L78","documentation":"Assistant::External#respond_to streams the external agent's reply into the assistant_message via append_text! inside the client.chat block. After the call returns, if assistant_message.content is still blank it raises Assistant::Error(\"External assistant returned an empty response.\") — the agent was reachable and authorized but produced zero visible text (or only whitespace). The method's own rescue catches it: since response_completed is still false, cleanup_partial_response removes the empty message and the error is attached to the chat via chat.add_error, so the user sees it inline as a chat error, not a crash.","triggerScenarios":"The external agent responds 200 with an empty body or an SSE stream containing no text events; the agent replies only with tool-call/function frames the bridge doesn't map to text; a proxy (nginx empty response, 204) between app and agent stripping the body; the agent hit its own internal error and closed the stream cleanly with no content; a model misconfiguration on the agent side producing empty completions.","commonSituations":"First bring-up of a custom agent whose response schema doesn't match what the client parses; agent gateway returning 204 on overload; streaming responses disabled mid-deploy; prompts configured to return structured JSON only, yielding no natural-language text; intermittent provider outages that manifest as empty streams.","solutions":["Check the external agent's own logs for the failed turn — the request reached it and came back content-less","Test the agent directly with the same message (curl its chat endpoint) and confirm it emits non-empty text the bridge recognizes as output","If the agent legitimately returns tool-call/structured output only, adjust it (or the bridge config) to include a final text frame","If empty responses are transient, simply retry the message — the failed empty assistant_message is cleaned up automatically"],"exampleFix":"# before (external agent returns no text events)\nclient.chat(messages:, user:) { |text| assistant_message.append_text!(text) }\nassistant_message.content.blank? # => true -> Assistant::Error, message purged\n\n# after (agent-side: always emit a final text frame)\n# in the agent's handler, after tool runs:\n#   yield \"Done. Summary: ...\"  (non-empty final text)\n# app-side guard before showing, optional:\nraise Assistant::Error, \"empty response\" if assistant_message.content.blank? # already built-in; just retry","handlingStrategy":"fallback","validationCode":"# Agent-side contract: always end a turn with a non-empty text frame\n# Client-side: verify the endpoint yields text before wiring it in\nresp = client.chat(messages: [ { role: \"user\", content: \"ping\" } ], user: \"smoke\") { |t| collected << t }\nabort \"agent produced no text\" if collected.join.strip.empty?","typeGuard":"def agent_returns_text?(client)\n  buffer = +\"\"\n  client.chat(messages: [ { role: \"user\", content: \"ping\" } ]) { |t| buffer << t }\n  !buffer.strip.empty?\nend","tryCatchPattern":"rescue Assistant::Error => e\n  if e.message.include?(\"empty response\")\n    # transient-shaped: safe to let the user retry once; empty message already purged\n    chat.add_error(e) # shown inline; UI offers \"Try again\"\n  else\n    raise\n  end\nend","preventionTips":["Make the external agent always emit a final non-empty text frame per turn","Run a smoke chat against new agent versions before enabling them for users","Ensure tool-only turns still end with a human-readable summary from the agent","Treat empty streams as retriable but investigate agent logs after repeated hits"],"tags":["rails","assistant","external-agent","empty-response","streaming"],"backgroundTag":"empty-api-response","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}