1. Инструмент агента: что это такое на самом деле
В предыдущих модулях вы уже видели инструменты со стороны Apps SDK — как «функции бэкенда», к которым ChatGPT ходит через ваш App. Сейчас сместим ракурс: посмотрим на инструменты глазами агента в Agents SDK и разберём, как он выбирает, что вызвать и что делать с ошибками.
В обычном бэкенде вы привыкли думать категориями «эндпоинт», «метод контроллера», «функция сервиса». В агентном мире базовой единицей действий становится инструмент (tool). tools агента и mcp-tools — это разные, хоть и пересекающиеся вещи.
Если говорить строго: инструмент в контексте ChatGPT Agents SDK — это описание функции, которую модель может попросить выполнить. Модель сама код не запускает; она генерирует структурированный запрос (обычно JSON), а рантайм (ваш код, MCP‑сервер или Agents SDK) уже выполняет эту операцию и возвращает результат.
В экосистеме ChatGPT Agents SDK инструмент описывается конфигурацией: у него есть name, description и parameters (JSON Schema аргументов). Агент видит этот набор инструментов, держит их в своём контексте и в процессе reasoning решает, какой tool вызвать и с какими аргументами.
Агент (или ChatGPT как хост) получает этот список, «запоминает» его в своём контексте и в процессе рассуждений (reasoning) решает: на какой пользовательский запрос вызвать какой инструмент и с какими аргументами. Именно поэтому в спецификациях постоянно повторяют мантру «tools are a contract» — инструменты — это контракт между моделью и вашим кодом, а не просто «функция в Python/TS».
Можно провести аналогию с классическим API. Роут /api/gifts/search — это чисто синтаксис: URL, метод, формат тела. А tool search_gifts — это семантика: «поиск подарков по профилю и бюджету». Описание инструмента — это такой же промпт, только структурированный и рассчитанный на LLM, а не на человека.
2. Типы инструментов: чем именно может заниматься LLM-агент
Чтобы не утонуть в хаосе «функций, которые всё умеют», полезно смотреть на инструменты как на несколько типичных категорий. Это не формальная типизация SDK, а архитектурное мышление, которое вам очень поможет.
В нашем бэкенде у LLM-агентов обычно есть три источника инструментов.
- Локальные бизнес‑инструменты. Это то, что живёт в вашем бэкенде: работа с БД, доменная логика (фильтрация, рекомендации, скоринг). Например, для GiftGenius у нас могут быть инструменты, которые достают товары из своей таблицы PostgreSQL или считают персональный скоринг «насколько подарок зайдёт этому человеку».
- MCP‑инструменты. Здесь MCP‑сервер выступает поставщиком инструментов (tools): он регистрирует функции, ресурсы и промпты и отдаёт их клиенту (ChatGPT, LLM-агент). Инструменты через MCP могут вызывать внешние API, работать с файлами или предоставлять шаблоны промптов.
- Интеграционные инструменты. Это всё, что связывает вас с остальным миром: ACP/commerce (создание заказа и checkout), отправка писем, webhooks, запись в CRM. Такие инструменты (tools) часто более опасны, потому что меняют состояние внешних систем, и к ним нужно относиться особенно строго по безопасности и идемпотентности.
Есть и другая полезная классификация — по характеру действия. В исследованиях по LLM‑инструментам обычно выделяют: инструменты получения данных (поиск, RAG, get_*), инструменты действий с побочными эффектами (create_order, send_email), чисто вычислительные (calculate_loan) и системные/управляющие (handoff_to_human, finish_task).
Чтобы это зафиксировать, удобно посмотреть маленькую таблицу.
| Категория | Пример в GiftGenius | Побочный эффект | Риск |
|---|---|---|---|
| Data Retrieval | |
Нет | Низкий |
| Action / Mutating | |
Да | Высокий |
| Computation | |
Нет | Средний |
| System / Control | |
Нет | Логический |
С архитектурной точки зрения самое важное: read‑only инструменты должны быть массовыми и дешевыми, а изменяющие — редкими, предельно аккуратными, с логами, идемпотентностью и часто с пользовательским подтверждением.
Дальше мы будем в основном говорить про инструменты получения данных и Action‑инструменты, потому что именно на них строится логика GiftGenius.
3. JSON Schema как контракт между моделью и вашим кодом
Теперь давайте углубимся в то, как инструмент описывается. В ChatGPT Agents SDK (как и в Apps SDK) стандартным форматом описания параметров инструмента является JSON Schema: вы описываете тип object, его properties, типы полей, обязательные поля, ограничения и так далее.
Важно понимать: JSON Schema здесь не только и не столько про валидацию. Это часть промпта для модели. В официальных гайдах OpenAI по проектированию инструментов (tools) прямо говорится, что качество работы агента сильно зависит от того, насколько подробно и однозначно описаны поля, их названия и комментарии.
Посмотрим на пример для GiftGenius, который уже мелькал в планах курса.
{
"name": "search_gifts",
"description": "Находит подарки по типу получателя, интересам и бюджету.",
"parameters": {
"type": "object",
"properties": {
"recipient_type": {
"type": "string",
"description": "Кто получатель подарка (например, 'мужчина', 'женщина', 'ребёнок')."
},
"interests": {
"type": "array",
"items": { "type": "string" },
"description": "Ключевые интересы (спорт, книги, технологии и т.п.)."
},
"budget": {
"type": "number",
"description": "Максимальный бюджет в валюте пользователя."
}
},
"required": ["recipient_type", "budget"]
}
}
Здесь есть несколько важных моментов.
- Во‑первых, name и description. Для модели это главный сигнал, когда вообще использовать этот инструмент. Документация по семантическому роутингу подчёркивает, что описание инструмента — это фактически API для модели: если вы назовёте его func1 и подпишете «делает что-то полезное», модель честно не поймёт, когда его вызывать. А если написать search_gifts и добавить понятное описание, выбор становится тривиальнее.
- Во‑вторых, parameters. Названия полей и их описания крайне важны. Для LLM recipient_type гораздо понятнее, чем type. Хороший description вроде «Кто получатель подарка…» подсказывает модели, что сюда нужно подставить именно тип получателя, а не, скажем, формат упаковки.
- В‑третьих, required. Это не только валидация на вашей стороне, но и подсказка модели: она будет стараться заполнить обязательные поля, а необязательные пропустит, если из контекста не ясно. Это уменьшает количество «пустых» или некорректных tool‑вызовов.
Официальные гайды по Apps SDK прямо рекомендуют: делайте инструменты узкими, с одной ответственностью, с чёткими именами и описаниями, и избегайте инструментов «сделай всё для подарков», которые пытаются объединить разные задачи.
4. Проектируем инструменты GiftGenius: от схемы к коду
Возьмём наш GiftGenius и добавим туда два ключевых инструмента LLM-агента, которые будут нужны почти во всех сценариях:
- suggest_gifts(profile, budget) — выдаёт список кандидатов;
- get_gift_details(gift_id) — раскрывает подробности по конкретному подарку.
Наши suggest_gifts и get_gift_details — типичный пример локальных бизнес‑инструментов из предыдущей классификации, в основном из категории Data Retrieval.
Схема для suggest_gifts
Начнём с чистого JSON Schema, а потом покажем, как это может выглядеть в TypeScript-коде бэкенда/агентного рантайма.
{
"name": "suggest_gifts",
"description": "Подбирает список подарков на основе профиля получателя и бюджета.",
"parameters": {
"type": "object",
"properties": {
"age": {
"type": "integer",
"minimum": 0,
"maximum": 120,
"description": "Возраст получателя в годах."
},
"relationship": {
"type": "string",
"enum": ["friend", "coworker", "partner", "family"],
"description": "Отношения с получателем: друг, коллега, партнёр, семья."
},
"interests": {
"type": "array",
"items": { "type": "string" },
"description": "Интересы получателя (спорт, книги, технологии и т.п.)."
},
"budget": {
"type": "number",
"minimum": 1,
"description": "Максимальный бюджет в валюте пользователя."
}
},
"required": ["budget"]
}
}
Здесь мы используем enum для relationship, чтобы модель не выдумывала свои произвольные строки типа "плохой коллега" и не засовывала их дальше в код. Такой аккуратный дизайн схемы помогает как модели (она видит допустимые варианты), так и разработчику (меньше неожиданностей в рантайме).
Теперь представим, что у нас MCP‑сервер на Node.js с каким‑то условным McpServer. Регистрация инструмента может выглядеть так:
// упрощённый пример регистрации инструмента в MCP-сервере
server.registerTool(
{
name: "suggest_gifts",
description: "Подбирает подарки по профилю и бюджету.",
inputSchema: suggestGiftsSchema
},
async (input, ctx) => {
const gifts = await findGiftsInDb(input, ctx.userLocale);
return { items: gifts }; // JSON, который потом увидит агент
}
);
Код сильно упрощён, но логика понятна: в одном месте — описание контракта (имя, описание, схема), в другом — реализация.
Схема для get_gift_details
Второй инструмент, который нужен почти в любой витрине:
{
"name": "get_gift_details",
"description": "Получает полные сведения о подарке по его идентификатору.",
"parameters": {
"type": "object",
"properties": {
"gift_id": {
"type": "string",
"description": "UUID подарка в базе GiftGenius."
}
},
"required": ["gift_id"]
}
}
И аналогичная регистрация:
server.registerTool(
{
name: "get_gift_details",
description: "Возвращает детальную информацию о подарке.",
inputSchema: getGiftDetailsSchema
},
async ({ gift_id }) => {
const gift = await db.gifts.findById(gift_id);
if (!gift) return { notFound: true };
return { gift };
}
);
Обратите внимание: мы здесь сразу показываем, что инструмент может вернуть notFound: true. Это уже зачатки семантических ошибок (бизнес‑ошибок), о которых поговорим ниже. Агент сможет увидеть «подарок не найден» и принять решение: например, попробовать другой id или предложить пользователю выбрать другой товар.
5. Как агент выбирает, какой инструмент вызвать
Теперь самое интересное: маршрутизация. В традиционном веб‑приложении роутинг жёсткий: URL → конкретный контроллер. В мире ChatGPT Apps и агентов выбор инструмента происходит семантически и вероятностно.
Высокоуровневый цикл можно изобразить так:
flowchart TD
U[User message] --> M["Модель (агент)"]
M -->|анализ запроса| C{Нужен tool?}
C -->|нет| T[Текстовый ответ]
C -->|да| S[Выбор инструмента]
S --> K[Формирование JSON аргументов]
K --> R[Выполнение инструмента]
R --> M2[Модель видит результат]
M2 --> T2[Финальный ответ или следующий шаг]
На каждом шаге агент видит несколько вещей:
- Во‑первых, system‑инструкции (роль агента, ограничения);
- Во‑вторых, историю диалога;
- И наконец, список инструментов (tools) с их name, description, inputSchema.
Когда приходит очередное пользовательское сообщение, модель сравнивает смысл запроса с описаниями инструментов (семантическое сопоставление). Если запрос «подбери подарок другу до 50 долларов», описание suggest_gifts звучит гораздо релевантнее, чем get_gift_details, и агент с высокой вероятностью выберет именно его.
Официальные гайды подчёркивают две вещи, которые сильно влияют на качество маршрутизации.
- Во‑первых, нужно избегать пересекающихся по смыслу инструментов: если у вас есть search_gifts и find_gifts, описанные примерно одинаково, модель будет путаться.
- Во‑вторых, нужно стараться придерживаться принципа одной ответственности для инструмента: один tool — одна чёткая задача, а не «подобрать подарки и создать заказ и отправить письмо».
Внутри различных LLM‑агентов есть механизмы управления режимом выбора инструментов: например, «авто» (модель сама решает, нужен ли инструмент), «required» (обязательно вызвать tool), «none» (tools отключены). Это помогает в сложных workflow (многошаговых сценариях), когда, скажем, на определённом шаге вы хотите принудительно вызвать suggest_gifts, а не позволять модели просто болтать.
Пример семантической маршрутизации в GiftGenius
Пусть у нашего агента есть как минимум два инструмента: suggest_gifts и get_gift_details.
- Пользователь пишет: «Подбери подарок коллеге до 30 долларов, он любит настольные игры».
- Агент видит, что запрос содержит цель «подобрать подарок», информацию о бюджете и интересах. Описание suggest_gifts идеально подходит — вызываем этот инструмент.
- Инструмент возвращает список из пяти подарков с их id, названиями и кратким описанием.
- Пользователь дальше пишет: «Расскажи подробнее про третий вариант». Агент сопоставляет «третий вариант» с id из предыдущего результата, и теперь по смыслу подходит инструмент get_gift_details — вызывается он.
Важно заметить: нигде в коде вы явно не писали «если в запросе есть слово “подбери”, то вызови suggest_gifts». Этим занимается сама модель на основе ваших описаний и истории диалога. Ваша ответственность как разработчика — сделать так, чтобы выбор был очевидным и для модели, и для человека.
6. Ошибки инструментов: не 500, а сигнал для модели
Помните, в get_gift_details мы уже показывали notFound: true? Это как раз пример бизнес‑ошибки, которую агент должен увидеть и осмысленно обработать, а не получать голый 500.
Теперь перейдём к самой болезненной части. В обычном REST‑API упало что‑то в глубинах бэкенда — вернули 500 Internal Server Error, записали стектрейс в лог — и дальше пользователь уже как‑то справится. В случае агента такой подход плохо работает.
Практические гайды и материалы по Agents SDK рекомендуют относиться к ошибкам инструментов как к наблюдаемым событиям, а не просто к падениям. Это часто называют паттерном «Error as Observation».
Грубо говоря, вы не должны «падать» без объяснения; вы должны вернуть модели структурированный ответ, который объясняет, что пошло не так, чтобы она могла адаптировать своё поведение: переформулировать запрос, спросить пользователя, попробовать другой инструмент и так далее.
Типы ошибок обычно делят на три группы.
- Ошибки валидации аргументов. Модель может сгенерировать некорректные параметры: пропустить обязательное поле, подставить строку вместо числа, выйти за пределы допустимых значений. Здесь вашу схему и валидацию нужно использовать не только для выбрасывания исключений, но и для осмысленного ответа: например, вернуть, какое поле неверное и почему.
- Бизнес‑ошибки. Это вполне ожидаемые ситуации вроде «товар не найден», «регион недоступен», «бюджет слишком мал для этого типа подарков». С точки зрения API это тоже ошибки, но их нужно возвращать в пределах нормального ответа — с понятным кодом и сообщением, а не как крэш.
- Системные ошибки. Таймауты внешнего сервиса, проблемы сети, сбои базы. Здесь агенту обычно достаточно аккуратного, обобщённого сообщения вроде «сервис временно недоступен, попробуй позже». Никаких стектрейсов, имён таблиц и прочих подробностей, которые не нужны модели и могут быть опасны с точки зрения безопасности.
Официальные материалы по Agents SDK даже предлагают специальный механизм failure_error_function, позволяющий аккуратно сформировать текст ошибки, который увидит модель, вместо того чтобы просто бросать исключение вверх по стеку.
Структура «дружелюбной» ошибки
В инструменте агента (в вашем бэкенде) вы можете договориться, что любая ошибка возвращается, например, в виде объекта:
type ToolError = {
code: string; // 'VALIDATION_ERROR', 'OUT_OF_STOCK', ...
message: string; // для модели
retryable: boolean;
};
А результат работы инструмента — как объединение:
type SuggestGiftsResult =
| {
ok: true;
items: GiftSummary[];
}
| {
ok: false;
error: ToolError;
};
Модель (или агентный рантайм) увидит такой JSON и сможет решить: если retryable: true, можно попробовать ещё раз с небольшими изменениями; если ошибка бизнес‑уровня и не ретраибельная, лучше вернуться к пользователю и объяснить, что не так.
7. Примеры: валидация, бизнес‑ошибка и системная ошибка
Вернёмся к нашему бэкенду/инструментам агента и посмотрим, как можно реализовать те же самые идеи в коде.
Ошибка валидации
Представим, что к вам пришёл инструмент suggest_gifts, но модель зачем‑то решила передать отрицательный бюджет.
async function handleSuggestGifts(input: SuggestGiftsInput)
: Promise<SuggestGiftsResult> {
if (input.budget <= 0) {
return {
ok: false,
error: {
code: "VALIDATION_ERROR",
message: "budget должен быть положительным числом.",
retryable: false
}
};
}
const items = await findGiftsInDb(input);
return { ok: true, items };
}
Здесь мы сознательно не бросаем исключение, а возвращаем структурированную ошибку. Агент может переосмыслить запрос: возможно, он решит, что перепутал валюту, и спросит пользователя или просто признается, что не может подобрать подарок с таким бюджетом.
Бизнес‑ошибка
Теперь пример с get_gift_details. Подарка с указанным id может просто не быть.
async function handleGetGiftDetails(input: { gift_id: string }) {
const gift = await db.gifts.findById(input.gift_id);
if (!gift) {
return {
ok: false,
error: {
code: "GIFT_NOT_FOUND",
message: "Подарок с таким идентификатором не найден.",
retryable: false
}
};
}
return { ok: true, gift };
}
В ответе модели можно ожидать что‑то вроде: «Кажется, выбранный подарок больше недоступен. Могу предложить несколько альтернатив из похожей категории?». Для этого агенту не нужно видеть SQL‑ошибки и стектрейсы — только понятный code и message.
Системная ошибка
Наконец, пример системной ошибки. Пусть ваш инструмент обращается к внешнему API доставки, который иногда «падает».
async function handleEstimateDelivery(input: EstimateDeliveryInput) {
try {
const eta = await callDeliveryApi(input);
return { ok: true, eta_days: eta };
} catch (e) {
return {
ok: false,
error: {
code: "DELIVERY_SERVICE_UNAVAILABLE",
message: "Сервис доставки временно недоступен.",
retryable: true
}
};
}
}
Агент может решить: «Похоже, сервис доставки сейчас недоступен. Я всё равно покажу вам подарки, но точное время доставки может отличаться. Хотите продолжить?».
8. Безопасность и идемпотентность инструментов (быстрый взгляд со стороны tools)
Полноценный разговор о безопасности и пермишенах будет в отдельной теме, но инструменты агента слишком тесно связаны с этим, чтобы совсем не затронуть.
Во‑первых, нужно разделять инструменты чтения и инструменты записи. В описаниях, схемах и пермишенах явно указывайте, какие tools только читают данные и абсолютно безопасны, а какие умеют списывать деньги, изменять заказы и т.п. Документация и форумы агентным сценариям прямо говорят про разделение ReadOnly и Mutating‑инструментов (tools).
Во‑вторых, для мутирующих инструментов нужно думать про идемпотентность. Агент или MCP‑клиент вполне может повторить вызов (например, из‑за сетевой ошибки), и вы не хотите, чтобы create_order создал два заказа вместо одного. Типичные паттерны здесь:
- idempotency‑key, который передаётся как аргумент инструмента;
- проверка существования операции перед выполнением;
- разделение шагов на «создай черновик заказа» и «подтверди заказ».
Всё это очень тесно связано с тем, как именно вы проектируете контракт инструмента: если в JSON Schema нет поля для idempotency‑key, добавить идемпотентность потом будет гораздо больнее.
9. Небольшой взгляд на Agents SDK: как это выглядит в агентном рантайме
Этот раздел — небольшой обзор для тех, кто будет работать с TypeScript-ориентированным Agents SDK. Хотя основная часть курса у нас про MCP, полезно понимать, как подобные инструменты видит Agents SDK и как выглядит типичный tool в рантайме.
В официальной документации обычно описывается сущность вроде «функционального инструмента»: любая функция, описанная через конфигурационный объект (или helper наподобие tool(...)) и снабжённая типами, может быть автоматически превращена в инструмент, для которого SDK сгенерирует JSON Schema и описание.
На уровне концепции это то же самое, что мы уже обсуждали: имя функции, её параметры и комментарий/description играют роль имени, схемы и описания инструмента. Разница в том, что за вас большую часть «механической» работы делает SDK и/или вспомогательная библиотека для схем (например, Zod или JSON Schema).
Условный пример (псевдо-TypeScript, упрощённый):
type Gift = {
id: string;
title: string;
// ...
};
const suggestGifts = tool({
name: "suggest_gifts",
description: "Подбирает список подарков по типу получателя и бюджету.",
parameters: {
type: "object",
properties: {
recipient_type: {
type: "string",
description: "Кто получатель подарка (например, 'мужчина', 'женщина', 'ребёнок')."
},
budget: {
type: "number",
description: "Максимальный бюджет в валюте пользователя."
}
},
required: ["recipient_type", "budget"]
}
}, async (args: { recipient_type: string; budget: number }): Promise<Gift[]> => {
// Внутри — ваша доменная логика
return findGifts(args.recipient_type, args.budget);
});
SDK (или ваш helper tool) на основе объекта parameters построит JSON Schema и передаст его агенту, а рантайм позаботится о валидации и маршаллировании аргументов туда и обратно. Концептуально это ровно то, что вы вручную делали в MCP-сервере на TypeScript, только теперь инструмент «подключён» прямо в агентный рантайм.
Важно здесь не заучить конкретный синтаксис хелпера tool, а уловить мысль: качественная типизация + внятный description/комментарии = качественный инструмент.
Если всё это собрать: хороший инструмент агента — это узкая, чётко описанная функция с продуманной JSON Schema, понятным описанием для модели и аккуратной обработкой ошибок. Семантическая маршрутизация будет работать, только если инструменты не пересекаются по смыслу. А мутирующие операции должны быть безопасны и идемпотентны, иначе агент в проде быстро превратится в источник сюрпризов.
10. Типичные ошибки при проектировании инструментов агента
Ошибка №1: Слишком широкие инструменты «do_everything».
Иногда очень хочется запихнуть всё в один инструмент manage_gifts, который и ищет подарки, и показывает детали, и создаёт заказ, и отправляет письмо. Модели после этого тяжело: описание становится расплывчатым, семантический роутинг деградирует, и агент начинает вызывать этот инструмент «на всякий случай» даже там, где нужен простой поиск. Лучше разбивать задачи на отдельные инструменты с одной хорошо понятной ответственностью.
Ошибка №2: Переопределяющиеся по смыслу инструменты.
Если у вас есть search_gifts и find_gifts, оба «ищут подарки по интересам», модель будет случайным образом выбирать между ними. В результате поведение становится нестабильным: одинаковые запросы иногда идут в один tool, иногда в другой. Старайтесь, чтобы каждое имя и описание занимали уникальную «нишу» в смысловом пространстве.
Ошибка №3: Плохие или отсутствующие описания и поля схемы.
Имя func1, описание «Does something» и параметр data: string — классический способ сделать агента глупым. Модель не телепат и не может прочитать ваш исходный код. Она опирается на description, properties и их description в схеме. Если вы не объясните, что такое recipient_type, модель будет гадать и ошибаться.
Ошибка №4: Ориентация только на happy‑path, игнорирование ошибок.
Многие реализации инструментов предполагают: «Ну у нас же всегда будут корректные аргументы и доступный сервис». В реальном мире модель легко генерирует неверные параметры, внешние сервисы падают, а база иногда говорит «timeout». Если не продумать форматы ошибок и не вернуть агенту осмысленное сообщение, он не сможет скорректировать поведение и будет либо молча падать, либо галлюцинировать.
Ошибка №5: Бросать сырой 500 и стектрейс в LLM.
В REST‑API мы привыкли логировать полный стектрейс, чтобы быстрее отлаживаться. В контексте агента стектрейс, переданный модели, — это одновременно бесполезно (модель не знает, что такое SQLException в вашей конкретной библиотеке) и потенциально опасно (лишние детали реализации и, возможно, конфиденциальная информация). Гораздо полезнее перехватить исключение, записать детали в лог, а в модель отправить аккуратный code и message.
Ошибка №6: Отсутствие идемпотентности у мутирующих инструментов.
Инструмент create_order без idempotency‑key — это прямое приглашение к двойным заказам, особенно в условиях сетевых сбоев и автоматических ретраев. Если ваш агент работает в коммерческом сценарии, инструменты, связанные с деньгами, должны быть спроектированы так, чтобы повторные вызовы не приводили к дополнительным списаниям или дублям.
Ошибка №7: Хранить секреты и технические детали в схеме или описании.
Иногда разработчик по привычке пишет в description: «Внутри вызывает сервис X на https://internal-api.example.com». Модели эта информация не нужна, пользователю — тем более. Схемы и описания — часть промпта, они живут в контексте модели, и туда не стоит класть URL‑ы внутренних сервисов, названия приватных таблиц и тем более секреты.
Ошибка №8: Передавать в инструменты всё подряд, вместо продуманного набора полей.
Легко соблазниться идеей «просто передадим внутрь весь промпт пользователя строкой, а там разберёмся». Так вы теряете пользу структурирования через JSON Schema: модель уже не понимает, какие именно части запроса важны для логики, вы лишаетесь валидации и предсказуемости. Лучше вытащить из запроса явные поля (budget, interests, user_location) и описать их как часть контракта инструмента.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ