Chapter 09 of 11

Trajectory Search

Concepts

WHAT YOU NEED TO KNOW

Trajectory search preserves several partial futures of the agent long enough for evidence to distinguish them. It is search over possible continuations of the agent itself.

PREFIX-LEVEL TEST-TIME SCALING

sequential
extend one trajectory

leaf-level
generate complete alternatives, then select

prefix-level
branch before completion and selectively continue promising partial states

This chapter is about the third regime.

PARTIAL FUTURES VS COMPLETE CANDIDATES

Best-of-N pays for several complete proposals. Trajectory search can stop spending on weak branches before they finish, which matters when a trajectory includes many tool calls or environment interactions.

NODE = RESUMABLE STATE

A search node is not a sentence describing an approach. It must contain enough agent state—and, where necessary, environment state—to resume the branch without reconstructing it from prose.

BRANCH ISOLATION

Sibling branches must not contaminate one another. A branch that edits a file, browser session, database, or other mutable environment needs an isolated checkpoint or sandbox.

BRANCH FREELY, COMMIT ONCE

Proposals, validation, simulation, previews, and sandboxed effects can branch. Real irreversible execution remains single-valued. After search collapses the frontier, authorization and preconditions are checked again before the winning action executes once.

SEARCH DOES NOT GRANT AUTHORITY

A high branch score means “worth continuing” or “preferred among explored futures.” It does not mean the branch has permission to mutate the real environment.

FIVE SEARCH POLICIES

Trajectory search decomposes into five responsibilities:

expansion
evaluation
allocation
pruning
termination

Different algorithms bundle these differently, but the responsibilities remain separately measurable.

DISTINCT FUTURES, NOT DISTINCT WORDING

A nominal branch factor of eight is useless if the branches are paraphrases of the same idea. Expansion quality is about semantic or state diversity, not string diversity.

PROMISE VS PROGRESS

A partial branch can be promising without having made measurable progress, or can show local progress while introducing regressions. Keep these signals distinct.

ALLOCATE COMPUTE WHERE THE DECISION IS UNRESOLVED

Fixed width is a policy, not a law. Extra compute is most useful where the frontier remains uncertain or evidence has not yet separated the alternatives.

PRUNING REGRET

Pruning can discard a branch that could have reached success. Search quality therefore depends not only on what it selects at the end, but on whether it killed viable futures too early.

Explain this chapter with AI

Copy this prompt into ChatGPT, Claude, Gemini, a local model, or another AI.

Apply this chapter with AI

Copy this prompt into ChatGPT, Claude, Gemini, a local model, or another AI.

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:

  1. Calibrate uncertainty against downstream outcomes. Do not assume entropy predicts difficulty because it has the right shape.
  2. 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.

  1. 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
  2. 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
  3. 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
  4. 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/progress split in section 7.
    https://arxiv.org/abs/2511.08325
  5. 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
  6. 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.