Серия: redb ecosystem (анонс, разбор позже)
В 3.1.0 у redb.Route вышло два новых транспорта: redb.Route.Llm (24-й) и redb.Route.Exec (25-й). LLM теперь — обычный endpoint наравне с Kafka, RabbitMQ и HTTP: вызов модели — это шаг .To("llm://claude"), инструмент агента — это маршрут с .AsLlmTool("shell"), периодический агент — From("llm://factory?schedule=5m"). Exec — спавнер процессов с allowlist, working-dir и таймаутом; работает и как backend shell-инструментов агента, и как самостоятельный scheduled consumer (cron-less health-probes, бэкапы и т.п.). Никаких «отдельных AI-фреймворков рядом с ESB»: всё внутри той же DSL, тех же retry/throttle/circuit-breaker/audit, тех же OpenTelemetry-трейсов.
Это анонс. Подробный разбор внутренностей — отдельной статьёй позже. Здесь — что появилось, как это выглядит в коде, и что честно ещё не сделано.
Если читаете про
redb.Routeвпервые — короткий контекст из предыдущих статей серии:
redb.Route — Apache Camel для .NET — зачем вообще, и почему «Apache Camel под .NET»
redb.Route изнутри: четыре in-memory канала и Exchange — как устроен runtime
redb.Route 3.0.1 — плоская навигация по DSL, рефакторинг CRTP и тихий null — предыдущий патч перед 3.1.0
Самое короткое объяснение
From("kafka://orders")
.To(Llm.Factory("claude").Temperature(0.2).MaxTokens(1024).AsUri())
.To("kafka://orders.translated");
Эта одна строка — полный вызов LLM:
-
тело входящего сообщения уходит как user-промпт;
-
агент выполняет круг (модель → опционально tool-use → модель → …) до
EndTurnилиMaxIterations; -
assistant-ответ ложится в
exchange.Out.Body; -
использование токенов, id модели, причина остановки, число итераций — в заголовки;
-
OpenTelemetry-трейсы и метрики поднимаются автоматически;
-
endpoint виден в dashboard’е tsak.web с messages/sec, средней длительностью, error rate и last-error — как любой другой коннектор.
Это весь смысл «LLM как коннектор, а не библиотека». Если у вас уже есть Apache Camel-уровневый ESB с retry, breaker, idempotent consumer и audit, то превращать LLM в ещё один endpoint — единственное честное решение. Не нужно заново писать ретраи, не нужно отдельные дашборды, не нужно тащить «AI-инфраструктуру» рядом с интеграционной.
Один провайдер-адаптер, 14 OpenAI-совместимых API
В пакете два production-провайдера:
-
OpenAiProvider— один универсальный транспорт для 14 OpenAI-совместимых API:openai,anthropic/claude(через официальный OpenAI-compat endpoint Anthropic),groq,cerebras,openrouter,gemini(OpenAI-compat),github-models,mistral,together,huggingface,deepseek,ollama,lmstudio+ универсальныйcustomдля self-hosted шлюзов. -
StubProvider— детерминированный echo для unit-тестов без ключей.
Нативный AnthropicProvider (Messages API) — на очереди для фич, которых нет в OpenAI-compat surface.
Смена провайдера — это смена одной строки в DI:
services.AddLlmConnectionFactory("groq", f =>
{
f.Provider = "groq";
f.ModelId = "llama-3.3-70b-versatile";
f.ApiKey = Environment.GetEnvironmentVariable("REDB_LLM_GROQ_KEY");
});
То же самое с provider = "anthropic" и modelId = "claude-haiku-4-5" — и тот же .To("llm://...") уже звонит в Claude. DSL не меняется ни на символ.
Tools — это маршруты
Главное архитектурное решение: инструмент агента — это обычный RouteBuilder-маршрут плюс один DSL-аспект .AsLlmTool("name"). Из этого выпадает четыре свойства, которые иначе пришлось бы держать отдельно.
From("direct:tool-shell")
.AsLlmTool("shell")
.Description("Run a small shell command on the host. Input: {"command":"<name>","args":["..."]}.")
.Input("""
{
"type":"object",
"properties":{
"command":{"type":"string"},
"args":{"type":"array","items":{"type":"string"}}
},
"required":["command"]
}
""")
.SideEffect(ToolSideEffect.ReadOnly)
.Cost(ToolCostClass.Cheap)
.Then()
.To(ExecDsl.Run()
.AllowedCommands("cmd", "pwsh")
.WorkingDirectory(scratchDir)
.TimeoutMs(5_000)
.MaxStdoutBytes(8_192)
.MaxStderrBytes(8_192));
Что получаем бесплатно:
-
Tools-as-routes — внутри инструмента доступны все 30+ EIP-паттернов (Splitter, Aggregator, CircuitBreaker, Throttle, Filter, TryCatch). Tool, который дёргает базу, можно завернуть в
Transaction(), breaker и retry, не написав ни строки runtime-кода руками. -
Tool из существующего маршрута — берёшь любой
From("http://...")илиFrom("sql://...")и навешиваешь.AsLlmTool("name"). У него уже есть авторизация, аудит, метрики — потому что это просто маршрут. -
Inventory-as-data —
IToolDescriptorRegistryзнает все инструменты по имени с JSON-schema. Можно фильтровать?tools=*или?tools=lookup,shell, можно собирать «универсального агента из всех read-only-инструментов» одной строкой. -
Zero bumps на остальные коннекторы. Аспект
AsLlmTool()живёт вredb.Route.Llm.Abstractions. Любой из 22 других коннекторов получает его без обновления своих NuGet-пакетов.
Реальный пример: standalone-демо «curl → Claude → shell → ответ»

Это полная программа. Два файла — Llm.HttpShell.csproj и Program.cs, top-level statements, никаких host-абстракций сверху. Вставляешь свой Anthropic-ключ в одно место и dotnet run поднимает HTTP-эндпоинт на 5088, в котором Claude Haiku умеет вызвать shell на хосте и ответить в контексте предыдущих реплик.
csproj — пять NuGet-пакетов
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="redb.Route" Version="3.1.0" />
<PackageReference Include="redb.Route.Http" Version="3.1.0" />
<PackageReference Include="redb.Route.Llm" Version="3.1.0" />
<PackageReference Include="redb.Route.Llm.Abstractions" Version="3.1.0" />
<PackageReference Include="redb.Route.Exec" Version="3.1.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
</ItemGroup>
</Project>
redb.Route — ядро ESB, redb.Route.Http — HTTP-транспорт, redb.Route.Llm[.Abstractions] — LLM-коннектор и DSL-аспект .AsLlmTool(), redb.Route.Exec — спавнер процессов с allowlist-ом и таймаутом. Никакого redb.Core/redb.Postgres — демо самодостаточен и держит conversation memory в памяти процесса.
1. Ключ
const string ApiKey = "<your-key>";
Тот, что виден в https://console.anthropic.com/. Поменять на Groq — это Provider = "groq", ModelId = "llama-3.3-70b-versatile" и ключ из console.groq.com; DSL ниже не двинется.
2. DI и RouteContext
RouteContext ctx = null!;
var services = new ServiceCollection();
services.AddLogging(b => b
.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; })
.SetMinimumLevel(LogLevel.Information));
services.AddSingleton<IRouteContext>(_ => ctx);
services.AddSingleton<ILogger>(sp => sp.GetRequiredService<ILoggerFactory>().CreateLogger("redb.Route"));
var sp = services.BuildServiceProvider();
ctx = new RouteContext(sp, contextId: "llm-http-shell");
ctx.AddService(typeof(ILoggerFactory), sp.GetRequiredService<ILoggerFactory>());
RouteContext — runtime-сосуд: маршруты, компоненты, сервисы. Замыкание _ => ctx — чтобы зарегистрировать IRouteContext как DI-сервис до того, как сам контекст создан (он в свою очередь требует IServiceProvider). Последняя строка — кладём ILoggerFactory в context руками; без этого .Log(...)-шаги в маршрутах молча превращаются в no-op (внутренности — в отдельной статье).
3. Три компонента
ctx.AddComponent(new HttpComponent { ServerManager = new SharedHttpServerManager() });
ctx.AddComponent(new LlmComponent());
ctx.AddComponent(new ExecComponent());
Компонент в redb.Route — плагин транспорта, который владеет URI-схемой: http://, llm://, exec://. Просто регистрация; ничего бизнес-логического тут не происходит.
4. Connection factory для Claude Haiku
ctx.AddToRegistry("haiku", new LlmConnectionFactory
{
Name = "haiku",
Provider = "anthropic",
ModelId = "claude-haiku-4-5",
ApiKey = ApiKey,
Temperature = 0.0,
MaxTokens = 512
});
"haiku" — лейбл, по которому маршрут потом скажет Llm.Factory("haiku"). Тот же приём, что у redb.Route на all-connectors уровне для именованных connection-factory.
5. Agent engine + tool registry
var toolRegistry = new ToolDescriptorRegistry();
ctx.AddService(typeof(IToolDescriptorRegistry), toolRegistry);
var producerTemplate = new ProducerTemplate(ctx);
ctx.AddService(typeof(IProducerTemplate), producerTemplate);
var engine = new AgentEngine(
logger: null,
producerTemplate: producerTemplate,
observer: new NoopAgentObserver(),
budget: new NoopBudgetEnforcer(),
approval: new AutoApproveGate(),
redaction: new NoopRedactionFilter(),
shadow: new NoopShadowRunner(),
conversation: new InMemoryConversationStore(),
idempotency: null,
approvalStore: null);
ctx.AddService(typeof(IAgentEngine), engine);
Все state-поверхности агента — observability, budget, approval, redaction, shadow, conversation, idempotency — за интерфейсами. В демо все Noop/InMemory: agent-loop работает, но ничего не персистится. Боевой вариант — AddRedbLlmStorage() (см. секцию ниже): подменяет InMemoryConversationStore на RedbConversationStore, прицепляет RedbAuditObserver и т.д.
6a. HTTP-маршрут
var isWindows = OperatingSystem.IsWindows();
var allowed = isWindows ? new[] { "cmd", "pwsh", "powershell" } : new[] { "sh", "bash" };
var scratchDir = Path.Combine(Path.GetTempPath(), "redb-llm-shell");
Directory.CreateDirectory(scratchDir);
var systemPrompt =
"You can run small shell commands through the 'shell' tool. " +
$"The host is {(isWindows ? "Windows (use cmd /c)" : "Linux (use sh -c)")}. " +
"Use the tool when the user asks about the system, files, or commands; " +
"then summarise what you learned in one short sentence.";
ctx.AddRoutes(r =>
{
r.From("http:0.0.0.0:5088/api/llm/shell?inOut=true")
.RouteId("llm-http-shell")
.ConvertBody<string>()
.Process(e =>
{
e.In.Headers[LlmHeaders.SystemPrompt] = systemPrompt;
e.In.Headers[LlmHeaders.ConversationId] =
e.In.Headers.TryGetValue("X-Chat-Id", out var v) && v is string s && s.Length > 0
? s : "default";
})
.Log("[LLM-SHELL] ▶ chat=${header.llm.conversation.id} prompt=${body}")
.To(LlmDsl.Factory("haiku")
.Tools("shell")
.ConversationFromHeader()
.MaxIterations(10)
.Temperature(0.0)
.AsUri())
.Log("[LLM-SHELL] ◀ iters=${header.llm.tool.iterations} stop=${header.llm.stop_reason} " +
"tokensIn=${header.llm.tokens.in} tokensOut=${header.llm.tokens.out}")
.Log("[LLM-SHELL] ◀ reply=${body}");
Тело POST’а конвертируется в строку и становится user-промптом. Process ставит system-промпт в стандартный заголовок LlmHeaders.SystemPrompt и резолвит conversation id из клиентского X-Chat-Id (без него — default). .Tools("shell") — единственный LLM-specific knob: «дай агенту инструмент с этим именем». MaxIterations(10) — потолок tool-loop’а (иначе бесконечный пинг между моделью и инструментом).
6b. Сам инструмент — это маршрут
r.From("direct:tool-shell")
.AsLlmTool("shell")
.Description(
"Run a small shell command on the host. Input: " +
"{"command":"<name>","args":["..."]}. Output: " +
"{"stdout":"...","stderr":"...","exitCode":N}. " +
$"Allowed commands: {string.Join(", ", allowed)}. " +
$"Working directory is pinned to '{scratchDir}'.")
.Input("""
{
"type": "object",
"properties": {
"command": { "type": "string" },
"args": { "type": "array", "items": { "type": "string" } }
},
"required": ["command"]
}
""")
.SideEffect(ToolSideEffect.ReadOnly)
.Cost(ToolCostClass.Cheap)
.Then()
.Log("[SHELL-TOOL] ▶ in=${body}")
.To(ExecDsl.Run()
.AllowedCommands(allowed)
.WorkingDirectory(scratchDir)
.TimeoutMs(5_000)
.MaxStdoutBytes(8_192)
.MaxStderrBytes(8_192))
.Log("[SHELL-TOOL] ◀ exit=${header.redbExec.ExitCode} body=${body}");
});
Description + JSON-схема — то, что Claude увидит как «эта функция мне доступна, вот её сигнатура». SideEffect.ReadOnly и Cost.Cheap — гайдлайны для governance-хуков (бюджеты, требование апрува). После .Then() — обычный route: Log → ExecDsl.Run() с allowlist’ом, рабочим каталогом, таймаутом 5 сек и cap-ом на stdout/stderr → Log. Никакого LLM-specific кода ниже .Then(). Это всё ещё просто маршрут — поэтому в него можно завернуть CircuitBreaker, Throttle, Transaction, WireTap-shadow, что угодно из 30+ EIP-паттернов redb.Route.

7. Старт и блокирование
await ctx.Start();
producerTemplate.Start();
var stop = new ManualResetEventSlim();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Set(); };
stop.Wait();
await ctx.DisposeAsync();
Запуск
dotnet run
curl -d "how much free disk space?" http://localhost:5088/api/llm/shell
curl -d "what did you just say?" -H "X-Chat-Id: my-chat" http://localhost:5088/api/llm/shell
Первый запрос — Claude видит system-промпт «у тебя есть shell», решает посмотреть свободное место, дёргает tool с чем-то вроде {"command":"cmd","args":["/c","fsutil","volume","diskfree","C:"]}, получает stdout, формулирует ответ человеку. Второй запрос — с X-Chat-Id: my-chat — видит предыдущий обмен в InMemoryConversationStore и отвечает в контексте.
В логах в этот момент видно полный путь: [LLM-SHELL] ▶ prompt=... → [SHELL-TOOL] ▶ in={"command":"cmd",...} → [SHELL-TOOL] ◀ exit=0 body={"stdout":"...","exitCode":0} → [LLM-SHELL] ◀ iters=2 stop=end_turn tokensIn=... tokensOut=... reply=.... Это всё — обычный .Log()-шаг redb.Route, не отдельный LLM-tracing.
Allowlist команд (cmd, pwsh, sh, bash) — security envelope: всё вне списка redb.Route.Exec отвергает ещё до запуска процесса.
redb.Route.Exec — спавнер процессов как 25-й транспорт
В демо выше ExecDsl.Run() появился как «бэкенд shell-инструмента», но это самостоятельный коннектор, который вышел в 3.1.0 одновременно с redb.Route.Llm. Закрывает скучный, но повсеместный пробел: framework уже умел в 22+ транспорта (HTTP, Kafka, SQL, …), а до самой ОС — нет.
Producer — .To(ExecDsl.Run(...)) — синхронный спавн. Команду резолвит в порядке: JSON body → заголовки redbExec.Command/redbExec.Args → URI-опции ?command=.... Тело Out — JSON, удобный для LLM-tool ABI:
{ "stdout": "...", "stderr": "...", "exitCode": 0, "timedOut": false }
Ровно поэтому shell-инструмент в демо — это .To(ExecDsl.Run().AllowedCommands(...)) без единой строки склеечного кода. Модель присылает {"command":"cmd","args":[...]}, продьюсер парсит, исполняет, отдаёт структурированный JSON. Маршрутные фичи (CircuitBreaker, Throttle, OnException, audit) применяются автоматически — это endpoint как любой другой.
Consumer — From(ExecDsl.Run(...).Schedule("5m")) — тот же спавнер, но как первоклассный source-endpoint со встроенным шедулером. Без cron, без отдельного worker’а:
routes.From(ExecDsl.Run("./scripts/health-check.sh")
.Schedule("5m")
.TimeoutMs(30_000))
.Choice()
.When(e => e.In.Headers["redbExec.ExitCode"]?.ToString() != "0")
.To("http://alerts.internal/oncall")
.Otherwise()
.To("kafka://metrics.healthy");
Каждые 5 минут — запуск скрипта, ветвление по exit code, в одну сторону HTTP-вебхук дежурному, в другую — Kafka-топик. ?schedule= принимает простые интервалы (500ms, 30s, 5m, 1h). Для cron — From("quartz://<cron>").To(ExecDsl.Run(...)) (Quartz уже шедулер, дублировать его внутри Exec нет смысла).
Security-обвязка — то, что вообще даёт shell-as-tool право существовать:
-
AllowedCommands— case-insensitive по file-name,/usr/bin/gitиgit.exeоба матчатgit. Команды вне списка отвергаютсяUnauthorizedAccessExceptionещё до запуска процесса. -
WorkingDirectory— пинит cwd; процесс не может выйти за неё сам. -
EnvironmentOverrides+ScrubEnvironment— старт с пустого окружения, применяем только нужныеKEY=VALUE. -
TimeoutMs— wall-clock kill-switch, убивает весь process tree. -
MaxStdoutBytes/MaxStderrBytes— cap, защищает host от runaway-процесса.
Почему отдельный пакет, а не часть redb.Route.Llm — три причины: (1) Exec полезен и без LLM (scheduled health-probes, log rotation, deploy-glue, бэкапы); (2) redb.Route.Llm не должен тащить зависимость на спавн процессов в проектах, где агент дёргает только HTTP/SQL-инструменты; (3) тот же allowlist/timeout/cap-механизм будет переиспользован в будущих коннекторах с тем же классом security-проблем.
From(“llm://…”) — периодический агент-консьюмер
Это та фича, которой нет в Camel langchain4j-* (там LLM — только продьюсер): LLM как первоклассный source-endpoint, у которого внутри уже сидит шедулер.
From("llm://groq?schedule=5m" +
"&systemPromptRef=#watchdog-system" +
"&initialBodyRef=#daily-brief" +
"&tools=*")
.To("rabbitmq://alerts");
Каждые 5 минут — свежий запуск агента с system-промптом из реестра #watchdog-system и user-промптом из #daily-brief. Ответ уходит в RabbitMQ. ?schedule= принимает простые интервалы (500ms, 30s, 5m, 1h); для cron — From("quartz://...").To("llm://..."), у Quartz это уже его работа.
Что хорошо ложится в эту форму: watchdog-агенты, периодическая генерация отчётов, self-improving агенты с conversation memory (та же история между запусками).
#-промпты — динамический реестр
Префикс # на параметре превращает URI-значение в lookup. Любой другой маршрут может переписать промпт по имени — следующий запуск агента подхватит свежее значение без редеплоя:
host.Context.AddToRegistry("style.terse", "Reply in fewer than 5 words.");
r.From("direct:chat")
.To("llm://scripted?systemPromptRef=#style.terse")
.To("mock:done");
// ... позже, другой маршрут ...
host.Context.AddToRegistry("style.terse", "Reply in French only.");
// следующий запуск увидит новое значение
Резолвинг идёт сначала через IPromptTemplateRegistry (версионированное хранилище — нужно для replay в eval-прогонах), потом через generic-registry с обычной строкой. Без # — литерал, передаётся как есть.
Это тот же #name-механизм, что используется в redb.Route framework-wide для connection-factory: единая конвенция, не отдельный «promptref-DSL».
Память агента — AddRedbLlmStorage()
По умолчанию все state-поверхности — in-memory (стенограммы диалогов, tool idempotency, approvals, cost ledgers, audit). AddRedbRouteLlm() остаётся zero-dependency — годится для тестов и stateless-агентов, но всё теряется на рестарте.
Альтернатива — одна строка:
services.AddRedbRoute(route =>
{
route.Services.AddRedbRouteLlm();
route.Services.AddRedbIdempotentRepository(); // нужен для idempotency
route.Services.AddRedbLlmStorage(); // ← REDB-стораджи на все поверхности
});
AddRedbLlmStorage() заменяет дефолтные синглтоны:
|
Интерфейс |
Default |
REDB store |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Per-row бизнес-идентификаторы лежат на индексированной value_string, не внутри JSON. Lookup — O(log n) по одной колонке, не full-scan-with-JSON-decode. Tree integrity для transcripts — через нативный parent_id (IRedbService.CreateChildAsync), не soft FK в props. Стораджи lazy-синкают схему на первом использовании — никакого migration step.

Что наследуется от движка бесплатно
Это центральный аргумент за «коннектор, а не библиотеку». Всё, что redb.Route уже умеет, применяется к LLM-вызовам без отдельного кода:
|
Concern |
DSL-примитив |
|---|---|
|
Retry / backoff |
|
|
Rate limiting |
|
|
Resilience |
|
|
Idempotency |
|
|
Compensation |
|
|
Audit / shadow |
|
|
Tracing & метрики |
|
|
Персистенс |
|
Tool, который ходит в дорогой API — оборачиваешь в CircuitBreaker и Throttle. Хочешь shadow-запуск нового системного промпта параллельно со старым — Multicast + WireTap. Это не «фичи LLM-коннектора», это движок, к которому LLM приставлен как endpoint.
Что live-протестировано, а что — нет
Честно про статус провайдеров (это в README, дублирую тут):
-
Groq + Llama 3.3 70B — самый надёжный free-tier из доступных. Идёт со strict-ассертом (модель должна выдать буквально требуемое слово) в
BasicChatTestsиToolRouteTests. -
Mistral small-latest — стабильно отвечает на short-form, но tool-use на free-tier плавает; в тестах с tools ассертим философски.
-
Gemini 2.0 Flash — 15 RPM на free-tier, отвечает на спокойном репозитории.
-
Anthropic Claude (Haiku 4.5 / Sonnet 4.6) — через OpenAI-compat endpoint, live-протестирован в
ClaudeChatTestsи вredb.Route.Demo. -
OpenRouter / Cerebras — каркас рабочий, отдельные free-маршруты дают rate-limit, в тестах помечены
[EnvFact]и пропускаются без ключа.
Honest skip-list, что ещё не сделано:
-
Embeddings и vector store — Phase 2 (
embed://,vector://запланированы). -
RAG-примитивы, document loaders, web search — пока нет (для web search есть отдельный Tavily-tool в
redb.Route.Llm.Tools, который уже работает как обычный.AsLlmTool("web_search")). -
Sliding-window memory shapes (window-by-N-messages, window-by-K-tokens) — пока не first-class. Persistent transcripts через
AddRedbLlmStorage()есть; windowed-shape поверх них реализуется маршрутомProcess+ conversation store, но не одной опцией. -
Native AnthropicProvider — OpenAI-compat surface закрывает большинство сценариев; нативный Messages API нужен для фич, которых там нет.
Ссылки
GitHub впереди NuGet. Свежие баг-фиксы (например, починка
tool_use/tool_resultпар при перезагрузке диалога и декодирование OEM- кодировок Windows вredb.Route.Exec) уже зафиксированы вmainпод версией 3.1.1 — см.CHANGELOG.mdв публичной репе. В NuGet эти фиксы выходят пачками с очередным релизом, так что если в production уперлись в баг — сначала смотритеmainи тег последнего pre-release, и только потом ждите пакет на nuget.org. Это нормальная практика, не баг процесса.
|
|
|
|---|---|
|
redb.Route на GitHub |
|
|
redb.Route.Llm на NuGet |
|
|
redb.Route.Exec на NuGet |
|
|
Полный README пакета |
|
|
USER-GUIDE (подробный гайд по DSL, governance, conversation) |
|
|
STORAGE — REDB-схемы под conversation / approval / audit / idempotency |
|
|
README Exec-коннектора |
|
|
Standalone-демо |
|
|
Полная демо-репа (22+ маршрутов) |
|
|
Discussions |
Всё под Apache 2.0. Подробный разбор tool-loop, governance-хуков и REDB-стораджей — отдельной статьёй в серии. Вопросы про конкретные сценарии — в комментариях; именно так пишутся следующие части.
Автор: grelikt


