Multi-Turn State Management
Chat history is not state. I've debugged agents that "forgot" the order ID at turn 8 not because the model has amnesia, but because nobody built a state object — the orchestrator hoped the LLM would remember a number mentioned twelve messages ago. Multi-turn agent state is a typed, orchestrator-owned data structure that survives summarization, survives model swaps, and gives you something to assert on in tests. The message log is an audit trail; state is what the agent actually knows.
State layers
┌─────────────────────────────────────┐
│ Turn state (ephemeral) │ current LLM response, pending tool calls
├─────────────────────────────────────┤
│ Session state (in-memory/Redis) │ goal, entities, phase, step count
├─────────────────────────────────────┤
│ Checkpoint state (durable) │ graph position, approval status
├─────────────────────────────────────┤
│ Semantic memory (long-term) │ user prefs, project facts
└─────────────────────────────────────┘
Each layer has different lifetime, storage, and access patterns. Conflating them creates bugs.
The session state object
Define it explicitly — don't infer state from chat history:
@dataclass
class SessionState:
session_id: str
user_id: str
goal: str | None = None
phase: Literal["intake", "research", "execute", "review"] = "intake"
entities: dict[str, Any] = field(default_factory=dict)
tool_results: dict[str, Any] = field(default_factory=dict)
pending_tool_calls: list[ToolCall] = field(default_factory=list)
step_count: int = 0
errors: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
Update state in your orchestrator after every turn — never let the model directly mutate it. The model proposes actions; code commits state changes.
async def process_turn(state: SessionState, user_message: str) -> AgentResponse:
state.step_count += 1
context = render_context(state, user_message)
llm_response = await llm.complete(context, tools=available_tools(state))
if llm_response.tool_calls:
state.pending_tool_calls = llm_response.tool_calls
results = await execute_tools(llm_response.tool_calls, state)
state.tool_results.update(results)
# Second LLM call with tool results — or loop
llm_response = await llm.complete(render_context(state, user_message))
update_entities_from_response(state, llm_response)
return AgentResponse(text=llm_response.content, state=state)
Rendering state for the LLM
The model doesn't read your Python objects. Render a consistent context block:
def render_context(state: SessionState, user_message: str) -> list[Message]:
system = f"""You are a support agent.
Current goal: {state.goal or 'Not yet determined'}
Phase: {state.phase}
Known entities: {json.dumps(state.entities, indent=2)}
Steps taken: {state.step_count}
"""
messages = [SystemMessage(system)]
messages.extend(state.recent_history(limit=5))
messages.append(UserMessage(user_message))
return messages
Keep rendering deterministic. If the same state produces different context strings between turns, the model behaves inconsistently.
Tool result lifecycle
Tool results have a lifecycle most agents ignore:
- Pending — tool call proposed, not yet executed
- Active — result in current context, agent hasn't acted on it
- Consumed — agent used the result, safe to drop from active context
- Archived — stored in state.tool_results, retrievable but not in prompt
Mark results as consumed explicitly:
def mark_consumed(state: SessionState, tool_call_id: str):
state.tool_results[tool_call_id]["status"] = "consumed"
When assembling context, include only Active results. This alone can cut context size 40% in tool-heavy agents.
Threading and concurrency
Users send messages while the agent is still processing. Handle it:
- Reject with status: "Still working on your previous request" (simple, safe)
- Queue: process messages sequentially (most common)
- Interrupt: cancel in-flight work, restart with new message (complex, use sparingly)
Never process two turns concurrently against the same session state. Race conditions on entity extraction are subtle and nasty.
Store processing: bool on session state with a TTL lock in Redis.
State transitions for workflow phases
If your agent has phases, enforce transitions in code:
VALID_TRANSITIONS = {
"intake": ["research"],
"research": ["execute", "intake"], # intake = user changed goal
"execute": ["review", "research"],
"review": ["execute", "done"],
}
def transition(state: SessionState, new_phase: str):
if new_phase not in VALID_TRANSITIONS.get(state.phase, []):
raise InvalidTransition(f"{state.phase} → {new_phase}")
state.phase = new_phase
The model can suggest a phase change via a tool call; your orchestrator validates and commits it. This prevents the agent from skipping research and jumping to execute.
Testing state management
State objects make agents testable:
def test_entity_extraction_updates_state():
state = SessionState(session_id="s1", user_id="u1")
update_entities_from_response(state, mock_response(order_id="4521"))
assert state.entities["order_id"] == "4521"
def test_invalid_phase_transition_raises():
state = SessionState(phase="intake")
with pytest.raises(InvalidTransition):
transition(state, "execute")
Pair with deterministic replay tests for full-turn integration coverage.
Common production mistakes
Teams get multi turn state management wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
Agent systems using multi turn state management loop infinitely when tool errors are swallowed, subagent budgets have no hard cap, and human-in-the-loop gates are bypassed under latency pressure.
Debugging and triage workflow
When multi turn state management misbehaves in production, work top-down instead of guessing:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.
Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.
Resources
- LangGraph state management
- Redis session storage patterns
- Temporal workflow state
- OpenAI assistants API — threads and runs
- Agent graph workflows
Frequently asked questions
What state does a multi-turn agent need to track?
At minimum: conversation history, current goal and subtask, tool results pending action, user identity and permissions, extracted entities (IDs, names, dates), and execution metadata (step count, cost, errors). Separate ephemeral turn state from durable session state and from long-term semantic memory.
How is agent state different from chat history?
Chat history is the raw message log — necessary but insufficient. Agent state includes structured data the orchestrator needs: which phase the workflow is in, what tools have been called, parsed entities, approval status, and retry counts. The LLM sees a rendered view of state; the orchestrator owns the canonical typed object.
Where should agent state be stored?
Active session state lives in memory or Redis for low-latency access during a conversation. Checkpoint state for resumable workflows goes to durable storage (Postgres, DynamoDB). Long-term facts promote to a semantic memory store at session end. Never rely on the LLM's context window as your state store.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →