MODULE_01 · УРОК 1.1

От чата к агенту

Попросите программу «посмотреть» pyproject.toml — и без доступа к диску она уверенно опишет, что там обычно бывает. Файл она не открывала: это обычный чат. В этом уроке дадим ей один настоящий инструмент — чтение файла — и увидим разницу.

ЧТО ПОЛУЧИТСЯ Небольшой скрипт на Pydantic AI: по вашей фразе модель вызывает инструмент read, реально читает файл с диска и отвечает уже по фактам, а не по догадкам.

Слова, которые встретятся

  • Чатбот — модель только пишет текст. Файлы, терминал, поиск ей недоступны.
  • Агент — та же модель, но у неё есть инструменты (tools): функции, которые вы написали на Python и которые она может вызвать по ходу ответа.
  • Tool / инструмент — обычная функция с описанием. Модель решает «когда вызвать» по docstring; вы решаете «что сделать» в теле функции.
  • Harness — «обвязка» вокруг модели: инструменты, правила безопасности, лимиты. Весь курс как раз про то, как её собрать.
  • Контекст — всё, что модель «помнит» в этом разговоре (ваши сообщения, ответы, результаты инструментов). Место не бесконечное: поэтому мы ограничиваем, сколько строк возвращает read.

Быстрый путь

  1. Создайте проект и поставьте библиотеку pydantic-ai.
  2. Напишите main.py с агентом без инструментов — посмотрите на «догадки».
  3. Добавьте инструмент read: нумерация строк и потолок в 500 строк.
  4. Снова спросите про файл — теперь ответ должен опираться на реальное содержимое.

Подготовка проекта

Команда uv run подхватывает только то, что записано в проекте. Без шага ниже получите ModuleNotFoundError: No module named 'pydantic_ai'.

Terminal

mkdir teensycode && cd teensycode
uv init --name teensycode
uv add pydantic-ai

В магазине пакетов имя с дефисом: pydantic-ai. В коде — подчёркивание: from pydantic_ai import Agent. Модель DeepSeek подключается строкой deepseek:deepseek-chat (подробнее в документации Pydantic AI).

Ключ API возьмите на platform.deepseek.com и положите в окружение:

export DEEPSEEK_API_KEY="sk-..."
ПРОВЕРКА Выполните uv run python -c "import pydantic_ai; print(pydantic_ai.__version__)". Должна появиться версия. Если снова ошибка импорта — вы не в папке с pyproject.toml или забыли uv add.

Упражнение 1.1

Сначала минимальный агент, потом один инструмент.

  • Импорты: Agent и (для инструмента) RunContext.
  • Модель: deepseek:deepseek-chat.
  • Инструмент read: путь к файлу, по желанию с какой строки начать (offset) и сколько строк взять (limit).
  • Не больше 500 строк за раз; в ответе инструмента у каждой строки номер.

Шаг 1. Чатбот (без инструментов)

Пока инструментов нет — модель может только говорить. Напишите такой main.py:

main.py

import asyncio
import sys
from pathlib import Path

from pydantic_ai import Agent

cwd = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
prompt = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "Привет!"

agent = Agent(
    "deepseek:deepseek-chat",
    instructions=(
        f"Ты coding-агент.\nРабочая директория: {cwd}\n"
        "У тебя нет инструментов и нет доступа к файловой системе. "
        "Не выдумывай вызовы tools, XML, DSML или shell-команды. "
        "Отвечай только обычным текстом — можно честно сказать, "
        "что не видишь файлы, или предположить типичную структуру."
    ),
)

async def main() -> None:
    result = await agent.run(prompt)
    print(result.output)

if __name__ == "__main__":
    asyncio.run(main())

Terminal

uv run python main.py . "Какие файлы в этом проекте?"

Хороший исход — обычный текст: «не вижу файлы» или вымышленная структура. Плохой — «запуск» ls или странная разметка в ответе. Модель всё ещё только болтает: это чатбот.

ЕСЛИ В ОТВЕТЕ ПОЯВИЛСЯ DSML / exec_command DeepSeek иногда рисует вызов команды текстом, даже когда инструментов нет. Это не выполнение на вашем компьютере — просто фантазия в result.output. Усильте instructions (как выше) или спросите мягче: «Что обычно лежит в pyproject.toml?». Когда появится настоящий @agent.tool, библиотека передаст модели список реальных инструментов, и вызовы пойдут через ваш Python-код.

Шаг 2. Один инструмент меняет всё

Замените main.py целиком (не дописывайте кусок к версии без tools). Нужны оба импорта: Agent и RunContext.

RunContext — «контекст запуска»: через него инструмент получает то, что вы передали в deps= (у нас — корневая папка проекта). Без deps=cwd функция не знает, откуда читать.

main.py

import asyncio
import sys
from pathlib import Path

from pydantic_ai import Agent, RunContext

MAX_LINES = 500

cwd = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
prompt = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "Привет!"

agent = Agent(
    "deepseek:deepseek-chat",
    deps_type=Path,
    instructions=(
        f"Ты coding-агент.\nРабочая директория: {cwd}\n"
        "Используй инструмент read, чтобы открывать файлы. "
        "Не выдумывай содержимое и не имитируй tool-calls текстом."
    ),
)

@agent.tool
def read(
    ctx: RunContext[Path],
    path: str,
    offset: int | None = None,
    limit: int | None = None,
) -> str:
    """Прочитать файл проекта. Возвращает пронумерованные строки.
    WHEN TO USE: просмотр содержимого, конфигов, исходников.
    WHEN NOT TO USE: поиск по нескольким файлам (используй grep).
    """
    abs_path = (ctx.deps / path).resolve()
    if not str(abs_path).startswith(str(ctx.deps.resolve())):
        return "Ошибка: путь вне рабочей директории."
    if not abs_path.is_file():
        return f"Ошибка: файл не найден: {path}"

    lines = abs_path.read_text(encoding="utf-8").splitlines()
    start = (offset or 1) - 1
    lines = lines[start:]
    if limit is not None:
        lines = lines[:limit]

    truncated = len(lines) > MAX_LINES
    if truncated:
        lines = lines[:MAX_LINES]

    # Явная нумерация: каждая строка вида "12: содержимое"
    numbered_lines: list[str] = []
    first_line_no = offset or 1
    for i, line in enumerate(lines):
        numbered_lines.append(f"{first_line_no + i}: {line}")

    body = "\n".join(numbered_lines)
    if truncated:
        body += f"\n... (обрезано на {MAX_LINES} строках)"

    # Печатаем результат инструмента в консоль — иначе в ответе модели
    # будет только пересказ без номеров строк.
    print("--- tool read output (пронумерованные строки) ---")
    print(body)
    print("--- конец tool output ---")
    return body


async def main() -> None:
    result = await agent.run(prompt, deps=cwd)
    print("=== ответ модели ===")
    print(result.output)


if __name__ == "__main__":
    asyncio.run(main())
NameError: RunContext is not defined В импорте остался только Agent. Нужно: from pydantic_ai import Agent, RunContext.
ЗАЧЕМ ЛИМИТ 500 СТРОК Без него чтение огромного файла зальёт разговор тысячами строк. Они останутся в контексте до конца сессии и съедят место (и деньги на токены). Позже будет отдельный модуль про контекст — привычку ограничивать вывод начинаем уже здесь.

Проверьте

Terminal

uv run python main.py . "Прочитай pyproject.toml"

Ожидаемый вид консоли (сначала инструмент, потом модель):

--- tool read output (пронумерованные строки) ---
1: [project]
2: name = "ai-loop-agent"
3: version = "0.1.0"
...
--- конец tool output ---
=== ответ модели ===
Вот содержимое файла pyproject.toml: ...

Если видите только пересказ без блока tool read output — в read нет print(body) из листинга. Номера в ответе модели не обязательны: они в выводе инструмента. Пока нет поиска и терминала, держитесь фраз вида «Прочитай вот этот файл».

Пункт про «путь относительно рабочей директории»: первый аргумент (. или полный путь к проекту) — это корень. Модель передаёт относительное имя вроде pyproject.toml, а код склеивает его с корнем. Запрос вроде «прочитай /etc/passwd» должен вернуть ошибку «путь вне рабочей директории», а не секреты системы.

Готово, если:

  • Есть pyproject.toml, пакет ставится через uv add pydantic-ai
  • Без инструментов — обычный текстовый ответ, без реальных вызовов функций
  • С read — модель вызывает инструмент и опирается на содержимое файла
  • В консоли есть блок с строками 1: …, 2: …; в коде — offset, limit и потолок 500
  • Пути считаются от корня проекта; выход «наверх» или в /etc блокируется
ЭКСПЕРИМЕНТ Сделайте файл на 1000 строк: seq 1 1000 > /tmp/big.txt, попросите агента прочитать его (скопируйте файл в проект или укажите доступный путь). Уберите MAX_LINES и сравните. Что сломается раньше на длинной задаче — здравый смысл ответа или лимит контекста?