Explicit task state
- Lesson 4
- 50 min
- Module 2: Represent state and tools
Lesson 4 of 18 · AI Agents and Workflow OrchestrationExplicit task state
1. Choose the right orchestration pattern
2. Represent state and tools
3. Reasoning, planning and context
4. Human approval and recovery
5. Multiple workers and integrations
6. Evaluate and operate a bounded agent
What you will be able to do
Keep everything that a task knows, decides and gets wrong in typed fields that your code can check. Then a model’s text can never pass for a fact.
Before you startWhat to know and to have ready
What you need to know
- The single agent and its loop, from Workflow and agent boundaries: the model proposes a step, and the application runs it.
- Why an application must keep the conversation and send it again with each request, from Manage conversation state in Building Reliable Applications with LLM APIs.
- Pydantic models (
BaseModel, typed fields, validation), from Define a schema.
What you need on your computer
- The project
resolution-workflowat the end of Module 1, after Decide on complexity: Add the evaluation to the project. In the project folder, with the virtual environment active,python -m pytestshows23 passed. - If your project is broken, copy the snapshot
end-of-m01, as the snapshots README shows.
This lesson adds no file to the project. You read the state of real runs in a Python session. Every model decision on this page is a recording: gemma3:4b in Ollama, or gpt-6-luna on Azure as chat-small. No account and no key are needed. Models and agent frameworks change fast, so the numbers in this module are a snapshot; the method lasts. Tested with Python 3.12.13 on macOS. The Windows commands are not tested.
A bike shop and its job cards
Section titled “A bike shop and its job cards”A customer brings a bike to a repair shop. Over the next two days, she and the mechanic exchange twenty text messages. “The brakes squeak.” “Maybe the back wheel too?” “Can you also look at the chain?” “The new brake pads cost 30 dollars, is that OK?” “Yes, go ahead.”
On the third day, a second mechanic takes over the bike. He could read all twenty messages and try to work out where things stand. Or he could read the job card on the handlebars. It has a few fixed lines: the job, the checks done, the parts approved and the money spent. The last line is the status: waiting for parts.
The messages are a record of the talk. The job card is a record of the work. “Maybe the back wheel too?” is in the messages, but nobody checked the back wheel. Only the job card says what is true.
The problem: the conversation as the state
Section titled “The problem: the conversation as the state”The agent in this course talks to its model in messages. Each model call sends the system message, the ticket and every earlier step again. After a few steps, this chat history holds everything that happened, as text. So Tomás makes a suggestion: “The conversation history is the state. We don’t need anything else.”
Look at a real run first. Ticket T-80001 is from Tom. His neighbour asked him to check on her order, LK-615204. gemma3:4b, a small local model, ran the agent loop on it. Here is what it did in its first three steps:
- It asked for
get_orderwith LK-615204. The application refused:No order LK-615204 is available for this customer.The order belongs to another customer. - It asked for
get_customer_orders, Tom’s own orders. Tom has none, so the result was an empty list. In the same answer, it drafted a reply that names LK-615204. - It asked for
get_customer_ordersagain.
Now suppose that your code must answer one question: which orders has this task really seen? Grace’s policy needs the answer before any action, because an action on an order that no tool returned is never allowed. If the chat history is the state, the code must find the answer in the text. The text names LK-615204 five times: in the ticket, in the call, in the refusal and in two reply drafts. A search for order IDs in the history finds exactly one order: the neighbour’s.
So a check built on the history would treat the neighbour’s order as known. That is the opposite of the truth. The application refused that order, and no tool returned any order at all.
The idea: explicit task state
Section titled “The idea: explicit task state”The state of a task is what the application knows about the task now. That is what the task is for, what it found, where it is, what it decided and what went wrong. Explicit task state writes each of these as a separate, typed field. “Explicit” means that the code does not have to work anything out from text. Each answer is one field away.
The project keeps this state in TaskState, a Pydantic model in resolver/state.py. The diagram shows the same run of T-80001 twice: as the chat history, and as the task state.
Text description of the diagram
The left side lists the 8 messages of the chat history. The neighbour’s order LK-615204 appears in the ticket, in the model’s call, in the refusal and in two reply drafts.
The right side lists the fields of the task state. The status is stopped, after 3 steps, because of the step limit. The evidence has three tool results, and none of them is an order, so seen_orders is empty. The proposal is None, there are no errors, and the usage is 3 model calls, 6,104 tokens in and 9.981 seconds.
Read the right side as the job card. The evidence is a list of the tool results that the application really received. The model cannot add to it: only the loop’s own code appends an Evidence row, after it runs a tool. seen_orders() reads the order IDs from the successful results only. For T-80001 it returns an empty set, which is the truth.
These are the fields of TaskState at this stage of the project:
| Field | What it holds | Who writes it |
|---|---|---|
goal, attachments |
the ticket text and its attachments | the runner, once, from the task |
status, step, stop_reason |
where the task is, model calls so far, why the loop ended | the loop and the runner |
plan |
the model’s current plan (structured mode only) | the loop, from the model’s answer |
evidence |
every tool result: tool, arguments, ok, code, data, order IDs |
the loop, after it runs a tool |
proposal |
the resolution, after the code has validated it | the loop, only if the checks pass |
errors |
provider errors, for the trace and for a person | the loop |
usage |
model calls, tokens, cost, model time | the loop, after each call |
events |
everything that happened, in order (the trace) | the loop |
messages |
the chat history: the working context of the next model call | the loop |
The chat history is still there, in messages. The model needs it, because it sees nothing else. But the code never makes a decision by reading it. In Module 4 the state gains two more fields, approval and operations: who approved what, and which writes happened.
A tiny worked example: three steps of T-80001
Section titled “A tiny worked example: three steps of T-80001”Follow the state of the gemma3:4b run step by step. Each row shows the fields after that step:
| After step | Model asked for | Application answered | evidence (codes) |
seen_orders() |
status |
|---|---|---|---|---|---|
| 0 | none | none | running |
||
| 1 | get_order LK-615204 |
refused | other_customer |
none | running |
| 2 | get_customer_orders |
ok, an empty list | other_customer, ok |
none | running |
| 3 | get_customer_orders |
ok, an empty list | other_customer, ok, ok |
none | stopped |
Three things change, and each change is visible. step counts the model calls. evidence grows by one row per tool call, with a code that says what happened. status changes once, at the end, because a step limit of 3 stopped this run. Nothing in the table depends on what the model wrote. The reply drafts that name LK-615204 are in the history, not in the state.
Read the state of a run
Section titled “Read the state of a run”Do this in the project folder, with the virtual environment active.
-
Start Python:
Terminal window python -
Run T-80001 with gemma3:4b’s recorded decisions. The step limit of 3 keeps the run short:
from resolver.config import Settings, make_providerfrom resolver.data import load_taskfrom resolver.loop import Limits, accept_resolutionfrom resolver.runner import run_tasktask = load_task("T-80001")provider = make_provider(Settings.from_env())state = run_task(task, "agent_structured", "gemma3:4b", provider.complete, limits=Limits(max_steps=3))state.status, state.stop_reason, state.stepPython prints:
('stopped', 'max_steps', 3) -
Look at the chat history. Count the times that it names the neighbour’s order:
[m["role"] for m in state.messages]history = " ".join(m["content"] for m in state.messages)history.count("LK-615204")['system', 'user', 'assistant', 'user', 'assistant', 'user', 'assistant', 'user']5In this structured mode, each tool result goes back to the model as a
usermessage. With tool calling, it is atoolmessage. Code that reads the history must know both formats. Code that reads the state does not. -
Now ask the state:
[(e.step, e.tool, e.ok, e.code, e.order_ids) for e in state.evidence]state.seen_orders()[(1, 'get_order', False, 'other_customer', []), (2, 'get_customer_orders', True, 'ok', []), (3, 'get_customer_orders', True, 'ok', [])]set() -
Compare it with Tomás’s way, a search for order IDs in the history:
import reset(re.findall(r"LK-\d{6}", history)){'LK-615204'} -
Give the loop’s own check a proposal that acts on the neighbour’s order. This proposal is constructed: no model wrote it.
refund = {"outcome": "resolve", "rule": "W3", "reply": "We will refund the shed.", "reason": "lost","actions": [{"tool": "request_refund", "order_id": "LK-615204", "amount": 899.0,"reason": "RFD-LATE", "payment_id": None}]}accept_resolution(state, refund)'request_refund on LK-615204: that order was not returned by a tool in this task. Look it up first; never act on an order that you have not seen.' -
Print the fields that a person would read first:
print(state.model_dump_json(include={"goal", "status", "step", "plan", "stop_reason", "errors", "usage"}, indent=2))The output starts with the goal (the ticket text) and ends with the usage:
"model_calls": 3,"input_tokens": 6104,"output_tokens": 302,"cost_usd": 0.0,"model_seconds": 9.981. A local model costs no money, so the cost is 0.0. The time is the recorded model time. -
Type
exit()to leave Python.
accept_resolution uses seen_orders(), so it refuses the refund, with a reason that the model can read. A check that reads the history would let it through.
The status is a small state machine
Section titled “The status is a small state machine”The status field can take only a few values. Each value has a fixed meaning, and only some changes are allowed. That is a state machine: a fixed set of states and the allowed moves between them. It answers “where is this ticket now?” for every ticket, in one word.
Text description of the diagram
A task starts as running. It leaves running in one of four ways:
- A limit stops the loop before any proposal: stopped. A person takes the ticket.
- The proposal is reply_only or ask_customer, so nothing changes: done.
- The proposal is hand_to_person: handed_to_person.
- The proposal is resolve, with actions: waiting_approval.
From Module 4: a person approves (executing) or rejects (handed_to_person). From executing, the task is done when every write is done once, and handed_to_person if a write fails.
The runner sets the status from the state, never from the model’s text. This is the code, in resolver/runner.py:
if state.proposal is None: state.status = "stopped" elif state.proposal.actions: state.status = "waiting_approval" elif state.proposal.outcome == "hand_to_person": state.status = "handed_to_person" else: state.status = "done"Notice the first line. A run that stops without a proposal does not disappear as an error. It has its own status, and a person gets the ticket. The evaluation in Module 1 counts a stop the same way: as handed to a person.
What Larkfield gets from the state
Section titled “What Larkfield gets from the state”Each person at Larkfield reads a different field. Grace approves changes, and she wants to see exactly what will happen. She reads proposal, which the code has validated, and evidence, which shows where each fact came from. Omar asks what a ticket costs, and usage answers it per task. Mei owns the order and payment services. She reads the codes in evidence (service_timeout, service_unavailable) to see when her services failed a task.
The state also makes the next modules possible. In Module 4, the workflow stops at waiting_approval and may wait for days. A checkpoint saves the TaskState to a SQLite file, and the run continues later from the saved fields. A chat history in memory is gone when the process ends.
When to use explicit state, and what it costs
Section titled “When to use explicit state, and what it costs”Use typed state when a task has more than one step, or when code must check its decisions. Use it also when the task can change something, or must stop and continue later. The support-resolution workflow has all four.
You do not need it for a single model call, such as drafting one reply. There the request and the response are the whole task. The ticket assistant of the LLM applications course kept only a conversation, and that was enough for drafting.
The cost is code and discipline. Each field needs a writer, and every writer must be your code, not the model. When you add a field, you must decide who sets it and when. You still keep the messages, because the model needs them, so the state is extra data, not a replacement. In return, every check reads one field, and every person reads the field that they need.
Common mistake
'The conversation history is the state, we don't need a database'
Symptom: Tomás writes the check “never act on an order that you have not seen” as a search in the history. Every LK- number in the messages counts as seen. On T-80001, the search returns {'LK-615204'}, the neighbour’s order. A proposal to refund 899.00 dollars on that order now passes the check.
Cause: the history mixes three kinds of text: what the customer wrote, what the model wrote and what the application returned. A search cannot tell them apart. A refusal names the order that it refuses, and a model’s draft can name any order at all.
Fix: record facts in typed fields when they happen. The loop appends an Evidence row after each tool call, with ok and the order IDs that the tool really returned. Checks read state.seen_orders(), never the messages.
What it means for a real AI service: a conversation is input for the model, not a record for the application. Anything that a check, a person or a later process depends on belongs in a field that only your code writes.
Your turn
Section titled “Your turn”Predict before you run it. Ticket T-90103 is the cracked cedar planter, and the customer attached a photo. Run it with the default agent (chat-small, tool calling) in a Python session, as in the steps above:
s = run_task(load_task("T-90103"), "agent", "chat-small", provider.complete)s.status, s.step, s.seen_orders(), s.proposal.outcome, len(s.proposal.actions)What will status be, and why?
Show the answer
('waiting_approval', 2, {'LK-640436'}, 'resolve', 2)The model made one read (get_order LK-640436) and then proposed a resolution with two actions: reship_item and create_return_label. The resolution has actions, so the runner sets waiting_approval. Nothing is written: the actions are proposals until a person approves them, in Module 4. seen_orders() has LK-640436 because get_order returned it.
Knowledge check
Section titled “Knowledge check”Check your understanding
The model writes in its reply draft: 'I have checked order LK-640436 and it was delivered.' No tool returned LK-640436 in this task. Does LK-640436 appear in state.seen_orders()? Explain.
Show the answer
No. seen_orders() reads only the evidence, and only the rows with ok true. The loop appends an evidence row after it runs a tool, with the order IDs that the tool returned. A sentence in the model’s text is in the messages, not in the evidence. So the loop refuses a later action on LK-640436 until a tool returns that order.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
A run ends because the step limit stops the loop. What are status and proposal, and what happens to the ticket?
Show the answer
proposal is None, because the model never gave a valid resolution, and the runner sets status to stopped. The ticket goes to a person. The stop is not hidden: stop_reason says why (max_steps), and the evidence shows what the run found before it stopped. The evaluation counts a stopped run as handed to a person.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Why does the project keep the messages in TaskState at all, if no check reads them?
Show the answer
The model needs them. A model keeps nothing between two requests, so each model call must carry the conversation so far: the ticket, the earlier calls and their results. The messages are the model’s working context. Module 4 also uses them to continue a loop after a checkpoint. But they are input for the model, not facts for the code.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- A chat history is a record of the talk. It mixes the customer’s text, the model’s text and the application’s results, so a check cannot trust it.
- Explicit task state keeps each fact in a typed field. The fields are the goal, evidence, step, plan, proposal, errors, usage, status and stop reason. In Module 4 it gains the approval and the writes.
- Only the application writes evidence.
seen_orders()reads it, andaccept_resolutionrefuses an action on any other order. On T-80001, the history names the neighbour’s order five times; the state has no order. statusis a small state machine:running, thenstopped,done,handed_to_personorwaiting_approval. A stop has its own status, and a person gets the ticket.
Next, you make the tools themselves strict: each call must match a contract before it runs.
Next: Tool contracts