Match a job Paths Subjects Questions Quizzes Blog Pricing

Two Loops, One Value Function: TD Learning Meets Reflexion

An LLM rewrites a value function's code from the agent's failures while TD learning fits the weights. In five iterations the mean reward went from -760 to about -116.

HA
Hamid Ahmadian
· · 18 min read

TL;DR. An agent has to cross a 32×32 grid full of randomly moving obstacles. Plain TD learning tunes the weights of a value function, but it can only tune the features a human chose. We wrapped it in an outer loop where an LLM reads the agent's recent experience, works out in plain language why the agent failed, and rewrites the value function's code. TD learning then fits the new weights from scratch. This is verbal reinforcement learning, in the spirit of Reflexion. In five iterations, the mean episode reward rose from −760 to about −116, against an optimum of −62. Every value function along the way was a few lines of readable Python.

The problem

The environment (env.py) is deliberately simple:

Element Details
Grid 32 × 32
Agent Starts at (0, 0). Actions: 0=right, 1=down, 2=left, 3=up. Moving into a wall leaves it in place.
Goal (31, 31). Reaching it ends the episode.
Obstacles 32 of them, placed at random. Each step, each one moves left or right (uniformly at random, clipped at the walls). Obstacles move first, then the agent.
Reward −1 per step, and −50 on a step where the agent ends on an obstacle's cell.
Truncation 500 steps

The shortest path is 62 steps, so the best achievable return is about −62. A random walk usually never reaches the goal and ends around −500 or worse.

The state is State(ai_position, goal_position, obstacle_positions). That is 66 integers, which is too many for a table but small enough that a few well-chosen features can capture what matters. The whole project rests on one question:

Which features?

Why the value function is the bottleneck

The agent doesn't learn a policy directly. It learns a parametric state-value function V(s; θ) and acts by one-step lookahead:

Q(s, a) = V(s')          where s' ~ P(· | s, a)    (one sampled successor)
a*      = argmax_a Q(s, a)   (ε-greedy during training)

The starting value function (value.py) is a reasonable-looking hand design:

V(s) = θ₁ · manhattan(ai, goal) + θ₂ · Σᵢ manhattan(ai, obstacleᵢ)

It has two parameters, is interpretable, and turns out to be badly wrong. The obstacle sum covers all 32 obstacles, so it is in the hundreds, and a single move changes it by up to ±32. The goal distance changes by only ±1. Worse, a sum of distances is a global quantity. It cannot tell "an obstacle is right next to me" from "obstacles are spread out", and that difference is exactly what predicts the −50 penalty.

No amount of gradient descent on θ fixes this. TD learning finds the best weights for the features you give it, and it can't invent new ones. With this V, the agent reaches a mean reward of −759.9 and mostly gets truncated at 500 steps while repeatedly hitting obstacles.

The fix needs a different function class, and that is where the outer loop comes in.

The big picture: two nested loops

flowchart TB
    subgraph OUTER["Outer loop: Reflexion / verbal RL (LLM)"]
        direction TB
        V0["value.py (version k)"] --> INNER
        subgraph INNER["Inner loop: TD learning (PyTorch)"]
            direction LR
            A["Act ε-greedily<br/>with V(s'; θ)"] --> B["Store transition<br/>in replay buffer"]
            B --> C["Sample minibatch,<br/>TD update on θ"]
            C --> A
        end
        INNER --> E["Evaluate: mean reward<br/>of last 10 episodes"]
        E --> S["Sample transitions from<br/>the last N episodes"]
        S --> R["LLM reflects in language:<br/>mechanics → failures →<br/>what V can't express → fix"]
        R --> VAL{"Validate code<br/>(imports, scalar,<br/>differentiable)"}
        VAL -- "error, retry ≤ 2" --> R
        VAL -- ok --> V1["value.py (version k+1)<br/>θ reset to 0"]
        V1 -.next iteration.-> V0
    end

The two loops search over different things.

Inner loop Outer loop
Searches over Parameters θ (continuous) The function class: features and their form (discrete, symbolic)
Signal Scalar TD error Transitions, rewards, and the current code, interpreted in language
Update Gradient step (Adam) Rewritten value.py
Timescale ~19,000 updates per iteration 1 update per iteration
Implemented in train.py reflect.py

The LLM never picks a number. It proposes features, such as "count of obstacles on my cell" or "obstacle one column to my right". TD learning then decides how much each one matters. The LLM is good at the symbolic part and bad at fitting weights; gradient descent is the opposite.

The inner loop: TD learning in detail

Acting

def q_values(value, env, state):
    # Q(s, a) = V(s') for ONE sampled successor per action
    return torch.stack([value(env.transition(state, a)[0]) for a in range(4)])

env.transition simulates a step without changing the environment. It samples obstacle moves and applies the agent's move. Actions are chosen ε-greedily, with ε decaying exponentially:

ε(frame) = 0.01 + (1.0 − 0.01) · exp(−frame / 10,000)

ε starts at 1.0 (fully random) and is about 0.14 at frame 20,000.

Learning

Every real step (s, a, r, s', done) goes into a replay buffer of 5,000 transitions. Once the buffer holds more than 1,000, each frame performs one semi-gradient Q-learning update on a minibatch of 32:

target = r                                  if done
       = r + γ · max_a' Q(s', a')           otherwise   (no gradient through the target)

loss   = mean over batch of (target − Q(s, a))²
θ ← Adam(θ, ∇_θ loss),  lr = 1e-2,  γ = 0.9
q_sa = value(env.transition(t.state, t.action)[0])  # differentiable
with torch.no_grad():
    target = t.reward + cfg.gamma * q_values(value, env, t.next_state).max()
loss = (target - q_sa).pow(2).mean()

Some details worth noting:

  • Truncation is not termination. done is set only when the agent reaches the goal. An episode cut off at 500 steps still bootstraps, which is the correct treatment for time limits.
  • The model is known but stochastic. Q(s,a) re-samples the obstacle moves each time it is evaluated, so the targets are noisy. Replay plus Adam smooths this out.
  • Adam rather than SGD. The raw features are large (the original obstacle sum is in the hundreds), so plain SGD diverges unless lr ≲ 1e-6. Adam's per-parameter scaling makes the inner loop robust to whatever feature scales the LLM produces. This matters, because the outer loop shouldn't have to tune learning rates as well.
  • θ starts at zero every iteration. Each new value.py gets a clean slate, so the result measures the function class, not leftover weights.

What the inner loop hands to the outer loop

train() returns a TrainResult with:

  • the learned θ;
  • per-episode rewards (the evaluation signal);
  • the loss and θ history (saved to history.npz);
  • the full transitions of the last N episodes (5 by default). This is the raw experience the LLM will reflect on.

The outer loop: Reflexion and verbal reinforcement learning

What Reflexion is

Reflexion (Shinn et al., 2023, "Reflexion: Language Agents with Verbal Reinforcement Learning") proposed that an LLM agent can improve without any weight updates. It does so by turning feedback from the environment into a written self-reflection that is stored in memory and conditions the next attempt. The paper describes three roles:

  1. Actor. Produces behaviour (a trajectory).
  2. Evaluator. Scores the behaviour (a reward, a unit test, a heuristic).
  3. Self-Reflection model. Converts the score and trajectory into verbal feedback, such as "I failed because X, next time I should do Y".

The reflections act as a semantic gradient. The "parameters" being updated are text: a prompt, a plan, or here, source code. That is what verbal reinforcement learning means. The policy-improvement step happens in natural language rather than in weight space.

Classic RL Verbal RL (Reflexion)
Scalar reward Reward plus trajectory plus a verbal explanation of the failure
Gradient ∇θ A written diagnosis ("V can't see adjacent obstacles")
Parameter update Rewritten text or code
Replay buffer Episodic memory of reflections and past attempts
Credit assignment by backprop or TD Credit assignment by reasoning ("the −50 at t=12 happened because…")

How this project maps onto Reflexion

Reflexion role In this project
Actor The ε-greedy agent driven by V(s'; θ), together with the TD inner loop that tunes θ
Evaluator Environment reward: the mean of the last 10 training episodes, plus a regression suite in the critique variant (see "Variant: critique cases" below)
Trajectory transitions_sent_to_llm.jsonl, a curated sample of (s, a, s', r, done)
Self-reflection The LLM's written reasoning in llm_response.md
Updated artifact value.py, the feature design of the critic
Memory Default: the current value.py. Critique variant: every previous version with its score, plus a growing suite of labelled failure cases

One twist: in the original paper, reflection improves the actor's next attempt. Here, reflection improves the critic's function class, and an ordinary RL algorithm turns that critic into behaviour. The result is a hybrid: verbal RL on the structure, numerical RL on the parameters.

Step 1: choosing what the LLM sees

Five episodes can contain about 2,500 transitions, which is too many to send and mostly identical −1 steps. sample_transitions() reduces them to at most 100:

  1. Find the most common reward (here −1). Everything else, such as −50, counts as rare.
  2. Keep rare transitions, up to half the budget, evenly spaced if there are more.
  3. Fill the remaining budget with evenly spaced common transitions.
  4. Keep chronological order and tag each one with episode and t.

This is importance sampling for attention: failures are over-represented so the LLM has evidence to reason about, and enough ordinary steps remain to show loops and wall-bumping.

The LLM is told nothing about the task. There is no description of the grid, the obstacles, or the reward. It has to infer the mechanics from the transitions, which keeps the setup honest and general.

Step 2: structured reflection (the system prompt)

The prompt asks for reasoning before code, in four explicit steps:

  1. Infer the mechanics. How do actions change the state? What does each reward mean? What moves on its own?
  2. Find the bad transitions. Which ones have bad reward or no progress (loops, oscillation, repeated penalties)? What distinguishes them from the good ones?
  3. Diagnose V. What can and can't the current V express about those features, and why does that lead to bad actions?
  4. Make the smallest fix and sanity-check it on concrete transitions: would the new V rank the bad action below a better alternative, without harming the good transitions?

This turns a vague "make it better" into an explicit hypothesis → diagnosis → intervention → check cycle. Without it, in the earlier runs exp_5iter and exp_5iter_b, the LLM produced a short paragraph followed by code. Progress was slower and less stable (see "Results across all experiments" below).

Step 3: validation and retries

LLM-written code runs inside the training loop, so loading.py checks it before use:

  • AST import allow-list: only torch, math, typing, __future__, and random_agent_rl;
  • it must define class ValueFunction(ValueBase) with num_params;
  • forward(state) must return a scalar tensor on sample states;
  • backward() must give a finite gradient w.r.t. θ at θ = 0, so the inner loop can actually learn.

If a check fails, the error message is sent back to the LLM as the next user turn ("That value.py was rejected: …") for up to 2 retries. That is a small Reflexion loop of its own, where the evaluator is the validator. If the code never passes, the previous value.py is kept.

⚠️ The import allow-list is a guard rail, not a sandbox. Generated code still runs locally.

Step 4: hand-off

The validated file becomes proposal_{k+1}.py and the next iteration's value.py, with θ initialised to zeros. The newest proposal is always used; there is no rollback. That keeps the loop simple and lets you see regressions in the logs, but it is a design choice you may want to change (see "Lessons and practical tips" below).

A real run, iteration by iteration

This section follows the runs/exp_reasoning_5iter experiment (20,000 frames per iteration, seed 0, Claude, 100 transitions from the last 5 episodes).

Iteration 0: the hand-designed baseline → −759.9

V = θ₀·dist_goal + θ₁·Σ dist(ai, obstacle)
θ ≈ [-0.266, -0.013]

The LLM's reflection:

"Almost all of the −50 penalties happen when an obstacle sits at Manhattan distance 0–2 from the cell the agent steps into… The sum is a global quantity. One obstacle at distance 1 changes it by about 1 out of a total of several hundred… So V carries essentially no collision signal, and the −50 penalties cannot be represented."

It proposed replacing the sum with local proximity counts.

Iteration 1: local counts → −133.5 (5.7× better)

V = θ₀·dist_goal + θ₁·#obs@d0 + θ₂·#obs@d1 + θ₃·#obs@d2
θ ≈ [-0.22, -29.57, -2.30, -1.02]

Look at the learned weights. TD learning found by itself that sharing a cell with an obstacle is very costly (−29.6), being adjacent is mildly costly, and two cells away barely matters. The LLM supplied the right questions and TD supplied the answers. Episodes per iteration rose from 64 to 122, so the agent now reaches the goal regularly.

Iteration 2: a misleading sample → −146.9 (slight regression)

This time the sampled transitions happened to contain no −50 rewards. The LLM concluded, reasonably from what it saw, that obstacles "carry no visible reward effect". It dropped the distance-1 and distance-2 features and added a quadratic distance term to model discounting curvature.

That made the distance term non-monotone, so near the start, moving toward the goal sometimes looked no better than bumping into a wall.

Iteration 3: diagnosing its own mistake → −115.8 (best)

From new transitions, the LLM spotted the agent stuck at (0,0) for about 150 steps and traced the cause to its previous change:

"Its distance part is t0·d + t1·d², and the quadratic term lets it be non-monotone… moving toward the goal looks no better than staying put or bumping a wall, so the agent loops at (0,0). Make the distance term purely linear, so that reducing d always raises V."

It also noticed that obstacles move only horizontally and replaced the Manhattan-ball counts with same-row features:

V = θ₀·(d/31) + θ₁·#obs on my cell + θ₂·#obs in my row at col ±1
θ ≈ [-7.85, -25.36, -7.18]

This is the Reflexion pattern at its clearest: a failure, a verbal diagnosis that blames a specific earlier decision, and a targeted correction.

Here is that iteration-3 agent in action. The blue square is the agent, the red squares are the 32 obstacles, and the goal is the bottom-right corner. The agent makes its way through the drifting obstacles and reaches the goal in 82 steps (the optimum is 62).

Iteration 3's agent crossing the 32×32 grid. The blue agent starts top-left and moves to the goal in the bottom-right corner while red obstacles drift left and right.

Iteration 4: a plausible but wrong hypothesis → −131.9

The LLM claimed obstacles drift left "roughly 70–75%" of the time. It split the adjacency feature by side and added a right-at-distance-2 feature. The environment actually picks left or right uniformly, so the "asymmetry" was a pattern read into a small sample. The learned weights don't support it either: right-adjacent came out at −2.3 and left-adjacent at −5.5, the opposite of the hypothesis. Reward got slightly worse.

Lesson: verbal reflection can produce confident, well-argued, wrong stories. The inner loop and the evaluator are what keep it honest, because a feature that doesn't help gets a small weight and the score doesn't improve. Grounding matters.

Summary of the run

Iter Features in V #θ Mean reward (last 10 ep.) Episodes in 20k frames
0 goal dist, Σ obstacle dist 2 −759.9 64
1 goal dist, #obs at d = 0, 1, 2 4 −133.5 122
2 d, d², #obs on cell 3 −146.9 164
3 d (linear), #obs on cell, #obs in row at ±1 3 −115.8 162
4 d, on cell, row +1, row −1, row +2 5 −131.9 163

The reward is measured during training, so it still includes ε ≈ 0.14 exploration. The greedy policy does better than these numbers suggest.

Variant: critique cases, a regression suite, and long-term memory

The runs/exp_critique_5iter experiment (artifacts only; the default reflect.py uses the simpler loop above) adds two things that bring the loop closer to full Reflexion.

1. The LLM labels failures as well as rewriting code. Along with value.py, it returns a JSON list of critique cases:

{"id": 12, "bad_action": 2, "suggested_action": 1,
 "reason": "Moving to [29,30] collided with the obstacle at [29,29]; row+1 goes toward the goal and away from it"}

Each case (a state plus the action that should be preferred) is added to a growing regression suite (suite.json). After every training run, the greedy action of the new V is checked on every case (suite_eval.json):

Iter Mean reward Suite pass
0 −759.9 n/a
1 −261.3 5 / 5
2 −549.0 9 / 12
3 −170.0 12 / 18
4 −119.5 15 / 24

2. Episodic memory across iterations. The prompt includes every earlier value.py together with its reward and suite pass rate, plus the failing suite cases with their Q-values. This is the long-term memory component of Reflexion. The LLM can see that "version 2 regressed to −549" and avoid repeating that idea.

The suite acts as a verbal unit test for the value function. It turns past reflections into checks that every future version has to answer to. Unit tests for code generation were one of the main evaluators in the Reflexion paper, and this variant applies the same idea to RL.

Results across all experiments

All runs share iteration 0 (same seed, same baseline, −759.9).

Experiment Prompt style Iter 1 Iter 2 Iter 3 Iter 4 Best
exp_5iter short notes, then code −578.4 −549.0 −524.5 −418.9 −418.9
exp_5iter_b short notes, then code −176.7 −504.9 −234.5 −121.7 −121.7
exp_critique_5iter critiques + suite + version history −261.3 −549.0 −170.0 −119.5 −119.5
exp_reasoning_5iter 4-step structured reflection −133.5 −146.9 −115.8 −131.9 −115.8

What the table shows:

  • Every outer-loop variant beat the baseline by a wide margin. The function class was the bottleneck, not the optimiser.
  • Structured reflection made progress faster and steadier. The 4-step prompt reached −133 on its first rewrite and never fell below −147. The unstructured runs swung by hundreds of points.
  • Memory and tests help recover from regressions. The critique variant regressed at iteration 2, then recovered and kept improving once the history and suite showed it what had gone wrong.
  • Progress is not monotone. Without rollback, a bad rewrite costs a whole iteration.

These numbers come from single runs with one seed. Treat them as illustrations, not benchmarks.

Lessons and practical tips

  1. Split the work by strength. LLMs are good at structure: noticing that collisions are local, that obstacles move horizontally, that a quadratic term is non-monotone. Gradient descent is good at magnitudes. Don't ask the LLM for numbers, and don't expect SGD to invent features.
  2. Make the reflection explicit. Asking for mechanics → failures → limitations of V → smallest fix → check, before the code, gave the most stable results here. It is chain-of-thought aimed at diagnosis.
  3. Curate the evidence. What the LLM sees determines what it can conclude. Iteration 2 dropped the obstacle features because its sample happened to contain no collisions. Over-sample rare rewards, and consider guaranteeing at least a few examples of each reward type.
  4. Expect confident hallucinations. The "70% left drift" story was detailed and wrong. Ground every hypothesis in measured outcomes: reward, suite pass rate, learned weights. A near-zero weight is the inner loop telling you a feature doesn't matter.
  5. Validate generated code mechanically. Check that it parses, uses allowed imports, returns a scalar, and has finite gradients at θ = 0, and send the errors back to the LLM. Cheap checks catch most bad proposals before they waste 20,000 frames.
  6. Reset θ when the features change. Old weights mean nothing for new features. Starting from zero also makes each iteration a fair test of the function class.
  7. Use an optimiser that tolerates scale. Adam lets the outer loop propose features of any scale without retuning the learning rate.
  8. Add memory and rollback for longer runs. Keeping version history, a regression suite, and "keep the best so far" (or "revert if reward drops by more than X") would turn the loop into a more reliable optimiser.
  9. Interpretability comes for free. Every iteration's V is a short, documented Python file with a few weights, so you can read exactly what the agent has learned to care about.
  • Reflexion (Shinn et al., 2023): verbal self-reflection and episodic memory as a substitute for weight updates. This is the main inspiration here.
  • Eureka (Ma et al., 2023): LLMs iteratively write and refine reward functions from training feedback. This project does the same for the value function's features.
  • FunSearch (Romera-Paredes et al., 2023): LLM-proposed programs, scored by an evaluator, refined in a loop.
  • Classic feature engineering for linear function approximation (as in Sutton & Barto): the outer loop automates what a human RL practitioner does by looking at failure cases and adding a feature.

Glossary

  • Value function V(s; θ): the expected discounted return from state s, here approximated by a hand- or LLM-designed function with a few weights θ.
  • TD (temporal-difference) learning: updates V toward r + γ·V(next), bootstrapping from its own estimate instead of waiting for the episode to end.
  • Semi-gradient: the gradient is taken through Q(s,a) only, not through the bootstrapped target.
  • Experience replay: a buffer of past transitions that are sampled in random minibatches to break correlations between consecutive steps.
  • ε-greedy: take a random action with probability ε, otherwise the best action according to Q.
  • Function class / features: the form of V (which quantities it can see). The inner loop cannot change it; the outer loop can.
  • Reflexion: a framework in which an LLM agent improves by writing verbal self-reflections on its failures and storing them in memory, without updating weights.
  • Verbal reinforcement learning: reinforcement where the feedback and the update are expressed in natural language (and here, code) instead of gradients.
  • Regression suite (critique variant): states labelled by the LLM with the action that should be preferred, which every new V is checked against.

Share this post

Want to go deeper?

Follow a learning path

We use cookies for product analytics to improve OmniAtlas. See our Privacy Policy.