VKVlad KulikovPublic / lectures
RL for LLM11 / 13

Module 11. RL for LLM agents: tools, environments, and long trajectories

Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.

Download course files (.zip) 382.1 KB

The archive preserves the source folder structure, both languages, and supporting files.

Four information layers

An agent works with an online store. The database contains the actual order status; the API returns only the two most recent events; the model context contains the dialogue history; and the agent separately maintains a compact fact table.

Match these objects to:

  1. environment state;

  2. observation;

  3. history;

  4. external memory.

Explain why the dialogue history is not necessarily a Bayesian belief state.

Show answer and solution
  • The actual order status stored in the database is the environment state. It exists whether or not the agent can observe it.
  • The two latest events returned by the API are an observation.
  • The dialogue available to the model on the current turn is the history.
  • The separate fact table is external memory.

A history is not automatically a belief state. A Bayesian belief state is a probability distribution over hidden states, updated using a transition model and an observation model. A text transcript may be incomplete, contradictory, or stale, and it usually does not define a normalized distribution over possible database states.

Two action scales

A model generates a run_tests call token by token. The environment then executes the tests for 40 seconds and returns a report.

  1. What is the micro-action?

  2. What is the macro-action?

  3. Why may the macro-action require a semi-Markov model?

  4. Which quantity should represent the difference between a 0.1-second call and a 40-second call when latency matters to the user?

Show answer and solution
  1. A micro-action is the next token of the run_tests call, or of the structured representation from which that call is assembled.

  2. A macro-action is the completed tool invocation, including the environment transition and the returned test report.

  3. Macro-actions can have different durations. In a semi-Markov decision process (semi-MDP), a transition caused by an action of duration τt\tau_t is discounted with γτt\gamma^{\tau_t} rather than with one fixed power per call.

  4. The difference should appear through the duration τt\tau_t, an explicit latency cost in the reward, or a separate cost constraint and metric.

Token provenance

A transcript contains system, user, assistant(tool_call), tool, and assistant(final) segments. Which segments normally enter the policy loss, and why? What changes if the tool-call JSON was produced by a deterministic controller rather than by the model?

Show answer and solution

The assistant(tool_call) and assistant(final) segments normally enter the policy loss because those tokens were chosen by the policy and can be paired with behavior-policy log probabilities. The system, user, and tool segments condition later actions, but they are observations rather than actions of the policy.

If a deterministic controller generated the JSON, those JSON tokens were not sampled from the trainable model. The model's action is then the controller input—for example, a function identifier and arguments in some latent or structured form. The controller transformation must be logged separately. It is invalid to assign the finished JSON a model log probability unless the model actually defined a distribution over that representation.

Four stopping causes

For each case, choose success, task_failure, truncated, or infra_error.

  1. The agent completes the task and the world-state check passes.

  2. The task rules explicitly allow at most 10 calls, and the agent makes an 11th call.

  3. The rollout collector stops the episode after 60 seconds even though that limit is not part of the task.

  4. The container fails to start because its image is broken.

Explain why assigning the same reward of 0 to cases 2–4 can damage learning.

Show answer and solution
  1. success.

  2. task_failure, because the call limit is part of the task specification.

  3. truncated, because an external collector ended the episode.

  4. infra_error.

In case 2, zero reward describes a genuine task outcome. In case 3, the continuation is unknown: the policy might have completed the task if collection had continued. In case 4, the policy may not have caused the outcome at all. Collapsing all three into the same terminal record teaches the model to treat task difficulty and infrastructure failures as if they were its own mistakes, and it gives the critic incorrect terminal targets.

One reward for the whole trajectory

Is the following statement correct: “If one outcome-level advantage coefficient is applied to every action, the policy-gradient estimator must be biased”? Give a precise answer and name two genuine problems of this scheme on a long horizon.

Show answer and solution

The statement is false. The estimator

R(τ)∑t∇θlog⁡πθ(at∣ht)R(\tau)\sum_t\nabla_\theta\log\pi_\theta(a_t\mid h_t)

can be an unbiased estimator of the gradient of expected trajectory reward. Two real problems are:

  • high variance, especially on long horizons;
  • coarse credit assignment: every action receives the same sign even if some actions were unnecessary or harmful.

The scheme can also be data-inefficient, and one fortunate environment response may hide weak policy decisions earlier in the trajectory.

Safe reward shaping

An agent receives +0.1+0.1 for every inspect call and +1+1 for completing the task. It learns to call inspect repeatedly.

  1. What failure mode is this?

  2. Write the potential-based shaping term.

  3. List at least three assumptions or qualifications required for the conclusion that the optimal policy is invariant.

Show answer and solution
  1. This is reward farming, or exploitation of a dense proxy: the agent repeats the paid intermediate action instead of completing the task.

  2. The potential-based shaping term is

F(st,st+1)=γΦ(st+1)−Φ(st).F(s_t,s_{t+1})=\gamma\Phi(s_{t+1})-\Phi(s_t).
  1. Conditions and qualifications include:
  • the same discount factor γ\gamma as in the original task;
  • a Markov state on which Φ\Phi is defined;
  • correct treatment of terminal potential and the boundary term;
  • unchanged transitions, base reward, and stopping rules;
  • an exact potential-difference form rather than an arbitrary bonus;
  • additional care under partial observability.

Even when the invariance theorem applies, it preserves the optimal policy set; it does not guarantee faster or more stable learning.

A degenerate group

With binary rewards, the current policy solves a task with probability p=0.005p=0.005. Why does increasing the learning rate not repair the absence of relative signal in a group of G=8G=8 rollouts? Name at least four more direct levers.

Show answer and solution

With p=0.005p=0.005 and G=8G=8, almost every group contains only failures. Group centering then produces equal or zero advantages. A larger learning rate rescales an existing gradient; it cannot manufacture the missing comparison signal.

More direct levers include:

  • increasing GG;
  • selecting tasks of intermediate difficulty;
  • improving the initial policy with SFT;
  • changing decoding parameters to obtain useful diversity;
  • adding verifiable partial feedback;
  • dynamically sampling tasks;
  • replaying rare successful trajectories while explicitly controlling off-policy bias;
  • branching from informative intermediate states.

Similar method names

Explain the differences among:

  • Agentic Reinforced Policy Optimization;
  • Agentic Replay Policy Optimization;
  • DSPO;
  • Agentic-DPO.

For each, state whether its data are online or offline, identify the main signal unit, and name its central risk. Why must the acronym ARPO be expanded in a comparison table?

Show answer and solution
MethodDataSignal unitCentral risk
Agentic Reinforced Policy Optimizationfresh online tool-use trajectories with branchinga post-observation step and the trajectory outcomebranch-point selection bias, cost, and difficult credit assignment
Agentic Replay Policy Optimizationonline trajectories plus replay of successful experiencea full GUI-agent trajectoryoff-policy bias and over-replay of rare lucky episodes
Dynamic-filter Sequence-level Policy Optimization (DSPO)primarily fresh environment trajectoriesa sequencethe dynamic filter changes the task distribution and may hide weak regimes
Agentic-DPOoffline expert trajectories plus one-step negative actions from the current learneran action preference in a particular statelimited expert coverage and weak or artificial negative actions

The acronym ARPO is ambiguous because the first two papers expand it differently. A comparison table must spell out the full name so readers know whether the method refers to adaptive branching or to replay-buffer training.

Masking and dilution

A trajectory contains these segments:

RoleNumber of tokens
assistant18
tool160
assistant12
tool70
assistant9

Only assistant tokens are trainable.

  1. Find the total length and the number of trainable tokens.

  2. Find the masked fraction.

  3. By what factor does the scale change if the loss is divided by total length rather than by the number of policy tokens?

  4. For A^=1.5\widehat A=1.5, find the coefficient of one token under both normalizations.

Round fractions to four decimal places, coefficients to six, and the factor to three.

Show answer and solution

The total length is

18+160+12+70+9=269.18+160+12+70+9=269.

The number of policy tokens is

18+12+9=39.18+12+9=39.

The masked fraction is

1−39269=230269≈0.8550.1-\frac{39}{269}=\frac{230}{269}\approx0.8550.

The dilution factor is

26939≈6.897.\frac{269}{39}\approx6.897.

For A^=1.5\widehat A=1.5, the coefficient of one policy token is

1.539≈0.038462\frac{1.5}{39}\approx0.038462

when normalizing by policy tokens, and

1.5269≈0.005576\frac{1.5}{269}\approx0.005576

when dividing by total transcript length.

Probability of an informative group

For a binary reward, compute

Pinfo=1−pG−(1−p)GP_{\mathrm{info}}=1-p^G-(1-p)^G

and the expected number of groups 1/Pinfo1/P_{\mathrm{info}} for:

  1. p=0.01p=0.01, G=8G=8;

  2. p=0.05p=0.05, G=4G=4;

  3. p=0.05p=0.05, G=16G=16.

Compare the last two regimes when one rollout has the same cost in both.

Show answer and solution

Use

Pinfo=1−pG−(1−p)G.P_{\mathrm{info}}=1-p^G-(1-p)^G.
ppGGPinfoP_{\mathrm{info}}1/Pinfo1/P_{\mathrm{info}}
0.0180.07725512.9441
0.0540.1854885.3912
0.05160.5598731.7861

At p=0.05p=0.05, a group of 16 rollouts is informative much more often, but it costs four times as much as a group of 4. The expected rollout cost per informative group is approximately

4⋅5.3912≈21.564\cdot5.3912\approx21.56

versus

16⋅1.7861≈28.58.16\cdot1.7861\approx28.58.

Thus, increasing GG improves the probability of signal per group, but it does not necessarily reduce the cost of one informative comparison.

TD residuals and telescoping

Let

r=(0,0,1),V=(0.20,0.35,0.60,0),γ=0.9.r=(0,0,1), \quad V=(0.20,0.35,0.60,0), \quad \gamma=0.9.
  1. Compute δ0,δ1,δ2\delta_0,\delta_1,\delta_2.

  2. Find ∑t=02γtδt\sum_{t=0}^{2}\gamma^t\delta_t.

  3. Find G0−V0+γ3V3G_0-V_0+\gamma^3V_3 and confirm equality.

  4. Explain why the smallest δt\delta_t need not identify the “worst action.”

Show answer and solution
δ0=0+0.9⋅0.35−0.20=0.115,\delta_0=0+0.9\cdot0.35-0.20=0.115, δ1=0+0.9⋅0.60−0.35=0.190,\delta_1=0+0.9\cdot0.60-0.35=0.190, δ2=1+0−0.60=0.400.\delta_2=1+0-0.60=0.400.

The discounted residual sum is

0.115+0.9⋅0.190+0.92⋅0.400=0.610.0.115+0.9\cdot0.190+0.9^2\cdot0.400 =0.610.

The original return is

G0=0+0.9⋅0+0.92⋅1=0.81.G_0=0+0.9\cdot0+0.9^2\cdot1=0.81.

Therefore,

G0−V0+γ3V3=0.81−0.20+0=0.61.G_0-V_0+\gamma^3V_3=0.81-0.20+0=0.61.

The smallest residual need not identify the worst action because a residual depends on both value estimates, the environment observation, and when the benefit of an action becomes visible.

Potential-based shaping numerically

Let γ=0.9\gamma=0.9, base rewards be

r=(0,0,1),r=(0,0,1),

and potentials along the trajectory be

Φ=(0,0.3,0.6,0).\Phi=(0,0.3,0.6,0).
  1. Find Ft=γΦt+1−ΦtF_t=\gamma\Phi_{t+1}-\Phi_t.

  2. Find shaped rewards rt′=rt+Ftr'_t=r_t+F_t.

  3. Compare the discounted returns of the original and shaped trajectories.

  4. How does the answer change if Φ(s0)=0.2\Phi(s_0)=0.2 while the rest of the trajectory stays the same?

Show answer and solution

The shaping additions are

F0=0.9⋅0.3−0=0.27,F_0=0.9\cdot0.3-0=0.27, F1=0.9⋅0.6−0.3=0.24,F_1=0.9\cdot0.6-0.3=0.24, F2=0.9⋅0−0.6=−0.60.F_2=0.9\cdot0-0.6=-0.60.

The shaped rewards are

r′=(0.27,0.24,0.40).r'=(0.27,0.24,0.40).

The original return is

G0=0.92=0.81.G_0=0.9^2=0.81.

The shaped return is

0.27+0.9⋅0.24+0.92⋅0.40=0.81.0.27+0.9\cdot0.24+0.9^2\cdot0.40 =0.81.

If Φ(s0)=0.2\Phi(s_0)=0.2, the first shaping term decreases by 0.2, so the shaped return becomes

0.81−0.2=0.61.0.81-0.2=0.61.

This is exactly the boundary term −Φ(s0)+γTΦ(sT)-\Phi(s_0)+\gamma^T\Phi(s_T).

Different loss reductions

There are two trajectories. The first has two policy-token loss terms (0.2,0.2)(0.2,0.2); the second has eight tokens, each with loss term 0.10.1.

  1. Compute the token-level mean.

  2. Compute the trajectory-level mean when terms are first averaged within each trajectory.

  3. Which scheme gives more weight to the longer second trajectory?

  4. Why can neither number be called “correct” without defining the objective?

Show answer and solution

The sum of all token-level terms is

2⋅0.2+8⋅0.1=1.2.2\cdot0.2+8\cdot0.1=1.2.

The token-level mean is

1.210=0.12.\frac{1.2}{10}=0.12.

The within-trajectory means are 0.2 and 0.1, so the trajectory-level mean is

0.2+0.12=0.15.\frac{0.2+0.1}{2}=0.15.

Token-level reduction gives the second trajectory weight 8/108/10; trajectory-level reduction gives it weight 1/21/2. Neither is intrinsically correct. The choice depends on whether the statistical unit is a token or an episode and on what length incentive the objective is intended to create.

Semi-Markov discounting

An agent performs two macro-actions. The first lasts τ0=2\tau_0=2 base time units and produces reward 0. The second then lasts τ1=3\tau_1=3 units and pays terminal reward 1 at elapsed time 5. In this task, a reward paid after dd elapsed base units is multiplied by γd\gamma^d, with γ=0.9\gamma=0.9.

  1. What is the return from the initial state?

  2. What discount multiplier is applied to the terminal reward?

  3. What would the result be under the incorrect rule “one discount step per tool call,” with the first call at exponent 0 and the second at exponent 1?

  4. Which engineering error does that simplification hide?

Show answer and solution

The terminal reward is paid after

τ0+τ1=5\tau_0+\tau_1=5

base time units. Its multiplier is

γ5=0.95=0.59049.\gamma^5=0.9^5=0.59049.

The return from the initial state is therefore 0.590490.59049.

Under the incorrect rule “one power per tool call,” the reward from the second call would receive multiplier 0.91=0.90.9^1=0.9. That convention makes a 40-second call equivalent to a 0.1-second call and hides latency, cost, and the different real-time horizons of macro-actions.

A role-based policy mask

Implement:

Python
def build_policy_mask(roles: list[str], lengths: list[int]) -> list[int]:
    """Return a token-level mask: 1 only for the assistant role."""

Requirements:

  • the two lists have equal lengths;
  • every segment length is a non-negative integer;
  • allowed roles are system, user, assistant, and tool;
  • an unknown role raises ValueError;
  • the output has length sum(lengths).

Example:

Python
build_policy_mask(["user", "assistant", "tool", "assistant"], [2, 3, 4, 1])
# [0, 0, 1, 1, 1, 0, 0, 0, 0, 1]

Open checks:

Python
mask = build_policy_mask(["user", "assistant", "tool", "assistant"], [2, 3, 4, 1])
assert mask == [0, 0, 1, 1, 1, 0, 0, 0, 0, 1]
Show answer and solution
Python
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

The explicit bool check matters because bool is a subclass of int in Python.

TD residuals

Implement:

Python
def td_residuals(rewards: list[float], values: list[float], gamma: float) -> list[float]:
    """values has T+1 elements and rewards has T."""

Validate dimensions, finiteness, and 0≤γ≤10\le\gamma\le1. For Task B3, the function should return [0.115, 0.19, 0.4] up to ordinary floating-point error.

Open checks:

Python
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)
Show answer and solution
Python
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))
    ]

A general implementation also needs a bootstrap mask for terminal transitions. In this exercise, the final value has already been set to zero.

Group informativeness

Implement:

Python
def informative_group_probability(p: float, group_size: int) -> float:
    """Probability of seeing at least one success and one failure."""

The function should remain numerically stable for small pp, validate p∈[0,1]p\in[0,1], and require a positive integer group_size.

Open checks:

Python
assert math.isclose(informative_group_probability(0.01, 8), 0.07725530557207982, rel_tol=0.0, abs_tol=1e-15)
Show answer and solution
Python
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 and log1p reduce cancellation error when pp is small.

Potential-based shaping

Implement:

Python
def potential_shaping(
    rewards: list[float],
    potentials: list[float],
    gamma: float,
) -> list[float]:
    """Return r[t] + gamma * Phi[t+1] - Phi[t]."""

potentials contains one more element than rewards. Add shape and finiteness checks.

Open checks:

Python
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]))
Show answer and solution
Python
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))
    ]

Validating a complete rollout record

Implement:

Python
def validate_rollout_record(record: dict) -> list[str]:
    """Return detected errors; an empty list means that the record is valid."""

Required fields:

text
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

Invariants:

  • token_ids, policy_mask, and turn_ids have the same length;
  • the mask contains only integer zeros and ones;
  • the number of ones equals the length of behavior_logprobs;
  • policy_versions contains a version for every turn that has policy tokens;
  • observations contains the initial observation and one new observation after every action, so its length is one greater than the lengths of action_ids and rewards;
  • at most one of terminated, truncated, and infra_error is true;
  • a stopped rollout has a string stop_reason, while an unfinished record uses None.

Choose your own error messages, but every violated class of conditions must be detected.

Open checks:

Python
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],  # one value per policy token
    "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) != []
Show answer and solution
Python
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

A production validator would also check identifier types, tokenizer and template versions, whether the tokens match the original context, whether actions were legal in the environment state, and whether rewards agree with the recorded verifier version.

A key-and-door mini-environment

Implement a deterministic executor:

Python
def run_keydoor_episode(
    actions: list[str],
    *,
    key_color: str,
    door_color: str,
    max_steps: int = 6,
) -> dict:
    """Execute actions and return an episode report."""

Allowed actions:

text
inspect
pickup_red
pickup_blue
open_red
open_blue
finish

Rules:

  • a key can be picked up only after inspect;
  • the correct key has color key_color;
  • a door opens only with the matching carried key;
  • success is recorded only after finish if the door_color door is open;
  • an invalid action increments invalid_actions, but the episode continues;
  • after max_steps without termination, set truncated=True;
  • a successful episode gets reward=1.0; task failure after finish gets 0.0; truncation gets 0.0 with a separate flag.

The returned dictionary must contain:

text
reward, success, terminated, truncated,
steps, invalid_actions, carrying, opened

Test at least a successful sequence, early finish, a wrong key, and truncation.

Open checks:

Python
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
Show answer and solution
Python
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),
    }

Minimal checks:

Python
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"]