{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "192a5723",
   "metadata": {},
   "source": [
    "# Lesson 17 - Creating Local AI Agents with Foundry Local and Qwen\n",
    "\n",
    "In this notebook you build a **local engineering assistant** that runs entirely on your workstation. No cloud inference is used at any point. The assistant can:\n",
    "\n",
    "1. **Call tools** via Qwen function calling through Foundry Local.\n",
    "2. **List and read files** inside a sandboxed project directory.\n",
    "3. **Analyse code** with simple metrics.\n",
    "4. **Search documentation** with local RAG (Chroma).\n",
    "5. **Use a local MCP server** (skipped gracefully if none is configured).\n",
    "\n",
    "The agent code looks almost identical to the cloud lessons — only the client endpoint moves from the cloud to `localhost`."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "40f2192b",
   "metadata": {},
   "source": [
    "## Setup\n",
    "\n",
    "Before running this notebook:\n",
    "\n",
    "1. **Install Microsoft Foundry Local** (see the [documentation](https://learn.microsoft.com/azure/ai-foundry/foundry-local/) for your OS).\n",
    "2. **Download and start a Qwen model:**\n",
    "   ```bash\n",
    "   foundry model run qwen2.5-7b-instruct\n",
    "   foundry service status\n",
    "   ```\n",
    "3. Install the Python packages below.\n",
    "\n",
    "Everything runs locally. A machine with ~8 GB RAM is a realistic minimum."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "80565bf0",
   "metadata": {},
   "outputs": [],
   "source": [
    "%pip install foundry-local-sdk openai chromadb -q"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f815e0c2",
   "metadata": {},
   "source": [
    "## 1. Connect to Foundry Local\n",
    "\n",
    "`FoundryLocalManager` downloads the model if needed, starts the local service, and gives us an **OpenAI-compatible endpoint**. We then point the standard OpenAI SDK at it. The API key is a local placeholder — no cloud credential is involved."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "248ef257",
   "metadata": {},
   "outputs": [],
   "source": [
    "from foundry_local import FoundryLocalManager\n",
    "from openai import OpenAI\n",
    "\n",
    "MODEL_ALIAS = \"qwen2.5-7b-instruct\"\n",
    "\n",
    "# Foundry Local selects the best build for your hardware (CPU / GPU / NPU) automatically.\n",
    "manager = FoundryLocalManager(MODEL_ALIAS)\n",
    "model_info = manager.get_model_info(MODEL_ALIAS)\n",
    "\n",
    "client = OpenAI(\n",
    "    base_url=manager.endpoint,   # e.g. http://localhost:PORT/v1\n",
    "    api_key=manager.api_key,     # local placeholder\n",
    ")\n",
    "\n",
    "MODEL_ID = model_info.id\n",
    "print(f\"Connected to Foundry Local. Serving: {MODEL_ID}\")\n",
    "print(f\"Endpoint: {manager.endpoint}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9ff96f0b",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Quick sanity check: a plain chat completion, running fully on-device.\n",
    "resp = client.chat.completions.create(\n",
    "    model=MODEL_ID,\n",
    "    messages=[{\"role\": \"user\", \"content\": \"In one sentence, what is a small language model?\"}],\n",
    ")\n",
    "print(resp.choices[0].message.content)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1f01c3be",
   "metadata": {},
   "source": [
    "## 2. Local Tools (Sandboxed File Operations)\n",
    "\n",
    "We create a small sample project on disk, then define tools scoped to that project root. The sandbox check matters even locally: a tool that reads arbitrary paths runs with your user's permissions and can touch anything you can."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c7c472f3",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "from pathlib import Path\n",
    "\n",
    "# Create a tiny sample project so the notebook is self-contained.\n",
    "PROJECT_ROOT = Path.cwd() / \"sample_project\"\n",
    "PROJECT_ROOT.mkdir(exist_ok=True)\n",
    "\n",
    "(PROJECT_ROOT / \"auth.py\").write_text(\n",
    "    '\"\"\"Authentication helpers.\"\"\"\\n\\n'\n",
    "    \"def login(user, password):\\n\"\n",
    "    \"    # TODO: hash the password before comparing\\n\"\n",
    "    \"    return user == 'admin' and password == 'secret'\\n\\n\"\n",
    "    \"def logout(session):\\n\"\n",
    "    \"    session.clear()\\n\",\n",
    "    encoding=\"utf-8\",\n",
    ")\n",
    "(PROJECT_ROOT / \"utils.py\").write_text(\n",
    "    '\"\"\"Utility functions.\"\"\"\\n\\n'\n",
    "    \"def clamp(value, low, high):\\n\"\n",
    "    \"    return max(low, min(value, high))\\n\",\n",
    "    encoding=\"utf-8\",\n",
    ")\n",
    "print(\"Sample project created at:\", PROJECT_ROOT)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9cf382db",
   "metadata": {},
   "outputs": [],
   "source": [
    "def _safe_path(path: str) -> Path | None:\n",
    "    \"\"\"Resolve a path and confirm it stays inside the project sandbox.\"\"\"\n",
    "    full = (PROJECT_ROOT / path).resolve()\n",
    "    if full == PROJECT_ROOT or PROJECT_ROOT in full.parents:\n",
    "        return full\n",
    "    return None\n",
    "\n",
    "\n",
    "def list_files() -> str:\n",
    "    \"\"\"List files in the project directory.\"\"\"\n",
    "    files = [p.name for p in PROJECT_ROOT.iterdir() if p.is_file()]\n",
    "    return \", \".join(files) if files else \"(no files)\"\n",
    "\n",
    "\n",
    "def read_file(path: str) -> str:\n",
    "    \"\"\"Read a file, but only inside the sandboxed project directory.\"\"\"\n",
    "    full = _safe_path(path)\n",
    "    if full is None:\n",
    "        return \"Access denied: path is outside the project directory.\"\n",
    "    if not full.is_file():\n",
    "        return f\"No such file: {path}\"\n",
    "    return full.read_text(encoding=\"utf-8\")\n",
    "\n",
    "\n",
    "def analyze_code(path: str) -> str:\n",
    "    \"\"\"Report simple metrics about a source file.\"\"\"\n",
    "    full = _safe_path(path)\n",
    "    if full is None or not full.is_file():\n",
    "        return \"File not found or access denied.\"\n",
    "    text = full.read_text(encoding=\"utf-8\")\n",
    "    lines = text.splitlines()\n",
    "    return json.dumps({\n",
    "        \"path\": path,\n",
    "        \"lines\": len(lines),\n",
    "        \"functions\": sum(1 for ln in lines if ln.strip().startswith(\"def \")),\n",
    "        \"todos\": sum(1 for ln in lines if \"TODO\" in ln or \"FIXME\" in ln),\n",
    "    })\n",
    "\n",
    "\n",
    "print(list_files())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "49489826",
   "metadata": {},
   "source": [
    "## 3. Local RAG with Chroma\n",
    "\n",
    "We embed a small set of documentation snippets into a local Chroma collection. Chroma runs in-process and stores vectors on disk — no server, no cloud. The `search_docs` tool retrieves the most relevant snippets for a query."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "43f9333e",
   "metadata": {},
   "outputs": [],
   "source": [
    "import chromadb\n",
    "\n",
    "DOCS = {\n",
    "    \"auth\": \"The login() function checks credentials. It currently compares passwords in plain text, which should be hashed.\",\n",
    "    \"sessions\": \"Sessions are cleared on logout via session.clear(). Sessions are stored in memory and lost on restart.\",\n",
    "    \"utils\": \"clamp(value, low, high) constrains a number to a range. Used throughout the UI layer for bounds checking.\",\n",
    "    \"style\": \"This project follows PEP 8. Functions use snake_case and modules include a docstring at the top.\",\n",
    "}\n",
    "\n",
    "# Chroma ships with a local default embedding model, so embedding stays on-device.\n",
    "chroma_client = chromadb.Client()\n",
    "collection = chroma_client.get_or_create_collection(\"project_docs\")\n",
    "collection.upsert(\n",
    "    ids=list(DOCS.keys()),\n",
    "    documents=list(DOCS.values()),\n",
    ")\n",
    "\n",
    "\n",
    "def search_docs(query: str) -> str:\n",
    "    \"\"\"Search the local documentation index for relevant snippets.\"\"\"\n",
    "    results = collection.query(query_texts=[query], n_results=2)\n",
    "    docs = results.get(\"documents\", [[]])[0]\n",
    "    return \"\\n\".join(docs) if docs else \"No relevant documentation found.\"\n",
    "\n",
    "\n",
    "print(search_docs(\"how are passwords handled?\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7d7ed86c",
   "metadata": {},
   "source": [
    "## 4. The Tool-Calling Loop\n",
    "\n",
    "Now we register the tools with the model using the OpenAI tools schema and run the standard tool-calling loop — the model requests a tool, we execute it locally, feed the result back, and repeat until the model produces a final answer. Qwen's reliable function calling is what makes this work on-device."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8b7f06cd",
   "metadata": {},
   "outputs": [],
   "source": [
    "TOOLS_SCHEMA = [\n",
    "    {\"type\": \"function\", \"function\": {\n",
    "        \"name\": \"list_files\", \"description\": \"List files in the project directory.\",\n",
    "        \"parameters\": {\"type\": \"object\", \"properties\": {}},\n",
    "    }},\n",
    "    {\"type\": \"function\", \"function\": {\n",
    "        \"name\": \"read_file\", \"description\": \"Read a file inside the project directory.\",\n",
    "        \"parameters\": {\"type\": \"object\", \"properties\": {\n",
    "            \"path\": {\"type\": \"string\", \"description\": \"File name, e.g. auth.py\"}}, \"required\": [\"path\"]},\n",
    "    }},\n",
    "    {\"type\": \"function\", \"function\": {\n",
    "        \"name\": \"analyze_code\", \"description\": \"Report line count, function count and TODO count for a file.\",\n",
    "        \"parameters\": {\"type\": \"object\", \"properties\": {\n",
    "            \"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]},\n",
    "    }},\n",
    "    {\"type\": \"function\", \"function\": {\n",
    "        \"name\": \"search_docs\", \"description\": \"Search local documentation for a query.\",\n",
    "        \"parameters\": {\"type\": \"object\", \"properties\": {\n",
    "            \"query\": {\"type\": \"string\"}}, \"required\": [\"query\"]},\n",
    "    }},\n",
    "]\n",
    "\n",
    "TOOL_IMPL = {\n",
    "    \"list_files\": list_files,\n",
    "    \"read_file\": read_file,\n",
    "    \"analyze_code\": analyze_code,\n",
    "    \"search_docs\": search_docs,\n",
    "}\n",
    "\n",
    "SYSTEM_PROMPT = (\n",
    "    \"You are a local engineering assistant. Use the provided tools to inspect the project \"\n",
    "    \"and its documentation. Prefer calling a tool over guessing. Be concise.\"\n",
    ")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dbb0778c",
   "metadata": {},
   "outputs": [],
   "source": [
    "def run_agent(user_query: str, max_iterations: int = 5) -> str:\n",
    "    \"\"\"Standard tool-calling loop, running entirely against the local model.\"\"\"\n",
    "    messages = [\n",
    "        {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
    "        {\"role\": \"user\", \"content\": user_query},\n",
    "    ]\n",
    "\n",
    "    for _ in range(max_iterations):\n",
    "        response = client.chat.completions.create(\n",
    "            model=MODEL_ID,\n",
    "            messages=messages,\n",
    "            tools=TOOLS_SCHEMA,\n",
    "        )\n",
    "        msg = response.choices[0].message\n",
    "\n",
    "        if not msg.tool_calls:\n",
    "            return msg.content or \"(no answer)\"\n",
    "\n",
    "        # Record the assistant's tool-call request.\n",
    "        messages.append({\n",
    "            \"role\": \"assistant\",\n",
    "            \"content\": msg.content,\n",
    "            \"tool_calls\": [tc.model_dump() for tc in msg.tool_calls],\n",
    "        })\n",
    "\n",
    "        # Execute each requested tool locally and feed results back.\n",
    "        for tc in msg.tool_calls:\n",
    "            name = tc.function.name\n",
    "            args = json.loads(tc.function.arguments or \"{}\")\n",
    "            result = TOOL_IMPL[name](**args) if name in TOOL_IMPL else f\"Unknown tool: {name}\"\n",
    "            messages.append({\n",
    "                \"role\": \"tool\",\n",
    "                \"tool_call_id\": tc.id,\n",
    "                \"content\": str(result),\n",
    "            })\n",
    "\n",
    "    return \"Stopped: reached max tool-calling iterations.\"\n",
    "\n",
    "\n",
    "print(\"Agent ready.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9f0c0c05",
   "metadata": {},
   "outputs": [],
   "source": [
    "# A file-reading question.\n",
    "print(run_agent(\"What does auth.py do, and is there anything to fix in it?\"))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "80646664",
   "metadata": {},
   "outputs": [],
   "source": [
    "# A RAG question.\n",
    "print(run_agent(\"According to the docs, how are passwords currently handled?\"))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f321f218",
   "metadata": {},
   "outputs": [],
   "source": [
    "# A code-analysis question.\n",
    "print(run_agent(\"How many functions and TODOs are in auth.py?\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "41cedd11",
   "metadata": {},
   "source": [
    "## 5. Local MCP (Optional)\n",
    "\n",
    "MCP is a transport, not a cloud service — an MCP server can run as a local process over `stdio`. The cell below shows how you would connect to a local MCP server if you have one configured (for example a filesystem server). It skips gracefully when `LOCAL_MCP_COMMAND` is not set, so the notebook still runs end to end without it.\n",
    "\n",
    "Security note: a local MCP server runs with your user's permissions. Scope it to a project directory and validate its outputs before acting on them."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9250c63f",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "\n",
    "LOCAL_MCP_COMMAND = os.getenv(\"LOCAL_MCP_COMMAND\")  # e.g. \"npx -y @modelcontextprotocol/server-filesystem ./sample_project\"\n",
    "\n",
    "if not LOCAL_MCP_COMMAND:\n",
    "    print(\"No LOCAL_MCP_COMMAND set — skipping the MCP demo. \"\n",
    "          \"Set it to a local MCP server command to try this section.\")\n",
    "else:\n",
    "    import asyncio\n",
    "    from mcp import ClientSession, StdioServerParameters\n",
    "    from mcp.client.stdio import stdio_client\n",
    "\n",
    "    async def list_mcp_tools(command: str):\n",
    "        parts = command.split()\n",
    "        params = StdioServerParameters(command=parts[0], args=parts[1:])\n",
    "        async with stdio_client(params) as (read, write):\n",
    "            async with ClientSession(read, write) as session:\n",
    "                await session.initialize()\n",
    "                tools = await session.list_tools()\n",
    "                return [t.name for t in tools.tools]\n",
    "\n",
    "    names = await list_mcp_tools(LOCAL_MCP_COMMAND)\n",
    "    print(\"Local MCP server exposes tools:\", names)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e4828211",
   "metadata": {},
   "source": [
    "## Summary\n",
    "\n",
    "You built an engineering assistant that runs entirely on your machine:\n",
    "\n",
    "- **Foundry Local** served a **Qwen** model behind an OpenAI-compatible endpoint — so the agent code matches the cloud lessons.\n",
    "- **Sandboxed tools** gave the agent file access and code analysis without leaving a project directory.\n",
    "- **Chroma** provided **local RAG** over documentation.\n",
    "- **Local MCP** showed how to reuse the MCP ecosystem offline.\n",
    "\n",
    "No cloud inference was used at any point.\n",
    "\n",
    "### Challenge\n",
    "\n",
    "Extend the local agent to:\n",
    "\n",
    "1. **Work with multiple MCP servers** — connect a filesystem server and a git server and let the agent choose between them.\n",
    "2. **Use local memory** — persist a short conversation history to disk so the assistant remembers earlier turns across notebook restarts.\n",
    "3. **Support local multi-agent orchestration** — add a second local agent (e.g. a reviewer) and have the two collaborate on a task.\n",
    "\n",
    "In the next lesson you will learn how to secure deployed AI agents."
   ]
  }
 ],
 "metadata": {
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
