LangChain

ToolRuntime и Runtime: окружение вызова в инструменте | Курс LangChain урок 10

ToolRuntime и Runtime: окружение вызова в инструменте | Курс LangChain урок 10
Михаил Омельченко
Автор
Михаил Омельченко
Опубликовано 08.10.2026
5,0
Views 6

Цель урока: передавать инструменту данные запуска, которых нет в схеме для модели и которые нельзя подменить репликой в разговоре. Читать из инструмента состояние разговора и хранилище, писать в состояние. Узнавать в чужом коде прежние способы такой передачи.

Необходимые знания:

1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены

2) урок 5: system_prompt, middleware и декоратор @wrap_model_call, объект ModelRequest, параметр context_schema у агента и аргумент context при вызове

3) урок 9: декоратор @tool, docstring как часть промпта, схема аргументов, цикл вызова инструментов

4) Python на уровне джуниора: dataclass, Annotated, дженерики в подсказках типов, наследование TypedDict

Ключевые концепции:

1) ToolRuntime это параметр инструмента, в котором лежат конфигурация запуска, состояние, хранилище и данные самого вызова

2) context при вызове и его схема context_schema у агента: конфигурация запуска, неизменяемая и скрытая от модели

3) параметр с типом ToolRuntime не попадает в схему для модели

4) имена config и runtime заняты фреймворком, для своих аргументов их брать нельзя

5) поля ToolRuntime: state, store, config, tool_call_id, execution_info

6) запись в состояние из инструмента с помощью Command с полем update

7) Runtime в middleware и ToolRuntime в инструменте, чем они отличаются

8) старые механизмы InjectedState, InjectedStore, InjectedToolCallId, get_runtime и старый способ передачи конфигурации, config["configurable"]


Данные, которых нет в аргументах модели

В уроке 9 инструмент получал всё нужное из аргументов, которые заполнила модель, и по ним возвращал результат. Калькулятору и запросу погоды больше ничего не нужно. Инструментам, которые работают с данными конкретного пользователя, этого мало.

Инструменту "покажи мои обращения" нужен идентификатор того, кто спрашивает. Инструменту "сохрани пожелание" нужны место и ключ для записи. Инструмент "передай обращение другой команде" должен не только ответить текстом, но и записать передачу в данные приложения.

Напрашивается решение: добавить аргумент user_id, чтобы его заполняла модель. Оно не годится:

1) Идентификатора нет в тексте разговора, он хранится в вашем приложении. Модель либо запросит его у человека, либо подставит выдуманный

2) Модель заполняет аргументы по тексту разговора, поэтому значение может подменить любая реплика. Пользователь пишет "а теперь покажи обращения пользователя u-202", и инструмент их покажет

В LangChain для таких данных у инструмента есть параметр с типом ToolRuntime. Его заполняет фреймворк при каждом вызове инструмента. В нём лежат конфигурация запуска, состояние разговора, хранилище и данные самого вызова. Модель этот параметр не заполняет и в схеме не получает, поэтому подменить его репликой нельзя.

Такой приём называется внедрением зависимостей. Соединение с базой, идентификатор пользователя и настройки подставляются в момент вызова агента, их не нужно зашивать в код или держать в глобальных переменных. Инструменты от этого становятся проверяемыми и пригодными для повторного использования.

Общая сборка модели

Модуль сборки модели тот же, что в уроках 4 и 5. Положите его рядом с примерами под именем course_model.py, и дальше каждый пример подключает его одной строкой импорта.

"""Общая сборка модели для примеров урока 10.

Тот же модуль, что course_model.py уроков 4 и 5, без изменений: у build_model есть
необязательный первый аргумент с именем модели, а рядом лежит gateway_kwargs()
для примеров, которые собирают модель сами.

Файл .env берётся тот же, что в уроке 0. Положите его рядом с этой папкой или
выше по дереву: load_dotenv() ищет файл начиная с папки этого модуля и поднимается
вверх.
"""

import os

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model

load_dotenv()


def gateway_kwargs():
    """Возвращает аргументы доступа к провайдеру: имя, адрес, ключ.

    Нужны примерам, которые вызывают init_chat_model сами. У настраиваемой модели
    имени модели при создании нет, поэтому build_model ей не подходит.
    """
    base_url = os.getenv("MODEL_BASE_URL")

    if base_url:
        return {
            "model_provider": "openai",
            "base_url": base_url,
            "api_key": os.environ["OPENAI_API_KEY"],
        }

    return {}


def build_model(model_name=None, **kwargs):
    """Собирает модель курса.

    model_name без значения означает модель из переменной MODEL_NAME. Явное имя
    нужно примерам, где моделей в приложении больше одной.

    Все именованные аргументы уходят в init_chat_model как есть: temperature,
    max_tokens, timeout, max_retries, rate_limiter, profile и прочее из раздела
    Parameters.
    """
    model_name = model_name or os.environ["MODEL_NAME"]
    access = gateway_kwargs()

    if access:
        # Путь для любого адреса, совместимого с OpenAI Chat Completions API.
        return init_chat_model(model=model_name, **access, **kwargs)

    # Путь напрямую к провайдеру.
    return init_chat_model(model_name, **kwargs)

Конфигурация запуска: context и context_schema

Конфигурация запуска (в терминах фреймворка runtime context), это данные, с которыми запущен агент: идентификатор пользователя, его отдел, соединение с базой, ключ доступа к внутреннему сервису. За время запуска они не меняются и на следующий вызов invoke сами не переходят. В уроке 5 вы уже передавали их аргументом context и читали в middleware как request.runtime.context. Здесь тот же механизм со стороны инструмента.

Собирается он из таких частей:

1) Форма данных: обычный dataclass с полями, в примерах используется именно он

2) Схема у агента: параметр context_schema при создании, он задаёт тип объекта и превращает переданный словарь в объект этого класса

3) Значение при вызове: аргумент context у invoke и у stream, рядом с обычным словарём сообщений

Без context_schema значение тоже дойдёт до runtime.context, но как есть: словарь останется словарём. Пример 1 показывает все три части на одном инструменте.

Пример 01_context.py

"""Пример 1 урока 10: конфигурация запуска через context и context_schema.

Один и тот же вопрос от двух разных пользователей. Идентификатора пользователя
нет в схеме для модели, поэтому модель не может его подставить. Он приходит в
агента отдельным аргументом context, а инструмент читает его через runtime.context.
"""

from dataclasses import dataclass

from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool

from course_model import build_model


@dataclass
class SessionContext:
    """Конфигурация запуска: кто спрашивает и из какого отдела."""

    user_id: str
    department: str


TICKETS = {
    "u-101": ["T-5501 не приходит письмо о доставке", "T-5502 задвоился платёж"],
    "u-202": ["T-6100 не открывается отчёт по продажам"],
}


@tool
def list_my_tickets(runtime: ToolRuntime[SessionContext]) -> str:
    """Вернуть список обращений текущего пользователя."""
    user_id = runtime.context.user_id
    tickets = TICKETS.get(user_id, [])

    if not tickets:
        return "Открытых обращений нет."

    return "\n".join(tickets)


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[list_my_tickets],
    system_prompt="Вы помощник службы поддержки. Отвечайте по-русски и коротко.",
    context_schema=SessionContext,
)

QUESTION = "Какие у меня открыты обращения?"

for user_id in ("u-101", "u-202"):
    result = agent.invoke(
        {"messages": [{"role": "user", "content": QUESTION}]},
        context=SessionContext(user_id=user_id, department="support"),
    )
    answer = result["messages"][-1].text.replace("\n", " ")
    print(f"ПОЛЬЗОВАТЕЛЬ {user_id}: {answer}")

print()
print("АРГУМЕНТЫ, КОТОРЫЕ ВИДИТ МОДЕЛЬ:", list_my_tickets.args)

# Вывод:
# ПОЛЬЗОВАТЕЛЬ u-101:  Вот ваши открытые обращения:  1. **T-5501** — не приходит письмо о доставке 2. **T-5502** — задвоился платёж  Могу подробнее разобрать любое из них. Что вас интересует?
# ПОЛЬЗОВАТЕЛЬ u-202: У вас открыто одно обращение:  **T-6100** — не открывается отчёт по продажам.  Могу я чем-то ещё помочь?
#
# АРГУМЕНТЫ, КОТОРЫЕ ВИДИТ МОДЕЛЬ: {}

В выводе вопрос один и тот же, а списки обращений разные. Последняя строка печатает пустой словарь: в схеме для модели у инструмента нет ни одного аргумента.

Тип в квадратных скобках, SessionContext в записи ToolRuntime[SessionContext], сообщает редактору, объект какого типа лежит в runtime.context. Редактор после этого подсказывает поля, а опечатку в имени поля видно до запуска. При вызове фреймворк этот тип не проверяет, и без скобок код тоже работает.

Вызов инструмента по-прежнему не гарантирован: в ответе модели его может не оказаться, и тогда на вопрос про обращения придёт текст без списка. Это поведение цикла из урока 9, к конфигурации запуска оно не относится.

Что из подписи инструмента попадает в схему для модели

Устройство невидимого параметра объясняет две ошибки из конца урока. Схемы аргументов у инструмента разные для фреймворка и для модели:

1) Полная схема, tool.args_schema. По ней фреймворк собирает вызов функции, внедряемые параметры в ней есть

2) Схема для модели, tool.tool_call_schema, и её короткая форма tool.args. Она уходит провайдеру вместе с именем и описанием инструмента, внедряемых параметров в ней нет

Из схемы для модели параметр вырезается по типу. Тип ToolRuntime в коде фреймворка наследует служебный класс _DirectlyInjectedToolArg, и этого родителя достаточно: пометка в Annotated не нужна.

Параметр с именем config или runtime из схемы для модели не вырезается. Но имя меняет то, что фреймворк подставит при вызове, поэтому для своих аргументов эти имена брать нельзя.

Имя параметра Чем занято
config передача RunnableConfig внутрь инструмента
runtime параметр ToolRuntime: состояние, конфигурация запуска, хранилище

Имя runtime фреймворк проверяет отдельно от типа: параметр с таким именем получает ToolRuntime при любой аннотации. Аргумент runtime: str попадёт в схему для модели как строка, и модель его заполнит. Перед вызовом узел инструментов заменит эту строку объектом ToolRuntime. Проверка аргументов его не пропустит, потому что это не строка, и функция не выполнится. Модель получит ToolMessage со статусом error.

Пример 02_hidden_args.py

"""Пример 2 урока 10: что из подписи инструмента попадает в схему для модели.

Сети здесь нет, модель не вызывается. Для каждого инструмента сравниваются две
схемы: полная, по которой фреймворк собирает вызов, и та, что уходит в модель.
Последние два инструмента названы зарезервированными именами, и по разнице
между схемами видно, к чему это приводит.
"""

from typing import Any

from langchain.tools import ToolRuntime, tool


@tool
def weather_plain(city: str) -> str:
    """Вернуть погоду в городе."""
    return f"В городе {city} ясно."


@tool
def weather_with_runtime(city: str, runtime: ToolRuntime) -> str:
    """Вернуть погоду в городе и номер вызова инструмента."""
    return f"В городе {city} ясно. Вызов {runtime.tool_call_id}."


@tool
def weather_bad_runtime(city: str, runtime: str) -> str:
    """Вернуть погоду в городе с пометкой о режиме работы."""
    return f"В городе {city} ясно. Режим {runtime}."


@tool
def weather_bad_config(city: str, config: dict) -> str:
    """Вернуть погоду в городе с учётом настроек."""
    return f"В городе {city} ясно. Настройки {config}."


def full_schema_fields(some_tool: Any) -> list[str]:
    """Имена полей полной схемы инструмента.

    У инструмента, собранного из функции, args_schema это модель Pydantic.
    Защитная ветка нужна на случай схемы, заданной словарём JSON Schema.
    """
    schema = some_tool.args_schema

    if isinstance(schema, dict):
        return sorted(schema.get("properties", {}))

    return sorted(schema.model_fields)


TOOLS = [
    weather_plain,
    weather_with_runtime,
    weather_bad_runtime,
    weather_bad_config,
]

print(f"{'инструмент':<22} {'полная схема':<34} схема для модели")
for item in TOOLS:
    full = ", ".join(full_schema_fields(item))
    visible = ", ".join(sorted(item.args))
    print(f"{item.name:<22} {full:<34} {visible}")

print()
print("ПОДРОБНО ПРО weather_with_runtime")
print("  тип поля runtime в полной схеме:", weather_with_runtime.args_schema.model_fields["runtime"].annotation)
print("  описание для модели:", weather_with_runtime.description)

# Вывод:
# инструмент             полная схема                       схема для модели
# weather_plain          city                               city
# weather_with_runtime   city, runtime                      city
# weather_bad_runtime    city, runtime                      city, runtime
# weather_bad_config     city, config                       city, config
#
# ПОДРОБНО ПРО weather_with_runtime
#   тип поля runtime в полной схеме: <class 'langgraph.prebuilt.tool_node.ToolRuntime'>
#   описание для модели: Вернуть погоду в городе и номер вызова инструмента.

Пример работает без сети и без модели, поэтому его удобно запускать каждый раз, когда сомневаетесь, что именно уходит провайдеру.

В таблице у первого инструмента схемы совпадают. У второго параметр runtime есть в полной схеме и отсутствует в схеме для модели. У последних двух зарезервированное имя оказывается в обеих.

Параметр runtime объявляйте с типом ToolRuntime. Если указать тип данных, которые из него достаёте, например SessionContext, параметр останется в схеме для модели и заполнять его будет модель, подробнее в конце урока.

В таблице Reserved argument names на странице документации про инструменты два имени: config и runtime. В docstring декоратора @tool в langchain-core 1.6.3 другая тройка: config, run_manager и callbacks, а runtime там не упоминается.

Свой аргумент config с другим типом, например config: dict, до функции не доходит: она вызывается без него и падает с TypeError про недостающий позиционный аргумент. С аргументом runtime модель получает ToolMessage со статусом error, это разобрано выше. Аргументы run_manager и callbacks вырезаются из схемы без предупреждения, и своего значения функция в них не получит. В callbacks придёт менеджер обратных вызовов фреймворка, а run_manager не передаётся вовсе, и без значения по умолчанию вызов падает с TypeError.

Служебные имена под свои данные не занимайте. Если в чужом инструменте есть аргумент config, runtime, run_manager или callbacks, проверьте его по tool.args.

Состояние разговора внутри инструмента

Состояние, в терминах фреймворка state, это короткая память: данные, которые меняются по ходу разговора. Без памяти сессии они существуют один запуск агента. Обязательное поле у состояния одно, messages, туда складывается вся переписка.

Инструменту состояние нужно, когда ответ зависит от уже сказанного в разговоре. Например, сводка разговора, проверка, задавал ли пользователь этот вопрос раньше, или счётчик вызовов инструмента за один запуск.

Внутри инструмента состояние лежит в runtime.state, это обычный словарь.

Пример 03_state.py

"""Пример 3 урока 10: инструмент читает состояние разговора.

Инструмент подсчитывает сообщения разговора к моменту вызова и возвращает
сводку. Это короткая память: она существует один запуск агента и обнуляется
вместе с ним.
"""

from collections import Counter

from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool

from course_model import build_model


@tool
def conversation_summary(runtime: ToolRuntime) -> str:
    """Вернуть сводку по текущему разговору: сколько сообщений и каких."""
    messages = runtime.state["messages"]
    kinds = Counter(type(message).__name__ for message in messages)

    first = messages[0].text.replace("\n", " ") if messages else ""
    if len(first) > 40:
        first = first[:37] + "..."

    parts = ", ".join(f"{name} {count}" for name, count in sorted(kinds.items()))
    return f"Сообщений {len(messages)} ({parts}). Первое: {first!r}"


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[conversation_summary],
    system_prompt=(
        "Вы помощник по разговору. Про состояние разговора отвечайте только по "
        "данным инструмента, ничего не придумывайте. Отвечайте по-русски и коротко."
    ),
)

DIALOG = [
    {"role": "user", "content": "Здравствуйте, у меня задвоился платёж по заказу 4412."},
    {"role": "assistant", "content": "Здравствуйте. Проверяю списания по заказу 4412."},
    {"role": "user", "content": "Сколько сообщений уже в нашем разговоре?"},
]

result = agent.invoke({"messages": DIALOG})

print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
print()
print("СОСТОЯНИЕ ПОСЛЕ ПРОГОНА")
for number, message in enumerate(result["messages"], start=1):
    text = message.text.replace("\n", " ")
    if len(text) > 50:
        text = text[:47] + "..."
    print(f"  {number}. {type(message).__name__:<14} {text!r}")

# Вывод:
# ОТВЕТ:  В нашем разговоре пока 4 сообщения. По вашему вопросу о задвоенном платеже по заказу 4412 я уже начал проверку — уточните, пожалуйста, какую именно информацию вам нужно получить?
#
# СОСТОЯНИЕ ПОСЛЕ ПРОГОНА
#   1. HumanMessage   'Здравствуйте, у меня задвоился платёж по заказу...'
#   2. AIMessage      'Здравствуйте. Проверяю списания по заказу 4412.'
#   3. HumanMessage   'Сколько сообщений уже в нашем разговоре?'
#   4. AIMessage      ' '
#   5. ToolMessage    'Сообщений 4 (AIMessage 2, HumanMessage 2). Перв...'
#   6. AIMessage      ' В нашем разговоре пока 4 сообщения. По вашему ...'

В выводе состояние после прогона, в нём шесть сообщений. Инструмент вызван раньше, когда их было четыре: три из DIALOG и под номером 4 ответ модели с вызовом инструмента. Сообщения 5 и 6, ответ инструмента и финальный ответ модели, добавились после вызова.

Прошлые реплики доступны инструменту и без памяти сессии: в состояние попадает всё, что передано в messages при запуске. Как сделать, чтобы переписка накапливалась между вызовами сама, разбирается в уроке 12.

Своя схема состояния задаётся параметром state_schema и разбирается в уроке 11, вместе с устройством агента. Для записи из инструмента ниже хватит одного дополнительного поля.

Инструмент, который пишет в состояние

Обычный инструмент возвращает строку, и она превращается в ToolMessage. Иногда результат нужен и вашему приложению, причём отдельным полем состояния.

Для этого инструмент возвращает Command с полем update. В словаре обновления вы перечисляете поля состояния, которые надо изменить.

В обновление обязательно кладите ToolMessage с полем tool_call_id: без него узел инструментов остановит запуск с ValueError. Идентификатор вызова лежит в runtime.tool_call_id.

Пример 04_state_write.py

"""Пример 4 урока 10: инструмент пишет в состояние.

Обычный инструмент возвращает строку, и она становится сообщением для модели.
Этот инструмент возвращает Command с полем update и записывает в состояние,
какой команде передано обращение. Поле escalated_to добавлено в своей схеме
состояния, поэтому после прогона его видно рядом с messages.
"""

from langchain.agents import AgentState, create_agent
from langchain.messages import ToolMessage
from langchain.tools import ToolRuntime, tool
from langgraph.types import Command

from course_model import build_model


class SupportState(AgentState):
    """Состояние агента поддержки: к messages добавлено поле передачи."""

    escalated_to: str


@tool
def escalate(team: str, runtime: ToolRuntime[None, SupportState]) -> Command:
    """Передать обращение другой команде. Название команды на латинице."""
    return Command(
        update={
            "escalated_to": team,
            "messages": [
                ToolMessage(
                    content=f"Обращение передано команде {team}.",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
        }
    )


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[escalate],
    system_prompt=(
        "Вы первая линия поддержки. Вопросы про деньги и списания передавайте "
        "команде billing, вопросы про доставку команде logistics. Отвечайте "
        "по-русски и коротко."
    ),
    state_schema=SupportState,
)

result = agent.invoke(
    {
        "messages": [
            {"role": "user", "content": "С меня дважды списали деньги за заказ 4412."}
        ]
    }
)

print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
print("КЛЮЧИ СОСТОЯНИЯ:", sorted(result))
print("ПОЛЕ escalated_to:", result.get("escalated_to", "<поля нет>"))
print()
print("СООБЩЕНИЯ В СОСТОЯНИИ")
for number, message in enumerate(result["messages"], start=1):
    text = message.text.replace("\n", " ")
    if len(text) > 50:
        text = text[:47] + "..."
    print(f"  {number}. {type(message).__name__:<14} {text!r}")

# Вывод:
# ОТВЕТ:  Ваш вопрос передан команде billing. Они разберутся с двойным списанием по заказу 4412 и свяжутся с вами.
# КЛЮЧИ СОСТОЯНИЯ: ['escalated_to', 'messages']
# ПОЛЕ escalated_to: billing
#
# СООБЩЕНИЯ В СОСТОЯНИИ
#   1. HumanMessage   'С меня дважды списали деньги за заказ 4412.'
#   2. AIMessage      ' Я передам ваш вопрос команде billing, так как ...'
#   3. ToolMessage    'Обращение передано команде billing.'
#   4. AIMessage      ' Ваш вопрос передан команде billing. Они разбер...'

В ключах состояния рядом с messages появилось escalated_to, и приложение читает название команды как обычное поле словаря, без разбора текста ответа.

В записи ToolRuntime[None, SupportState] первый параметр в скобках, это тип конфигурации запуска, второй это тип состояния. Конфигурация здесь не нужна, поэтому на её месте None.

Модель может запросить несколько вызовов инструментов за один шаг, и они выполняются параллельно. Если два таких вызова пишут одно и то же поле, итоговое значение задаёт редьюсер поля: функция, которая сводит два значения в одно. У поля messages редьюсер есть, поэтому оба ToolMessage добавляются в список. У поля без редьюсера за шаг может быть только одно значение, а второе останавливает запуск с InvalidUpdateError. Устройство редьюсеров разбирается в уроке 11.

Хранилище: то, что переживает запуск

Без памяти сессии состояние обнуляется вместе с запуском агента. Хранилище, в терминах фреймворка store, сохраняется дольше: записанное в одном разговоре достаётся в другом.

Внутри инструмента хранилище лежит в runtime.store, а записи в нём адресуются парой из пространства имён и ключа. Само хранилище передаётся агенту параметром store при создании.

Если агент собран без хранилища, поле runtime.store равно None, и обращение к нему без проверки остановит весь запуск агента.

Пример 05_store.py

"""Пример 5 урока 10: инструмент читает и пишет долгую память.

Два запуска агента подряд. Сообщения первого запуска во второй не попадают,
а хранилище у них общее, поэтому записанное в первом запуске достаётся во
втором.
"""

from dataclasses import dataclass

from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from langgraph.store.memory import InMemoryStore

from course_model import build_model

NAMESPACE = ("preferences",)


@dataclass
class SessionContext:
    """Конфигурация запуска: кто спрашивает."""

    user_id: str


@tool
def save_preference(preference: str, runtime: ToolRuntime[SessionContext]) -> str:
    """Запомнить пожелание пользователя о том, как ему отвечать."""
    if runtime.store is None:
        return "Хранилище не подключено, запомнить нечем."

    runtime.store.put(NAMESPACE, runtime.context.user_id, {"preference": preference})
    return f"Запомнил: {preference}"


@tool
def get_preference(runtime: ToolRuntime[SessionContext]) -> str:
    """Вернуть ранее сохранённое пожелание пользователя."""
    if runtime.store is None:
        return "Хранилище не подключено, вспомнить нечем."

    item = runtime.store.get(NAMESPACE, runtime.context.user_id)
    if item is None:
        return "Пожеланий не сохранено."

    return item.value["preference"]


store = InMemoryStore()

agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[save_preference, get_preference],
    system_prompt=(
        "Вы помощник. Пожелания пользователя о форме ответа сохраняйте "
        "инструментом и доставайте инструментом, по памяти не отвечайте. "
        "Отвечайте по-русски и коротко."
    ),
    context_schema=SessionContext,
    store=store,
)

context = SessionContext(user_id="u-101")

first = agent.invoke(
    {"messages": [{"role": "user", "content": "Запомните: отвечайте мне без списков."}]},
    context=context,
)
print("ЗАПУСК 1:", first["messages"][-1].text.replace("\n", " "))
print("  сообщений в состоянии:", len(first["messages"]))

second = agent.invoke(
    {"messages": [{"role": "user", "content": "Как я просил вам отвечать?"}]},
    context=context,
)
print("ЗАПУСК 2:", second["messages"][-1].text.replace("\n", " "))
print("  сообщений в состоянии:", len(second["messages"]))

print()
item = store.get(NAMESPACE, "u-101")
print("В ХРАНИЛИЩЕ ПОСЛЕ ДВУХ ЗАПУСКОВ:", item.value if item else "<пусто>")

# Вывод:
# ЗАПУСК 1:  Запомнил. Буду отвечать без списков.
#   сообщений в состоянии: 4
# ЗАПУСК 2: Вы просили отвечать без списков. Учту это в дальнейшем.
#   сообщений в состоянии: 4
#
# В ХРАНИЛИЩЕ ПОСЛЕ ДВУХ ЗАПУСКОВ: {'preference': 'Отвечать без списков'}

Во втором запуске в состоянии снова четыре сообщения: разговор начался заново, реплик первого запуска в нём нет. Пожелание при этом нашлось, его отдало хранилище. В этом и разница между состоянием и хранилищем.

Ключ здесь взят из конфигурации запуска, runtime.context.user_id: конфигурация указывает, чьи это записи, а хранилище держит их содержимое. InMemoryStore хранит всё в словаре внутри процесса, и при перезапуске записи пропадают. Такое хранилище подходит для примеров и тестов, а в рабочем приложении нужно хранилище на базе данных, например PostgresStore. Что имеет смысл держать в долгой памяти и как не копить в ней лишнее, разбирается в уроке 13.

Runtime и ToolRuntime: два объекта, одна конфигурация

До сих пор речь шла про инструменты, но конфигурация запуска нужна и middleware: промпт под пользователя, проверка прав перед вызовом модели, запись в журнал. В middleware конфигурацию передаёт объект Runtime. Декоратор @before_model делает middleware из функции, которая выполняется перед каждым вызовом модели. Такая функция получает два параметра: состояние и runtime. В обёртке @wrap_model_call из урока 5 тот же объект лежит в request.runtime.

ToolRuntime это отдельный класс, от Runtime он не наследуется: проверка isinstance(runtime, Runtime) в инструменте вернёт False. Часть полей у них общая, остальные у каждого свои. Собственные поля Runtime, например previous и control, в этом уроке не нужны, и в таблице их нет.

Что Runtime (middleware) ToolRuntime (инструмент)
context есть есть
store есть есть
stream_writer есть есть
execution_info, server_info есть есть
state нет, состояние приходит отдельным параметром хука есть
config нет есть
tool_call_id нет есть
tools нет есть, все инструменты агента

В execution_info лежат сведения о запуске: thread_id, run_id, номер попытки node_attempt. Поле thread_id берётся из config["configurable"] и пустое, если thread_id при вызове не передан. Поле server_info заполняет сервер LangSmith Deployment, когда агент работает на нём: в поле лежат идентификаторы ассистента и графа, а при настроенной авторизации ещё и данные пользователя. При вызове invoke из своего кода, как в примерах этого урока, оно равно None.

Пример 06_runtime_objects.py

"""Пример 6 урока 10: два объекта, Runtime и ToolRuntime, в одном прогоне.

Middleware получает Runtime, инструмент получает ToolRuntime. Конфигурация
запуска, хранилище и запись в поток у них общие, а состояние, объект config,
идентификатор вызова и список инструментов есть только у второго.

Чекпойнтер подключён, как в приложении с памятью сессии, а thread_id передан
в config: из него заполняется поле execution_info.thread_id. Память сессии
разбирается в уроке 12.
"""

from dataclasses import dataclass

from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import before_model
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.runtime import Runtime

from course_model import build_model


@dataclass
class SessionContext:
    """Конфигурация запуска."""

    user_id: str


@before_model
def show_runtime(state: AgentState, runtime: Runtime[SessionContext]) -> None:
    """Печатает поля Runtime перед вызовом модели."""
    info = runtime.execution_info
    print("MIDDLEWARE, объект Runtime")
    print("  context.user_id:", runtime.context.user_id)
    print("  store:", runtime.store)
    print("  server_info:", runtime.server_info)
    print("  есть ли поле config:", hasattr(runtime, "config"))
    print("  execution_info.thread_id:", info.thread_id if info else None)
    print("  сообщений в состоянии:", len(state["messages"]))
    return None


@tool
def whoami(runtime: ToolRuntime[SessionContext]) -> str:
    """Вернуть идентификатор текущего пользователя."""
    info = runtime.execution_info
    configurable = runtime.config.get("configurable", {})

    print("ИНСТРУМЕНТ, объект ToolRuntime")
    print("  context.user_id:", runtime.context.user_id)
    print("  tool_call_id:", runtime.tool_call_id)
    print("  сообщений в состоянии:", len(runtime.state["messages"]))
    print("  config configurable thread_id:", configurable.get("thread_id"))
    print("  execution_info.thread_id:", info.thread_id if info else None)
    print("  execution_info.node_attempt:", info.node_attempt if info else None)
    print("  инструментов в runtime.tools:", len(runtime.tools))

    return runtime.context.user_id


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[whoami],
    system_prompt="Вы помощник. Отвечайте по-русски одним предложением.",
    context_schema=SessionContext,
    middleware=[show_runtime],
    checkpointer=InMemorySaver(),
)

result = agent.invoke(
    {"messages": [{"role": "user", "content": "Под каким идентификатором я вошёл?"}]},
    config={"configurable": {"thread_id": "session-1"}},
    context=SessionContext(user_id="u-101"),
)

print()
print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))

# Вывод:
# MIDDLEWARE, объект Runtime
#   context.user_id: u-101
#   store: None
#   server_info: None
#   есть ли поле config: False
#   execution_info.thread_id: session-1
#   сообщений в состоянии: 1
# ИНСТРУМЕНТ, объект ToolRuntime
#   context.user_id: u-101
#   tool_call_id: call_06889f8154da40d38e463a3a
#   сообщений в состоянии: 2
#   config configurable thread_id: session-1
#   execution_info.thread_id: session-1
#   execution_info.node_attempt: 1
#   инструментов в runtime.tools: 1
# MIDDLEWARE, объект Runtime
#   context.user_id: u-101
#   store: None
#   server_info: None
#   есть ли поле config: False
#   execution_info.thread_id: session-1
#   сообщений в состоянии: 3
#
# ОТВЕТ: Вы вошли под идентификатором **u-101**.

Middleware печатает дважды, потому что модель вызывается дважды: первый ответ содержит вызов инструмента, второй собирается по его результату.

Строки context.user_id и config configurable thread_id в выводе инструмента показывают два разных источника. thread_id задаёт разговор, а в context лежат данные этого конкретного запуска. Передавайте их вместе: постоянный thread_id на весь разговор и объект context при каждом вызове.

Поле runtime.tools, это список всех инструментов, которые агент может вызвать. Здесь в нём один whoami. В документации это поле не описано.

Старые механизмы внедрения

Всё, что выше, собрано вокруг одного параметра. Так было не всегда, и в чужом коде встречаются отдельные механизмы для тех же задач:

1) InjectedState, аннотация на аргумент, в который подставляется состояние или одно его поле

2) InjectedStore, то же самое для хранилища

3) InjectedToolCallId, то же самое для идентификатора вызова

4) get_runtime(), функция, которую вызывают прямо в теле инструмента, чтобы получить объект Runtime

Первые три механизма пишутся меткой рядом с типом аргумента, например Annotated[dict, InjectedState], а get_runtime() вызывается в теле функции. В новом коде всё это заменяет один параметр ToolRuntime.

Старые механизмы работают и сейчас. В версиях, на которых собран курс, ни один из них не удалён, InjectedState, InjectedStore и InjectedToolCallId импортируются из langchain.tools, а предупреждения об устаревании нет.

Пример 7 собирает один и тот же инструмент двумя способами и запускает агента с каждым.

Пример 07_legacy_injection.py

"""Пример 7 урока 10: старые механизмы внедрения рядом с нынешним.

Два инструмента делают одно и то же. Первый собран по-старому, четырьмя разными
механизмами: InjectedState, InjectedStore, InjectedToolCallId и вызов
get_runtime внутри тела. Второй берёт то же самое из одного параметра
ToolRuntime. Схема для модели у обоих должна быть одинаковой.

Каждый агент получает свой единственный инструмент, иначе выбор между ними
остаётся за моделью и прогон перестаёт быть сравнением.
"""

from dataclasses import dataclass
from typing import Annotated

from langchain.agents import create_agent
from langchain.tools import (
    InjectedState,
    InjectedStore,
    InjectedToolCallId,
    ToolRuntime,
    tool,
)
from langgraph.runtime import get_runtime
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore

from course_model import build_model

NAMESPACE = ("plans",)


@dataclass
class SessionContext:
    """Конфигурация запуска."""

    user_id: str


@tool
def old_style_report(
    topic: str,
    state: Annotated[dict, InjectedState],
    store: Annotated[BaseStore, InjectedStore],
    tool_call_id: Annotated[str, InjectedToolCallId],
) -> str:
    """Собрать служебную справку по теме обращения."""
    try:
        user_id = get_runtime(SessionContext).context.user_id
    except Exception as error:  # noqa: BLE001
        # Защитная ветка: get_runtime работает только внутри прогона графа.
        user_id = f"<{type(error).__name__}>"

    item = store.get(NAMESPACE, user_id)
    plan = item.value["plan"] if item else "неизвестен"

    return (
        f"тема={topic} пользователь={user_id} тариф={plan} "
        f"сообщений={len(state['messages'])} вызов={tool_call_id[:8]}"
    )


@tool
def new_style_report(topic: str, runtime: ToolRuntime[SessionContext]) -> str:
    """Собрать служебную справку по теме обращения."""
    user_id = runtime.context.user_id

    item = runtime.store.get(NAMESPACE, user_id) if runtime.store else None
    plan = item.value["plan"] if item else "неизвестен"

    return (
        f"тема={topic} пользователь={user_id} тариф={plan} "
        f"сообщений={len(runtime.state['messages'])} "
        f"вызов={runtime.tool_call_id[:8]}"
    )


store = InMemoryStore()
store.put(NAMESPACE, "u-101", {"plan": "premium"})

QUESTION = "Соберите справку по теме billing."

for report_tool in (old_style_report, new_style_report):
    agent = create_agent(
        model=build_model(temperature=0, max_tokens=512),
        tools=[report_tool],
        system_prompt=(
            "Вы помощник поддержки. Справку собирайте инструментом и возвращайте "
            "пользователю ровно ту строку, которую вернул инструмент, без правок."
        ),
        context_schema=SessionContext,
        store=store,
    )

    print(f"ИНСТРУМЕНТ {report_tool.name}")
    print("  видит модель:", sorted(report_tool.args))

    try:
        result = agent.invoke(
            {"messages": [{"role": "user", "content": QUESTION}]},
            context=SessionContext(user_id="u-101"),
        )
    except Exception as error:  # noqa: BLE001
        # Защитная ветка: прежняя схема могла перестать работать.
        print(f"  ОТКАЗ: {type(error).__name__}: {error}")
        continue

    tool_results = [
        message.text.replace("\n", " ")
        for message in result["messages"]
        if type(message).__name__ == "ToolMessage"
    ]
    print("  вернул инструмент:", tool_results)
    print("  ответ агента:", result["messages"][-1].text.replace("\n", " "))

# Вывод:
# ИНСТРУМЕНТ old_style_report
#   видит модель: ['topic']
#   вернул инструмент: ['тема=billing пользователь=u-101 тариф=premium сообщений=2 вызов=call_8e8']
#   ответ агента: тема=billing пользователь=u-101 тариф=premium сообщений=2 вызов=call_8e8
# ИНСТРУМЕНТ new_style_report
#   видит модель: ['topic']
#   вернул инструмент: ['тема=billing пользователь=u-101 тариф=premium сообщений=2 вызов=call_ba6']
#   ответ агента: тема=billing пользователь=u-101 тариф=premium сообщений=2 вызов=call_ba6

В строке "видит модель" у обоих инструментов один topic. У старого варианта три служебных параметра, и все три из схемы для модели вырезаны: метки Injected* наследуют класс InjectedToolArg, по нему фреймворк и узнаёт внедряемый аргумент.

При переписывании старого кода все четыре механизма заменяются одним параметром ToolRuntime. Если агент собран без хранилища, InjectedStore останавливает запуск с ValueError, а runtime.store в той же ситуации равен None. Поэтому после переписывания нужна проверка if runtime.store.

Старый способ: config["configurable"]

До версии 1 конфигурацию запуска передавали словарём configurable внутри config. Этот способ оставлен для совместимости, а в новых приложениях и при переезде на версию 1 берите context. Внутри инструмента старое значение лежит в runtime.config["configurable"]. Поле config есть у ToolRuntime, а у Runtime в middleware его нет.

Пример 08_configurable.py

"""Пример 8 урока 10: старый способ, config configurable, рядом с новым.

Один и тот же агент запускается дважды. В первом запуске идентификатор
пользователя приходит аргументом context, во втором тем способом, которым
это делали до версии 1: словарём configurable внутри config. Инструмент
возвращает оба источника одной строкой, поэтому видно, что в каждом запуске
пришло, а что нет.
"""

from dataclasses import dataclass

from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool

from course_model import build_model


@dataclass
class SessionContext:
    """Конфигурация запуска."""

    user_id: str


@tool
def whoami(runtime: ToolRuntime[SessionContext]) -> str:
    """Вернуть идентификатор текущего пользователя из обоих источников."""
    # Защитная ветка: без аргумента context поле runtime.context равно None.
    from_context = getattr(runtime.context, "user_id", None)
    from_configurable = runtime.config.get("configurable", {}).get("user_id")

    return f"context={from_context} configurable={from_configurable}"


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[whoami],
    system_prompt=(
        "Вы помощник. Идентификатор пользователя берите инструментом и "
        "возвращайте ровно ту строку, которую он вернул, без правок."
    ),
    context_schema=SessionContext,
)

QUESTION = "Под каким идентификатором я вошёл?"


def run(title, **invoke_kwargs):
    """Один запуск агента со своим способом передачи данных."""
    print(title)
    result = agent.invoke(
        {"messages": [{"role": "user", "content": QUESTION}]}, **invoke_kwargs
    )

    for message in result["messages"]:
        if type(message).__name__ == "ToolMessage":
            print("  инструмент вернул:", message.text.replace("\n", " "))

    print("  ответ агента:", result["messages"][-1].text.replace("\n", " "))


run("ЗАПУСК 1, новый способ: аргумент context", context=SessionContext(user_id="u-101"))
print()
run(
    "ЗАПУСК 2, старый способ: config configurable",
    config={"configurable": {"user_id": "u-303"}},
)

# Вывод:
# ЗАПУСК 1, новый способ: аргумент context
#   инструмент вернул: context=u-101 configurable=None
#   ответ агента:  Вы вошли под идентификатором: `context=u-101 configurable=None`
#
# ЗАПУСК 2, старый способ: config configurable
#   инструмент вернул: context=None configurable=u-303
#   ответ агента:  Вы вошли под идентификатором: **u-303**

В каждом запуске заполнен свой источник, и по строке "инструмент вернул" видно, какой именно.

Системный промпт просит вернуть строку инструмента без правок, но модель выполняет такую просьбу не дословно. В первом запуске она добавила к строке свою фразу и обратные кавычки, во втором оставила от строки один идентификатор и выделила его жирным. Поэтому в проверках и при разборе ответа на последнюю реплику не опирайтесь. Берите данные из ToolMessage, в выводе это строка "инструмент вернул": её модель не меняет.

У context есть схема, поэтому редактор подсказывает поля, а опечатка в имени поля даёт ошибку. Код на config["configurable"] при этом не ломается: оба способа работают в одном агенте, как в примере 8, поэтому старый код можно переводить на context постепенно.

runtime.config это обычный RunnableConfig, поэтому инструменту доступно и остальное его содержимое: метки, метаданные, thread_id. Это пригодится при записи в журнал.

Распространённые ошибки

Разборы ниже даны фрагментами, отдельных файлов у них нет. Модель, класс SessionContext и агент в них считаются уже определёнными, как в примерах выше.

Ошибка: свой аргумент назван runtime

# Неправильно: имя занято фреймворком
@tool
def render(template: str, runtime: str) -> str:
    """Отрисовать шаблон в заданном режиме."""
    return f"{template} в режиме {runtime}"

# Правильно: своему аргументу своё имя
@tool
def render(template: str, mode: str) -> str:
    """Отрисовать шаблон в заданном режиме."""
    return f"{template} в режиме {mode}"

Почему так: имя runtime фреймворк проверяет отдельно от типа, и параметр с этим именем получает ToolRuntime при любой аннотации. Модель заполнит его как строку, а перед вызовом на его место встанет объект ToolRuntime. Проверка аргументов его не пропустит, функция не выполнится, и модель получит ToolMessage со статусом error. Аргумент config с любым типом, кроме RunnableConfig, до функции не доходит: она вызывается без него и падает с TypeError, и весь запуск агента останавливается. Пример 2 показывает, что оба имени попадают в схему для модели.

Ошибка: тип данных вместо ToolRuntime

# Неправильно: аннотация описывает то, что лежит внутри
@tool
def whoami(runtime: SessionContext) -> str:
    """Вернуть идентификатор пользователя."""
    return runtime.user_id

# Правильно: аннотация описывает окружение, данные достаются из поля
@tool
def whoami(runtime: ToolRuntime[SessionContext]) -> str:
    """Вернуть идентификатор пользователя."""
    return runtime.context.user_id

Почему так: из схемы для модели параметр вырезается по типу. SessionContext это обычный dataclass, поэтому параметр останется в схеме вместе с полем user_id, и заполнять его будет модель. Если назвать такой параметр иначе, например ctx, значение от модели дойдёт до функции, и получится та самая подмена, с которой начинался урок. С именем runtime перед вызовом на его место встанет объект ToolRuntime, проверка аргументов его не пропустит, и функция не выполнится.

Ошибка: Command без ToolMessage

# Неправильно: поле обновили, а ответа на вызов инструмента нет
@tool
def escalate(team: str, runtime: ToolRuntime[None, SupportState]) -> Command:
    """Передать обращение другой команде."""
    return Command(update={"escalated_to": team})

# Правильно: в обновление кладётся и сообщение с идентификатором вызова
@tool
def escalate(team: str, runtime: ToolRuntime[None, SupportState]) -> Command:
    """Передать обращение другой команде."""
    return Command(
        update={
            "escalated_to": team,
            "messages": [
                ToolMessage(
                    content=f"Обращение передано команде {team}.",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
        }
    )

Почему так: на каждый вызов инструмента в истории должен стоять ответ ToolMessage с тем же tool_call_id. Узел инструментов проверяет это сам: Command без такого сообщения останавливает запуск с ValueError "Expected to have a matching ToolMessage in Command.update". ToolMessage кладётся в то же обновление состояния, вместе с остальными полями.

Ошибка: обращение к хранилищу без проверки

# Неправильно: агент собран без store, и поле пустое
@tool
def remember(fact: str, runtime: ToolRuntime) -> str:
    """Запомнить факт о пользователе."""
    runtime.store.put(("facts",), "last", {"fact": fact})
    return "Запомнил"

# Правильно
@tool
def remember(fact: str, runtime: ToolRuntime) -> str:
    """Запомнить факт о пользователе."""
    if runtime.store is None:
        return "Хранилище не подключено, запомнить нечем."

    runtime.store.put(("facts",), "last", {"fact": fact})
    return "Запомнил"

Почему так: runtime.store заполняется только тогда, когда агенту передали параметр store. Без него runtime.store.put падает с AttributeError, и, как в уроке 9, исключение из тела инструмента останавливает весь вызов agent.invoke. Поэтому проверка if runtime.store нужна всегда.

Практическое задание

Напишите скрипт runtime_desk.py: агент службы доставки, у которого данные запуска не попадают в схему для модели.

Требования:

1) объявите dataclass с тремя полями: идентификатор пользователя, город и роль (client или operator). Укажите его в context_schema, а значение передайте аргументом context

2) сделайте инструмент my_orders, который возвращает заказы текущего пользователя из словаря в модуле. Идентификатор берите из runtime.context, аргументов у инструмента быть не должно

3) сделайте инструмент cancel_order, который принимает номер заказа и возвращает Command. В обновление положите поле cancelled со списком отменённых номеров и ToolMessage с tool_call_id. Поле объявите в своей схеме состояния и передайте её агенту параметром state_schema

4) пользователю с ролью client отмена запрещена: инструмент возвращает ему строку с отказом и Command не создаёт. Роль проверяйте по runtime.context.role

5) добавьте middleware с декоратором @before_model: перед каждым вызовом модели он печатает роль пользователя и число сообщений в состоянии. Роль берите из Runtime, состояние из параметра хука. Если конфигурация запуска не передана, middleware печатает об этом строку и не падает

6) запустите один и тот же запрос "отмените мой последний заказ" дважды, для роли client и для роли operator

7) в конце каждого запуска напечатайте ключи состояния и значение поля cancelled

Как проверить результат:

1) my_orders.args пустой словарь, то есть в схеме для модели аргументов нет

2) при роли client поля cancelled в состоянии нет или оно пустое, при роли operator в нём номер заказа

3) middleware печатает столько раз, сколько было вызовов модели, и роль в его выводе совпадает с той, что передана в context

4) если убрать аргумент context из вызова, запуск доходит до конца без AttributeError: middleware печатает строку об отсутствии конфигурации, инструменты сообщают, что конфигурация запуска не передана

Подсказка: для четвёртого пункта проверки пригодится getattr(runtime.context, "role", None), как в примере 8. В middleware подойдёт та же запись: без аргумента context поле runtime.context у Runtime тоже равно None.

Итоги урока

Инструменту в LangChain доступны не только аргументы, которые заполнила модель. Параметр с типом ToolRuntime даёт ему конфигурацию запуска, состояние, хранилище и данные самого вызова. Из схемы для модели этот параметр вырезается по типу, поэтому подменить его репликой нельзя.

Конфигурация запуска складывается из dataclass с полями, параметра context_schema у агента и аргумента context при вызове. Внутри инструмента она лежит в runtime.context. Идентификатор пользователя и другие данные, от которых зависит доступ, передавайте именно так.

Рядом в том же объекте лежат состояние разговора runtime.state, хранилище runtime.store, объект config в поле runtime.config, идентификатор вызова runtime.tool_call_id и сведения о запуске runtime.execution_info. Чтобы записать что-то в состояние, инструмент возвращает Command с полем update, и в это обновление обязательно кладётся ToolMessage.

В middleware конфигурацию запуска передаёт объект Runtime. Конфигурация, хранилище и запись в поток у него те же, а состояния, объекта config и идентификатора вызова инструмента нет: состояние приходит хуку отдельным параметром.

Имена config, runtime, run_manager и callbacks заняты фреймворком, для своих аргументов их брать нельзя. Старые механизмы InjectedState, InjectedStore, InjectedToolCallId и get_runtime работают и сейчас, в чужом коде они встречаются, и все заменяются одним параметром ToolRuntime. Словарь config["configurable"] тоже продолжает работать, а в новом коде берите context.

Слово "агент" звучало всё время, а его части вы использовали по отдельности: состояние, middleware, хранилище, цикл вызова инструментов. Что такое агент целиком и из каких узлов он собран, я пока не разбирал. Когда агент останавливается и как устроена его схема состояния с редьюсерами полей, тоже осталось в стороне.

В уроке 11, "Первый агент", покажу create_agent изнутри: цикл модель-инструменты, состояние агента и свой подкласс состояния в параметре state_schema. Там же объясню, чем агент отличается от цепочки и по какой рамке из шести категорий настраивается всё остальное в курсе.

Код урока

Примеры этого урока лежат в репозитории курса, папка lesson_10. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.


Предыдущий урок: Инструменты и цикл вызова

Следующий урок: Первый агент: цикл, состояние и рамка настройки


Подписывайтесь на мой Telegram канал

Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы