A collection conversation has one hard requirement — know when it is finished — and one hard failure — feeling like a form read aloud. Both are solved in the same place: a slot state your code owns, sent into every turn, with completion defined as a condition over it.
The prompt
You are gathering the information in <slots> from the person you are talking
to. This is a conversation, not a form.
<slots>
name required free text
company_size required one of: 1-9, 10-49, 50-249, 250+
current_tool required free text; "none" is a valid answer
timeline required one of: now, this_quarter, this_year, exploring
budget_band optional one of: under_1k, 1k_5k, 5k_20k, over_20k
email required a valid email address
</slots>
<filled> is what has already been established. Trust it.
Rules for the conversation:
- Ask at most two things per turn, and only when they naturally belong
together.
- Never re-ask anything in <filled>. If someone answered in passing, however
loosely, fill it and confirm it inside your next sentence rather than
asking again: "Sounds like around 30 people - I'll put you down as 10 to
49." Then move on.
- If they ask you a question, answer it first. Their question outranks your
next slot. Return to the slot afterwards, in the same turn if it fits.
- If they decline a slot, mark it declined and never raise it again. Do not
rephrase and retry.
- If an answer does not fit an enumerated slot, offer the nearest options in
your own words. Never read the list out.
- Do not say "I just need a few more details" more than once.
- Do not thank them for every answer.
Return, every turn:
{"say": "<what to send them>",
"slots": {"<slot>": {"value": <value or null>,
"status": "filled" | "inferred" | "declined" | "empty",
"source": "<verbatim span from their message, or null>"}},
"complete": true | false,
"blocked_on": "<what is stopping completion, or null>"}
Status rules:
- "filled" they stated it. "source" is the span where they did.
- "inferred" you derived it from something they said. "source" is the span
you derived it from, and it is REQUIRED. An inferred slot with
no source is not allowed; use "empty".
- "declined" they refused or deflected twice. Terminal; never ask again.
- "empty" not yet established.
"complete" is true when every required slot is "filled" or "declined", and
no slot is "inferred" without a source. It is not true because the
conversation feels finished.
<filled>{{slot_state_json}}</filled>
<history>{{last_n_turns}}</history>
Their latest message: {{message}}
Slot state lives in your code
This is the architectural point and it is the one that decides whether the thing works at turn 20.
If the only record of what has been collected is the conversation transcript, then every turn the model must re-derive the state by re-reading the whole conversation. That re-derivation is a task with an error rate, it is performed again on every turn, and its errors are sticky: a slot mistakenly re-derived as empty gets asked again, the person answers with irritation, and now the transcript contains an exchange that makes the next derivation harder.
Instead: your code keeps the slot dictionary. Each turn you send it in filled, the model returns an updated one, and you merge with your own rules — never regressing a filled slot to empty, never overwriting a declined.
TERMINAL = {"filled", "declined"}
def merge(current: dict, proposed: dict) -> dict:
"""The model proposes. Your code decides. Never lose a settled slot."""
out = dict(current)
for name, new in proposed.items():
old = out.get(name, {"status": "empty", "value": None, "source": None})
if old["status"] == "declined":
continue # terminal, always
if old["status"] == "filled" and new["status"] != "filled":
continue # never regress
if new["status"] == "inferred" and not new.get("source"):
continue # unsourced inference is not state
out[name] = new
return out
def complete(state: dict, schema: dict) -> bool:
return all(state.get(k, {}).get("status") in TERMINAL
for k, spec in schema.items() if spec["required"])
With this in place you can also truncate the history aggressively — the last six turns is usually plenty — because the state is not in it. That keeps the prompt short and the cost flat as the conversation grows, instead of both climbing with every turn.
Filled, inferred, declined
| Status | Description |
|---|---|
| filled | They stated it. The source span is where. Safe to use without confirmation. |
| inferred | You derived it. “We’re about thirty people” gives company_size = 10-49 — a correct inference and still an inference. Requiring the source span makes it reviewable, and it is the status your code should confirm out loud rather than silently accept. |
| declined | Terminal, and terminal means terminal. The most common way these conversations become unpleasant is a slot that is asked again in a new phrasing after a refusal. One line in the prompt, one branch in the merge, and it never happens. |
| empty | Not yet established. The only status the model may move away from freely. |
Collapsing inferred into filled is the tempting simplification and it costs you the ability to confirm selectively. You cannot confirm everything — that is the interrogation failure — and you should not confirm nothing. Confirming inferences only is the right amount, and it needs the distinction to exist.
Not an interrogation
Four rules carry this, and the second is the one that matters most.
- At most two slots per turn. Three or more reads as a form. Two that belong together — name and company, timeline and budget — reads as a conversation.
- Their question outranks your slot. The single difference between a conversation and an interrogation is whether questions can go the other way. A model that acknowledges a question and continues collecting is worse than a form, because it is a form pretending.
- Fill from passing mentions and confirm inline. Asking for something the person has already told you is the failure they notice and remember. The example phrasing in the prompt — state the inference, then move on — does this in one clause and gives them a chance to correct it without a question mark.
- Never read the enumeration out. “Are you 1 to 9, 10 to 49, 50 to 249, or 250 plus?” is a database schema spoken aloud. “Roughly how many people are you?” gets the same information and lets the model do the bucketing.
Add a stall escape in your loop rather than the prompt: after three turns with no new slot reaching a terminal status, stop and hand over with what you have. Without it, a conversation with someone who will not give an email continues indefinitely, and every turn costs money and goodwill.
The completion criterion
“Complete when the conversation feels finished” is not a criterion, and a model asked to judge it will end early on a cooperative person and late on a chatty one. The condition here is computable, and your code computes it — the model’s complete field is a hint you check rather than a decision you accept.
Two clauses, both load-bearing. Every required slot must be terminal: declined counts, because a person who will not give a budget has finished that slot as definitively as one who gave it. And no slot may be inferred without a source, which prevents completion by assumption — the failure where the conversation ends with a full-looking record whose fields nobody said.
Optional slots are not in the condition at all. If an optional slot blocks completion, it was not optional; delete the distinction or fix the schema.
Four turns, with the state
The state after each turn is what makes the behaviour legible. Watch three slots fill without being asked for, one question go the other way, and one slot decline.
turn 1
them: "Hi - we're a 30-person agency in Rotterdam, currently on Trello,
and honestly it's falling apart. I'm Sanne."
state after:
name filled "Sanne" <- "I'm Sanne."
company_size inferred "10-49" <- "we're a 30-person agency"
current_tool filled "Trello" <- "currently on Trello"
timeline empty
email empty
say: "Thanks Sanne - I'll put you down as 10 to 49 people, and Trello as
what you're on now. Roughly when are you hoping to have moved?"
Three slots from one message, none of them asked for. company_size is
inferred, so it is confirmed out loud in the same sentence; the other two
are filled and are not.
turn 2
them: "This quarter ideally. Does your import handle Trello attachments?
That's the thing that's stopped us before."
state after: timeline filled "this_quarter" <- "This quarter ideally"
say: "It does - attachments come across with the cards, including ones over
the 10 MB Trello limit if they were added by URL. Since you're moving
this quarter, what's the best email to send the migration checklist
to?"
Their question is answered first and concretely. The slot follows in the
same turn, attached to a reason they would want to give it.
turn 3
them: "[email protected]. I'd rather not get into budget at this stage."
state after:
email filled "[email protected]"
budget_band declined
say: "Sent. No problem on budget - I've left it out."
budget_band is terminal. It is optional anyway, so it was never going to
block completion, but the declined status is what stops a later turn
circling back to it.
turn 4
complete: true
every required slot is filled; company_size is inferred WITH a source.
Four turns, one of which was them asking a question.
The turn-2 exchange is the one to copy. The slot request is attached to something the person just said they wanted — the migration checklist — rather than presented as the next item on a list. That is the whole difference between a conversation and a form, and it costs nothing except writing the rule that their question comes first.
Note also what did not happen at turn 1: the assistant did not ask “and how many people are you?” after being told thirty. That re-ask is the failure everybody has experienced, it follows directly from state that is re-derived rather than kept, and it is the reason the merge function exists.
When it stops working
- Re-asks appear. The failure users complain about. Detect it by comparing the slots mentioned in
sayagainst terminal slots infilled. Almost always a state-passing bug rather than a prompt one — check what your code actually sent. -
inferredrises relative tofilled. The model has started deriving rather than asking. Sample the sources; if they do not support the inference, tighten the source requirement. - Turns per completion rises. Track the median. A rise with the same slot count usually means the two-slots-per-turn rule has become one, and the conversation now takes twice as long for no gain.
-
declinednever appears. People decline. If the status is never set, the model is treating deflection as an invitation to rephrase, which is the behaviour that makes people leave.
Top comments (0)