{"slug": "rubyllm-2-0-the-agentic-loop-exposed", "title": "RubyLLM 2.0: The Agentic Loop, Exposed", "summary": "RubyLLM 2.0, the Ruby gem for AI chat, exposes the agentic loop as a set of composable verbs—ask_later, generate, run_tools, step, complete?, and complete—so developers can control each model call and tool execution, with cancelable generation via chat.cancel! and resumable runs across processes. The update removes the halt mechanism from version 1.x, moving loop control to the caller, and supports batch generation and Rails 8.1 ActiveJob Continuations for checkpointing.", "body_md": "Strip any agent framework down and you find the same loop: call the model, run the tools it asked for, call the model again, stop when it answers without wanting a tool. In RubyLLM 1.x that loop lived inside `ask`\n\n, sealed. In RubyLLM 2.0, you can also make it yours.\n\n```\n# Run the agentic loop automatically\nchat = RubyLLM.chat(model: \"claude-sonnet-4-6\")\n  .with_tools(Weather)\n  .ask(\"What's the weather in Paris?\")\n# => #<RubyLLM::Message role: :assistant, content: \"Here's the current...\n# Run the agentic loop manually\nchat = RubyLLM.chat(model: \"claude-sonnet-4-6\")\n  .with_tools(Weather)\n  .ask_later(\"What's the weather in Paris?\")\n\nchat.step until chat.complete?  # generate, run_tools, generate\nchat.messages.last.content\n=> \"Here's the current weather in **Paris, France**:\\n\\n- 🌡️ **Tempera...\n```\n\n`ask`\n\nstill works exactly as before: one method call runs the conversation to completion. But now it decomposes into verbs you can call yourself:\n\n`ask_later`\n\nstages your message without sending anything.`generate`\n\nmakes one model call and appends the response. The model’s move.`run_tools`\n\nexecutes the pending tool calls and appends their results. Your move. No model call.`step`\n\ndoes whichever move is next: tools if any are unanswered, otherwise a model call.`complete?`\n\ntells you when the conversation is settled: the model answered without calling a tool.`complete`\n\nsteps until done.`ask`\n\nis`ask_later`\n\nfollowed by`complete`\n\n.\n\nWhy bother? Because sometimes you need finer control about what happens between or around steps. Iteration budgets. Batch generation. Human approval before a tool runs. Logging each move. Persisting the conversation and picking it up somewhere else. In 1.x you worked around a sealed loop. In 2.0 the loop is plain [Ruby](https://www.ruby-lang.org/en/) in your code if you want it.\n\n## One Move Per Job\n\nEach verb decides what to do next by reading the persisted messages. That means the loop doesn’t need to live in one process, or one machine, or one deploy:\n\n``` python\nclass AgentTurnJob < ApplicationJob\n  def perform(chat_id)\n    chat = Chat.find(chat_id)\n    chat.step\n    AgentTurnJob.perform_later(chat_id) unless chat.complete?\n  end\nend\n```\n\nEvery turn is its own job. Your queue gets granular retries, your agents survive restarts, and a long run never monopolizes a worker.\n\nThe loop is now resumable mid-tool-round too. `run_tools`\n\nskips tool calls that already have results, so if a process dies after finishing one tool call of three, reloading the chat and calling `step`\n\nexecutes only the remaining two. On [Rails](https://rubyonrails.org) 8.1 and later, you can use ActiveJob Continuations to build on this: checkpoint after each move and an agent run survives a redeploy, resuming from the persisted messages with no cursor to manage.\n\nBatches are the same idea at scale: a batch is `generate`\n\ndeferred for many chats at once, with `run_tools`\n\nrun locally between rounds.\n\n## Cancelable generation\n\n`chat.cancel!`\n\ncancels a run from another thread. At the next checkpoint, before a model call, before a tool executes, or between streamed chunks, the run raises `RubyLLM::CancelledError`\n\nand clears the flag so the chat can be reused.\n\nIn Rails, `acts_as_chat`\n\nstores the cancellation request on the chat record, so the signal travels through the database. A stop button in your web process halts a background job mid-stream:\n\n```\nclass ChatsController < ApplicationController\n  def cancel\n    Chat.find(params[:id]).cancel!\n    head :no_content\n  end\nend\n```\n\nNo pub/sub channel, no Redis flag, no process signals. The job checks the record it already has and stops.\n\n## Halt Is Gone\n\nRubyLLM 1.x let a tool terminate the loop from the inside: return `halt(\"done\")`\n\nand the conversation ended. That put control flow inside a return value, and it’s gone in 2.0, along with `RubyLLM::Tool::Halt`\n\n. Tools return results. Stopping belongs to the caller:\n\n```\nuntil chat.complete?\n  chat.step\n  break if handed_off? # your halt, in your code\nend\n```\n\nIf what you want is one tool call per model response rather than a condition, `chat.with_tool_options(calls: :one)`\n\ndoes that. For a total round budget, count `step`\n\nor `generate`\n\ncalls in the loop you control.\n\nThe full guide, including the workflow patterns built on these verbs, is at https://rubyllm.com/next/agentic-workflows/.", "url": "https://wpnews.pro/news/rubyllm-2-0-the-agentic-loop-exposed", "canonical_source": "https://paolino.me/rubyllm-2-0-agentic-loop/", "published_at": "2026-08-27 00:00:00+00:00", "updated_at": "2026-08-28 11:49:25.834461+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents", "artificial-intelligence"], "entities": ["RubyLLM", "Ruby", "Rails", "ActiveJob"], "alternates": {"html": "https://wpnews.pro/news/rubyllm-2-0-the-agentic-loop-exposed", "markdown": "https://wpnews.pro/news/rubyllm-2-0-the-agentic-loop-exposed.md", "text": "https://wpnews.pro/news/rubyllm-2-0-the-agentic-loop-exposed.txt", "jsonld": "https://wpnews.pro/news/rubyllm-2-0-the-agentic-loop-exposed.jsonld"}}