Skip to content

Explicit task state

  • Lesson 4
  • 50 min
  • Module 2: Represent state and tools

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 readyAbout 50 minutes

What you need to know

What you need on your computer

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 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:

  1. It asked for get_order with LK-615204. The application refused: No order LK-615204 is available for this customer. The order belongs to another customer.
  2. 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.
  3. It asked for get_customer_orders again.

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 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.

Left: the chat history of the run, 8 messages. The system message; the user message with the ticket, which names LK-615204; the assistant's get_order call for LK-615204; the refusal, which names LK-615204; the assistant's get_customer_orders call with a reply draft that names LK-615204; the empty result; the same call again; the empty result. Right: the task state. Goal: the ticket text. Status stopped, stop reason max_steps, step 3. Plan: ask_customer. Evidence: three results: get_order other_customer, then get_customer_orders ok with an empty list, twice. seen_orders: none. Proposal: None. Errors: none. Usage: 3 model calls, 6,104 tokens in, 9.981 s.
One real run of T-80001 (gemma3:4b with Ollama, stopped after 3 steps), as a chat history and as the task state. The history names LK-615204 five times. The state has no order that a tool returned.
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.

Do this in the project folder, with the virtual environment active.

  1. Start Python:

    Terminal window
    python
  2. Run T-80001 with gemma3:4b’s recorded decisions. The step limit of 3 keeps the run short:

    from resolver.config import Settings, make_provider
    from resolver.data import load_task
    from resolver.loop import Limits, accept_resolution
    from resolver.runner import run_task
    task = 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.step

    Python prints:

    ('stopped', 'max_steps', 3)
  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']
    5

    In this structured mode, each tool result goes back to the model as a user message. With tool calling, it is a tool message. Code that reads the history must know both formats. Code that reads the state does not.

  4. 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()
  5. Compare it with Tomás’s way, a search for order IDs in the history:

    import re
    set(re.findall(r"LK-\d{6}", history))
    {'LK-615204'}
  6. 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.'
  7. 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.

  8. 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 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.

A state machine. A ticket arrives: running. From running: a limit stops the loop with no proposal: stopped, then a person takes the ticket. Reply_only or ask_customer, nothing to change: done. Hand_to_person: handed_to_person. Resolve with proposed actions: waiting_approval. From waiting_approval: a person approves: executing (dashed, Module 4); a person rejects: handed_to_person. From executing: every write done once: done; a write fails: handed_to_person.
The statuses of one task. The dashed state, executing, and the moves out of waiting_approval come in Module 4. In this module, a task ends in stopped, done, handed_to_person or waiting_approval.
Text description of the diagram

A task starts as running. It leaves running in one of four ways:

  1. A limit stops the loop before any proposal: stopped. A person takes the ticket.
  2. The proposal is reply_only or ask_customer, so nothing changes: done.
  3. The proposal is hand_to_person: handed_to_person.
  4. 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:

resolver/runner.py (end of run_task)
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.

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.

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.

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.

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.

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.

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, and accept_resolution refuses an action on any other order. On T-80001, the history names the neighbour’s order five times; the state has no order.
  • status is a small state machine: running, then stopped, done, handed_to_person or waiting_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