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