Bridle
Concepts

Calls and lazy resolution

Every primitive returns a Call — a lazy description of work that resolves on first use.

step, branch, loop, every wrapper, every @agent invocation — they all return the same thing: a Call. A Call describes work to do. It does not do the work until something reads its value.

This page covers what that means and the gotchas it creates.

A Call is a description, not a result

plan = step("draft a plan", schema=Plan, context=topic)
# Nothing has hit the model yet. plan is a Call.

The model fires the moment you read plan:

print(plan.topics)            # resolves now
for t in plan.topics: ...     # already resolved; reuses the cached result

Once resolved, the value is cached on the Call. Reading it again does not re-run the model. That is why control flow inside an agent reads as plain Python: each value is computed once, on demand, and reused after.

Three ways resolution fires

A Call resolves when:

  1. You read an attribute. plan.topics calls __getattr__, which forces resolution.
  2. You iterate, take len, or use it as a bool. for x in plan.topics, if branch(...), len(sources).
  3. You call bridle.resolve(call). Explicit forcing. Returns the underlying value.

For values you have already finished computing — e.g. you want the typed result to return from an agent — resolve is the readable choice:

return bridle.resolve(step("write the brief", schema=Brief, context=...))

When the call is the condition in an if or the iterable in a for, you do not need resolve. Just use it:

if branch("is the evidence sufficient?", context=sources):
    ...

Why lazy

Three reasons.

Wrappers compose without changing the call. cache(retry(step(...))) works because each wrapper takes a Call and returns a new Call. Nothing has run yet — the layers are still being added.

The trace stays clean. Resolution opens a call_start event in the trace and closes a call_end when it finishes. Lazy means the boundary is well-defined: you set up the call, then resolve it, then the events are nested correctly.

You can mock anywhere. mock(call, value) substitutes a constant for the inner dispatch without re-running anything. Tests assert on shape, not on side effects.

The truthiness gotcha

A Call is truthy or falsy based on its resolved value. That is exactly what you want for branch:

if branch("is the evidence sufficient?", context=sources):
    # branch resolved to True

It is exactly what you do not want when you check whether a call object exists:

Watch out: do not test a Call for None or truthiness to see if it ran

A Call is always a Call instance — never None — but checking it with if call: will force resolution. The same goes for bool(call) and if not call:.

maybe = step("...", schema=str)
if maybe:                # WRONG — this triggers a model call
    ...

If you need to check whether a call has been built, check the variable explicitly against None (when you assign None as a sentinel) or use isinstance(maybe, bridle.Call).

Wrappers preserve laziness

Every wrapper returns a new, unresolved Call. You can stack them, store them, pass them across function boundaries:

draft = retry(
    timeout(step("write the brief", schema=Brief, context=ctx), seconds=30),
    attempts=2,
)
# Still nothing has run. draft is a Call.

return bridle.resolve(draft)  # runs the whole stack now

Inside retry, the inner call is cloned before each attempt so the cached result of one failed attempt does not leak into the next. The outer Call you held remains a description.

Equality and identity

Two Calls with the same fields are not equal. Equality is identity-based:

a = step("...", schema=str)
b = step("...", schema=str)
a == b      # False — same fields, different Calls
a == a      # True

Each Call represents a distinct unit of work. If you want them to share a result, pass the same Call around — or wrap once with cache and use that.

The trade

Lazy resolution makes wrappers compose, traces nest cleanly, and tests mock without surgery. The cost is that you have to keep one rule in mind: use the value, not the call. Do not test a Call for "did it run" — read the result, or assert on the trace.

Next

On this page