Google Research published ReAct in November 2022. Yao and Cao argued that a language model should alternate between a reasoning step and an action. It should not do only one of those. Almost every article since then has treated ReAct as a prompting technique.
ReAct is not a prompting technique. It is a design pattern. Strategy and Decorator are design patterns in the same sense. Each one has a name, participants, collaborations, and consequences that you must live with.
Here is the part that should bother you. If you shipped OpenAI function calling in a .NET application, you already implemented this pattern. You probably did it by accident. You had no turn limit, no trust boundary, and no name for it. This post gives you those three things.
Intent
Let a model alternate between thinking about a goal and taking limited actions in the outside world. Each result must limit the next thought.
The paper measures two weaker cases:
- Chain of thought (reason only). The model thinks, but it never touches the world. If a fact is wrong, the model cannot look it up. It invents an answer.
- Act only. The model calls tools, but it keeps no plan across steps. It repeats actions with no progress.
The paper's numbers are more honest than the marketing around them. On HotpotQA, ReAct scored 27.4. Chain of thought scored 29.4. ReAct alone lost. The two methods together scored 35.1 and won. The gap is wider on the interactive benchmarks. ALFWorld scored 71 for ReAct and 45 for act-only. WebShop scored 40 for ReAct and 30.1 for act-only.
Do not take the lesson "add a loop." Facts from the world and knowledge inside the model must support each other. Neither source is enough alone.
Structure
The pattern has five participants. Four of them live in your code. Only one of them is the model.
+-------------------+
question ---> | Reasoner | (LLM: emits Thought + optional Action)
+-------------------+
| ^
Action | | Observation
v |
+-------------------+
| Loop Governor | (turn limit, deadline, token budget)
+-------------------+
|
v
+-------------------+
| Tool Executor | <-- TRUST BOUNDARY
+-------------------+
|
v
+-------------------+
| Environment | (Dapper services, SQL Server, files)
+-------------------+
| Participant | Responsibility | Who implements it |
|---|---|---|
| Reasoner | Writes a thought and, if needed, a named action with arguments | The model |
| Tool Catalog | Lists the actions that exist, with typed schemas and descriptions | You |
| Loop Governor | Decides whether another turn is allowed | You |
| Tool Executor | Checks the action, runs it, and shapes the result | You |
| Observation | The short result that goes back into context | You |
People skip the important collaboration. A thought writes to context. An action writes to the world. Those are different operations. They have different risk. Put them behind different gates.
Implementation
Start with the Loop Governor. Most people leave it out. It is the part that protects you.
public sealed record AgentBudget
{
public int MaxTurns { get; init; } = 5;
public int MaxToolCalls { get; init; } = 8;
public TimeSpan Deadline { get; init; } = TimeSpan.FromMinutes(5);
public int MaxObservationChars { get; init; } = 8_000;
}
These four limits look dull. All four are necessary. MaxTurns stops a model that keeps searching with no end. Deadline matches real request time. A request that calls a model, runs a tool, and calls the model again is not a 200 ms MVC action. MaxObservationChars is the limit people learn too late. One SELECT with no size limit can fill your context window and your bill.
Now the loop:
public sealed class ReActAgent
{
private readonly IReasoner _reasoner;
private readonly IToolExecutor _executor;
private readonly AgentBudget _budget;
private readonly ILogger<ReActAgent> _logger;
public async Task<AgentResult> RunAsync(string question, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_budget.Deadline);
var transcript = Transcript.Start(question);
var toolCalls = 0;
for (var turn = 1; turn <= _budget.MaxTurns; turn++)
{
var step = await _reasoner.NextStepAsync(transcript, cts.Token);
if (step.IsFinalAnswer)
return AgentResult.Answered(step.Text, turn, toolCalls);
if (++toolCalls > _budget.MaxToolCalls)
return AgentResult.BudgetExceeded("tool call cap", turn);
var observation = await _executor.ObserveAsync(step.Action!, cts.Token);
transcript = transcript.Append(step, observation);
_logger.LogInformation(
"ReAct turn {Turn}: {Tool} -> {Outcome}",
turn, step.Action!.Name, observation.Outcome);
}
return AgentResult.BudgetExceeded("turn limit", _budget.MaxTurns);
}
}
Read that for loop as a bounded interpreter. The instruction stream is not deterministic. Every exit has a name. The model answered. The tool cap stopped the run. The turn limit stopped the run. Or the deadline cancelled the run. This loop cannot run forever. That limit is in the structure. It is not a line in a prompt.
The executor is the trust boundary
This is where the pattern pays for itself in an enterprise code base. The model does not call your database. It names a door. Your code decides whether that door opens.
public sealed class ToolExecutor : IToolExecutor
{
private readonly IQueryExecutor _queries;
private readonly IToolCatalog _catalog;
private readonly AgentBudget _budget;
public async Task<Observation> ObserveAsync(ToolCall call, CancellationToken ct)
{
if (!_catalog.TryGet(call.Name, out var tool))
return Observation.Rejected($"Unknown tool '{call.Name}'.");
if (tool.Effect is not ToolEffect.ReadOnly)
return Observation.Rejected($"Tool '{call.Name}' is not permitted.");
if (!tool.TryBindArguments(call.Arguments, out var args, out var bindError))
return Observation.Rejected(bindError);
try
{
var result = await _queries.ExecuteQueryAsync(call.Name, args, ct);
return Observation.FromResult(result, _budget.MaxObservationChars);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// A failed action is an observation, not an exception the loop should see.
return Observation.Failed($"'{call.Name}' failed: {ex.Message}");
}
}
}
Three choices in that class are deliberate.
Put the read-only check in code, not in a prompt. A prompt that says "never change data" is a suggestion. A ToolEffect check before dispatch is a guarantee. I have watched teams put that rule in a system message and call the result security.
Bind and validate the arguments before you dispatch the call. Do this the same way any model binder does it. The model returns JSON that it believes matches your schema. Trust the schema. Do not trust the model.
A tool failure must become an Observation. Do not let it become a thrown exception. This matters more than it looks. In ReAct, "that query returned nothing" is information. That information lets the next thought correct course. If the failure escapes as an exception, you turned a self-correcting loop into a 500.
Under that executor, IQueryExecutor is the same interface the rest of the application already uses. The caching Decorator from Thin Controller, Fat Pipeline fits with no extra work:
services.AddScoped<QueryExecutor>();
services.AddScoped<IQueryExecutor>(sp =>
new CachedQueryExecutor(
sp.GetRequiredService<QueryExecutor>(),
sp.GetRequiredService<IMemoryCache>(),
sp.GetRequiredService<IConfiguration>()));
The agent asks for IQueryExecutor and receives a cached one. The agent never learns that a cache exists. That is what Decorator is for. Keep the ReAct action step behind an ordinary .NET interface. You do not need a special agent framework for this step.
Variation: Check the Output on Unattended Runs
The paper says that mixed traces make errors easier to find, because a person can read the trace. That claim assumes a person is reading.
My nine legacy-extraction subagents convert 20-year-old VB.NET WinForms applications to ASP.NET Core. They run in parallel, usually while I am in a meeting. Each one has the opposite instruction:
- Private scratchpad: think step-by-step privately; do not reveal chain-of-thought.
If nobody reads the trace, the trace is a cost with no value. Move the check from the thought to the output file:
public static Observation Verify(
string tmpPath,
string finalPath,
IReadOnlyCollection<string> requiredKeys,
string agentResponse)
{
if (agentResponse.Contains("ANALYSIS_BLOCKED:", StringComparison.Ordinal))
return Observation.Failed("agent reported blocked input");
if (!File.Exists(tmpPath) || new FileInfo(tmpPath).Length == 0)
return Observation.Failed("no output written");
using var doc = JsonDocument.Parse(File.ReadAllText(tmpPath));
var missing = requiredKeys
.Where(k => !doc.RootElement.TryGetProperty(k, out _))
.ToList();
if (missing.Count > 0)
return Observation.Failed($"missing keys: {string.Join(", ", missing)}");
File.Move(tmpPath, finalPath, overwrite: true); // atomic promotion
return Observation.Ok(finalPath);
}
Write the file to .tmp. Check it. Then promote it with an atomic rename. A failed check leaves the last good file in place. Treat a file that parses as {} as a failure. An empty object is worse than no file. A later step will trust it.
That is the real split between the paper and production. The paper checks the thought. An unattended system checks the file. Both checks find errors. Only the file check works at 2 a.m.
Variation: A Plan You Compute First
The other change is to know when to skip this pattern.
My nine subagents do not choose their own order. A command runs them in three waves. Wave 1 runs alone, because every later step reads its output. Wave 2 runs two agents in parallel. Wave 3 runs the other six. I already know that dependency graph. A fixed schedule costs nothing, and it does not change.
Use this rule. Let the model reason inside a phase. Do not let it reason about the schedule. Reasoning is for the messy part. One example is a BrokenRules.Assert block, where the model must decide what the rules mean. Reasoning is not for an execution order that you can write in a switch. Each turn you give the loop adds latency and tokens. It also gives the model one more chance to improvise where you wanted a fixed order.
Consequences
| You gain | You pay |
|---|---|
| A bad result becomes feedback, so the loop can correct itself | Each turn is another model pass, so latency increases |
| Answers use facts from your systems | Token cost grows with the number of turns, not with the question |
| The executor writes an action log you can audit | The step count is not fixed, so latency is hard to budget |
| Tools stay as normal, testable .NET services | You must build the controls. You cannot turn them on in a setting |
Two production costs need numbers. A benchmark has no budget column. My chat endpoint uses a five-minute timeout, because a multi-turn request can take that long in the slow cases. I also limit each user to 60 queries an hour. An endpoint backed by a model has a cost profile that nothing else in the application shares. One client must not set your monthly bill.
When to Use It
| Use Reason-Act-Observe | Use something simpler |
|---|---|
| The next action depends on the last result | One tool call answers the question |
| The question needs facts the model does not hold | You already know the workflow order. Use a fixed plan |
| You must search, remove ambiguity, or find the record the user meant | The action is a write, or you cannot undo it |
| You must recover from an empty result or a wrong result | The latency budget is in milliseconds |
Remember the third row on the right. A reason-act loop with no limit, and with write access to a production database, is not an architecture. It is an incident. A research citation does not make it safe.
Related Patterns
| Pattern | Relationship |
|---|---|
| Command | Each tool is a command: it has a name, parameters, a validation step, and a dispatch step |
| Decorator | Caching, logging, and rate limits wrap the executor. The loop does not know |
| Interpreter | The loop reads an instruction stream that is not deterministic, against a limited grammar |
| Circuit Breaker | The Loop Governor stops bad thinking, the way a breaker stops bad I/O |
| Result | An observation has the shape of a Result. Failure is a value the loop reads. It is not an exception (post) |
The core idea is the same idea behind the Result pattern. Make the contract explicit. The structure will then carry you.
ReAct did not matter because of the prompt format. It mattered because reasoning and acting are separate operations, and each one must feed the other. The parts that make ReAct safe in production are ordinary .NET engineering. Those parts are the turn limit, the read-only catalog, the size limit on each result, and the checked output file. They sit on a participant that is not deterministic.
The model is one participant in the pattern. You own the other four. Most agent failures I have debugged were failures in the four that you own.
The chatbot in this post is the subject of Building a Production Chatbot in .NET. The nine extraction subagents are the subject of I Replaced My Own Conversion Tool.