Skip to content

17 min read

A stop sequence on "Observation:" dates the code you inherited

On this page

Inheriting an agent service means deciding, early and with no help from the vocabulary, whether it carries its tool calls as typed fields or recovers them from prose. That decision is worth making early because the protocol determines the failure modes. A system that recovers calls from text will corrupt arguments containing its delimiters, will report retries to the model as prose indistinguishable from facts, and will turn any tool result carrying external text into an execution path — all of which follow from the transport rather than from the tool implementations, and none of which you have to read the tool implementations to predict.

The awkward part is that the vocabulary is unreliable. The specimen throughout this chapter is a seller's repository examined during a technical due diligence, in which the word ReAct appeared nowhere, and neither did Thought, Action or Observation. The prompt templates used Reflection, Invoke and Result. The main loop lived in a class called TurnOrchestrator with an enum called TurnPhase. Recognising the ReAct pattern in unfamiliar code is a small skill with a large payoff, because the protocol determines the failure modes and the protocol is visible in about four places regardless of what anyone called things. Renaming is cosmetic here in a way it usually is not: you can rename every identifier in the system, but you cannot rename the mechanism, because the mechanism is a string comparison against a literal that also has to appear in a prompt.

The literal that has to exist twice

That last sentence is the whole of the recognition skill, and it is worth taking apart into steps, because every grep later in this chapter is one consequence of it. Dispatch in this design is decided by comparing characters: the loop looks for a fixed string — Action:, Invoke:, whatever the local spelling is — and treats what follows as a request to run a function. That comparison only fires if the model actually emits the string, and a model emits a string because it was told to, which in a completion-endpoint design means it was told in the prompt. There is nowhere else to tell it. So the literal has to be written down at least twice: once in the code that matches it, and once in the prose that teaches it.

Two consequences follow, and the second is the useful one. The first is that a rename is never partial in the way a rename usually is, because changing the literal in the code without changing it in the prompt breaks dispatch on the next request — the two copies are coupled by behaviour rather than by a compiler, which is why they stay in sync even in code nobody maintains. The second is that finding either copy finds the protocol. You do not need to guess the local vocabulary, because you can search for the shape of the thing instead: a capitalised word followed by a colon, sitting in a stop-sequence configuration, or in a split, or in a prompt template. Whatever the seller called their labels, the labels are capitalised words followed by colons in at least two files.

This is one property doing several jobs, so it is worth saying which jobs before the greps arrive. It explains why a stop sequence is a decisive signal: a stop parameter set to a label rather than to a message boundary can only exist in a design where labels are structural. It explains why a split or a regex on a capitalised label is decisive: that is the dispatch comparison itself. It explains why exemplars in a prompt are strong: they are the second copy, the one that teaches the literal. And it explains why a framework wrapper counts at all: an imported agent class carries both copies inside itself, so the repository can contain neither the regex nor the prompt and still be a parsed-transcript system.

What the seller's code actually looked like

Stripped of the surrounding class, the orchestrator's core was this:

python
class TurnPhase(str, Enum):
    REFLECTION = "Reflection"
    INVOCATION = "Invoke"
    FEEDBACK = "Result"

INVOKE_RE = re.compile(r"Invoke:\s*(?P<capability>[a-z_]+)\((?P<payload>.*?)\)", re.S)

def advance(self, state: TurnState) -> TurnState:
    reply = self.provider.complete(
        prompt=state.render(),
        stop=[f"{TurnPhase.FEEDBACK.value}:"],
        max_tokens=700,
    )
    state.append_raw(reply)

    if TurnPhase.INVOCATION.value + ":" not in reply:
        return state.finish(reply)

    m = INVOKE_RE.search(reply)
    if m is None:
        state.append_raw(f"\n{TurnPhase.FEEDBACK.value}: malformed invocation\n")
        return state
    ...

Everything from chapters one through three applies to it unchanged. The enum values are the three labels. The stop on "Result:" is the mechanism that prevents the model from writing its own tool output. INVOKE_RE is the dispatch decision. The if ... not in reply branch that ends the turn is the same test as looking for Answer:, expressed as a negation. There is no schema anywhere, and the capabilities the model may request exist only as prose in state.render() and as keys in a registry.

Read that listing against the two-copies argument and the correspondence is exact. TurnPhase.FEEDBACK.value is interpolated into the stop parameter, and the same enum value is interpolated into the text appended after a malformed invocation, which is text the model will read and imitate. The literal "Result" is therefore reaching the model through the prompt path and reaching the provider through the configuration path, from one constant. Somebody made that constant an enum member precisely so the two uses could not drift, which is a good engineering instinct that also happens to leave the protocol in one grep-able place.

The re.S flag on that pattern is worth a second look, because it is a specific prediction about how this system fails. re.DOTALL makes . match newlines, so (?P<payload>.*?) will happily swallow line breaks between the parentheses. Combined with a non-greedy quantifier it stops at the first ), which means a payload containing a legitimate closing parenthesis truncates the argument silently, and a payload containing a newline followed by more protocol text carries that text into the argument rather than leaving it in the transcript. Whoever wrote re.S was fixing a bug where multi-line arguments failed to parse, and the fix widened the injection surface described in the previous chapter.

Walking that in order makes it checkable rather than ominous. Without re.S, a payload containing a newline simply fails to match, and the malformed-invocation branch fires — noisy, visible, and safe. With re.S, the same payload matches, and the argument now contains everything up to the first closing parenthesis, newlines included. So the flag converts a loud failure into a silent one for legitimate multi-line arguments, which is what it was added for, and converts the same loud failure into a silent one for attacker-supplied multi-line text, which nobody asked for. That is the general shape of a parser fix: a change that accepts more input accepts more attacker input too, and the two cannot be separated because the parser has no way to tell whose newline it is reading.

Grepping for the ReAct pattern in code that never mentions it

The search is quick because the mechanism has to be textual somewhere. Four patterns cover almost all of it:

bash
#-- 1. Stop sequences set to a label rather than to a message boundary
rg -n --glob '!node_modules' \
   -e 'stop["_ ]?(sequences|tokens)?\s*[:=]\s*\[?["'"'"'][A-Z][a-z]+:'

#-- 2. Splitting or matching on a capitalised label followed by a colon
rg -n -e '(split|rsplit|partition|search|findall|match)\s*\(\s*["'"'"'][A-Z][a-z]+:'

#-- 3. Prompt templates that spell the cycle out
rg -ni -e 'thought:|observation:|scratchpad|you (may|can) use the following tools'

#-- 4. Framework wrappers that carry the protocol for you
rg -n -e 'ZERO_SHOT_REACT_DESCRIPTION|initialize_agent|create_react_agent|AgentExecutor'

The first two find the mechanism, the third finds the prompt, and the fourth, whose identifiers are LangChain's, finds the case where a library is running the protocol on the codebase's behalf — which is common enough that a repository with no regular expressions of its own can still be a parsed-transcript system underneath. Each of those four is the two-copies argument applied once: the first two search the code copy, the third searches the prompt copy, and the fourth searches for an import that contains both. None of them searches for the word ReAct, and none of them needs to.

Two hiding places defeat all four greps and are common enough to check by default. The first is prompts that do not live in the repository: a prompt-management service, a prompts table in Postgres, a YAML file in a separate config repo deployed on its own cadence. In the seller's system the stop token happened to be in config/models.yaml, but the tool descriptions were rows in a database, which is why the prose grep returned nothing on the first pass. If the code interpolates a template it fetched at runtime, the protocol may be entirely invisible to static search, and the fastest way to see it is to log one outbound request body in a staging environment and read what was actually sent. The second hiding place is a vendored or forked framework, where the parser is three directories deep in something nobody has opened since it was added.

Both hiding places are the same failure of the two-copies argument, which is worth naming because it tells you what to do rather than only what went wrong. The argument guarantees that the literal exists twice in the system; it does not guarantee that both copies are in the repository you were handed. When a copy has left the repository, the request body is where the two copies are forced back together, because the prompt and the stop parameter are serialised into the same HTTP call no matter where they were stored. Reading one outbound request is therefore not a fallback — it is the only place the guarantee is unconditional.

Tests are an underrated place to look, because they preserve the protocol in its rawest form. A fixtures directory containing golden files — recorded outputs that a test asserts against verbatim — full of Thought: and Observation: lines, or a test named something like test_parses_multiline_action, tells you both that the pattern is present and that someone has already been bitten by it. Two different things are being claimed there, and they should be kept apart. Looking in the tests is a recommendation, and it holds whatever the codebase does. The stronger claim — that the set of parse-recovery tests is close to a bug history, so reading them in commit order gives you the incident timeline without asking anybody — depends on how such tests accumulate: each one is usually written in response to a shape of model output that broke dispatch once, which makes the set a record of failures rather than a record of intent. Where a team wrote its parse tests up front from a specification, the second claim does not hold and the first still does.

Signals on the dispatch path, and signals on the prompt path

The signals differ in how much they tell you, and it is worth being explicit about the ranking, because the weak ones get over-read.

SignalStrengthWhat it predicts
A stop sequence set to a label such as "Observation:", together with a regex or split on "Action:"DecisiveThe full parsed protocol, with every failure mode in chapters two and three
A regex whose captures are named for a tool and its arguments, applied to raw completion textDecisive on its ownDispatch decided client-side; check next whether it reads the completion or the whole transcript
Few-shot exemplars in the prompt showing a three-label cycleStrongThe protocol exists; the model has been trained by example to emit it, so removing the prompt breaks dispatch
A prompt paragraph describing think-then-act in prose, with no parsingWeakPossibly just a system prompt over native tool calling; check the request body before concluding anything
An iteration cap, a message list, a dispatch tableNoneEvery agent loop has these; they say nothing about the protocol
json.loads applied to the whole completion, with a "respond only in JSON" instructionAdjacent speciesA different legacy pattern with its own failures: truncation, prose preamble, markdown fences around the object

That table is a ranking on its face and a gap underneath, and the gap is the more useful reading. What separates the decisive rows from the weak one is not how much the code resembles the paper. It is whether the signal sits on the dispatch path or on the prompt path. A stop sequence and a regex over completion text are dispatch: they are the machinery that turns characters into a function call, and their presence is the protocol rather than evidence of it. Exemplars sit on the prompt path but are load-bearing for dispatch, since the model only emits the literal because it was shown it — which is why they rank strong rather than weak. A paragraph asking the model to think first sits on the prompt path and nothing branches on it. The "None" row falls out of the same reading without needing its own argument: an iteration cap, a message list and a dispatch table are on neither path, because every agent loop has them whatever carries its calls.

That last row is the one most often mistaken for this pattern. Hand-rolled JSON mode has the same underlying problem — structure recovered from a text field after generation — but different symptoms, and it usually shows up as a strip("```json") call and a retry loop keyed on JSONDecodeError. Rather than a shifted-argument bug it produces a parse that fails cleanly and often, and it is meaningfully safer than the label protocol because a JSON string with an embedded newline cannot become a second command. If you find it, the migration in the next chapter still applies, but the urgency is lower.

The weak signal is worth its own warning in the other direction, and it is the mistake to expect from a careful reader rather than a careless one. A system prompt saying "reason about the request before calling a tool" over a modern provider is not this pattern; it is a prompt, and it may be a perfectly good one. Nothing is being parsed, no label is reserved, and the model's reasoning arrives as content the loop ignores. The reason it is tempting is that the prose is genuinely constant across the two designs — the same sentence, sometimes word for word, appears in a 2023 completion-endpoint agent and in a system prompt written last week over typed tool calls — while the property that decides everything, whether anything parses the reply, varies underneath it. That is the same confusion the first chapter of this topic names from the other side, where calling ReAct "telling the model to think before acting" describes the prompt and misses ACTION_RE; here it is met while reading code rather than while reading a description, and it is the same mistake. Concluding "this is ReAct, rewrite it" from a prompt paragraph is the reading error that makes people distrust the recognition skill. The question that settles it in one step is what the request body contains: a tools or functions array means the provider is doing the work, and a lone prompt with a stop means your client is.

That question settles it in one step for a reason the gap reading supplies. The request body is where the dispatch path becomes visible, because a tools array can only be acted on by the provider and a stop on a label can only be acted on by a client parser. Every other artefact — the prompt, the class names, the docstrings — is on the prompt path and is compatible with either design.

Reading the age of the code from the mechanism

The dating is reliable because the protocol had a window. A design that carries actions as labelled text over a completion endpoint was, in 2022 and the first half of 2023, the only option available. Once OpenAI shipped function calling in June 2023 and the other providers followed within months, new code written this way was almost always copied from a tutorial or a framework example that predated the change, or written against a self-hosted model whose serving stack had no tool-calling endpoint.

Knowing the age changes the questions you ask. If the mechanism predates the alternative, the original authors made no mistake and the code is unmigrated rather than misjudged, which usually means the tests, the prompts and the tool inventory are all shaped around the parser and a migration is a week rather than an afternoon. If the mechanism postdates it — a repository whose first commit is 2025 and which parses labels out of prose — someone chose this, and the useful question is what constraint drove the choice, since there is one legitimate answer and several bad ones. That question is the subject of the final chapter.

The ordering is what does the work there, and it is worth stating as arithmetic rather than as atmosphere. The window that the first chapter of this topic measures — the eight months between the ReAct paper and function calling, during which labelled text was the only carrier — is what makes an early commit date exculpatory. A codebase whose agent predates the alternative by three months could not have chosen otherwise, so its protocol carries no information about its authors' judgement and all of the information about their calendar. Reverse the ordering and the inference reverses with it: a first commit well after the alternative shipped licenses the opposite conclusion, that somebody weighed the two and picked this one, and it makes "what constraint drove that" the first question rather than an afterthought.

Recognition is not a verdict, and the conditions under which it stops mattering are specific enough to check. A parsed loop with three read-only tools, no user-controlled input reaching the transcript, and a human reading every output is a system that will run for years without incident, and rewriting it because of its protocol would be a poor use of a week. All three conditions are load-bearing and each fails in its own way: a read-only tool set stops being read-only the moment somebody adds a capability, no user-controlled input is a claim about every tool's result and not only the obvious ones, and a human reading every output is a staffing arrangement rather than a property of the code. The last chapter of this topic tests the durability of the first condition directly.

What recognition buys is a prediction: you now know, before reading the tool implementations, that arguments containing delimiters will corrupt calls, that retries will be reported to the model as indistinguishable prose, that any tool returning external text is an execution path, and that adding a tool means editing English. Whether those predictions matter depends on what the tools can reach. That dependency is why the recognition is worth doing first and is not worth doing alone: it tells you the shape of every failure available to the system and nothing about which of them would cost anything.

The seller's system did need migrating, because two of its capabilities wrote to customer records. That single fact converted every prediction above from a curiosity into a cost, and it did so without anyone reading the capability implementations — a capability that writes to a customer record is reached through an argument recovered from prose, which is the first prediction on the list. The afternoon session went into scoping that work, and the first question was the one this topic has been building towards: what, exactly, does the migration consist of, given that the interleaving of reasoning and action is the part everyone wants to keep. The mapping turns out to be short, and the argument is entirely about one of the three labels.

ReAct as history — read the current version at https://daviddominguez.dev/learn/en/react-as-history/react-pattern

Back to top