{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# AI Agents Are Just While Loops (That's the Scary Part)\n", "\n", "**The smallest real AI agent \u2014 one model, three tools, one `while` loop \u2014 plus the trap it builds for itself, and the one-sentence fix that only works when you write it in the right place.**\n", "\n", "This is the companion notebook to the [DiamantAI](https://www.youtube.com/@DiamantAI) video **[AI Agents Are Just While Loops. That's the Scary Part.](https://www.youtube.com/watch?v=FN1n_NVD9KM&list=PLBrpE2PttR2k)** Everything the film shows \u2014 the invoice run, the spiral, the rule that gets ignored in the prompt and obeyed in the loop \u2014 runs live here, in about 80 lines of Python.\n", "\n", "## The one idea\n", "\n", "An AI agent is **a text file with an engine attached**. The model remembers nothing between turns; the loop re-sends the whole conversation every time, so *the transcript is the agent's entire mind* \u2014 and a mind that trusts whatever is written in it can fill up with its own mistakes. That has one practical consequence, and you'll watch it happen below: **a rule written into the prompt is advice; a rule written into the loop is physics.**\n", "\n", "## Overview\n", "\n", "1. Build the smallest working agent: one model, three tools, a `while` loop\n", "2. Run it on a real question and watch the transcript grow\n", "3. **Spring the trap** \u2014 make a tool fail politely and watch the agent spiral\n", "4. Fix attempt #1: the rule in the **system prompt** (spoiler: ignored)\n", "5. Fix attempt #2: the same rule **in the loop as code** (recovers on the next turn)\n", "\n", "**Requirements:** `pip install anthropic`, and an `ANTHROPIC_API_KEY` in your environment. Each full run costs a few cents.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. Setup \u2014 a folder of invoices for the agent to work on\n", "\n", "The agent needs a real job. We'll give it the same one from the film: three invoices in a folder, and one question \u2014 *which of these is overdue, and by how much?*\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import os, json, subprocess\n", "from pathlib import Path\n", "\n", "os.environ.setdefault(\"AGENT_TODAY\", \"2026-08-30\") # the \"today\" the agent reasons against\n", "\n", "Path(\"invoices\").mkdir(exist_ok=True)\n", "Path(\"invoices/invoice-august.txt\").write_text(\n", " \"INVOICE 2026-041\\nClient: Meridian Systems\\nAmount due: $4,200\\nDue date: 2026-08-19\\nStatus: unpaid\\n\")\n", "Path(\"invoices/invoice-september.txt\").write_text(\n", " \"INVOICE 2026-052\\nClient: Meridian Systems\\nAmount due: $1,850\\nDue date: 2026-09-15\\nStatus: unpaid\\n\")\n", "Path(\"invoices/invoice-october.txt\").write_text(\n", " \"INVOICE 2026-063\\nClient: Northwind Ltd\\nAmount due: $980\\nDue date: 2026-10-02\\nStatus: unpaid\\n\")\n", "print(sorted(os.listdir(\"invoices\")))\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2. Three tiny tools\n", "\n", "The model only ever writes text. The trick that turns writing into doing is a promise **your code** makes: whatever the model asks for in a structured format (a *tool call*), your program will actually run \u2014 and paste the result back into the conversation.\n", "\n", "Note the two knobs on `run_tool`: `FLAKY` (the trap we'll spring in section 4) and the `failed_calls` guard (the fix we'll enable in section 6). Both are off for now.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "TOOLS = [\n", " {\"name\": \"list_files\", \"description\": \"List the files in a folder.\",\n", " \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n", " {\"name\": \"read_file\", \"description\": \"Read a text file and return its contents.\",\n", " \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n", " {\"name\": \"run_command\", \"description\": \"Run a shell command and return stdout+stderr.\",\n", " \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n", "]\n", "\n", "FLAKY = False # section 4 flips this: invoice reads start failing politely\n", "GUARD = False # section 6 flips this: the never-repeat rule, enforced by the LOOP\n", "failed_calls = set() # every (tool, args) pair that has already failed\n", "\n", "def run_tool(name, args):\n", " key = name + json.dumps(args, sort_keys=True)\n", " if GUARD and key in failed_calls:\n", " return \"BLOCKED by the loop: this exact call already failed. Change the path, the tool, or the question.\"\n", " try:\n", " if FLAKY and name == \"read_file\" and \"invoice\" in args.get(\"path\", \"\"):\n", " return \"ERROR: resource temporarily unavailable (errno 35), try again\"\n", " if name == \"list_files\":\n", " return \"\\n\".join(sorted(os.listdir(args[\"path\"])))\n", " if name == \"read_file\":\n", " return Path(args[\"path\"]).read_text()\n", " if name == \"run_command\":\n", " r = subprocess.run(args[\"command\"], shell=True, capture_output=True, text=True, timeout=60)\n", " return (r.stdout + r.stderr).strip() or f\"(exit {r.returncode}, no output)\"\n", " except Exception as e:\n", " return f\"ERROR: {e}\"\n", "\n", "def note_failure(name, args, out):\n", " if out.startswith(\"ERROR\"):\n", " failed_calls.add(name + json.dumps(args, sort_keys=True))\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3. The entire program\n", "\n", "This is the part the film shows once, huge, because it **is** the whole machine. Send the conversation to the model. Run whatever it asks for. Paste the result back in. Go around again. Every agent framework you've heard of \u2014 LangChain, CrewAI, AutoGen \u2014 wraps this exact loop with nicer handles.\n", "\n", "Two production defenses are already here, and both live **in the loop, not in the prompt**: the hard stop (`MAX_TURNS`) and, later, the never-repeat guard.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import anthropic\n", "\n", "MODEL = \"claude-sonnet-5\"\n", "MAX_TURNS = 10\n", "client = anthropic.Anthropic() # uses ANTHROPIC_API_KEY from your environment\n", "\n", "def run_agent(question, extra_system=\"\"):\n", " system = f\"You are an agent. Use the tools to answer. Today is {os.environ['AGENT_TODAY']}.\" + extra_system\n", " transcript = [{\"role\": \"user\", \"content\": question}] # <-- the agent's entire mind\n", "\n", " for turn in range(1, MAX_TURNS + 1): # the hard stop lives in the LOOP\n", " reply = client.messages.create(model=MODEL, max_tokens=1024, system=system,\n", " tools=TOOLS, messages=transcript)\n", " transcript.append({\"role\": \"assistant\", \"content\": reply.content})\n", " calls = [b for b in reply.content if b.type == \"tool_use\"]\n", " for b in reply.content:\n", " if b.type == \"text\" and b.text.strip():\n", " print(f\"[turn {turn}] {b.text.strip()}\")\n", " if not calls:\n", " print(f\"(done in {turn} turns)\")\n", " return transcript\n", " results = []\n", " for c in calls:\n", " out = run_tool(c.name, c.input)\n", " note_failure(c.name, c.input, out)\n", " print(f\"[turn {turn}] -> {c.name}({json.dumps(c.input)})\\n{out}\\n\")\n", " results.append({\"type\": \"tool_result\", \"tool_use_id\": c.id, \"content\": out})\n", " transcript.append({\"role\": \"user\", \"content\": results}) # errors append exactly like results\n", " print(f\"(hit the {MAX_TURNS}-turn cap; stopping)\")\n", " return transcript\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3b. Run it\n", "\n", "Watch the turns: on turn 1 the model doesn't answer \u2014 it asks for the file list. Turn 2, it reads all three invoices and does the date arithmetic itself (nobody gave it a calculator). Turn 3, it answers \u2014 checked against files it had never seen.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "FLAKY, GUARD = False, False\n", "failed_calls.clear()\n", "transcript = run_agent(\"Which of the invoices in ./invoices is overdue, and by how much?\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3c. Freeze it and read the mind\n", "\n", "Between any two of those turns the model remembered **nothing** \u2014 every turn is a brand-new call to a stranger. It only looks continuous because the loop re-sends this whole pile every time. Read it: this pile of text is everything the agent *is*. **The transcript is the mind.**\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "for i, msg in enumerate(transcript):\n", " if isinstance(msg[\"content\"], str):\n", " print(f\"{i}. [{msg['role']}] {msg['content'][:100]}\")\n", " else:\n", " for b in msg[\"content\"]:\n", " t = getattr(b, \"type\", None) or b.get(\"type\")\n", " if t == \"text\": print(f\"{i}. [{msg['role']}] text: {b.text[:90]}\")\n", " elif t == \"tool_use\": print(f\"{i}. [{msg['role']}] tool_use: {b.name}({json.dumps(b.input)})\")\n", " elif t == \"tool_result\":print(f\"{i}. [{msg['role']}] tool_result: {str(b['content'])[:90]}\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4. Spring the trap\n", "\n", "Now we make every invoice read fail with the friendliest error in production: *temporarily unavailable, try again*. Watch what the loop does with that. The error lands in the transcript **exactly like a real result** \u2014 because to the loop, everything is a result. The model re-reads its mind, sees *try again*, and does the reasonable thing: it trusts its own notebook.\n", "\n", "From the film's real run (the same code you're holding):\n", "\n", "```text\n", "[turn 2] -> read_file(invoice-august.txt) ERROR: temporarily unavailable, try again\n", "[turn 3] -> read_file(invoice-august.txt) ERROR: temporarily unavailable, try again\n", "[turn 4] -> read_file(invoice-august.txt) ERROR: temporarily unavailable, try again\n", "[turn 5] -> run_command(cat invoice-august.txt) INVOICE 2026-041 ...\n", "```\n", "\n", "**That's the spiral** \u2014 a mind filling with its own mistake, at full price per lap. This run escaped on turn 5 (the model gave up on the broken tool and went through the shell), but it burned three paid turns first, and a slightly nastier error would have held the trap shut for good.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "FLAKY, GUARD = True, False\n", "failed_calls.clear()\n", "_ = run_agent(\"Which of the invoices in ./invoices is overdue, and by how much?\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 5. Fix attempt #1 \u2014 the rule in the system prompt\n", "\n", "The obvious fix is one sentence: *never repeat a tool call that already failed*. Write it into the system prompt \u2014 the standing instructions at the very top of the transcript, re-read every single turn. It cannot be missed.\n", "\n", "In the film's real run, the model read that rule \u2014 **and retried the dead call three more times anyway.** A prompt is advice. It lands in the same pile as everything else, and by then the pile also holds three errors saying *try again*. Three fresh errors are louder than one old rule.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "FLAKY, GUARD = True, False\n", "failed_calls.clear()\n", "_ = run_agent(\"Which of the invoices in ./invoices is overdue, and by how much?\",\n", " extra_system=\" After a failed tool call, never repeat it: change the path, the tool, or the question.\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 6. Fix attempt #2 \u2014 the same sentence, in the loop\n", "\n", "Now move the same rule out of the mind and into the engine: `GUARD = True` makes `run_tool` **refuse** any call that already failed. Same rule, same words. In the prompt it was a suggestion the spiral could shout over. In the loop, it's physics.\n", "\n", "The film's real run with the guard on:\n", "\n", "```text\n", "[turn 3] -> read_file(invoice-august.txt)\n", "BLOCKED by the loop: this exact call already failed. Change the path, the tool, or the question.\n", "[turn 4] -> run_command(cat invoice-august.txt; ...) INVOICE 2026-041 ...\n", "```\n", "\n", "One blocked attempt \u2014 and on the very next turn the model switched tools and the run recovered.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "FLAKY, GUARD = True, True\n", "failed_calls.clear()\n", "_ = run_agent(\"Which of the invoices in ./invoices is overdue, and by how much?\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## The fact to walk away with\n", "\n", "An AI agent is a text file with an engine attached \u2014 and the file can catch fire. So anything that **must** be true belongs in the engine, written as code:\n", "\n", "- a **spending cap** and a **hard stop** (`MAX_TURNS` \u2014 a loop that can't quit is the only true disaster),\n", "- the **never-repeat rule** (`failed_calls` \u2014 enforced, not suggested),\n", "- and a **curated transcript** (summarize old turns, keep the facts, drop the dead weight \u2014 a shorter, truer mind beats a complete one).\n", "\n", "The file is for thinking. The engine is for promises.\n", "\n", "---\n", "\n", "*From the [DiamantAI](https://www.youtube.com/@DiamantAI) video \\\"AI Agents Are Just While Loops. That's the Scary Part.\\\" \u2014 subscribe for the next one: somewhere in those turns, your agent will hand you an answer it invented, and it won't know it did. Why it **can't** know is the next film.*\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "📖 **Want the decisions behind agents like this one, explained without code?** [*AI Agents Made Simple*](https://europe-west1-rag-techniques-views-tracker.cloudfunctions.net/rag-techniques-tracker?notebook=all-agents-tutorials--agent-while-loop-from-scratch&click=book-nb-agents&target=https%3A%2F%2Fwww.amazon.com%2Fdp%2FB0HNXRSRH2&retarget=0&text=book-nb-agents) by the author of this repository covers this and much more. Available on Amazon." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11.0" } }, "nbformat": 4, "nbformat_minor": 5 }