Модуль 11. RL для LLM-агентов: инструменты, среда и длинные траектории
Сначала попробуйте решить упражнение. Ответы, подробные решения и проектные ориентиры раскрываются отдельно и сохраняют своё состояние при переключении RU/EN.
Скачать файлы курса (.zip) 382,1 КБАрхив содержит файлы на обоих языках и вспомогательные материалы с сохранением структуры каталогов.
Четыре слоя информации
Агент работает с интернет-магазином. В базе данных хранится фактический статус заказа; программный интерфейс (application programming interface, API) возвращает только последние два события; в контексте модели находится история диалога; отдельно агент ведёт краткую таблицу фактов.
Сопоставьте объекты с понятиями:
-
состояние среды;
-
наблюдение;
-
история;
-
внешняя память.
Объясните, почему история диалога не обязана быть байесовским состоянием убеждений.
Показать ответ и решение
- Фактический статус заказа в базе — состояние среды. Он существует независимо от того, показан ли агенту.
- Два последних события, возвращённые программным интерфейсом (
application programming interface, API), — наблюдение. - Диалог, доступный модели на текущем ходу, — история.
- Отдельная таблица фактов — внешняя память.
История не обязана быть состоянием убеждений. Байесовское состояние убеждений представляет распределение над скрытыми состояниями и обновляется по модели переходов и наблюдений. Текстовый диалог может содержать неполные, противоречивые или устаревшие сведения и обычно не задаёт нормированное распределение над состояниями базы.
Два масштаба действия
Модель токен за токеном генерирует вызов run_tests, после чего среда выполняет тесты 40 секунд и возвращает отчёт.
-
Что является микродействием?
-
Что является макродействием?
-
Почему для макродействия может понадобиться полумарковская модель?
-
Какая величина должна отражать различие между вызовом на 0,1 секунды и вызовом на 40 секунд, если задержка важна пользователю?
Показать ответ и решение
-
Микродействие — очередной токен строки
run_testsили структурированного вызова. -
Макродействие — завершённый вызов тестового инструмента вместе с переходом среды к новому состоянию и появлением отчёта.
-
Время выполнения макродействий различается. В полумарковском процессе принятия решений (
semi-Markov decision process, semi-MDP) переход после действия длительности дисконтируется через , а не через одну фиксированную степень на каждый вызов. -
Различие должно отражаться продолжительностью , явной стоимостью задержки в награде либо отдельным ограничением и метрикой стоимости.
Происхождение токенов
Транскрипт содержит system, user, assistant(tool_call), tool, assistant(final). Какие сегменты обычно входят в функцию потерь политики и почему? Что изменится, если вызов в формате обмена данными (JavaScript Object Notation, JSON) был создан не моделью, а детерминированным контроллером?
Показать ответ и решение
Обычно в функцию потерь входят assistant(tool_call) и assistant(final), поскольку эти токены были сгенерированы политикой и для них можно сохранить логарифмы вероятностей поведения. system, user и tool служат условием для будущих действий, но не являются действиями политики.
Если объект в формате обмена данными (JavaScript Object Notation, JSON) создал детерминированный контроллер, его токены не были сгенерированы обучаемой моделью. Тогда действием модели является вход контроллера: например, скрытый идентификатор функции и аргументы. Преобразование контроллера нужно записывать отдельно; приписывать модели логарифм вероятности готового JSON без соответствующего распределения нельзя.
Четыре причины остановки
Для каждого случая выберите: success, task_failure, truncated или infra_error.
-
Агент выполнил задачу и проверка состояния прошла.
-
Правила задачи явно разрешают не более 10 вызовов; сделан 11-й.
-
Сборщик остановил эпизод через 60 секунд, хотя лимит не входил в постановку.
-
Контейнер не запустился из-за ошибки образа.
Объясните, почему одинаковая награда 0 для случаев 2–4 может испортить обучение.
Показать ответ и решение
-
success. -
task_failure, потому что ограничение входит в правила задачи. -
truncated, потому что эпизод остановил внешний сборщик. -
infra_error.
Во втором случае нулевая награда описывает реальный задачный исход. В третьем продолжение неизвестно: политика могла бы завершить задачу. В четвёртом действие политики вообще могло не быть причиной исхода. Если все три записи смешать, модель будет наказываться за сложность задачи и сбои инфраструктуры как за собственные ошибки, а критик получит неверные терминальные цели.
Одна награда на всю траекторию
Верно ли утверждение: «если один итоговый коэффициент преимущества применяется ко всем действиям, оцениватель градиента обязательно смещён»? Дайте точный ответ и назовите две реальные проблемы такой схемы на длинном горизонте.
Показать ответ и решение
Утверждение неверно. Оцениватель
может быть несмещённым оценивателем градиента ожидаемой траекторной награды. Две реальные проблемы:
- высокая дисперсия, особенно на длинном горизонте;
- грубое распределение заслуги: все действия получают один знак, хотя часть из них лишняя или вредная.
Дополнительно возрастает стоимость данных, а один удачный ответ среды может маскировать слабые действия политики.
Безопасное формирование награды
Агент получает за каждый вызов inspect и за завершение задачи. Он научился многократно вызывать inspect.
-
Как называется этот режим отказа?
-
Запишите потенциальную формирующую добавку.
-
Перечислите не менее трёх условий или оговорок, необходимых для вывода об инвариантности оптимальной политики.
Показать ответ и решение
-
Это фермерство награды или эксплуатация промежуточного сигнала: агент повторяет оплачиваемое действие вместо достижения цели.
-
Потенциальная добавка:
- Для вывода об инвариантности нужны, например:
- та же величина , что и в исходной задаче;
- марковское состояние, от которого зависит ;
- корректная обработка терминального потенциала и граничного члена;
- неизменные переходы, исходная награда и правила остановки;
- точный потенциальный вид, а не произвольный бонус;
- отдельная осторожность при частичной наблюдаемости.
Даже при этих условиях теорема сохраняет оптимум, но не гарантирует ускорения или стабильности обучения.
Вырожденная группа
При бинарной награде текущая политика решает задачу с вероятностью . Почему увеличение скорости обучения не исправляет отсутствие сигнала в группе из роллаутов? Назовите не менее четырёх более прямых рычагов.
Показать ответ и решение
При и почти каждая группа состоит только из провалов. Групповое центрирование даёт одинаковые или нулевые преимущества. Скорость обучения меняет масштаб имеющегося градиента, но не создаёт отсутствующее сравнение.
Прямые рычаги:
- увеличить ;
- выбирать задачи промежуточной сложности;
- улучшить исходную политику с помощью SFT;
- изменить параметры генерации для полезного разнообразия;
- добавить проверяемый частичный сигнал;
- использовать динамический отбор задач;
- повторно использовать редкие успешные траектории с явным контролем смещения из-за данных другой политики;
- построить ветвление в информативных состояниях.
Методы с похожими названиями
Объясните различия между:
- Agentic Reinforced Policy Optimization;
- Agentic Replay Policy Optimization;
- Dynamic-filter Sequence-level Policy Optimization (DSPO);
- Agentic-DPO.
Для каждого укажите: онлайн или офлайн данные, основную единицу сигнала и главный риск. Почему сокращение ARPO нельзя использовать без расшифровки в сравнительной таблице?
Показать ответ и решение
| Метод | Данные | Единица сигнала | Главный риск |
|---|---|---|---|
| Agentic Reinforced Policy Optimization | онлайн, свежие траектории с инструментами и ветвлением | шаг после наблюдения и итог траектории | смещение отбора точек ветвления, стоимость и сложность атрибуции |
| Agentic Replay Policy Optimization | свежие траектории плюс повторное использование успешного опыта | целая траектория агента с графическим интерфейсом | смещение из-за данных другой политики и чрезмерное повторение редких удачных эпизодов |
| Dynamic-filter Sequence-level Policy Optimization (DSPO) | преимущественно свежие траектории среды | последовательность | динамический фильтр меняет распределение задач и может скрыть слабые режимы |
| Agentic-DPO | офлайн-экспертные траектории и одношаговые отрицательные действия текущей политики | предпочтение действия в конкретном состоянии | ограниченное покрытие эксперта и слабые или искусственные отрицательные примеры |
Сокращение ARPO неоднозначно: две первые работы раскрывают его по-разному. Без полного названия читатель не знает, идёт ли речь об адаптивном ветвлении или о буфере повторного использования.
Маска и разбавление
Траектория состоит из сегментов:
| Роль | Число токенов |
|---|---|
assistant | 18 |
tool | 160 |
assistant | 12 |
tool | 70 |
assistant | 9 |
Считайте обучаемыми только assistant-токены.
-
Найдите полную длину и число обучаемых токенов.
-
Найдите долю маскированных токенов.
-
Во сколько раз изменится масштаб, если делить на полную длину вместо числа токенов политики?
-
При найдите коэффициент одного токена при обеих нормировках.
Округлите доли до четырёх, коэффициенты до шести, фактор до трёх знаков.
Показать ответ и решение
Полная длина:
Число токенов политики:
Доля маскированных:
Фактор разбавления:
При коэффициент токена:
при нормировке по токенам политики и
при делении на полную длину.
Вероятность информативной группы
Для бинарной награды вычислите
и ожидаемое число групп для:
-
, ;
-
, ;
-
, .
Сравните два последних режима при равной стоимости одного роллаута.
Показать ответ и решение
Используем
| 0.01 | 8 | 0.077255 | 12.9441 |
| 0.05 | 4 | 0.185488 | 5.3912 |
| 0.05 | 16 | 0.559873 | 1.7861 |
При группа из 16 роллаутов значительно чаще информативна, но стоит в четыре раза дороже группы из 4. Для одной информативной группы ожидаемая стоимость в роллаутах составляет примерно
и
Следовательно, увеличение улучшает вероятность сигнала на группу, но не обязательно уменьшает стоимость одного информативного сравнения.
TD-остатки и телескоп
Даны
-
Вычислите .
-
Найдите .
-
Найдите и подтвердите равенство.
-
Объясните, почему наименьший не обязан указывать на «самый плохой ход».
Показать ответ и решение
Взвешенная сумма:
Исходная накопленная награда:
Поэтому
Наименьший остаток не обязательно соответствует худшему действию: он зависит от ошибок обеих оценок ценности, наблюдения среды и момента появления пользы действия.
Потенциальное формирование числом
Пусть , базовые награды равны
а потенциалы состояний вдоль траектории
-
Найдите .
-
Найдите сформированные награды .
-
Сравните дисконтированные накопленные награды исходной и сформированной траекторий.
-
Как изменится ответ, если , а остальная траектория та же?
Показать ответ и решение
Добавки:
Сформированные награды:
Исходная накопленная награда:
Сформированный:
При первая добавка уменьшается на 0.2, поэтому сформированная накопленная награда равна
Это тот самый граничный член .
Разные редукции
Есть две траектории. В первой два токена политики с членами функции потерь ; во второй восемь токенов, каждый с членом .
-
Вычислите токеновое среднее.
-
Вычислите траекторное среднее, если сначала усреднять внутри траектории.
-
Какой режим сильнее взвешивает вторую, более длинную траекторию?
-
Почему нельзя назвать одно из двух чисел «правильным» без определения целевой функции?
Показать ответ и решение
Сумма всех токеновых членов:
Токеновое среднее:
Средние внутри траекторий равны 0.2 и 0.1. Траекторное среднее:
Токеновая редукция даёт второй траектории вес , а траекторная — . Выбор зависит от того, является ли статистической единицей токен или эпизод и какой стимул к длине допустим.
Полумарковское дисконтирование
Агент выполняет два макродействия. Первое длится базовые единицы времени и даёт награду 0. Второе начинается после него, длится единицы и выплачивает терминальную награду 1 в момент времени 5. В этой задаче награда, полученная через базовых единиц, умножается на , где .
-
Какова накопленная награда из начального состояния?
-
Чему равен дисконтирующий множитель перед терминальной наградой?
-
Что бы получилось при ошибочном правиле «одна степень дисконтирования на один вызов», если первый вызов имеет степень 0, а второй — степень 1?
-
Какую инженерную ошибку скрывает такое упрощение?
Показать ответ и решение
Терминальная награда выплачивается через
базовых единиц. Множитель:
Накопленная награда равна .
При ошибочном правиле «одна степень на один вызов» награда второго вызова получила бы множитель . Такой расчёт делает вызов длительностью 40 секунд эквивалентным вызову на 0.1 секунды и скрывает задержку, стоимость и разный реальный горизонт макродействий.
Маска по ролям
Реализуйте функцию:
def build_policy_mask(roles: list[str], lengths: list[int]) -> list[int]:
"""Верните токеновую маску: 1 только для роли assistant."""Требования:
- длины списков совпадают;
- все длины — неотрицательные целые;
- допустимы роли
system,user,assistant,tool; - неизвестная роль вызывает
ValueError; - результат имеет длину
sum(lengths).
Пример:
build_policy_mask(["user", "assistant", "tool", "assistant"], [2, 3, 4, 1])
# [0, 0, 1, 1, 1, 0, 0, 0, 0, 1]Открытые проверки:
mask = build_policy_mask(["user", "assistant", "tool", "assistant"], [2, 3, 4, 1])
assert mask == [0, 0, 1, 1, 1, 0, 0, 0, 0, 1]Показать ответ и решение
def build_policy_mask(roles: list[str], lengths: list[int]) -> list[int]:
allowed = {"system", "user", "assistant", "tool"}
if len(roles) != len(lengths):
raise ValueError("roles and lengths must have equal lengths")
result: list[int] = []
for role, length in zip(roles, lengths, strict=True):
if role not in allowed:
raise ValueError(f"unknown role: {role}")
if isinstance(length, bool) or not isinstance(length, int) or length < 0:
raise ValueError("lengths must be non-negative integers")
result.extend([1 if role == "assistant" else 0] * length)
return resultПроверка bool нужна потому, что в Python bool является подклассом int.
TD-остатки
Реализуйте:
def td_residuals(rewards: list[float], values: list[float], gamma: float) -> list[float]:
"""values содержит T+1 элементов, rewards — T."""Проверьте размеры, конечность чисел и . Для примера B3 функция должна вернуть [0.115, 0.19, 0.4] с обычной погрешностью float.
Открытые проверки:
rewards = np.array([0.0, 0.0, 1.0])
values = np.array([0.2, 0.35, 0.6, 0.0])
deltas = td_residuals(rewards, values, 0.9)
assert np.allclose(deltas, [0.115, 0.19, 0.4])
assert len(deltas) == len(rewards)Показать ответ и решение
import math
def td_residuals(
rewards: list[float],
values: list[float],
gamma: float,
) -> list[float]:
if len(values) != len(rewards) + 1:
raise ValueError("values must have length len(rewards)+1")
if not math.isfinite(gamma) or not 0.0 <= gamma <= 1.0:
raise ValueError("gamma must lie in [0, 1]")
numbers = [*rewards, *values]
if not all(math.isfinite(float(x)) for x in numbers):
raise ValueError("inputs must be finite")
return [
float(rewards[t]) + gamma * float(values[t + 1]) - float(values[t])
for t in range(len(rewards))
]Для терминального перехода в общем коде нужна отдельная маска бутстрэппинга. В задаче последняя ценность уже задана нулём.
Информативность группы
Реализуйте:
def informative_group_probability(p: float, group_size: int) -> float:
"""Вероятность увидеть хотя бы один успех и один провал."""Функция должна быть численно устойчивой при малых , проверять и положительность целого group_size.
Открытые проверки:
assert math.isclose(informative_group_probability(0.01, 8), 0.07725530557207982, rel_tol=0.0, abs_tol=1e-15)Показать ответ и решение
import math
def informative_group_probability(p: float, group_size: int) -> float:
p = float(p)
if not math.isfinite(p) or not 0.0 <= p <= 1.0:
raise ValueError("p must lie in [0, 1]")
if isinstance(group_size, bool) or not isinstance(group_size, int) or group_size < 1:
raise ValueError("group_size must be a positive integer")
if p in (0.0, 1.0):
return 0.0
at_least_one_success = -math.expm1(group_size * math.log1p(-p))
all_success = math.exp(group_size * math.log(p))
return max(0.0, min(1.0, at_least_one_success - all_success))expm1 и log1p уменьшают потерю точности при малом .
Потенциальное формирование
Реализуйте:
def potential_shaping(
rewards: list[float],
potentials: list[float],
gamma: float,
) -> list[float]:
"""Верните r[t] + gamma * Phi[t+1] - Phi[t]."""potentials содержит на один элемент больше, чем rewards. Добавьте проверки размеров и конечности.
Открытые проверки:
shaped = potential_shaping([0.0, 0.0, 1.0], [0.0, 0.3, 0.6, 0.0], 0.9)
assert all(abs(a - b) < 1e-12 for a, b in zip(shaped, [0.27, 0.24, 0.4]))Показать ответ и решение
import math
def potential_shaping(
rewards: list[float],
potentials: list[float],
gamma: float,
) -> list[float]:
if len(potentials) != len(rewards) + 1:
raise ValueError("potentials must have length len(rewards)+1")
if not math.isfinite(gamma) or not 0.0 <= gamma <= 1.0:
raise ValueError("gamma must lie in [0, 1]")
if not all(math.isfinite(float(x)) for x in [*rewards, *potentials]):
raise ValueError("inputs must be finite")
return [
float(rewards[t])
+ gamma * float(potentials[t + 1])
- float(potentials[t])
for t in range(len(rewards))
]Проверка полной записи роллаута
Реализуйте:
def validate_rollout_record(record: dict) -> list[str]:
"""Верните список найденных ошибок; пустой список означает валидную запись."""Обязательные поля:
task_id, environment_id, environment_version,
token_ids, policy_mask, behavior_logprobs,
policy_versions, turn_ids,
action_ids, observations, rewards, stop_reason,
terminated, truncated, infra_errorИнварианты:
token_ids,policy_mask,turn_idsимеют одинаковую длину;- маска содержит только целые 0 и 1;
- число единиц маски равно длине
behavior_logprobs; policy_versionsсодержит версию для каждого хода, в котором есть токены политики;observationsсодержит начальное наблюдение и ещё по одному наблюдению после каждого действия, поэтому его длина на единицу больше длиныaction_idsиrewards;- не более одного из
terminated,truncated,infra_errorистинно; - остановившийся роллаут имеет строковую
stop_reason, а у незавершённой записи причина остановки равнаNone.
Тексты ошибок выберите сами, но каждая нарушенная группа условий должна обнаруживаться.
Открытые проверки:
record = {
"task_id": "t1",
"environment_id": "keydoor",
"environment_version": "1.0",
"token_ids": [1, 2, 3],
"policy_mask": [0, 1, 1],
"behavior_logprobs": [-0.2, -0.3], # по одному значению на токен политики
"policy_versions": {"0": "v1", "1": "v1"},
"turn_ids": [0, 0, 1],
"action_ids": [2, 4],
"observations": ["start", "carrying_red", "opened_red"],
"rewards": [0.0, 1.0],
"stop_reason": "success",
"terminated": True,
"truncated": False,
"infra_error": False,
}
assert validate_rollout_record(record) == []
assert any("missing" in e for e in validate_rollout_record({}))
broken = dict(record, observations=["start", "opened_red"])
assert validate_rollout_record(broken) != []Показать ответ и решение
import math
from collections.abc import Mapping
def validate_rollout_record(record: dict) -> list[str]:
required = {
"task_id", "environment_id", "environment_version",
"token_ids", "policy_mask", "behavior_logprobs",
"policy_versions", "turn_ids",
"action_ids", "observations", "rewards", "stop_reason",
"terminated", "truncated", "infra_error",
}
errors: list[str] = []
missing = sorted(required - set(record))
if missing:
return ["missing fields: " + ", ".join(missing)]
token_ids = record["token_ids"]
mask = record["policy_mask"]
turns = record["turn_ids"]
logprobs = record["behavior_logprobs"]
if not all(isinstance(item, list) for item in (token_ids, mask, turns, logprobs)):
return ["token_ids, policy_mask, turn_ids and behavior_logprobs must be lists"]
if not (len(token_ids) == len(mask) == len(turns)):
errors.append("token_ids, policy_mask and turn_ids must have equal lengths")
if not all(x in (0, 1) and not isinstance(x, bool) for x in mask):
errors.append("policy_mask must contain integer zeros and ones")
if sum(x == 1 for x in mask) != len(logprobs):
errors.append("logprob count must equal the number of policy tokens")
try:
valid_logprobs = all(math.isfinite(float(x)) and float(x) <= 0.0 for x in logprobs)
except (TypeError, ValueError):
valid_logprobs = False
if not valid_logprobs:
errors.append("logprobs must be finite and non-positive")
versions = record["policy_versions"]
if not isinstance(versions, Mapping):
errors.append("policy_versions must be a mapping")
elif len(token_ids) == len(mask) == len(turns):
policy_turns = {str(t) for t, m in zip(turns, mask, strict=True) if m == 1}
missing_turns = policy_turns - {str(t) for t in versions}
if missing_turns:
errors.append("missing versions for policy turns")
actions = record["action_ids"]
observations = record["observations"]
rewards = record["rewards"]
if not all(isinstance(item, list) for item in (actions, observations, rewards)):
errors.append("action_ids, observations and rewards must be lists")
elif not (len(actions) == len(rewards) and len(observations) == len(actions) + 1):
errors.append("observations must have one more item than actions and rewards")
status_count = sum(bool(record[name]) for name in ("terminated", "truncated", "infra_error"))
if status_count > 1:
errors.append("at most one terminal status may be true")
if status_count == 0 and record["stop_reason"] is not None:
errors.append("stop_reason requires a stopped rollout")
if status_count == 1 and not isinstance(record["stop_reason"], str):
errors.append("a stopped rollout must have a string stop_reason")
return errorsПолная производственная проверка дополнительно контролирует типы идентификаторов, версию токенизатора и шаблона, соответствие токенов исходному контексту, допустимость действий в состоянии среды и согласованность награды с версией проверяющей процедуры.
Мини-среда «ключ и дверь»
Реализуйте детерминированный исполнитель:
def run_keydoor_episode(
actions: list[str],
*,
key_color: str,
door_color: str,
max_steps: int = 6,
) -> dict:
"""Выполните действия и верните итоговый отчёт."""Допустимые действия:
inspect
pickup_red
pickup_blue
open_red
open_blue
finishПравила:
- ключ можно подобрать только после
inspect; - правильный ключ имеет цвет
key_color; - дверь открывается только соответствующим переносимым ключом;
- успех фиксируется только после
finish, если открыта дверь цветаdoor_color; - некорректное действие увеличивает
invalid_actions, но эпизод продолжается; - после
max_stepsбез завершения выставляетсяtruncated=True; - успешный эпизод получает
reward=1.0, задачный провал послеfinish—0.0, усечение —0.0с отдельным флагом.
Возвращаемый словарь должен содержать:
reward, success, terminated, truncated,
steps, invalid_actions, carrying, openedПроверьте как минимум успешную последовательность, ранний finish, неверный ключ и усечение.
Открытые проверки:
good = run_keydoor_episode(["inspect", "pickup_red", "open_red", "finish"], key_color="red", door_color="red")
assert good["success"] is True and good["reward"] == 1.0
assert good["invalid_actions"] == 0 and good["terminated"] is True
bad = run_keydoor_episode(["pickup_red", "open_red"], key_color="red", door_color="red")
assert bad["success"] is False and bad["invalid_actions"] == 2
assert bad["carrying"] is NoneПоказать ответ и решение
def run_keydoor_episode(
actions: list[str],
*,
key_color: str,
door_color: str,
max_steps: int = 6,
) -> dict:
colors = {"red", "blue"}
if key_color not in colors or door_color not in colors:
raise ValueError("unknown color")
if isinstance(max_steps, bool) or not isinstance(max_steps, int) or max_steps < 1:
raise ValueError("max_steps must be positive")
inspected = False
carrying = None
opened: set[str] = set()
invalid = 0
terminated = False
success = False
steps = 0
for action in actions[:max_steps]:
steps += 1
valid = True
if action == "inspect":
inspected = True
elif action in {"pickup_red", "pickup_blue"}:
color = action.removeprefix("pickup_")
if not inspected or color != key_color:
valid = False
else:
carrying = color
elif action in {"open_red", "open_blue"}:
color = action.removeprefix("open_")
if carrying != color:
valid = False
else:
opened.add(color)
elif action == "finish":
terminated = True
success = door_color in opened
else:
valid = False
if not valid:
invalid += 1
if terminated:
break
truncated = not terminated and steps >= max_steps
return {
"reward": 1.0 if success else 0.0,
"success": success,
"terminated": terminated,
"truncated": truncated,
"steps": steps,
"invalid_actions": invalid,
"carrying": carrying,
"opened": sorted(opened),
}Минимальные тесты:
assert run_keydoor_episode(
["inspect", "pickup_red", "open_red", "finish"],
key_color="red", door_color="red",
)["success"]
assert run_keydoor_episode(
["finish"], key_color="red", door_color="red",
)["terminated"]
assert run_keydoor_episode(
["inspect", "pickup_blue", "finish"],
key_color="red", door_color="red",
)["invalid_actions"] == 1
assert run_keydoor_episode(
["inspect"] * 3,
key_color="blue", door_color="blue", max_steps=3,
)["truncated"]