AgentState vs. Context: The Source of Truth in Agentic Workflows

A practical mental model for separating canonical agent state from PlannerContext, ResponseContext, and MemoryContext in production-style agentic workflows.

When building an agentic workflow, it is tempting to pass the entire runtime state into every module.

The planner needs context, so give it the state. The response renderer needs context, so give it the state. The memory extractor needs context, so give it the state.

That approach works for a prototype. It is also how many early agent demos are wired together.

But once the system grows, this design starts to blur ownership boundaries. Every module can see everything. Every module becomes coupled to internal state shape. And if an LLM is involved, the prompt may become larger, noisier, and harder to control.

A cleaner mental model is:

AgentState is the canonical internal state.
Context is a curated view derived from AgentState for a specific module.

In other words:

AgentState = source of truth
PlannerContext = what the planner needs to decide
ResponseContext = what the renderer needs to answer
MemoryContext = what the memory layer needs to update memory

This distinction is small, but it matters. It is one of the differences between a simple agent loop and a production-style agentic system.

AgentState Is the Canonical Runtime Snapshot

AgentState is the agent runtime’s current internal snapshot.

It records what the runtime knows right now: the conversation, tool calls, tool results, working memory, execution status, errors, and other operational details.

A simplified version might look like this:

from dataclasses import dataclass, field
from typing import Any


@dataclass
class AgentState:
    session_id: str
    messages: list[dict[str, Any]] = field(default_factory=list)
    tool_calls: list[dict[str, Any]] = field(default_factory=list)
    tool_results: list[dict[str, Any]] = field(default_factory=list)
    working_memory: dict[str, Any] = field(default_factory=dict)
    current_plan: dict[str, Any] | None = None
    last_error: str | None = None
    iteration_count: int = 0
    metadata: dict[str, Any] = field(default_factory=dict)

The exact fields depend on the application, but the role is the same:

AgentState is internal, complete, canonical, and mutable over time.

It is internal because it belongs to the runtime.

It is complete because it may contain everything the runtime needs to continue execution.

It is canonical because other modules should not maintain competing versions of the same facts.

It is mutable over time because each user message, tool call, tool result, and memory update can change it.

A useful shorthand is:

AgentState = what the runtime currently knows

Context Is a Curated View

Modules do not always need the full state.

The planner does not need every debug trace. The renderer does not need every intermediate failed tool call. The memory extractor does not need every formatting detail needed by the final response.

Instead, each module should receive a context object shaped around its responsibility.

For example:

@dataclass
class PlannerContext:
    user_message: str
    conversation_summary: str
    available_tools: list[dict[str, Any]]
    working_memory: dict[str, Any]
    business_constraints: list[str]

This is not the whole AgentState.

It is a projection of the state:

AgentState -> PlannerContext

The same idea applies to rendering:

@dataclass
class ResponseContext:
    user_message: str
    plan_result: dict[str, Any]
    latest_tool_result: dict[str, Any] | None
    response_policy: dict[str, Any]

And to memory:

@dataclass
class MemoryContext:
    recent_messages: list[dict[str, Any]]
    latest_tool_result: dict[str, Any] | None
    existing_memory: dict[str, Any]

These context objects are not competing sources of truth. They are temporary working views.

A useful shorthand is:

Context = what this module needs to do its job

Why Not Pass AgentState Everywhere?

Passing the entire AgentState into every module feels convenient, but it creates several problems.

1. Modules become coupled to internal state shape

If the planner directly reads state.messages, state.tool_results, state.metadata, and state.current_plan, then the planner depends on the internal structure of the runtime.

That makes future changes harder.

If you rename a field, restructure messages, or split tool traces into a separate object, the planner may break even though the planner’s actual responsibility did not change.

Context objects reduce that coupling.

The planner depends on PlannerContext, not the entire runtime state.

2. LLM prompts become noisy

If the planner is LLM-based, passing too much state can pollute the prompt.

AgentState may contain:

session_id
full message history
tool calls
tool results
debug metadata
execution traces
iteration counters
last errors
intermediate observations
internal flags

The planner probably does not need all of that.

A large and noisy prompt can make planning less reliable. It can also increase latency and cost.

A curated PlannerContext gives the LLM the information it needs and leaves out the rest.

3. Modules may see information they should not see

Not every module should have access to every internal detail.

A response renderer may need a grounded tool result, but it may not need raw internal traces.

A planner may need available tool specifications, but it may not need private audit metadata.

A memory extractor may need recent user preferences, but it should not store every casual detail from the conversation.

Curated contexts help enforce information boundaries.

4. Testing becomes harder

Testing a planner that takes the full AgentState means every test must construct a realistic state object.

That state object may require fields unrelated to planning.

Testing becomes easier when the planner accepts a smaller input:

context = PlannerContext(
    user_message="Compare the Costco card with Custom Cash.",
    conversation_summary="User is comparing credit card benefits.",
    available_tools=[compare_cards_tool],
    working_memory={"annual_fee_preference": "low"},
    business_constraints=["Do not provide personalized financial advice."],
)

result = planner.plan(context)

The test is focused on the planner’s responsibility, not on the entire runtime.

The Runtime Owns State

In a clean agent architecture, AgentRuntime owns the AgentState.

The runtime receives user input, updates state, builds context objects, calls modules, receives outputs, and writes updates back into state.

A simplified flow looks like this:

User input
  -> AgentRuntime
  -> Update AgentState
  -> Build PlannerContext
  -> Planner returns PlanResult
  -> ToolExecutor executes ToolCall
  -> Runtime writes ToolResult into AgentState
  -> Build ResponseContext
  -> Renderer returns AgentResponse

In code, the shape might look like this:

def run(user_input: str) -> AgentResponse:
    state.add_user_message(user_input)

    planner_context = planner_context_builder.build(state)
    plan_result = planner.plan(planner_context)
    state.current_plan = plan_result

    tool_result = None

    if plan_result.tool_call:
        tool_result = tool_executor.execute(plan_result.tool_call)
        state.add_tool_result(tool_result)

    response_context = response_context_builder.build(
        state=state,
        plan_result=plan_result,
        tool_result=tool_result,
    )

    response = response_renderer.render(response_context)
    state.add_assistant_message(response.content)

    return response

The important part is ownership:

AgentRuntime owns AgentState.
Planner receives PlannerContext.
ToolExecutor receives ToolCall.
Renderer receives ResponseContext.
MemoryExtractor receives MemoryContext.

Each module gets the input it needs, not the entire runtime.

Memory Needs a Small Nuance

Memory is slightly different from context.

It is easy to say:

AgentState produces PlannerContext, ResponseContext, and MemoryContext.

That is true.

But it is also important to distinguish working_memory from MemoryContext.

working_memory may be part of AgentState:

@dataclass
class AgentState:
    messages: list[Message]
    tool_results: list[ToolResult]
    working_memory: dict[str, Any]

This memory is persisted inside the runtime state.

For example:

{
  "preferred_reward_type": "travel",
  "annual_fee_preference": "no annual fee"
}

MemoryContext, on the other hand, is a temporary input for a memory extraction or summarization module.

For example:

memory_context = MemoryContext(
    recent_messages=state.messages[-5:],
    latest_tool_result=state.tool_results[-1] if state.tool_results else None,
    existing_memory=state.working_memory,
)

The memory extractor may return an update:

memory_update = memory_extractor.extract(memory_context)

Then the runtime writes that update back into state:

state.working_memory.update(memory_update)

So the more precise model is:

working_memory can be stored inside AgentState.
MemoryContext is derived from AgentState to decide how memory should be updated.

That distinction prevents confusion.

Memory is not always just another context. Persistent memory may be part of the canonical state. MemoryContext is the temporary view used by the memory module.

A Useful App Architecture Analogy

For engineers coming from app architecture, this maps well to familiar patterns.

AgentState is similar to:

AppState
Store
ViewModel state

PlannerContext, ResponseContext, and MemoryContext are closer to:

ViewState
DerivedState
Props

In a SwiftUI application, you usually do not pass the entire app state into every view.

You derive the state a specific view needs:

struct CardListViewState {
    let cards: [Card]
    let isLoading: Bool
    let errorMessage: String?
}

The same idea applies to agentic workflows.

Instead of passing the full AgentState into every module, build module-specific views:

planner_context = PlannerContextBuilder.build(state)
response_context = ResponseContextBuilder.build(state)
memory_context = MemoryContextBuilder.build(state)

This keeps modules focused.

It also makes the system easier to evolve, because the internal state can change without forcing every module to understand the full shape of the runtime.

How the Pieces Fit Together

Here is a compact architecture view:

User Input
   |
   v
AgentRuntime
   |
   v
Update AgentState
   |
   v
Build specialized contexts
   |
   +------------------+-------------------+------------------+
   |                  |                   |                  |
   v                  v                   v                  v
PlannerContext   ResponseContext    MemoryContext       ToolCall
   |                  |                   |                  |
   v                  v                   v                  v
Planner           Renderer          MemoryExtractor     ToolExecutor
   |                  |                   |                  |
   v                  v                   v                  v
PlanResult       AgentResponse      MemoryUpdate        ToolResult
   |                  |                   |                  |
   +------------------+-------------------+------------------+
                      |
                      v
                Update AgentState

The key idea is that AgentState remains the canonical runtime snapshot.

The other objects are purpose-built inputs and outputs.

Practical Design Guidelines

When designing these objects, a few guidelines help.

Keep AgentState internal

AgentState should be owned by the runtime.

Avoid making every module depend on it directly. If every module can mutate the state, it becomes harder to understand how the system got into a particular condition.

Build contexts explicitly

Use context builders:

planner_context = PlannerContextBuilder.build(state)
response_context = ResponseContextBuilder.build(state)
memory_context = MemoryContextBuilder.build(state)

This makes the transformation visible.

It also gives you a clean place to filter, summarize, redact, or reshape state before passing it to a module.

Keep context objects task-focused

A PlannerContext should be decision-focused.

A ResponseContext should be response-focused.

A MemoryContext should be memory-update-focused.

If a context starts looking like a copy of the entire AgentState, it probably needs to be trimmed.

Treat LLM-facing context as an API

If a context is sent to an LLM, treat it like a public API.

Be deliberate about:

What information is included
What information is excluded
What format the model sees
What output schema is expected
What happens when the model is uncertain

This is especially important for planners, renderers, evaluators, and guardrail classifiers.

Separate observations from decisions

ToolResult is an observation.

PlanResult is a decision.

AgentResponse is a user-facing output.

Keeping these separate makes logs easier to read and failures easier to debug.

A Common Anti-Pattern

A common early design looks like this:

plan_result = planner.plan(state)
response = renderer.render(state)
memory_update = memory_extractor.extract(state)

This is simple, but it makes every module depend on the full state.

A cleaner version is:

planner_context = planner_context_builder.build(state)
plan_result = planner.plan(planner_context)

response_context = response_context_builder.build(state, plan_result)
response = renderer.render(response_context)

memory_context = memory_context_builder.build(state)
memory_update = memory_extractor.extract(memory_context)

The second version is more verbose, but it is easier to test, easier to observe, and easier to change safely.

That is often a good tradeoff in production systems.

The Core Takeaway

The mental model I find most useful is:

AgentState is the complete internal state.
Context is a curated projection for a specific module.

More specifically:

AgentState:
  internal, complete, canonical, mutable over time

PlannerContext:
  partial, curated, decision-focused

ResponseContext:
  partial, curated, response-generation-focused

MemoryContext:
  partial, curated, memory-update-focused

ToolCall:
  execution instruction

ToolResult:
  execution observation

And the nuance:

working_memory may live inside AgentState.
MemoryContext is derived from AgentState to help update memory.

So the best one-line summary is:

AgentState is the runtime’s source of truth. Context objects are module-specific views derived from that state.

This separation keeps the agent runtime understandable as the system grows.

It also gives each module a clearer contract: the planner decides, the executor executes, the renderer writes, the memory layer updates memory, and the runtime owns the state that ties everything together.