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 KBThe 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:
-
environment state;
-
observation;
-
history;
-
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.
-
What is the micro-action?
-
What is the macro-action?
-
Why may the macro-action require a semi-Markov model?
-
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
-
A micro-action is the next token of the
run_testscall, or of the structured representation from which that call is assembled. -
A macro-action is the completed tool invocation, including the environment transition and the returned test report.
-
Macro-actions can have different durations. In a semi-Markov decision process (semi-MDP), a transition caused by an action of duration is discounted with rather than with one fixed power per call.
-
The difference should appear through the duration , 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.
-
The agent completes the task and the world-state check passes.
-
The task rules explicitly allow at most 10 calls, and the agent makes an 11th call.
-
The rollout collector stops the episode after 60 seconds even though that limit is not part of the task.
-
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
-
success. -
task_failure, because the call limit is part of the task specification. -
truncated, because an external collector ended the episode. -
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
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 for every inspect call and for completing the task. It learns to call inspect repeatedly.
-
What failure mode is this?
-
Write the potential-based shaping term.
-
List at least three assumptions or qualifications required for the conclusion that the optimal policy is invariant.
Show answer and solution
-
This is reward farming, or exploitation of a dense proxy: the agent repeats the paid intermediate action instead of completing the task.
-
The potential-based shaping term is
- Conditions and qualifications include:
- the same discount factor as in the original task;
- a Markov state on which 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 . Why does increasing the learning rate not repair the absence of relative signal in a group of rollouts? Name at least four more direct levers.
Show answer and solution
With and , 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 ;
- 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
| Method | Data | Signal unit | Central risk |
|---|---|---|---|
| Agentic Reinforced Policy Optimization | fresh online tool-use trajectories with branching | a post-observation step and the trajectory outcome | branch-point selection bias, cost, and difficult credit assignment |
| Agentic Replay Policy Optimization | online trajectories plus replay of successful experience | a full GUI-agent trajectory | off-policy bias and over-replay of rare lucky episodes |
| Dynamic-filter Sequence-level Policy Optimization (DSPO) | primarily fresh environment trajectories | a sequence | the dynamic filter changes the task distribution and may hide weak regimes |
| Agentic-DPO | offline expert trajectories plus one-step negative actions from the current learner | an action preference in a particular state | limited 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:
| Role | Number of tokens |
|---|---|
assistant | 18 |
tool | 160 |
assistant | 12 |
tool | 70 |
assistant | 9 |
Only assistant tokens are trainable.
-
Find the total length and the number of trainable tokens.
-
Find the masked fraction.
-
By what factor does the scale change if the loss is divided by total length rather than by the number of policy tokens?
-
For , 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
The number of policy tokens is
The masked fraction is
The dilution factor is
For , the coefficient of one policy token is
when normalizing by policy tokens, and
when dividing by total transcript length.
Probability of an informative group
For a binary reward, compute
and the expected number of groups for:
-
, ;
-
, ;
-
, .
Compare the last two regimes when one rollout has the same cost in both.
Show answer and solution
Use
| 0.01 | 8 | 0.077255 | 12.9441 |
| 0.05 | 4 | 0.185488 | 5.3912 |
| 0.05 | 16 | 0.559873 | 1.7861 |
At , 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
versus
Thus, increasing improves the probability of signal per group, but it does not necessarily reduce the cost of one informative comparison.
TD residuals and telescoping
Let
-
Compute .
-
Find .
-
Find and confirm equality.
-
Explain why the smallest need not identify the “worst action.”
Show answer and solution
The discounted residual sum is
The original return is
Therefore,
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 , base rewards be
and potentials along the trajectory be
-
Find .
-
Find shaped rewards .
-
Compare the discounted returns of the original and shaped trajectories.
-
How does the answer change if while the rest of the trajectory stays the same?
Show answer and solution
The shaping additions are
The shaped rewards are
The original return is
The shaped return is
If , the first shaping term decreases by 0.2, so the shaped return becomes
This is exactly the boundary term .
Different loss reductions
There are two trajectories. The first has two policy-token loss terms ; the second has eight tokens, each with loss term .
-
Compute the token-level mean.
-
Compute the trajectory-level mean when terms are first averaged within each trajectory.
-
Which scheme gives more weight to the longer second trajectory?
-
Why can neither number be called “correct” without defining the objective?
Show answer and solution
The sum of all token-level terms is
The token-level mean is
The within-trajectory means are 0.2 and 0.1, so the trajectory-level mean is
Token-level reduction gives the second trajectory weight ; trajectory-level reduction gives it weight . 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 base time units and produces reward 0. The second then lasts units and pays terminal reward 1 at elapsed time 5. In this task, a reward paid after elapsed base units is multiplied by , with .
-
What is the return from the initial state?
-
What discount multiplier is applied to the terminal reward?
-
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?
-
Which engineering error does that simplification hide?
Show answer and solution
The terminal reward is paid after
base time units. Its multiplier is
The return from the initial state is therefore .
Under the incorrect rule “one power per tool call,” the reward from the second call would receive multiplier . 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:
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, andtool; - an unknown role raises
ValueError; - the output has length
sum(lengths).
Example:
build_policy_mask(["user", "assistant", "tool", "assistant"], [2, 3, 4, 1])
# [0, 0, 1, 1, 1, 0, 0, 0, 0, 1]Open checks:
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
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 resultThe explicit bool check matters because bool is a subclass of int in Python.
TD residuals
Implement:
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 . For Task B3, the function should return [0.115, 0.19, 0.4] up to ordinary floating-point error.
Open checks:
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
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:
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 , validate , and require a positive integer group_size.
Open checks:
assert math.isclose(informative_group_probability(0.01, 8), 0.07725530557207982, rel_tol=0.0, abs_tol=1e-15)Show answer and solution
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 is small.
Potential-based shaping
Implement:
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:
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
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:
def validate_rollout_record(record: dict) -> list[str]:
"""Return detected errors; an empty list means that the record is valid."""Required fields:
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_errorInvariants:
token_ids,policy_mask, andturn_idshave the same length;- the mask contains only integer zeros and ones;
- the number of ones equals the length of
behavior_logprobs; policy_versionscontains a version for every turn that has policy tokens;observationscontains the initial observation and one new observation after every action, so its length is one greater than the lengths ofaction_idsandrewards;- at most one of
terminated,truncated, andinfra_erroris true; - a stopped rollout has a string
stop_reason, while an unfinished record usesNone.
Choose your own error messages, but every violated class of conditions must be detected.
Open checks:
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
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 errorsA 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:
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:
inspect
pickup_red
pickup_blue
open_red
open_blue
finishRules:
- 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
finishif thedoor_colordoor is open; - an invalid action increments
invalid_actions, but the episode continues; - after
max_stepswithout termination, settruncated=True; - a successful episode gets
reward=1.0; task failure afterfinishgets0.0; truncation gets0.0with a separate flag.
The returned dictionary must contain:
reward, success, terminated, truncated,
steps, invalid_actions, carrying, openedTest at least a successful sequence, early finish, a wrong key, and truncation.
Open checks:
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 NoneShow answer and solution
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:
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"]