Loops
Repeatedly produce typed values until a Python predicate is satisfied.
loop runs an inner step over and over, accumulating typed results, until your predicate returns True. Iteration is bounded — there is a hard cap, and exhausting it raises LoopExhaustedError.
Signature
from bridle import loop
loop(
prompt: str,
*,
schema: type[T],
until: Callable[[list[T]], bool],
context: Any = None,
tools: Sequence[Tool] = (),
max_iterations: int = 32,
label: str | None = None,
) -> list[T]Returns a Call typed as list[T]. Resolves on iteration, length, or attribute access.
The shape
sources = loop(
"gather a source on Mars weather",
schema=Source,
until=lambda acc: len(acc) >= 3,
tools=[search],
)Each iteration:
- Runs an inner
stepwith the originalcontextplus a running accumulator. - Appends the result to the accumulator.
- Calls
until(accumulator). If it returns True, returns the accumulator.
The accumulator is built in pure Python. The model never sees the predicate — it just keeps producing the next value.
Bounded by design
max_iterations (default 32) caps the number of steps. If the predicate has not fired by then, loop raises LoopExhaustedError carrying the partial accumulator:
from bridle import LoopExhaustedError
try:
sources = loop(
"gather a source on a niche topic",
schema=Source,
until=lambda acc: len(acc) >= 100,
max_iterations=10,
)
except LoopExhaustedError as exc:
partial: list = exc.accumulator
# Decide what to do with the partial result.Set max_iterations to whatever ceiling you actually want. Smaller is safer.
What until should do
Three rules.
Pure Python, no model calls. The predicate runs after each iteration; making it a model call would defeat the point. Hold any model judgment inside the inner step's prompt.
Read the accumulator, not state outside it. The predicate sees only the list of results so far. If you need to compare against external state, capture it via closure:
seen_urls: set[str] = set()
def enough(acc: list[Source]) -> bool:
for s in acc:
seen_urls.add(s.url)
return len(seen_urls) >= 5
sources = loop("gather a source", schema=Source, until=enough, tools=[search])Cheap. It runs after every iteration. Don't do anything expensive in it.
Each iteration sees previous results
The inner step's context is {"original_context": ..., "previous_results": [...], "iteration": N}. The model sees what has been gathered so far and can avoid duplicates. If duplicates do slip through, dedupe in the predicate or post-process the accumulator after the loop returns.
Pitfalls
Predicates that never return True. If the predicate cannot be satisfied with the schema's values (e.g. "until the model produces something with summary='exact match'"), the loop runs to max_iterations and raises. Make the predicate something the next iteration can plausibly achieve.
Schemas that drift over iterations. The schema is fixed for the loop. If you need the structure to change ("first three are summaries, last one is a synthesis"), you want a step after the loop, not a different schema mid-loop.
Using loop for parallelism. Loop iterations are sequential. Concurrent gathering arrives with the parallel primitive in v0.2.
A complete example
import bridle
from bridle import agent, loop, tool
from bridle.models.anthropic import install
from pydantic import BaseModel
class Source(BaseModel):
url: str
summary: str
@tool
def search(query: str) -> list[str]:
"""Search the web. Returns up to 10 result URLs."""
slug = query.lower().replace(" ", "-")[:40]
return [f"https://example.com/{slug}/{i}" for i in range(5)]
@agent(input=str, output=list[Source], model="claude-sonnet-4-6")
def gather(topic: str) -> list[Source]:
return loop(
f"gather a distinct source on {topic}",
schema=Source,
until=lambda acc: len({s.url for s in acc}) >= 3,
tools=[search],
max_iterations=8,
)
install()
sources = bridle.resolve(gather("Mars weather"))
for s in sources:
print(f"- {s.url}: {s.summary}")