Poly · planning

Planning with poly: a travel booking agent

An agent books a trip: a flight and a hotel, within a budget. A model chooses the steps. Halfway through, the airline cancels the flight and the agent plans again. This article shows what it means for poly to support planning, and the steps to write your own planner.

The planning problem

A plan written by a model is a path nobody wrote down in advance. Tests try the plans someone imagined: book the flight, then the hotel. They cannot try every order the model may choose, every answer a booking service may give, and every moment the airline may cancel. Yet the questions that matter are about all of them. Can the trip end with a hotel and no flight? Can the agent spend more than the budget? Can a cancellation be left unfinished?

Planning in poly

In poly, the model plans one step at a time, and the program owns the rules.

  1. The model proposes the next step, chosen from a fixed list.
  2. The program accepts the step only if its rules allow it. Otherwise it asks the traveller.
  3. Each booking is an outside call, declared with how it can fail and whether it can be undone.
  4. When the world changes, the program undoes what the old plan left behind. The model does not decide that.
The model proposes the next step The program checks its preconditions Chose(step, conf) The gate applies the policy emit a booking Traveller unsure or refused Booking services airline, hotel allowed escalated Trip state what is held, what is spent booked, refused, or no answer next decision

The model never calls a service. It proposes, the program decides, and the gate applies the traveller's policy.

Because the model's answer is a value from a fixed list, the check can try every value at every step. It explores every state the trip can reach, whatever the model picks. That is what "supports planning" means: the model plans freely, and the proof covers every plan it could make.

The steps to write your own planner

The code below comes from a small program written for this article and checked with poly v1.4: CLEAN, 162 states. Only the parts that matter are shown.

1The plan as a choice from a fixed list

Do not ask the model for a whole plan in free text. Ask it for the next step, as a value of an enum. The answer comes back as an action, with a confidence.

enum Step { BookFlight, BookHotel, Finish } decision next(request: Content) -> Step {  provider "small"  labels { egress:external, read:private }  timeout 2s  ok(r) -> Chose(step: r.answer, conf: r.conf)  failed -> Unsure()}

2The rules as conditions on each step

Each step has conditions: no hotel before a flight, one hotel at a time, enough budget left, a confident answer. A step that breaks a rule goes to the traveller, not to a service. The hotel branch is shown; the flight branch has the same shape.

on Chose(step, conf) {  require phase == Asking else reject "not-asking"  if conf >= 0.8 && step == BookHotel && flight == Held && hotel == None && spent <= 3 {    hotel' = Booking    spent' = spent + 2    phase' = Planning    emit bookHotel()  } else {    phase' = AskTraveller        // unsure, or a step the rules do not allow now  }}

3The trip as a small state

Record what the world holds: each booking and what has been spent. Keep the ranges small. Money in units of 100 dollars, from 0 to 8, is enough to prove the budget rule, and the check stays fast.

enum Slot { None, Booking, Held, Unknown, Cancelling } state {  phase  : Phase = Planning  flight : Slot  = None  hotel  : Slot  = None  spent  : 0..8  = 0             // units of 100 dollars}

4Each booking call with its risk

A non-refundable flight cannot be undone. If the call times out, the seat may be booked, so a retry could buy a second ticket: send it once. A hotel that honors an idempotency key can be retried safely, and it can be cancelled. The check refuses an irreversible call that may be sent twice (PL4003).

effect bookFlight() {  kind "airline.book"  labels { egress:external }  targets { egress: "https://airline.example/v1/bookings" }  class irreversible  retry { attempts 1, base 200ms, timeout 10s }  ok -> FlightBooked()  failed -> FlightRefused()  exhausted -> FlightUnknown()} effect bookHotel() {  kind "hotel.book"  labels { egress:external }  targets { egress: "https://hotels.example/v1/reservations" }  class compensable  idempotent header "Idempotency-Key" window 24h  ok -> HotelBooked()  failed -> HotelRefused()}

5A person for the unknown outcome

When the one attempt gets no answer, the program does not guess. The booking moves to Unknown, and a person looks it up.

on FlightUnknown {  require flight == Booking else reject "not-booking"  flight' = Unknown              // the seat may be bought: nothing is sent again} on Resolve(booked) {  require flight == Unknown else reject "not-unknown"  if booked {    flight' = Held  } else {    flight' = None    spent' = spent - 3           // not bought: the budget comes back  }}

6Re-planning without forgetting

This step makes planning safe. When the airline cancels, the old plan leaves a hotel for the wrong dates. The program cancels it and gives the budget back, then the model plans again. A hotel booking still in progress when the flight is cancelled is cancelled when it arrives.

None Booking Chose(step) model refused Held booked Unknown no answer Cancelling FlightCancelled program, not model cancelled Resolve(false) Resolve(true)

The life of one booking. The red arrow is the re-plan: the program, not the model, moves a held booking to Cancelling.

on FlightCancelled {  require flight == Held else reject "no-flight"  flight' = None  spent' = spent - 3             // the airline refunds the ticket  if hotel == Held {    hotel' = Cancelling    emit cancelHotel()  }  phase' = Planning} on HotelBooked {  require hotel == Booking else reject "not-booking"  if flight == Held {    hotel' = Held  } else {    hotel' = Cancelling          // the flight was cancelled while the hotel was booking    emit cancelHotel()  }}

7The properties

Write down what must always be true and what must eventually happen. The check proves both over every plan. A policy can add what needs the traveller's approval, such as a booking above a price; the gate applies it when the program runs.

invariant no-hotel-without-flight: hotel == Held implies flight == Heldinvariant within-budget: spent <= 5liveness cancels-finish:  hotel == Cancelling leads to hotel == None || hotel == Unknown  under fair { HotelCancelled, CancelFailed }

The check on our own sketch

The first version of this program looked right. The check found four defects in it, each with the shortest path that shows it.

Property brokenPathWhat happenedFix
no-hotel-without-flight7 stepsThe airline cancels while the hotel booking is in progress. The hotel is confirmed after the flight is gone.The hotel's answer checks the flight (step 6).
within-budget6 stepsA flight with no answer turns out not to be bought, but its cost stays in the budget. The next flight goes over.Resolve gives the budget back (step 5).
within-budget6 stepsThe airline cancels, its cost is not given back, and the budget rule allowed 3 + 3 against a limit of 5.The cancellation gives the budget back; the rule allows a flight only with 2 or less spent.
cancels-finishloop of 10The model says Finish while a hotel cancellation is still pending. The trip ends and the cancellation is never confirmed.Finish is allowed only when no booking or cancellation is in progress.

The first path is a race between two services, the kind of path tests rarely reach:

Plan → Chose(BookFlight) → Plan → FlightBooked → Chose(BookHotel) → FlightCancelled → HotelBooked

After the fixes the check is CLEAN. A coding agent can follow the same loop: each finding is a code and a path it can repair against.

Conclusion

An agent that plans is a goal-oriented system: it chooses its own steps, along paths nobody wrote down. poly does not limit the model's choices. It limits what a choice can do. The model picks the next step from a fixed list. The program checks the rules, makes the calls, and undoes what an old plan left behind. Because every choice is a value the check can try, the proof covers every plan the model could make, including the re-plan after a cancelled flight. Our own sketch had four defects that looked right on reading; the check found all four. Write your planner in seven steps, and let the check find the paths you did not think of.

Glossary

Decision
A typed question put to a model. The answer is a value of a declared type, returned with a confidence.
Effect
A call to the outside world, declared with whether it can be undone and how often it may be retried.
Irreversible
An effect that cannot be undone, such as a non-refundable ticket.
Compensable
An effect that can be undone by another call, such as a hotel cancellation.
Idempotency key
A value sent with each attempt, so a service that honors it performs the request once.
Invariant
A property that must hold in every state.
Liveness
A property that something eventually happens, assuming the services answer.
Finding
A property the check shows to be broken, with a code and the shortest path that breaks it.
Policy
Signed rules the gate applies when the program runs: allow, deny, or ask a person.
Gate
The part of the runtime that applies the policy to every call before it is made.
Re-plan
A new plan made after the world changed, starting from what the old plan left behind.