examples/stories/refund_desk/README.md
Resolver dependency injection: a tool parameter annotated Annotated[T, Resolve(fn)] is filled by running the resolver fn before the tool body,
instead of from the LLM-supplied arguments. Here refund_order(order_id, reason) refunds what the order record says — cents is resolver-computed and
does not appear in the input schema at all, so the model cannot supply or
inflate the amount. Resolvers form a DAG (load_order → refund_scope →
refund_amount / ask_restock), may return Elicit[...] to ask the human,
and ask each question at most once per call. A resolver's own plain
parameters are filled from the tool's arguments by name —
load_order(order_id) receives the order_id the model passed to
refund_order.
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.refund_desk.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it
# down (2026 protocol: the questions ride embedded input_required round-trips;
# add --legacy to ride synchronous push elicitation instead)
uv run python -m stories.refund_desk.client --http
server.py refund_order — the signature is the whole story: order_id and
reason are model-facing; cents and restock carry Resolve(...) markers
and never reach the input schema. client.py asserts properties and
required are exactly {order_id, reason}. At 2026 the resolver's elicited
answers ride between rounds inside a requestState the SDK seals by default;
see mrtr/ for the full security walk-through.server.py refund_scope — the no-round-trip fast path: a one-line order
returns Scope(full=True) directly; only a multi-line order returns
Elicit(...). The ORD-7001 call completes with zero elicitations.server.py _scoped — the elicited SKU is human-typed free text; it is
validated against the order (ToolError on a miss) before any amount is
computed.refund_amount takes scope unwrapped, so
declining the scope question aborts the whole cents chain with an error
containing the framework's
Resolver for parameter 'scope' could not resolve: elicitation was decline
(the client sees it behind the usual Error executing tool refund_order:
prefix); restock keeps the ElicitationResult union, so declining restock
still refunds — just with restocked: false.client.py — the scope counter proves memoization from outside: one call
consumes refund_scope from two resolvers but the question fires once.input_required round-trips (a resolver that depends on another's answer is
asked in a later round); at <= 2025-11-25 each is a synchronous
elicitation/create push request mid-call. Author code is identical on
both — this client runs unchanged on either era.cents resolves before restock, so ask_restock
never runs. Don't rely on a later resolver's side effects after an earlier
consumer can abort.requestState); any
resolver's body may run again on each round the call passes through. A
recorded answer is consulted only when the resolver asks its question again:
it satisfies the question without re-prompting the user, and it never stands
in for a value the resolver computes itself.
An answer is matched back to its question when the call resumes, so an
eliciting resolver must derive its question deterministically from the
tool's arguments and earlier answers; a per-call generated value (a
default_factory id, a timestamp) is re-derived each round and must not
appear in a question the answer is meant to bind to. Nothing is cached
across calls or connections._scoped does) before acting on them.Elicitation — client features, Input required tool results — server features
mrtr/ (the 2026 input_required carrier these questions ride at
= 2026-07-28),
legacy_elicitation/(the push mechanism they ride on handshake-era connections).