An agentic loop against Claude Haiku: the model calls Google-Places-backed tools across multiple turns to find and rank venues, capped at 8 turns as a safety limit.

# app/services/google_places/agentic_location_search.rb

# lines 169–213 (run_agentic_loop)

def run_agentic_loop
  messages = [{ role: "user", content: "Please find venues that match my criteria." }]
  turns    = 0

  loop do
    turns += 1
    if turns > MAX_TURNS
      Rails.logger.warn("[AgenticLocationSearch] Safety limit reached after #{MAX_TURNS} turns")
      break
    end

    response = anthropic_client.messages.create(
      model:      CLAUDE_MODEL,
      max_tokens: 4096,
      system:     build_system_prompt,
      tools:      TOOL_DEFINITIONS,
      messages:   messages
    )

    # Official SDK returns SDK objects — serialize to plain hashes for messages history.
    content_blocks = response.content
    messages << { role: "assistant", content: serialize_content(content_blocks) }

    # Official SDK may return stop_reason as a symbol (:tool_use) rather than
    # a string ("tool_use"), so normalise before comparing.
    case response.stop_reason.to_s
    when "end_turn"
      # Ineligible types are already stripped by the pre-filter in dispatch_tool;
      # post_filter_venues applies a final Ruby check using cached Google types.
      venues = post_filter_venues(extract_final_venues(content_blocks))
      Rails.logger.info("[AgenticLocationSearch] Returning #{venues.size} venues after #{turns} turn(s)")
      return venues

    when "tool_use"
      tool_results = execute_tool_calls(content_blocks)
      messages << { role: "user", content: tool_results }

    else
      Rails.logger.warn("[AgenticLocationSearch] Unexpected stop_reason: #{response.stop_reason}")
      break
    end
  end

  []
end

 The loop hands the model real tools (Google Places search, geocoding) and keeps feeding results back until it says end_turn or the turn cap trips.

# app/services/google_places/agentic_location_search.rb

# lines 464–493 (ineligible_venue? / post_filter_venues)

# Ruby-level safety net applied after Claude's verification pass.
# Uses @place_type_cache (real Google data) rather than whatever types
# Claude chose to write into its output JSON — so this cannot be fooled
# by Claude omitting or altering the types field.

def post_filter_venues(venues)
  venues.reject do |v|
    # Prefer cached types from Google; fall back to Claude's output types.
    types = (@place_type_cache[v[:place_id]] || Array(v[:types]))
              .map(&:to_s)

    if ineligible_venue?(types)
      Rails.logger.info(
        "[AgenticLocationSearch] Post-filter removed '#{v[:name]}' " \
        "(types: #{types.inspect})"
      )
      true
    else
      false
    end
  end
end

# Returns true if a types array belongs to a clearly non-social venue
# AND has no social type present to redeem it.
def ineligible_venue?(types)
  t = Array(types).map(&:to_s)
                  .reject { |x| %w[establishment point_of_interest].include?(x) }
  t.any? { |x| INELIGIBLE_TYPES.include?(x) } &&
    t.none? { |x| SOCIAL_TYPES.include?(x) }
end
Claude's own JSON output is never trusted for eligibility — this checks the venue's actual Google Places types (cached from the real API response) against a deny-list, with a social-venue allow-list as an escape hatch (a university bar shouldn't get excluded just because "university" is one of its types).
# app/services/google_places/agentic_location_search.rb

# lines 169–213 (run_agentic_loop)

def run_agentic_loop
  messages = [{ role: "user", content: "Please find venues that match my criteria." }]
  turns    = 0

  loop do
    turns += 1
    if turns > MAX_TURNS
      Rails.logger.warn("[AgenticLocationSearch] Safety limit reached after #{MAX_TURNS} turns")
      break
    end

    response = anthropic_client.messages.create(
      model:      CLAUDE_MODEL,
      max_tokens: 4096,
      system:     build_system_prompt,
      tools:      TOOL_DEFINITIONS,
      messages:   messages
    )

    # Official SDK returns SDK objects — serialize to plain hashes for messages history.
    content_blocks = response.content
    messages << { role: "assistant", content: serialize_content(content_blocks) }

    # Official SDK may return stop_reason as a symbol (:tool_use) rather than
    # a string ("tool_use"), so normalise before comparing.
    case response.stop_reason.to_s
    when "end_turn"
      # Ineligible types are already stripped by the pre-filter in dispatch_tool;
      # post_filter_venues applies a final Ruby check using cached Google types.
      venues = post_filter_venues(extract_final_venues(content_blocks))
      Rails.logger.info("[AgenticLocationSearch] Returning #{venues.size} venues after #{turns} turn(s)")
      return venues

    when "tool_use"
      tool_results = execute_tool_calls(content_blocks)
      messages << { role: "user", content: tool_results }

    else
      Rails.logger.warn("[AgenticLocationSearch] Unexpected stop_reason: #{response.stop_reason}")
      break
    end
  end

  []
end