Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ end

gem "csv"
gem "ruby_llm"
gem "faraday"
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ DEPENDENCIES
dotenv-rails (~> 3.2)
factory_bot_rails
faker
faraday
image_processing (~> 1.2)
importmap-rails
jbuilder
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<img src="https://img.shields.io/badge/status-learning%20project-f59e0b" alt="Status: learning project">
</p>

> **Status:** Lessons 1 to 3 of 14 are done: real and planted data, the first LLM call, and four read tools the model chooses between. Lesson 4 (the agent loop, written by hand) is next. This is a learning project, built step by step, not a finished product. The plan below describes where it is going, not what works today.
> **Status:** Lessons 1 to 4 of 14 are done: real and planted data, the first LLM call, four read tools the model chooses between, and the agent loop written by hand. Lesson 5 (an MCP server over the same tools) is next. This is a learning project, built step by step, not a finished product. The plan below describes where it is going, not what works today.

## The problem

Expand Down Expand Up @@ -120,7 +120,7 @@ For a quick try, `LIMIT=200 bin/rails data:import_olist` loads only 200 customer
| Observability | OpenTelemetry, Langfuse |
| Evals | eval-ruby, rspec-agents |

Gems are added in the lesson that needs them, not all at once. All LLM calls will go through one small `LlmClient` class, so changing provider is a config change.
Gems are added in the lesson that needs them, not all at once. The hand-written loop in Lesson 4 talks to Groq through one small `LlmClient`. From Lesson 5 on the app uses RubyLLM, so changing provider is a config change.

## Roadmap

Expand Down Expand Up @@ -192,5 +192,6 @@ This is a learning project about building a reliable AI agent, not a complete su
- Lesson progress, decisions and what broke: [docs/lessons/01-data-model-and-import.md](docs/lessons/01-data-model-and-import.md)
- Lesson 2, the first LLM call: [docs/lessons/02-first-llm-call.md](docs/lessons/02-first-llm-call.md)
- Lesson 3, tool calling with read tools: [docs/lessons/03-read-tools.md](docs/lessons/03-read-tools.md)
- Lesson 4, the agent loop written by hand: [docs/lessons/04-agent-loop.md](docs/lessons/04-agent-loop.md)
- Index of all documentation: [docs/README.md](docs/README.md)
- Where the code lives: this GitHub repo is the source of truth, and pull requests and issues are here. A read-only mirror is kept on [GitLab](https://gitlab.com/mjesar/support_engineer).
12 changes: 12 additions & 0 deletions app/agents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# app/agents

The agent code. There are two ways to run the same agent here, on purpose.

| File | Role |
| --- | --- |
| `support_agent.rb` | **The agent the app uses.** A RubyLLM agent: model, tool allowlist and prompt. `chat.ask` runs the loop. |
| `agent_loop.rb`, `llm_client.rb`, `agent_tools.rb` | **The Lesson 4 hand-written version,** kept for learning. The same loop in plain Ruby with a step limit and a trace. Nothing else depends on it. |
| `triage.rb`, `next_step.rb` | Routing: classify a message, then pick the next step. |

Both versions use the same tool classes in `app/tools/`. See
[Lesson 4](../../docs/lessons/04-agent-loop.md) for how they compare.
47 changes: 47 additions & 0 deletions app/agents/agent_loop.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# The hand-written agent loop: ask the model, run the tools it requests, give it the results,
# and repeat until it answers in plain text. It owns the messages (the context); LlmClient
# and AgentTools stay stateless.
class AgentLoop
# stop_reason is :answered or :step_limit, so the caller can tell a real answer from a cut-off.
# trace lists every model request and tool call in order, to see afterwards what the agent did.
Result = Struct.new(:answer, :stop_reason, :trace)

def initialize(client:, tools:, max_steps: 5)
@client = client
@tools = tools
@max_steps = max_steps
end

def run(question)
messages = [ { role: "user", content: question } ]
trace = []

# The model decides when to stop, so a step limit keeps a confused one from looping forever.
@max_steps.times do |step|
trace << { event: :model_request, step: step + 1, message_count: messages.size }
reply = @client.chat(messages: messages, tools: @tools.definitions)
messages << reply
return Result.new(reply["content"], :answered, trace) unless reply["tool_calls"]

reply["tool_calls"].each { |tool_call| messages << tool_message(tool_call, trace) }
end

Result.new(nil, :step_limit, trace)
end

private

def tool_message(tool_call, trace)
function = tool_call["function"]
result = run_tool(function)
trace << { event: :tool_call, name: function["name"], arguments: function["arguments"], result: result }
{ role: "tool", tool_call_id: tool_call["id"], content: result.to_json }
end

# Bad arguments come back as an error the model can read and fix, like an unknown tool name.
def run_tool(function)
@tools.run(function["name"], JSON.parse(function["arguments"]))
rescue JSON::ParserError
{ error: "Arguments for #{function['name']} are not valid JSON" }
end
end
28 changes: 28 additions & 0 deletions app/agents/agent_tools.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# The tools the loop may use. The wrapper classes stay the only place that describes a tool;
# this class turns them into what Groq expects and runs them by name.
class AgentTools
def initialize(tool_classes)
@tools = tool_classes.map(&:new).index_by(&:name)
end

def definitions
@tools.values.map { |tool| definition(tool) }
end

# Unknown names come back as an error hash so the model can see its mistake and recover.
def run(name, arguments)
tool = @tools[name]
return { error: "Unknown tool #{name}" } unless tool

tool.execute(**arguments.symbolize_keys)
end

private

# "strict" is dropped: RubyLLM sets it at a different level in its own requests.
def definition(tool)
{ type: "function",
function: { name: tool.name, description: tool.description,
parameters: tool.parameters_schema.except("strict") } }
end
end
26 changes: 26 additions & 0 deletions app/agents/llm_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# One request to Groq: sends the messages and tool definitions, returns the assistant message.
# It does not run tools or keep the conversation; that is AgentLoop's job.
class LlmClient
BASE_URL = "https://api.groq.com/openai/v1".freeze
MODEL = "openai/gpt-oss-120b".freeze

def chat(messages:, tools:)
response = connection.post("chat/completions", request_body(messages, tools))
response.body["choices"][0]["message"]
end

private

# include_reasoning: false because Groq rejects the reasoning field when it is sent back.
def request_body(messages, tools)
{ model: MODEL, messages: messages, tools: tools, include_reasoning: false }
end

def connection
@connection ||= Faraday.new(BASE_URL, headers: { "Authorization" => "Bearer #{ENV.fetch('GROQ_API_KEY')}" }) do |faraday|
faraday.request :json
faraday.response :json
faraday.response :raise_error
end
end
end
57 changes: 57 additions & 0 deletions assets/readme/hand-written-loop.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
108 changes: 108 additions & 0 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,111 @@ or a local `stdio` process. Hosted servers usually need OAuth or a bearer token.
This project does not use it yet (checked 2026-10-10): the course plan has the MCP server side in
Lesson 5 and no lesson where the agent calls an outside server. The `ai_shop_assistant` project
does this against Shopify's Catalog API.

## What is `AgentTools` for in the hand-written agent loop?

It is the bridge between the model and the Ruby tool classes. The model only reads and writes text, so
two translations are needed, and `AgentTools` does both:

1. **Code to model.** `definitions` turns the tool wrapper classes into the JSON `tools` list that
Groq expects (name, description, argument schema). It is sent with every request.
2. **Model to code.** `run(name, arguments)` takes the tool name the model asked for (`"get_order"`) and
its parsed arguments, finds the matching tool and executes it.

It is also the allowlist. The model can only run the tools passed to `AgentTools.new`; an invented name
such as `delete_order` returns an error hash and nothing runs.

```
AgentLoop keeps the messages, repeats until the model answers
├─ LlmClient talks to Groq
└─ AgentTools lists the tools and runs them by name
└─ GetOrderTool → GetOrder → database
```

The wrapper classes (`RubyLLM::Tool`) stay the only place that describes a tool, so the hand-written
loop and `SupportAgent` send the model identical tool descriptions. Only the declarations are reused;
the loop itself is hand-written. `AgentLoop` parses the raw `arguments` JSON string before calling
`run`, so `AgentTools` only ever sees a hash.

## What is an agent loop, and how does it work?

An agent loop is a `loop` around the model call. The model can only read and write text, so it cannot
look anything up itself. It asks for a tool, the code runs it, and the result goes back to the model.
This repeats until the model answers in plain text.

The model has no memory between calls, so the loop keeps the `messages` array (the context) and sends
all of it every time. Each round adds to it:

1. The user question is added.
2. The model replies with `tool_calls` (a request, no `content`) or with `content` (the answer).
3. The model's reply is added to `messages`, so the history stays complete.
4. For each tool call, the code runs the tool and adds a `tool` message. Its `tool_call_id` links the
result to the request it answers, and its `content` must be a string (`result.to_json`).
5. The model is called again with the longer `messages`.

```
ask model → tool_calls? → yes → run tools, add results to messages → ask model again
→ no → the content is the answer, stop
```

The model decides how many rounds happen, which is what makes it an agent and not a fixed pipeline. A
normal LLM call is one round trip. An agent loop is many, with tool results in between.

The stop condition is "the reply has no `tool_calls`". Because the model controls it, the loop needs
safety rails: a step limit (with a stop reason), unknown tool names, and arguments that are not valid
JSON. Without a step limit, a model that keeps asking for tools loops forever.

Frameworks such as RubyLLM run this same loop inside `chat.ask`. Writing it by hand once (Lesson 4)
shows what they hide.

## Are safety rails and guardrails the same thing?

They overlap and people use the words loosely, but in this project they mean two layers.

**Safety rails** are technical limits that keep the agent loop from breaking. They do not judge what
the agent says. Examples: a step limit so the loop cannot run forever, an unknown tool name returning an
error instead of running something, and invalid JSON arguments being caught instead of crashing the
run. They matter even if the model behaves well, because it can still make mechanical mistakes.

**Guardrails** is the broader industry term for rules about content and behavior:

- Input checks: block prompt injection, detect personal data, refuse off-topic requests.
- Output checks: do not leak another customer's data, do not promise a refund the policy does not allow.
- Action limits: a human approves risky actions (the approval screen for write tools, Lesson 9).
- Permissions: the allowlist of tools, and read tools kept apart from write tools.

| | Safety rails | Guardrails |
| --- | --- | --- |
| Question | Can the loop run without breaking? | Should the agent say or do this? |
| Example | Stop after 5 steps | A refund over a limit needs approval |
| Where | Inside `AgentLoop` | Around it, and in tools, prompts and approval screens |

Guardrails include some safety rails: the `AgentTools` allowlist is both a loop-robustness feature and a
guardrail on what the model may do.

## Why write the agent loop by hand if RubyLLM already does it?

"Hand-written" means the loop is plain Ruby in this repo (`AgentLoop`), not hidden inside a library.
The result is the same. The difference is whether you can see it.

- **With RubyLLM:** `chat.ask(question)` runs the whole loop in one call. It sends the messages, sees
`tool_calls`, runs the tool, adds the result and asks again until the model answers. The loop is
there, but you never see it.
- **By hand (Lesson 4):** `AgentLoop` is the same loop in about 25 lines. We build the `messages`
array, call Groq through Faraday, parse the arguments and add each tool result ourselves.

Reasons to write it once:

- **Understanding.** A hidden loop is hard to debug. After writing it, you know what a framework does
when it misbehaves.
- **Interviews.** "How does an agent work?" is easy to answer when you have built the loop: what
`tool_calls` looks like, why the reply goes back into the history, why a step limit is needed.
- **Failure modes.** Writing the safety rails (step limit, invalid JSON, unknown tool) shows why a
framework needs them too.

It is not the better way for real work. From Lesson 5 on the project uses RubyLLM, which also handles
streaming, retries, structured output and several providers, none of which are worth rebuilding.

The lesson ends with a check: the same question goes through `AgentLoop` and through `SupportAgent`
(RubyLLM), and both should make the same tool calls. If they do, the framework is doing what the
hand-written loop does.
Loading
Loading