CookbookTool loop in Ruby

Tool loop in Ruby

The model can call two functions, find_book and availability, as many times as it needs; the loop ends when it answers in text. It uses plain HTTP, so there is nothing to install beyond the standard library unless noted.

require "json"
require "net/http"
require "uri"
 
LIBRARY = {
  "978-0141439518" => { title: "Pride and Prejudice", copies: 3, on_loan: 3, due_back: "2026-10-19" },
  "978-0007119318" => { title: "The Hobbit", copies: 2, on_loan: 1, due_back: nil },
}.freeze
 
TOOLS = JSON.parse(<<~'JSON')
  [
    {"type": "function", "function": {"name": "find_book", "description": "Search the catalogue by title words. Returns ISBNs.",
      "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
    {"type": "function", "function": {"name": "availability", "description": "Copies available now for an ISBN, and when the next one is due back.",
      "parameters": {"type": "object", "properties": {"isbn": {"type": "string"}}, "required": ["isbn"]}}}
  ]
JSON
 
def run_tool(name, raw_args)
  args = JSON.parse(raw_args.to_s.empty? ? "{}" : raw_args)
  case name
  when "find_book"
    q = args.fetch("query").downcase
    { results: LIBRARY.select { |_, b| b[:title].downcase.include?(q) }.map { |isbn, b| { isbn: isbn, title: b[:title] } } }
  when "availability"
    book = LIBRARY[args.fetch("isbn")] or return { error: "no book with ISBN #{args['isbn']}" }
    free = book[:copies] - book[:on_loan]
    { available: free, next_due_back: free.zero? ? book[:due_back] : nil }
  else
    { error: "unknown tool #{name}" }
  end
rescue StandardError => e
  { error: e.message }
end
 
def complete(messages)
  uri = URI("https://app.syntic.ai/v1/chat/completions")
  req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{ENV.fetch('SYNTIC_API_KEY')}")
  req.body = JSON.generate(model: "Amara S1.2", stream: false, max_tokens: 800, tools: TOOLS, messages: messages)
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
  data = JSON.parse(res.body)
  raise "#{res.code} #{data.dig('error', 'code')}: #{data.dig('error', 'message')}" unless res.is_a?(Net::HTTPSuccess)
  data.dig("choices", 0, "message")
end
 
messages = [
  { role: "system", content: "You help members of a town library. Use the tools; never guess availability." },
  { role: "user", content: "Can I borrow Pride and Prejudice today? If not, when?" },
]
 
answer = 8.times do
  msg = complete(messages)
  calls = msg["tool_calls"] || []
  break msg["content"] if calls.empty?
 
  messages << msg
  calls.each do |call|
    result = run_tool(call.dig("function", "name"), call.dig("function", "arguments"))
    messages << { role: "tool", tool_call_id: call["id"], content: JSON.generate(result) }
  end
end
puts answer.is_a?(String) ? answer : "Sorry, I couldn't finish that request."

The three rules

  1. Append the assistant message with its tool_calls exactly as received.
  2. Add one tool message per call, with the matching tool_call_id.
  3. Send the same tools list on every round.

Errors from your functions are returned to the model as results so it can recover, and the round limit stops a runaway loop.

Was this page helpful?