Trajectory Search
An agent can make every local decision look reasonable and still lose the task.
A coding agent sees that test_checkout_redirect is failing, concludes there is an implementation bug in checkout.py, and then behaves impeccably for twenty steps: it reads the file, edits it, runs the tests, repairs the new failures its edit introduced, rewrites the patch, and runs the tests again. Every one of those steps is defensible given the step before it.
The run still fails, because the fixture was stale.
The machinery built so far can make this run safer and more legible without solving the root problem. Candidate generation samples several complete proposals and picks one, which helps only when complete alternatives are affordable. Critique and revision improve one candidate, which cannot rescue a route whose initial hypothesis was wrong. Planning represents one intended route, and progress tracking can correctly report that the route is advancing even while it advances toward the wrong explanation.
The missing capability is narrower than any of these:
Preserve several partial trajectories long enough for evidence to distinguish them.
That is search, in the sense used here. Not web search, and not database search. Search over possible futures of the agent itself.
The word doing the work there is partial. The runtime does not wait for every branch to produce a finished answer before deciding which branches deserve another unit of compute.
Deciding that, repeatedly and under a budget, is the whole subject of this chapter.
1. Prefix-level scaling, and why it is a distinct regime
A 2026 review of test-time scaling separates inference into three structural regimes, and the distinction gives this chapter a precise place to stand.[6]
| Regime | What gets more compute | Branches at | Covered by |
|---|---|---|---|
| Sequential | One trajectory, extended | Never | Critique, planning, progress |
| Leaf-level | Several complete outputs, then reduced | After completion | Candidate generation and selection |
| Prefix-level | Several incomplete states, selectively | Before completion | This chapter |
flowchart LR
subgraph S[sequential]
s0[s0] --> s1[s1] --> s2[s2] --> s3[s3]
end
subgraph L[leaf-level]
r1((root)) --> a[answer A]
r1 --> b[answer B]
r1 --> c[answer C]
a & b & c --> sel[select]
end
subgraph P[prefix-level]
r2((root)) --> A1[A1] --> A2[A2]
A1 --> A3[A3]
r2 --> B1[B1]
r2 --> C1[C1] --> C2[C2]
end
Leaf-level scaling and prefix-level scaling are often discussed as if they were the same trick at different sizes. They are not. Best-of-N pays for N complete trajectories and throws N−1 away, which is affordable when a trajectory is one model call and ruinous when it is forty tool invocations against a real environment. Prefix-level scaling pays only for the prefixes that still look worth continuing.
That buys it a cost profile Best-of-N cannot match, at the price of needing something Best-of-N does not: a way to score work that is not finished.
Trajectory search is prefix-level test-time scaling over partial agent states.
2. When branching is justified, and what it is not
Search is not automatically better than a single well-run loop, and adding it to a system that does not need it buys latency and a new failure surface. Four conditions should roughly hold: several plausible paths genuinely exist, early choices strongly constrain later outcomes, partial paths can be evaluated by something, and the extra inference or environment work is affordable.
Drop any one of them and a simpler mechanism wins. If the next step is deterministic, search adds machinery without adding capability. If complete answers are cheap to sample, leaf-level selection is sufficient and much easier to reason about. If the first attempt is usually nearly right, targeted revision beats breadth.
Search earns its place when several futures must remain alive at once.
It is worth being precise about what that does and does not overlap with, because three neighbouring mechanisms get conflated with it constantly.
| Mechanism | Question | Relation to search |
|---|---|---|
| Memory | What from the past should influence this decision? | Can seed, score or prune branches; creates none |
| Planning | What future work is intended? | Owns the representation; search competes between routes |
| Critique and revision | How is this one candidate improved? | Runs inside a branch; collapses breadth if it runs across them |
The last row is the one that catches teams out. A system that generates three branches and then revises them all toward the same answer has paid for breadth and kept none of it.
3. A node is a resumable state, not a sentence
A branch labelled “this approach seems promising” is not a search node.
It is a note. A node must carry enough state to continue the branch without reconstructing it from prose.
from dataclasses import dataclass, field
from typing import Any
@dataclass
class SearchNode:
id: str
parent_id: str | None
depth: int
state: dict[str, Any]
env_checkpoint: str | None = None
action: dict[str, Any] | None = None
observation: dict[str, Any] | None = None
score: "NodeScore | None" = None
terminal: bool = False
pruned_by: "SearchStage | None" = None
prune_reason: str | None = None
cost: float = 0.0
metadata: dict[str, Any] = field(default_factory=dict)
The state object typically carries the goal, current hypothesis, established facts, constraints, artifacts, remaining budget and any memory IDs in play. The test is blunt:
If a branch cannot be resumed from its node, the tree is a diagram rather than a data structure.
env_checkpoint is separate from state on purpose, and it is the field most often missing.
Search assumes competing futures can be considered independently. That assumption is free for pure reasoning and expensive for agents that mutate things. Suppose branch A edits parser.py and branch B then reads parser.py. Branch B has silently inherited branch A’s world, the two are no longer alternatives, and every downstream score measures a state that no search policy chose.
The tree is fiction, and nothing in the trace says so.
The previous chapter on recovery already established the stronger contract: a resumable point needs an agent-side state and an aligned environment state. Search generalises that requirement from one abandoned trajectory to several live trajectories. Isolation may use copy-on-write state, Git worktrees, containers, database transactions, separate browser sessions, or checkpoint-and-restore, but the invariant is the same:
branch A state + branch A environment
must never leak into
branch B state + branch B environment
The checkpoint identifier therefore belongs on the node rather than in a global.
There is one more trap. Two nodes are equivalent only if every difference that could affect their future has been intentionally excluded. A fingerprint that remembers facts but forgets the modified files, browser session, permissions or remaining budget can merge different futures into one. Section 10 makes state equivalence an application-supplied policy rather than pretending a generic tuple can know what matters.
4. Branch freely over proposals, once over side effects
Some branches cannot be executed speculatively at all. Three refunds cannot be issued, three emails sent, or three production deployments performed, with the best one kept afterwards.
The important boundary is therefore not before validation. Validation, authorization and precondition checks are side-effect-free runtime decisions and can be applied to every candidate. The single-valued boundary is real execution.
flowchart TD
P[propose candidate continuations] --> A{prepare each proposal:<br/>parse, validate, authorize,<br/>check preconditions}
A -->|Rejected| R[record rejection stage]
A -->|Accepted| S[inspect / simulate / preview<br/>in isolation]
S --> E[score candidate futures]
E --> C[rank and collapse frontier]
C --> K{re-check current<br/>authorization + preconditions}
K -->|Rejected| N[try next ranked candidate]
N --> K
K -->|Accepted| X[execute once]
X --> O[observation]
style X fill:#f6c8c8,stroke:#b04a4a
The single red box is the design constraint.
Everything above it must be side-effect-free or isolated from the real environment. It is not necessarily cheap: a sandboxed test suite can cost minutes, and a simulation can consume significant compute. Reversible is the important property, not inexpensive.
Search may branch over proposed, validated or simulated effects even when the real world must remain single-valued.
The action boundary therefore runs twice when necessary. It filters impossible or forbidden proposals before speculative evaluation, and the winning proposal is checked again immediately before commitment because permissions, budgets or environmental preconditions may have changed while search was running.
Search chooses among candidate futures. It never grants authority to execute one.
5. Five policies, not one algorithm
Most confusion about agent search comes from naming an algorithm and inheriting its assumptions. Beam search, best-first search and MCTS really are different algorithms, but each bundles answers to the same underlying policy questions.
For agent engineering, separating those questions is often more useful than beginning with the algorithm name.
| Policy | Question | Failure it causes when wrong |
|---|---|---|
| Expansion | What alternative continuations should exist? | The right path is never generated |
| Evaluation | What evidence estimates promise and progress? | Good branches score badly |
| Allocation | Which nodes receive more compute? | Budget spent on settled decisions |
| Pruning | Which nodes stop consuming compute? | The winning branch is discarded at depth 2 |
| Termination | When does the frontier collapse? | Perpetual exploration, or premature commitment |
flowchart LR
F[frontier] --> EX[expansion]
EX --> EV[evaluation]
EV --> AL[allocation]
AL --> PR[pruning]
PR --> TE{termination?}
TE -->|no| F
TE -->|yes| CM[commit]
A fixed-width beam is one particular allocation-and-pruning policy. Best-first search is another. MCTS adds a particular tree policy, value update rule and exploration mechanism. The chapter does not claim those algorithms are interchangeable; it claims the five responsibilities remain inspectable inside whichever one is chosen.
That decomposition matters because the policies can be measured and ablated separately. The next five sections take one each.
6. Expansion: distinct futures, not distinct strings
A nominal branch factor of five means nothing if all five are paraphrases. Consider an expansion that returns inspect parser.py, open parser.py, examine parser implementation, read parser.py carefully and inspect delimiter code in parser.py. Five branches were generated. One region of the search space was explored, and the budget was spent five times over.
Diversity improves when expansion is given semantic roles rather than a temperature. For debugging: the strongest current hypothesis, a competing hypothesis, an information-gathering action, a conservative fallback, and a challenge to a current assumption. For incident response the roles become layers — application, infrastructure, data, recent change. For research they become stances toward the evidence: support the current explanation, seek disconfirming evidence, investigate a competing explanation, test whether a premise is false.
None of that is worth arguing about without a number attached.
from collections import Counter
from typing import Callable, Sequence
def unique_branch_ratio(
children: Sequence[SearchNode],
signature: Callable[[SearchNode], object],
) -> float:
if not children:
return 0.0
return len({signature(c) for c in children}) / len(children)
def effective_branch_factor(
children: Sequence[SearchNode],
signature: Callable[[SearchNode], object],
) -> float:
return unique_branch_ratio(children, signature) * len(children)
The signature is where the judgement lives: an action kind plus its target, a hypothesis label, or a fingerprint of the resulting state. What matters is that it is a function you can change and re-measure.
A configuration running branch_factor = 8 at a unique branch ratio of 0.25 has an effective branching factor near two.
Raising N to sixteen will roughly double the bill and leave the effective width exactly where it was.
7. Evaluation: promise and progress are different signals
Terminal states admit direct questions. Did the tests pass, did the transaction complete, does the artifact satisfy the specification. A partial state usually admits none of them, because it is neither correct nor incorrect yet.
It is more or less worth continuing.
So the evaluator estimates something closer to: if I spend more compute here, how promising is this branch, and what measurable progress has it already made? AgentPRM makes essentially this distinction for agent decisions, separating promise from progress rather than treating intermediate actions as simply correct or incorrect.[4]
Two branches make the distinction concrete. Branch A has a hypothesis that strongly explains the observed failure and has attempted no patch. Branch B turned one failing test green and three unrelated tests red. A single undifferentiated score hides the fact that A has promise without progress while B has progress with regressions.
from enum import IntEnum
class EvidenceTier(IntEnum):
JUDGE = 0 # model judgement
PROCESS_MODEL = 1 # learned value / process model
DOMAIN_SIGNAL = 2 # task-specific deterministic signal
ENVIRONMENT = 3 # observed environment or verifier evidence
@dataclass(frozen=True)
class NodeScore:
promise: float
progress: float
regressions: int = 0
violations: int = 0
tier: EvidenceTier = EvidenceTier.JUDGE
uncertainty: float = 0.0
cost: float = 0.0
@property
def blocked(self) -> bool:
return self.violations > 0
@property
def rank_key(self) -> tuple:
return (
not self.blocked,
self.progress - 0.5 * self.regressions,
self.promise,
int(self.tier),
-self.cost,
)
That rank_key is an illustrative policy, not a universal scale. A model confidence of 0.9, a test-pass fraction of 0.9, and a learned value of 0.9 are not automatically commensurate because they share a decimal representation. Production systems should calibrate the signals they combine on held-out trajectories.
The hard rule is simpler: a hard constraint violation is not a slightly worse score, and direct observations should update the state variables they actually establish rather than merely adding points to an evaluator.
The evidence tier is therefore attribution as much as ranking information. When two branches are otherwise close, stronger external evidence is a sensible tie-breaker; it should not be used to pretend incomparable measurements have a universal total order.
Prefer evidence generated by the environment over confidence generated by the model, without pretending every evidence source is calibrated to every other one.
This also guards against the quietest search failure: self-confirmation. When one model generates the branches, scores the branches and explains the winner, the runtime has produced the appearance of diversity over a shared bias.
Three branches evaluated only by their own author are still one evidence source. Diversifying the evidence—deterministic checks, independent tools, environment state, a separate verifier or an authoritative source—matters more than diversifying the wording.
8. Allocation: spend where the decision is unresolved
A fixed beam keeps k branches at every depth, which is easy to explain and rarely optimal. There is no reason the useful width should be constant. Bug diagnosis may want six hypotheses at depth zero and one at depth three; another task may need the opposite.
Depth is only a rough proxy for what actually varies: how unresolved the decision is.
Consider two vote distributions over the next action. Eight votes for read_file against one each for search_code and run_tests look settled. Four, three and three look contentious.
from math import log
def uncertainty(votes: Counter[str]) -> float:
"""Normalised vote entropy in [0, 1]. 0 = unanimous."""
total = sum(votes.values())
if total == 0 or len(votes) < 2:
return 0.0
entropy = -sum(
(n / total) * log(n / total)
for n in votes.values()
if n
)
return entropy / log(len(votes))
def top_two_margin(votes: Counter[str]) -> float:
ranked = [n for _, n in votes.most_common(2)]
if len(ranked) < 2:
return 1.0
return (ranked[0] - ranked[1]) / sum(votes.values())
Vote entropy and top-two margin are disagreement signals, not truth meters. Ten identical wrong samples have zero entropy. A search policy that equates consensus with correctness can confidently starve the minority branch that contained the fix.
The useful claim is narrower: when these statistics are empirically predictive on the task distribution, they can tell the runtime where additional sampling is more likely to buy information. CATTS reports exactly this pattern for web agents, using vote-derived uncertainty to allocate extra inference selectively and outperform uniform scaling with fewer tokens in its evaluated settings.[5]
That leads to two safeguards:
- Calibrate uncertainty against downstream outcomes. Do not assume entropy predicts difficulty because it has the right shape.
- Preserve a minimum exploration floor. A low-uncertainty decision can still be wrong, especially early in a run when all samples share the same missing evidence.
flowchart LR
E[explore<br/>wide, cheap, informational] --> D[discriminate<br/>evidence separates branches]
D --> X[exploit<br/>narrow, deep, transformational]
Breadth and depth answer different uncertainties, and the useful regime can change during a task rather than being fixed for it.
Breadth asks which hypothesis deserves survival. Depth asks whether the surviving hypothesis can actually be carried through.
The regime shift also changes which actions are worth taking. Actions divide roughly into informational—read a file, run a targeted test, inspect metrics, query status—and transformational—edit a file, restart a service, submit a form, write to a database. If the agent is torn between a cache bug and a database lock, an immediate edit advances one branch by a step, while a diagnostic query that separates the two may collapse half the frontier.
When transformations are expensive or irreversible, cheap discriminating information is often the better buy. A search policy that scores only immediate task progress will miss that value.
9. Pruning: where a good search goes to die
The generator produces the successful branch. The evaluator scores it. The runtime prunes it. The search fails.
Generation succeeded. Search policy failed later.
Those need different fixes, and end-to-end success cannot tell them apart. The same decomposition used for candidate selection and memory recall applies to the tree, but the stage meanings have to be explicit.
from enum import StrEnum
class SearchStage(StrEnum):
REACHABLE = "reachable" # a successful route is expressible
GENERATED = "generated" # expansion produced that route/prefix
DISTINCT = "distinct" # survived deduplication
EVALUATED = "evaluated" # received a usable partial-state score
SURVIVED = "survived" # was not pruned while still viable
COMPLETED = "completed" # received enough depth to terminate
VERIFIED = "verified" # terminal result passed external verification
@dataclass(frozen=True)
class SearchTrial:
"""One labelled task with a known successful trajectory or prefix."""
task_id: str
reachable: bool = False
generated: bool = False
distinct: bool = False
evaluated: bool = False
survived: bool = False
completed: bool = False
verified: bool = False
@property
def flags(self) -> tuple[tuple[SearchStage, bool], ...]:
return (
(SearchStage.REACHABLE, self.reachable),
(SearchStage.GENERATED, self.generated),
(SearchStage.DISTINCT, self.distinct),
(SearchStage.EVALUATED, self.evaluated),
(SearchStage.SURVIVED, self.survived),
(SearchStage.COMPLETED, self.completed),
(SearchStage.VERIFIED, self.verified),
)
def validate(self) -> None:
reached_false = False
for stage, reached in self.flags:
if reached_false and reached:
raise ValueError(
f"{self.task_id}: {stage} cannot be reached after an earlier loss"
)
reached_false |= not reached
@property
def lost_at(self) -> SearchStage | None:
self.validate()
for stage, reached in self.flags:
if not reached:
return stage
return None
def oracle_generated_success(trials: Sequence[SearchTrial]) -> float:
"""Fraction of tasks for which search generated the known-good route."""
reachable = [t for t in trials if t.reachable]
return sum(1 for t in reachable if t.generated) / (len(reachable) or 1)
def selected_success(trials: Sequence[SearchTrial]) -> float:
reachable = [t for t in trials if t.reachable]
return sum(1 for t in reachable if t.verified) / (len(reachable) or 1)
def search_gap(trials: Sequence[SearchTrial]) -> float:
"""Generated opportunity that the remainder of search failed to convert."""
return oracle_generated_success(trials) - selected_success(trials)
def pruning_regret(trials: Sequence[SearchTrial]) -> float:
"""Known-good generated routes pruned before completion."""
generated = [t for t in trials if t.reachable and t.generated]
if not generated:
return 0.0
pruned = sum(
1
for t in generated
if t.lost_at is SearchStage.SURVIVED
)
return pruned / len(generated)
def allocation_regret(trials: Sequence[SearchTrial]) -> float:
"""Known-good routes kept alive but never given enough depth to complete."""
generated = [t for t in trials if t.reachable and t.generated]
if not generated:
return 0.0
starved = sum(
1
for t in generated
if t.lost_at is SearchStage.COMPLETED
)
return starved / len(generated)
Adding COMPLETED matters. Without it, “the branch was alive when the budget expired” and “the branch finished and failed verification” collapse into the same boolean.
The stages point at different subsystems:
| Lost at | What failed | Where to work |
|---|---|---|
| REACHABLE | The action space cannot express the solution | Capability design |
| GENERATED | Expansion never proposed the viable route | Expansion roles, sampling |
| DISTINCT | A viable route was merged as an equivalent duplicate | State key, dedup policy |
| EVALUATED | No usable score could be produced | Evaluator / instrumentation |
| SURVIVED | A viable branch was pruned | Pruning rule, evaluator ranking |
| COMPLETED | It survived but was starved of depth or budget | Allocation, node budget |
| VERIFIED | It completed and failed the final check | The completed branch was not actually successful |
oracle_generated_success is the prefix-search analogue of oracle@N: did search create the opportunity at all? selected_success asks whether the system converted that opportunity into a verified result.
Online, pruning_regret and allocation_regret are counterfactual and therefore not directly observable. In a benchmark environment, continue a controlled sample of pruned or starved branches after the point where production search would have stopped them. That experiment estimates how often the policy killed a future that would have succeeded.
Populating SearchNode.pruned_by and prune_reason at the moment of the decision is what makes the diagnosis reconstructable afterwards.
10. Termination: collapsing the frontier
Premature commitment is the failure this chapter exists to fix. Perpetual exploration is its mirror image: another hypothesis, another branch, another diagnostic, no commitment. The trace looks industrious.
Nothing ships.
A termination policy needs conditions that can be inspected: all but one branch violated hard constraints; a terminal branch passed the verifier; the ranking margin stayed stable through another discriminating expansion; the estimated marginal gain fell below marginal cost; or the search budget ended and the runtime must return control.
Model confidence alone is not one of those conditions.
Deduplication belongs beside termination because it changes whether the frontier is genuinely diverse. Two trajectories can arrive at the same effective state by different routes.
Continuing both pays twice for one future—but only if they are actually equivalent.
from collections.abc import Hashable, Mapping
from typing import Callable
StateKey = Callable[[Mapping[str, object]], Hashable]
def coding_state_key(state: Mapping[str, object]) -> Hashable:
"""Example only: include every field that can change future behaviour."""
return (
tuple(sorted(state.get("facts", ()))),
tuple(sorted(state.get("completed", ()))),
tuple(sorted(state.get("artifacts", ()))),
state.get("workspace_revision"),
state.get("environment_version"),
state.get("goal_phase"),
state.get("remaining_budget"),
)
There is no universal state_fingerprint() for agents.
If a key omits a future-relevant variable, deduplication becomes corruption. Two browser nodes with different authenticated sessions are not equivalent because their visible text happens to match. Two coding nodes with different working-tree changes are not equivalent because their facts sets match. Two nodes with different remaining budgets may not be equivalent even if every other field is.
The state key is therefore part of the search protocol and deserves tests of its own.
Even with a correct key, first seen is not automatically best. If two routes reach the same effective state, keep the representative with the better evidence-adjusted score or lower accumulated cost rather than whichever happened to be generated first.
Once equivalent paths can merge, the structure is no longer strictly a tree. Graph of Thoughts formalised a broader graph-structured reasoning view with aggregation and reuse of intermediate states.[2] A production agent does not need that framework to take the useful engineering lesson:
Do not pay twice to explore the same effective future, and do not merge futures until equivalence is actually justified.
11. The loop, assembled
The five policies compose into a search runtime that is still small, but a production-safe skeleton needs a few more guarantees than a toy beam loop: node budgets must be hard, empty frontiers must be representable, terminal nodes must not be expanded, and equivalent states must compete rather than obey first-seen order.
from enum import StrEnum
from typing import Hashable, Protocol
class SearchEnd(StrEnum):
POLICY_STOP = "policy_stop"
FRONTIER_EXHAUSTED = "frontier_exhausted"
MAX_DEPTH = "max_depth"
MAX_NODES = "max_nodes"
@dataclass(frozen=True)
class SearchResult:
best: SearchNode | None
nodes: tuple[SearchNode, ...]
reason: SearchEnd
class SearchPolicies(Protocol):
def expand(self, node: SearchNode, width: int) -> list[SearchNode]: ...
def evaluate(self, node: SearchNode) -> NodeScore: ...
def width_for(self, node: SearchNode) -> int: ...
def keep(self, scored: list[SearchNode]) -> list[SearchNode]: ...
def should_stop(self, frontier: list[SearchNode]) -> bool: ...
def state_key(self, node: SearchNode) -> Hashable: ...
def prefer_equivalent(
self,
new: SearchNode,
old: SearchNode,
) -> bool: ...
def search(
root: SearchNode,
policies: SearchPolicies,
*,
max_depth: int = 5,
max_nodes: int = 50,
) -> SearchResult:
if max_nodes < 1:
raise ValueError("max_nodes must be at least 1")
if root.score is None:
root.score = policies.evaluate(root)
frontier = [root]
nodes = [root]
best_by_state: dict[Hashable, SearchNode] = {
policies.state_key(root): root
}
for _ in range(max_depth):
if policies.should_stop(frontier):
best = max(frontier, key=lambda n: n.score.rank_key)
return SearchResult(best, tuple(nodes), SearchEnd.POLICY_STOP)
candidates = [node for node in frontier if node.terminal]
expandable = [node for node in frontier if not node.terminal]
remaining = max_nodes - len(nodes)
if remaining <= 0:
best = max(frontier, key=lambda n: n.score.rank_key)
return SearchResult(best, tuple(nodes), SearchEnd.MAX_NODES)
for node in expandable:
width = max(0, policies.width_for(node))
width = min(width, remaining)
if width == 0:
continue
proposed = policies.expand(node, width)[:width]
for child in proposed:
if len(nodes) >= max_nodes:
break
if child.depth != node.depth + 1:
raise ValueError(
f"{child.id}: child depth does not follow parent"
)
child.score = policies.evaluate(child)
nodes.append(child)
key = policies.state_key(child)
incumbent = best_by_state.get(key)
if incumbent is not None:
if not policies.prefer_equivalent(child, incumbent):
child.pruned_by = SearchStage.DISTINCT
child.prune_reason = "equivalent state, weaker representative"
continue
incumbent.pruned_by = SearchStage.DISTINCT
incumbent.prune_reason = "replaced by better equivalent state"
best_by_state[key] = child
candidates.append(child)
remaining = max_nodes - len(nodes)
if remaining <= 0:
break
if not candidates:
scored = [n for n in nodes if n.score is not None]
best = max(scored, key=lambda n: n.score.rank_key) if scored else None
return SearchResult(
best,
tuple(nodes),
SearchEnd.FRONTIER_EXHAUSTED,
)
kept = policies.keep(candidates)
kept_ids = {n.id for n in kept}
for child in candidates:
if child.id not in kept_ids and child.pruned_by is None:
child.pruned_by = SearchStage.SURVIVED
child.prune_reason = "removed by pruning policy"
frontier = kept
if not frontier:
scored = [n for n in nodes if n.score is not None]
best = max(scored, key=lambda n: n.score.rank_key) if scored else None
return SearchResult(
best,
tuple(nodes),
SearchEnd.FRONTIER_EXHAUSTED,
)
if len(nodes) >= max_nodes:
best = max(frontier, key=lambda n: n.score.rank_key)
return SearchResult(best, tuple(nodes), SearchEnd.MAX_NODES)
best = max(frontier, key=lambda n: n.score.rank_key) if frontier else None
return SearchResult(best, tuple(nodes), SearchEnd.MAX_DEPTH)
The runtime deliberately knows very little about why a node is good. That belongs to the policies. What the runtime owns are invariants: budgets are real, terminal nodes do not expand, every node is accounted for, equivalent states compete explicitly, and every exit has a name.
Branch isolation is still a contract of expand. For environment-changing agents, expand must resume the parent checkpoint into an isolated environment before producing each child. If it cannot guarantee that, the search is valid only over proposals or simulations and must collapse before real execution.
A more sophisticated implementation can use a priority queue, asynchronous workers, MCTS statistics or learned allocation. Those change scheduling. They do not remove these invariants.
12. Committing once, at the boundary
The generic loop is safe for pure reasoning and isolated environments. For irreversible real actions, production code searches over prepared proposals and previews, then executes one action once.
The action boundary should filter candidates before preview, and the winner should be checked again at commitment time.
from collections.abc import Callable
def search_then_commit(
state,
propose: Callable[[object, int], list[object]],
prepare: Callable[[object], "Decision"],
preview: Callable[[object, "Action"], object],
evaluate: Callable[[object, "Action", object], NodeScore],
execute: Callable[["Action"], object],
*,
base_width: int = 2,
extra_width: int = 3,
threshold: float = 0.35,
):
def consider(width: int):
accepted = []
for raw in propose(state, width):
decision = prepare(raw)
if not isinstance(decision, Accepted):
continue
simulated = preview(state, decision.action)
score = evaluate(state, decision.action, simulated)
accepted.append((raw, decision.action, score))
return accepted
candidates = consider(base_width)
votes = Counter(
action.kind
for _, action, _ in candidates
)
if uncertainty(votes) > threshold:
candidates += consider(extra_width)
candidates.sort(
key=lambda item: item[2].rank_key,
reverse=True,
)
for raw, _prepared_action, _score in candidates:
# Re-run the boundary immediately before the real side effect.
current = prepare(raw)
if isinstance(current, Accepted):
return execute(current.action)
return None
preview is required to be side-effect-free or isolated. If a capability has no safe preview, omit speculative execution and score the prepared proposal using whatever non-committing evidence exists.
The second prepare is not redundant. Search may take long enough for authorization, budgets or environment preconditions to change. The candidate that was admissible when generated is only a candidate at commitment time.
Search chooses what to propose next. The action boundary decides whether that proposal is still allowed. Execution happens once.
13. What the tree costs
A branch score of 0.82 is not interpretable without knowing what establishing it cost. One branch needed a single read-only call. Another needed four model calls, three tool calls and a sandbox execution. Node accounting should cover model tokens and calls, tool calls, wall time, sandbox cost, and any consumption of an irreversible-risk budget.
With costs attached, the useful question becomes marginal:
What did the next unit of search buy?
The naive expectation is that beam 2 loses to beam 4 loses to beam 8. Several effects break that monotonicity. A weak evaluator gets more chances to be fooled as the search space grows. Branches become redundant. Search budget can steal compute from final verification. Longer trajectories create more opportunities for ordinary execution error. And the proxy-overoptimisation problem from the candidate-selection chapter reappears inside the tree: a larger candidate space gives a flawed evaluator more opportunities to find something that scores well for the wrong reason.
An illustrative curve of the shape worth measuring:
| Strategy | Verified success | Calls | Tokens | Latency |
|---|---|---|---|---|
| One trajectory | 68% | 8 | 1.0× | 1.0× |
| Best-of-3 | 74% | 18 | 2.1× | 1.8× |
| Beam-2 | 81% | 24 | 2.8× | 2.2× |
| Beam-4 | 83% | 41 | 4.9× | 3.7× |
| Beam-8 | 82% | 73 | 8.4× | 6.2× |
| Adaptive | 82% | 29 | 3.1× | 2.5× |
The numbers are illustrative. The experiment is the point, and the object being sought is the quality–compute frontier rather than the largest tree.
Beam-8 is strictly worse than beam-4 in that example on every reported column.
Raw success also flatters expensive systems, so normalise the bill:
def cost_per_verified_success(
cost_per_run: float,
verified_success_rate: float,
) -> float:
if verified_success_rate <= 0:
return float("inf")
return cost_per_run / verified_success_rate
A system at 80% success and 1 unit per run costs 1.25 units per verified success. A system at 90% and 4 units per run costs about 4.44. The second may be the right system for a high-value incident and the wrong one for millions of low-value requests.
Verification frequency deserves the same treatment. Scoring every branch after every small step can dominate the bill; scoring rarely lets bad branches consume compute before anyone notices. Process-reward and agent-search work increasingly treats intermediate evaluation as a resource to place where it changes allocation rather than as a free constant.[3][4] Reasonable triggers include meaningful state changes, uncertainty spikes, the point before an expensive transformation, or the point where branches begin to diverge materially.
That is an allocation policy to measure, not a universal schedule.
14. The protocol is part of the system
A single-path agent trace records what happened.
A search trace must also record what nearly happened, or the search becomes unauditable the moment it finishes.
Per node, retain at least the identifier and parent, depth, state key, environment checkpoint, proposed action, observation, score components, evidence tier, uncertainty, accumulated cost, whether it was expanded, and, if it was pruned, the stage and reason. With that, four questions become answerable: where the winning branch diverged, why a promising branch was pruned, which evidence dominated its score, and how much compute went to dead futures.
The configuration needs the same treatment. Search has enough degrees of freedom that two runs described as “beam search” can differ by multiples in cost and behavior.
from dataclasses import asdict
import hashlib
import json
@dataclass(frozen=True)
class SearchProtocol:
model_id: str
evaluator_id: str
temperature: float
branch_factor: int
beam_width: int
scoring_frequency: str
prune_threshold: float
uncertainty_threshold: float
max_nodes: int
max_depth: int
tool_budget: int
state_key_version: str
isolation_mode: str
seed: int | None
def fingerprint(self) -> str:
payload = json.dumps(asdict(self), sort_keys=True)
return hashlib.sha256(payload.encode()).hexdigest()[:12]
The state_key_version and isolation_mode belong beside temperature and beam width because they can change the result just as dramatically. A run that merges browser sessions under one key is not the same inference system as a run that keeps them separate.
The 2026 test-time-scaling review makes this broader point explicitly: the evaluated object is the entire inference protocol, not merely the base model, and comparisons are uninterpretable when compute accounting and search procedure are omitted.[6]
A benchmark number without the protocol that produced it is not a reproducible result.
15. Test each policy separately
End-to-end success cannot tell you which of the five policies is failing, so build controlled fixtures for each one.
| Policy | Fixture | Metric |
|---|---|---|
| Expansion | Tasks with a known viable strategy | expansion_recall@k, unique_branch_ratio |
| Evaluation | Known-good and known-bad partial states | Pairwise ranking accuracy, calibration |
| Allocation | Labelled ambiguous decisions | Compute spent vs measured uncertainty, allocation regret |
| Pruning | Tasks with a known viable branch | pruning_regret, survival-to-depth |
| Termination | A known winner plus distractor branches | False-stop rate, excess-search cost |
| End to end | Full task set | Verified success, search gap, cost per verified success |
The termination row matters. A search can have excellent expansion, evaluation and pruning and still waste half its budget after the decision is already settled, or stop one expansion before the evidence would have separated the winner. Those are policy errors, not noise around the final score.
Then run the ablation, holding base model, tool set, task set, final verifier, context and cost envelope fixed:
single trajectory
→ Best-of-N complete trajectories
→ fixed-width search over partial states
→ + objective partial-state evidence
→ + adaptive allocation
→ + equivalence-aware deduplication
Each rung has to earn its complexity against the one below it.
The most informative task set is built around the failure search is supposed to fix: the first plausible route is deliberately misleading. In coding, the obvious file is not the cause. In research, the salient hypothesis is contradicted by primary evidence. In a browser, the first visible button is not the valid next step. In operations, the recent deployment correlates with the incident and did not cause it.
If prefix-level search cannot beat a single-trajectory agent on tasks designed around premature commitment, the implementation has not earned the extra machinery.
16. What search bought
The runtime can now hold several possible futures open, score them with explicit evidence, allocate extra compute where a decision remains unresolved, merge futures only under a declared equivalence relation, and collapse to one action at a boundary that still validates it.
The failure this chapter opened with—twenty competent steps down a path chosen by the first mistaken hypothesis—is now decomposable. Did expansion ever generate the viable route? Did deduplication erase it? Did the evaluator make it uncompetitive? Did pruning kill it? Did allocation starve it? Or did it complete and fail verification?
“Search failed” has become several testable statements.
None of that is a claim that branching makes an agent intrinsically smarter. It is a narrower engineering claim: prefix-level search can improve decisions when useful alternatives genuinely exist, branch state is isolated, partial futures can be evaluated with signals that add information, and additional compute follows evidence rather than habit. Branch diversity, evaluator quality, state equivalence, isolation and economics each cap the benefit independently.
There is also a limit search cannot remove.
A branch can score well at every depth. The frontier can collapse cleanly. The evaluator can be calibrated, the margin stable, the protocol reproducible, and the model can report that the task is complete.
None of those facts establish that the user’s goal was achieved.
Search has answered:
Which future should we commit to?
It has not answered:
What evidence entitles the runtime to call the resulting world a success?
That remaining boundary is the subject of the next chapter.
Research roots
This chapter is an engineering synthesis rather than a survey, and the citations are selective. These works anchor the mechanisms and, more importantly, the cautions.
- Yao et al. — Tree of Thoughts: Deliberate Problem Solving with Large Language Models (NeurIPS 2023). Explores intermediate reasoning states with lookahead and backtracking; cited as the canonical early example of branching before completion.
https://arxiv.org/abs/2305.10601 - Besta et al. — Graph of Thoughts: Solving Elaborate Problems with Large Language Models (AAAI 2024; preprint 2023). Generalises tree-structured reasoning into graph operations including aggregation and reuse of intermediate states; cited for the state-merging discussion in section 10.
https://arxiv.org/abs/2308.09687 - Choudhury — Process Reward Models for LLM Agents: Practical Framework and Directions (2025 preprint). Studies agent process rewards, exploration, reward shaping, test-time scaling and reward hacking; cited for the broader case that intermediate evaluation is part of agent search rather than merely terminal grading.
https://arxiv.org/abs/2502.10325 - Xi et al. — AgentPRM: Process Reward Models for LLM Agents via Step-Wise Promise and Progress (2025 preprint). Evaluates intermediate agent decisions through goal proximity and progress, and applies the resulting process model to step-level search; cited for the
promise/progresssplit in section 7.
https://arxiv.org/abs/2511.08325 - Lee et al. — Agentic Test-Time Scaling for WebAgents (2026 preprint). Finds diminishing returns from uniform per-step sampling and introduces confidence-aware allocation using vote entropy and top-two margin; cited for the adaptive allocation discussion in section 8.
https://arxiv.org/abs/2602.12276 - Hariri et al. — Test-Time Scaling in Reasoning LLMs: Inference Regimes, Evaluation, and Reproducibility (2026 preprint). Distinguishes sequential, leaf-level and prefix-level inference and argues that the complete inference protocol is the evaluated system; cited for the regime table and protocol record.
https://arxiv.org/abs/2608.04001
Next: Evidence and Verification
Search produces a winner. It does not produce a result.
Every signal used to pick that winner came from inside the system: an expansion policy that proposed it, an evaluator that scored it, a margin that stayed stable across one more expansion. Those are good reasons to stop searching. They are not reasons to believe the goal was achieved.
The gap between the two is where agents fail most expensively, because a confident wrong answer costs more than an admitted failure.
The next chapter moves truth outside the model. It separates the completion proposal from the evaluator score from the runtime observation from external evidence, and asks what evidence would have to exist before a runtime is entitled to record a success — including what happens when the verifier itself is the thing under attack.