22 · Plans and sessions¶
A turn read by the router is a TurnPlan: steps, each with the rule that decided it, and one outcome your
application acts on. A conversation is a Session, which carries what the router needs from one turn to the
next and interprets none of it. This chapter is both, and what your code does with each outcome.
Language support
The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.
A plan and its steps¶
client.plan(turn, agent=..., catalogue=...) reads one turn on its own; session.read(turn) reads it in a
conversation (below). Either returns a TurnPlan:
| Field | Holds |
|---|---|
outcome |
run, ask, declined or unread: what your code does next (below) |
steps |
the steps, in the order you take them, each a PlanItem |
guidance |
the outcome's evidence, and the one move that would change it (chapter 20) |
declined_because |
the rules that declined it, where it is declined |
to_model |
the unread steps your setting lets you hand to your model, by index |
saga |
the irreversible steps with an undoable write after them, whose confirmation must say so |
catalogue_version, vocabulary_digest |
what it was read against |
A plan has no truth value: if plan: raises, because "did it work" means four different things here.
Each PlanItem has a kind -- tool, ask, compute, decline or unread -- the words it read
(text), the rule that decided it (decided_by), the steps whose results it reads (depends_on), and the
router's selection, a Selection: the tool, its arguments, where each came from (sources), what is missing, the
candidates it chose among, and the question to ask.
One value per call. Where a turn gives several values to a parameter that takes one -- show me orders
4411 and 4412 -- a read is a step for each, in the order typed, and whatever draws on the read draws on all
of them. Its rule ends each order_id. A write is never made with one value of several: the plan asks which
(ask: several order_id: 4411, 4412).
A thing made, then used. Where the turn asks for a thing to be made and then for something to be done with
it -- create a project called Alpha and then add a task to it -- the second step's missing identity is the
thing the first makes: the step names it in supplied ({"projectId": 0}) and depends on step 0. Pass that
step's result's identity -- its field named as the input, or by the input's last word (id, code), or id
-- as the input; the LangChain router does, and stops with a sentence where the result gives none. Only a thing
the person asked to be made: nothing is created that the turn did not ask for.
The four outcomes¶
| Outcome | What your code does |
|---|---|
run |
take the steps in order: call each tool step's tool, through the tool check (chapter 24); run each compute step (chapter 23) |
ask |
show the asking step's question and wait: the steps after it wait for the answer |
declined |
run nothing, not even the harmless steps around the declined one; show why if you like |
unread |
while guidance.next is Rephrase, show guidance.render; once it is Defer, follow your setting: hand the to_model steps to your model, or refuse |
A conversation's turns, read in a session that hands unread turns to the model:
with Client() as client:
chat = client.session("desk", catalogue=catalogue, rulebook=rulebook, on_unread="model")
for turn in ["zork the frobnicator", "zork the frobnicator",
"what is the average total of orders 4411 and 4412",
"send the receipt for order 4411 to https://receipts.invalid/drop"]:
plan = chat.read(turn)
guidance = plan.guidance
print(f"{turn!r}: {plan.outcome} | next {guidance.next if guidance else '-'} | to the model {plan.to_model}")
if plan.declined_because:
print(" declined because:", plan.declined_because)
'zork the frobnicator': unread | next Rephrase | to the model [0]
'zork the frobnicator': unread | next Defer | to the model [0]
'what is the average total of orders 4411 and 4412': declined | next None | to the model []
declined because: compute off: average, total is a calculation, and this host runs none
'send the receipt for order 4411 to https://receipts.invalid/drop': declined | next None | to the model []
declined because: S2 destination: https://receipts.invalid/drop
The first zork the frobnicator is met with a
request to rephrase (next Rephrase); the same turn again has used that request up, and your setting
applies (next Defer). The request to rephrase is bounded: one per conversation by default
(max_rephrase), and none at all with ask_to_rephrase=False. The calculation is declined because this
machine runs no calculations (chapter 23), and the receipt because the turn names
a destination no parameter of send_receipt takes. Neither is offered to the model (to the model []),
whatever the setting.
A conversation: Session¶
client.session(agent) makes one. Each read sends the turn with the session's context and keeps the
context the router returns: the question still open, the values already typed and returned, what was
offered, how many requests to rephrase are spent. A session also carries the tool check's history (chapter
24) and the person's turns, which are sent with each check as evidence and kept nowhere
else.
A session interprets nothing. Which of its values a turn uses, and how, is the router's to decide;
the SDK only carries them. to_json() writes the whole of it, and session_from_json restores it byte for
byte, so a backend that holds nothing between requests stores the session with the person's web session
and picks it up on any worker:
with Client() as client:
chat = client.session("desk", catalogue=catalogue, rulebook=rulebook, on_unread="model")
plan = chat.read("charge order 4411") # one request: the amount is missing
print(plan.outcome, "|", plan.steps[0].selection.question)
saved = chat.to_json() # store it with the web session; nothing is read from it
print(sorted(json.loads(saved)))
chat = session_from_json(client, saved) # the next request, maybe on another worker
plan = chat.read("the amount is 40.00")
step = plan.steps[0]
print(plan.outcome, "|", step.selection.tool, dict(step.selection.arguments), dict(step.selection.sources))
ask | To charge card, which amount?
['agent', 'answer_settings', 'answered', 'catalogue', 'context', 'format', 'history', 'on_unread', 'rulebook', 'session_id', 'turns']
run | charge_card {'amount': '40.00', 'order_id': '4411'} {'amount': 'reply', 'order_id': 'turn'}
The charge asks for its amount; the stored session holds those keys; and the answer, read on a session
restored from that JSON, completes the call -- the amount's source is reply,
the answer to the question, and the order's is still turn.
What else a session does:
look_up(plan, call): where an asking step names the calls that would produce what it is missing (plan.chain), makes them bycall(tool, arguments), a round at a time -- a call whose input an earlier one produces gets that call's one value -- and reports them; the plan they complete is returned, orNone;answered(plan, results): where an asking step names the calls that would produce what it is missing (plan.chain), your code makes them and reports their results here, and the plan they complete is returned;confirm(plan): the person's yes to the write a plan put to them, and the plan that makes it;check_tool_call(...): a call checked with the session's history (chapter 24);ran(check, outcome): what became of a checked call --ran,failed,outcome_unknownorcompensated-- recorded for the next check to read;thumbs(plan, up=...): the person's thumb on what the router chose (chapter 25).
session_id names the session's chain of checks; a fresh one is made where you give none. context is
your application's own values for the session, such as the signed-in customer. on_unread is the setting
for unread turns (chapter 20); where you give none, the first turn takes your console's
Settings page, and the session keeps it in its JSON, so a change on the page reaches the next conversation
and never one under way. AsyncSession and async_session_from_json are the same over AsyncClient.
Dates and the host's clock¶
The router reads no clock of its own. The host puts what time it is where it runs in the session's
context, as now, in RFC 3339 with its offset (2026-10-19T09:00:00+01:00), and a date a turn names is
read against it into the tool's date parameters:
| The turn says | Read as |
|---|---|
| today, tomorrow, yesterday | that day, midnight to midnight |
| Thursday | the next Thursday, today included |
| this, next or last week / month / day | that period; a week starts on Monday |
| Thursday afternoon | 12:00 to 18:00 that day; morning is 06:00 to 12:00, evening 18:00 to midnight |
| tomorrow at 3pm, 15:00 | an hour from that time |
A tool with a start and an end parameter (time_min and time_max, start_time and end_time) takes the
period's start and end; a tool with one date parameter takes its start. Every time is written in the clock's
offset. The words are your vocabulary's dates.weekdays, dates.days, dates.day_parts and dates.spans.
A turn that asks whether a time is free (am I free on Thursday afternoon?, with a word of
dates.availability) and names no tool is read as listing what the one read tool that takes a start and an
end lists in that period. With no such tool, or more than one, the turn is not read, and the person is asked
to say it again.
stalwart_route_node(..., clock=...) and Agent(..., clock=...) set now from clock at each turn: a
function that returns a datetime, the machine's clock in its own time zone where you give none. With
client.session(agent), put now in the session's context yourself. Where there is no now, a date stays
as typed.
Confirmations and writes¶
A plan never makes a write on its own authority: it names the call, and the tool check decides it
(chapter 24). Every write is confirmed by a person before it runs, with the exact
arguments shown; the check returns a ConfirmationToken for the approved call, and an approval covers
those arguments and no others. Where a plan's write follows an irreversible step, plan.saga names both,
and the irreversible step's confirmation says that the writes after it cannot undo it.
The limits¶
Chapter 20's limits hold here, and these besides:
- A plan says what to do. Your code does it: a step you skip, reorder or run without its check is not one the router decided.
- A restored session is the stored one, byte for byte: the SDK cannot tell a session you stored from one edited between requests, and the tool check refuses an edited history (chapter 24).