{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "c00",
   "metadata": {},
   "source": [
    "# W9 Practice — LangChain Core Patterns\n",
    "\n",
    "[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/ralbu85/stml_2026/blob/main/lectures/week09/W9_practice_langchain_core.ipynb)\n",
    "\n",
    "**Goal.** Learn LangChain by writing the patterns of the Week 5 practice notebook (v2) again: one call, a conversation, structured output, a tool call by hand, and the agent loop. Most examples are the same as in Week 5, so you can compare each LangChain line with the OpenAI SDK line that you already know. At the end, `create_agent` runs the whole loop in one call.\n",
    "\n",
    "**LangChain** is an open-source framework for applications that use language models. It gives one interface for chat models from many providers, classes for messages and tools, and ready-made agents. Its agents run on **LangGraph**, a library that runs a program as a graph: steps connected by arrows, which can form a loop (Part 6).\n",
    "\n",
    "| Part | Week 5 (OpenAI SDK) | This notebook (LangChain) |\n",
    "|---|---|---|\n",
    "| 1. One call | `client.chat.completions.create(...)` | `model.invoke(...)` |\n",
    "| 2. A conversation | message dictionaries | message objects |\n",
    "| 3. Structured output | `response_format={\"type\": \"json_object\"}` + `json.loads` | `with_structured_output` |\n",
    "| 4. A tool call by hand | `tool_from_function`, `\"tool_calls\" in reply` | `@tool`, `bind_tools`, `reply.tool_calls` |\n",
    "| 5. The agent loop | `run_loop` | the same loop with LangChain objects |\n",
    "| 6. The ready-made agent | your `Agent` class (Week 5 Part 7) | `create_agent` |\n",
    "\n",
    "Week 5 Part 6 (ReAct) is left out here. The Week 9 lab uses Parts 1 and 2; later weeks use Parts 3 to 6.\n",
    "\n",
    "**Cell labels.** The first line of each code cell says what to do with it:\n",
    "\n",
    "- `# ✍️ TYPE`: an exercise. Write the code between the `FILL IN` lines yourself.\n",
    "- `# ✍️ WRITE`: answer in words (not checked).\n",
    "- `# ▶ RUN · example`: read each line, then run.\n",
    "- `# ▶ RUN · setup` and `# ▶ RUN · check`: just run.\n",
    "\n",
    "It takes about 60 minutes and is not collected. Do this notebook before the Week 9 lab."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c01",
   "metadata": {},
   "source": [
    "## 0. Setup\n",
    "\n",
    "*Do:* run the three cells; paste your API key into the second. The third cell makes the model object: `init_chat_model(\"openai:gpt-4.1\")` replaces `client = OpenAI()` and `MODEL` of Week 5, and the part before the colon names the provider. The cell also defines `show_messages`, which prints each message: its position, its type (its role, see Part 2), and its content, or for a request, the requested tool and its arguments."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c02",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · setup: just run\n",
    "%pip install -q \"langchain==1.4.4\" \"langchain-openai==1.7.0\" \"langgraph==1.2.14\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c03",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · setup: just run\n",
    "import os\n",
    "\n",
    "OPENAI_API_KEY = \"PASTE-YOUR-KEY-HERE\"   # paste your key between the quotes\n",
    "if OPENAI_API_KEY != \"PASTE-YOUR-KEY-HERE\":\n",
    "    os.environ[\"OPENAI_API_KEY\"] = OPENAI_API_KEY   # LangChain reads the key from here"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c04",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · setup: just run\n",
    "import json\n",
    "import requests\n",
    "from langchain.chat_models import init_chat_model\n",
    "from langchain.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage\n",
    "\n",
    "MODEL = \"openai:gpt-4.1\"           # \"provider:model\"; the prefix tells LangChain which client to use\n",
    "model = init_chat_model(MODEL)     # one model object for the whole notebook\n",
    "done = {}                          # the check cells record your results here\n",
    "\n",
    "def show_messages(messages):\n",
    "    \"\"\"Print each message: position, type, and content (or the requested tool and its arguments).\"\"\"\n",
    "    for i in range(len(messages)):\n",
    "        m = messages[i]\n",
    "        if m.type == \"ai\" and m.tool_calls:            # a request: print each requested tool\n",
    "            for request in m.tool_calls:\n",
    "                print(i, m.type, request[\"name\"], request[\"args\"])\n",
    "        else:\n",
    "            print(i, m.type, m.content)\n",
    "    print()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c05",
   "metadata": {},
   "source": [
    "## 1. One Call\n",
    "\n",
    "In Week 5, one call was three lines: `client.chat.completions.create(...)`, then `model_dump` to get a dictionary, then `append`. In LangChain, `model.invoke(...)` replaces the first two lines: it sends the messages and returns the reply as an `AIMessage` **object**. Keeping the reply with `append` is still your job (Part 2). `invoke` also takes a plain string and sends it as one user message.\n",
    "\n",
    "You read the parts of an object as attributes, with a dot: `reply.content` instead of `reply[\"content\"]`.\n",
    "\n",
    "### 1.1 One call\n",
    "\n",
    "*Do:* run the cell. It prints the class of the reply, its content, and the number of tokens that the call used."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c06",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "QUESTION = \"In one sentence, what is a canceled order in an online shop?\"\n",
    "reply = model.invoke(QUESTION)                     # a string is sent as one user message\n",
    "print(type(reply).__name__)                        # the name of the class of the reply\n",
    "print(reply.content)\n",
    "usage = reply.usage_metadata                       # a dictionary of token counts\n",
    "print(\"input tokens:\", usage[\"input_tokens\"], \"| output tokens:\", usage[\"output_tokens\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c07",
   "metadata": {},
   "source": [
    "**Reading the output.** `AIMessage` is the LangChain class for an assistant message. `reply.content` is the text, as `reply[\"content\"]` was in Week 5. `reply.usage_metadata` is a plain dictionary, so you read it with brackets: `input_tokens`, `output_tokens`, and `total_tokens`. The Week 9 lab reads `input_tokens` to compare the cost of different inputs.\n",
    "\n",
    "### 1.2 ✍️ Your first call\n",
    "\n",
    "*Do:* replace the placeholder with a call to the model that sends `\"Reply with exactly: PASS\"`. Expected: `PASS`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c08",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ✍️ TYPE · exercise: write the code between the FILL IN lines yourself\n",
    "### FILL IN (START) ###\n",
    "reply = AIMessage(content=\"not called yet\")        # replace: call the model with \"Reply with exactly: PASS\"\n",
    "### FILL IN (END) ###"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c09",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · check: just run\n",
    "print(reply.content)\n",
    "done[\"1.2\"] = \"PASS\" in reply.content\n",
    "done[\"1.2\"]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c10",
   "metadata": {},
   "source": [
    "## 2. A Conversation: the List Grows\n",
    "\n",
    "A list of messages works as in Week 5: you add the messages that you write, and you keep each reply. Each role has a class:\n",
    "\n",
    "| Week 5 dictionary | LangChain object | `.type` |\n",
    "|---|---|---|\n",
    "| `{\"role\": \"system\", \"content\": ...}` | `SystemMessage(...)` | `system` |\n",
    "| `{\"role\": \"user\", \"content\": ...}` | `HumanMessage(...)` | `human` |\n",
    "| the reply, `{\"role\": \"assistant\", ...}` | `AIMessage` (returned by `invoke`) | `ai` |\n",
    "| `{\"role\": \"tool\", \"tool_call_id\": ..., ...}` | `ToolMessage(...)` (Part 4) | `tool` |\n",
    "\n",
    "LangChain calls the user role `human` and the assistant role `ai`; `.type` holds this name. `invoke` also accepts the dictionaries of Week 5, but use the objects here: the check cells read `.type`.\n",
    "\n",
    "### 2.1 Two turns\n",
    "\n",
    "*Do:* run the four cells one by one. Units 2 and 4 are the same code: call the model with the whole list and keep the reply."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c11",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 1 - the list: a system message and a user message\n",
    "STAFF_SYSTEM = \"You answer questions from shop staff. Answer in at most two sentences.\"\n",
    "QUESTION = \"A customer ordered 2 mugs and 1 desk lamp. List the items in the order.\"\n",
    "\n",
    "messages = [\n",
    "    SystemMessage(STAFF_SYSTEM),                     # system message\n",
    "    HumanMessage(QUESTION),                          # human (user) message\n",
    "]\n",
    "show_messages(messages)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c12",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 2 - call the model, keep the reply\n",
    "reply = model.invoke(messages)\n",
    "messages.append(reply)                               # ai (assistant) message\n",
    "show_messages(messages)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c13",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 3 - add the next question\n",
    "messages.append(HumanMessage(\"The lamp was canceled. What is left in the order?\"))   # human (user) message\n",
    "show_messages(messages)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c14",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 4 - call the model, keep the reply (the same code as unit 2)\n",
    "reply = model.invoke(messages)\n",
    "messages.append(reply)                               # ai (assistant) message\n",
    "show_messages(messages)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c15",
   "metadata": {},
   "source": [
    "**Reading the output.** The list grows 2 → 3 → 4 → 5. The model answers the second question correctly only because the list still holds the first question and its answer: the model itself keeps nothing between calls, as in Week 5.\n",
    "\n",
    "### 2.2 ✍️ One more turn\n",
    "\n",
    "*Do:* add the question `\"The customer adds 3 notebooks. What is in the order now?\"`, call the model, and keep the reply: three lines, as in units 3 and 4. Expected: 7 messages, and an answer that names the mugs and the notebooks. If you run the cell twice, the list grows twice; then run 2.1 again from unit 1."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c16",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ✍️ TYPE · exercise: write the code between the FILL IN lines yourself\n",
    "### FILL IN (START) ###\n",
    "# add the question, call the model, keep the reply\n",
    "### FILL IN (END) ###"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c17",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · check: just run\n",
    "show_messages(messages)\n",
    "types = []\n",
    "for m in messages:\n",
    "    types.append(m.type)\n",
    "answer_text = messages[-1].content.lower()\n",
    "done[\"2.2\"] = types == [\"system\", \"human\", \"ai\", \"human\", \"ai\", \"human\", \"ai\"] and \"notebook\" in answer_text and \"mug\" in answer_text\n",
    "done[\"2.2\"]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c18",
   "metadata": {},
   "source": [
    "## 3. Structured Output: an Object instead of JSON Text\n",
    "\n",
    "In Week 5 you described the JSON keys in the prompt, set `response_format={\"type\": \"json_object\"}`, and read the text with `json.loads`. In LangChain you describe the result as a class instead.\n",
    "\n",
    "The class is written with **Pydantic**, a library installed with LangChain for classes whose fields have fixed types. `(BaseModel)` after the class name makes it a Pydantic class. Each line `name: type` declares one field, in the same notation as `city: str` in a function. The two classes describe the JSON of Week 5: `Item` has `product` and `quantity`, and `Order` has `items` (`list[Item]`, a list of `Item` objects) and `city`.\n",
    "\n",
    "`model.with_structured_output(Order)` returns a new model. It sends the class as a JSON schema with each call, so the API returns exactly these fields, and LangChain reads the JSON into an `Order` object.\n",
    "\n",
    "### 3.1 An order as an object\n",
    "\n",
    "*Do:* run the cell. It prints the object, then reads its fields with a dot."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c19",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "from pydantic import BaseModel\n",
    "\n",
    "class Item(BaseModel):\n",
    "    product: str\n",
    "    quantity: int\n",
    "\n",
    "class Order(BaseModel):\n",
    "    items: list[Item]                              # a list of Item objects\n",
    "    city: str\n",
    "\n",
    "order_model = model.with_structured_output(Order)  # replies of this model are Order objects\n",
    "customer_message = \"Hi, I'd like to order 3 notebooks and 2 pen sets, delivered to Busan.\"\n",
    "order = order_model.invoke(f\"\"\"Extract the order from the customer message.\n",
    "\n",
    "Customer message: {customer_message}\"\"\")\n",
    "print(order)\n",
    "for item in order.items:\n",
    "    print(item.product, item.quantity)\n",
    "print(order.city)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c20",
   "metadata": {},
   "source": [
    "**Reading the output.** The prompt says only what to extract; the keys come from the class, so the prompt no longer lists them. `order.items` is a list of `Item` objects. `item.quantity` is an `int` because Pydantic checked the type. You read the fields with a dot, `order.city`, where Week 5 used `order[\"city\"]`.\n",
    "\n",
    "### 3.2 ✍️ Another order\n",
    "\n",
    "*Do:* call `order_model` for the message below, with the prompt of 3.1. Then add up the quantities of the items in `total_quantity`. Expected: Seoul and 5."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c21",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ✍️ TYPE · exercise: write the code between the FILL IN lines yourself\n",
    "customer_message = \"Please send 1 desk lamp and 4 mugs to Seoul.\"\n",
    "### FILL IN (START) ###\n",
    "order = Order(items=[], city=\"\")                   # replace: call order_model with the prompt of 3.1\n",
    "### FILL IN (END) ###\n",
    "total_quantity = 0\n",
    "for item in order.items:\n",
    "    ### FILL IN (START) ###\n",
    "    pass                                           # add this item's quantity to total_quantity\n",
    "    ### FILL IN (END) ###"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c22",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · check: just run\n",
    "print(order)\n",
    "done[\"3.2\"] = order.city == \"Seoul\" and total_quantity == 5\n",
    "print(done[\"3.2\"], total_quantity)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c23",
   "metadata": {},
   "source": [
    "## 4. A Tool Call by Hand\n",
    "\n",
    "The round trip is the same as in Weeks 3 to 5: the model requests a tool, your code runs it, and a tool message carries the result back with the id of the request.\n",
    "\n",
    "### 4.1 The tool\n",
    "\n",
    "In Week 5 a tool was two things: the function in `FUNCTIONS` and its schema in `TOOLS`, made by `tool_from_function`. In LangChain one **tool object** holds both. `@tool` on the line above `def` is a **decorator**: Python passes the function to `tool` and stores the result under the same name. So `get_weather` is now a tool object, not a plain function. `get_weather(\"Seoul\")` no longer works; `get_weather.invoke({\"city\": \"Seoul\"})` runs it, with the arguments as one dictionary.\n",
    "\n",
    "The schema comes from the name, the docstring, and the type annotations, as with `tool_from_function`; `parse_docstring=True` also reads the `Args:` lines. The body is the Week 5 body with one change: it returns JSON text (`json.dumps`), because a tool message holds text.\n",
    "\n",
    "*Do:* run the cell. It prints the schema that the model will see, in the format of `TOOLS[0]` in Week 5 (`convert_to_openai_tool` writes it out), then runs the tool once by hand."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c24",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "from langchain.tools import tool\n",
    "from langchain_core.utils.function_calling import convert_to_openai_tool\n",
    "\n",
    "@tool(parse_docstring=True)\n",
    "def get_weather(city: str) -> str:\n",
    "    \"\"\"Get the current temperature of a city in Celsius.\n",
    "\n",
    "    Args:\n",
    "        city: City name in English, for example \"Seoul\".\n",
    "    \"\"\"\n",
    "    try:                                           # on a network or lookup error, return the error as text\n",
    "        place = requests.get(\"https://geocoding-api.open-meteo.com/v1/search\",\n",
    "                             params={\"name\": city, \"count\": 1}, timeout=10).json()[\"results\"][0]   # first match\n",
    "        weather = requests.get(\"https://api.open-meteo.com/v1/forecast\",\n",
    "                               params={\"latitude\": place[\"latitude\"], \"longitude\": place[\"longitude\"],\n",
    "                                       \"current\": \"temperature_2m\"}, timeout=10).json()\n",
    "        return json.dumps({\"city\": place[\"name\"], \"temperature_c\": weather[\"current\"][\"temperature_2m\"]})\n",
    "    except Exception as error:\n",
    "        return \"error: \" + str(error)\n",
    "\n",
    "print(json.dumps(convert_to_openai_tool(get_weather), indent=2))   # the schema, as TOOLS[0] in Week 5\n",
    "print(get_weather.invoke({\"city\": \"Seoul\"}))                        # run the tool: the arguments as one dictionary"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c25",
   "metadata": {},
   "source": [
    "**Reading the output.** The schema has the same keys as `TOOLS[0]` in Week 5: the name, the description, and the parameter `city` with its description. If Open-Meteo cannot be reached, the tool returns the error as text, and you can continue.\n",
    "\n",
    "### 4.2 Call, check, call, check\n",
    "\n",
    "`model.bind_tools([get_weather])` returns a new model that sends the tool schema with every call, as `tools=TOOLS` did in Week 5; `model` itself stays without tools. Every reply is an `AIMessage`. Week 5 checked whether `reply` had the key `\"tool_calls\"`; now you check whether the list `reply.tool_calls` is empty. For a request, the list holds one dictionary per request, with `name`, `args`, `id`, and `type`. `args` is already a dictionary, so `json.loads` and `**args` are not needed. For a text answer, the list is `[]`, and the text is in `reply.content`. As in Week 5, every request needs its tool message before the next call.\n",
    "\n",
    "*Do:* run the five cells in order and watch the list grow 2 → 3 → 4 → 5. Units 2 and 4 are the same code, and so are units 3 and 5. If you run a cell twice, start again from unit 1."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c26",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 1 - the list, and a model that knows the tool\n",
    "WEATHER_SYSTEM = \"You answer questions about the weather. Use the tools for current values; never guess them.\"\n",
    "QUESTION = \"What is the temperature in Seoul right now?\"\n",
    "\n",
    "weather_model = model.bind_tools([get_weather])    # calls of weather_model include the tool schema\n",
    "messages = [SystemMessage(WEATHER_SYSTEM), HumanMessage(QUESTION)]\n",
    "answer = None\n",
    "show_messages(messages)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c27",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 2 - call the model\n",
    "reply = weather_model.invoke(messages)\n",
    "messages.append(reply)                               # ai (assistant) message\n",
    "print(reply.tool_calls)                              # the requests: name, args, id, type\n",
    "print(repr(reply.content))                           # the text of the reply; repr shows an empty text as ''\n",
    "show_messages(messages)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c28",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 3 - check: run each requested tool, or read the answer\n",
    "## (one tool here, so get_weather runs directly; 5.2 finds each tool by its name)\n",
    "if reply.tool_calls:                                 # a non-empty list counts as True\n",
    "    for request in reply.tool_calls:\n",
    "        result = get_weather.invoke(request[\"args\"])\n",
    "        messages.append(ToolMessage(result, tool_call_id=request[\"id\"]))   # TOOL message\n",
    "else:\n",
    "    answer = reply.content\n",
    "show_messages(messages)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c29",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 4 - call the model (the same code as unit 2)\n",
    "reply = weather_model.invoke(messages)\n",
    "messages.append(reply)                               # ai (assistant) message\n",
    "print(reply.tool_calls)\n",
    "show_messages(messages)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c30",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "## Unit 5 - check (the same code as unit 3)\n",
    "if reply.tool_calls:\n",
    "    for request in reply.tool_calls:\n",
    "        result = get_weather.invoke(request[\"args\"])\n",
    "        messages.append(ToolMessage(result, tool_call_id=request[\"id\"]))\n",
    "else:\n",
    "    answer = reply.content\n",
    "print(\"answer:\", answer)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c31",
   "metadata": {},
   "source": [
    "**Reading the output.** The first reply has a request, and its content is empty (`''`). The second reply has no requests (`[]`) and holds the answer. `ToolMessage(result, tool_call_id=...)` is the tool message of Week 5, `{\"role\": \"tool\", \"tool_call_id\": ..., \"content\": ...}`, written as an object.\n",
    "\n",
    "### 4.3 ✍️ Run the tool for Tokyo\n",
    "\n",
    "*Do:* the cell builds the list for Tokyo and calls the model once. Complete the loop: for each request, run the tool and add the tool message (two lines, as in unit 3). The next cell prints the list; the last message must be a tool message. The next call would give the answer, as in unit 4."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c32",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ✍️ TYPE · exercise: write the code between the FILL IN lines yourself\n",
    "messages = [SystemMessage(WEATHER_SYSTEM), HumanMessage(\"What is the temperature in Tokyo right now?\")]\n",
    "reply = weather_model.invoke(messages)\n",
    "messages.append(reply)\n",
    "for request in reply.tool_calls:\n",
    "    ### FILL IN (START) ###\n",
    "    pass                                           # run the tool, then add the tool message\n",
    "    ### FILL IN (END) ###"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c33",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · check: just run\n",
    "show_messages(messages)\n",
    "done[\"4.3\"] = messages[-1].type == \"tool\"\n",
    "done[\"4.3\"]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c34",
   "metadata": {},
   "source": [
    "## 5. The Agent Loop\n",
    "\n",
    "The loop repeats call and check until the reply has no requests, as `run_loop` did in Week 5.\n",
    "\n",
    "### 5.1 A calculator tool\n",
    "\n",
    "This is the Week 5 `calculate` as a LangChain tool. Like `get_weather`, it returns text (`str(...)`), because a tool message holds text. `eval` runs the text as Python; `{\"__builtins__\": {}}` removes the built-in functions, which is enough for this practice but not a real safeguard (Chapter 14).\n",
    "\n",
    "*Do:* run the cell."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c35",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "@tool(parse_docstring=True)\n",
    "def calculate(expression: str) -> str:\n",
    "    \"\"\"Evaluate an arithmetic expression and return the result.\n",
    "\n",
    "    Args:\n",
    "        expression: A Python arithmetic expression, for example \"58 * 94 + 37 * 21\".\n",
    "    \"\"\"\n",
    "    try:\n",
    "        return str(eval(expression, {\"__builtins__\": {}}))   # run the text as Python, with no built-in functions\n",
    "    except Exception as error:\n",
    "        return \"error: \" + str(error)\n",
    "\n",
    "print(calculate.invoke({\"expression\": \"58 * 94 + 37 * 21\"}))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c36",
   "metadata": {},
   "source": [
    "### 5.2 The loop as a function\n",
    "\n",
    "`run_loop` is the Week 5 function with LangChain objects. Two things changed. There is no `functions` argument: each tool object carries its name and its function, so the dictionary `tools_by_name`, built inside, replaces `FUNCTIONS`. And `model.bind_tools(tools)` replaces `tools=tools` in the call.\n",
    "\n",
    "*Do:* run the cell. It defines `run_loop` and runs it with both tools on the Week 5 question about Seoul in Fahrenheit."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c37",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "def run_loop(messages, tools, max_steps=6):\n",
    "    \"\"\"Call the model, run the requested tools, and repeat until the reply has no requests.\"\"\"\n",
    "    tools_by_name = {}\n",
    "    for t in tools:\n",
    "        tools_by_name[t.name] = t                  # name -> tool object\n",
    "    loop_model = model.bind_tools(tools)\n",
    "    for step in range(max_steps):\n",
    "        reply = loop_model.invoke(messages)\n",
    "        messages.append(reply)\n",
    "        if reply.tool_calls:\n",
    "            for request in reply.tool_calls:\n",
    "                result = tools_by_name[request[\"name\"]].invoke(request[\"args\"])\n",
    "                messages.append(ToolMessage(result, tool_call_id=request[\"id\"]))\n",
    "        else:\n",
    "            return reply.content\n",
    "    return None                                    # the step limit ended the loop\n",
    "\n",
    "LOOP_SYSTEM = \"You answer questions with your tools. Use calculate for every calculation.\"\n",
    "QUESTION = \"What is the current temperature in Seoul in Fahrenheit? Use F = C × 1.8 + 32.\"\n",
    "messages = [SystemMessage(LOOP_SYSTEM), HumanMessage(QUESTION)]\n",
    "answer = run_loop(messages, [get_weather, calculate])\n",
    "show_messages(messages)\n",
    "print(\"answer:\", answer)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c38",
   "metadata": {},
   "source": [
    "**Reading the output.** In our runs, the model asked for the weather first, then for the calculation, and then answered: each reply waited for the result of the one before. One reply can also request several tools at once. Then the requests share one position in the printed list, and the loop adds one tool message for each request. In both cases, the loop calls the model again until a reply has no requests."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c39",
   "metadata": {},
   "source": [
    "## 6. The Ready-Made Agent: `create_agent`\n",
    "\n",
    "`create_agent` builds the loop of 5.2 for you. It takes the model, the tools, and the system prompt, as your `Agent` class did, and returns an agent. The agent keeps its data in a dictionary, the **state**; the message list is under the key `\"messages\"`. `invoke` takes a state with your question and returns the final state. Unlike your `Agent` class, the agent does not keep the list between calls: for a follow-up question, pass `state[\"messages\"]` with the new question added.\n",
    "\n",
    "### 6.1 The same question\n",
    "\n",
    "*Do:* run the cell and compare the list with the list of 5.2: usually the same types in the same order. The system prompt is not in the returned list, because the agent adds it to each model call, so each position is one lower than in 5.2. The last lines print the graph of the agent: each arrow from one step to the next."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c40",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · example: read each line, then run\n",
    "from langchain.agents import create_agent\n",
    "\n",
    "agent = create_agent(model, tools=[get_weather, calculate], system_prompt=LOOP_SYSTEM)\n",
    "state = agent.invoke({\"messages\": [HumanMessage(QUESTION)]})\n",
    "show_messages(state[\"messages\"])\n",
    "for edge in agent.get_graph().edges:               # each arrow from one step to the next\n",
    "    print(edge.source, \"->\", edge.target)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c41",
   "metadata": {},
   "source": [
    "**Reading the output.** Each step of the agent is a **node**, and each arrow is an **edge**. `__start__` and `__end__` mark where a run begins and ends. After `model`, the graph goes to `tools` if the reply has requests, and to `__end__` if not; after `tools`, it goes back to `model`. This is the `for` loop and the `if reply.tool_calls:` of `run_loop`.\n",
    "\n",
    "**Next.** The Week 9 lab uses Parts 1 and 2 (`init_chat_model`, `invoke`, message objects, `usage_metadata`). It builds a RAG program over the EU AI Act without an agent. In Week 10, the search of that program becomes a tool (Part 4) of a `create_agent` agent (Part 6).\n",
    "\n",
    "## 7. Explain in your own words\n",
    "\n",
    "Write two or three sentences for each in `answers` (not checked).\n",
    "\n",
    "1. Which Week 5 line does each of these replace: `model.invoke`, `reply.tool_calls`, `ToolMessage`, `with_structured_output`?\n",
    "2. In 3.1, where does the model learn the keys of the JSON, and what does LangChain do with the JSON that it gets back?\n",
    "3. Which steps of `run_loop` does `create_agent` do for you, and which lines did you not have to write?\n",
    "\n",
    "*Do:* write your answers, then run the last cell to see which exercises passed. For a `False` item, rerun that part from its first cell."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c42",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ✍️ WRITE · your answer in words (not checked)\n",
    "answers = \"\"\"\n",
    "1.\n",
    "\n",
    "2.\n",
    "\n",
    "3.\n",
    "\"\"\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c43",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ▶ RUN · check: just run\n",
    "for key in done:\n",
    "    print(key, done[key])"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
