Agent State Internals
Every agent execution revolves around a single, immutable data structure:AgentState. This object carries the full picture of an agent’s identity, conversation context, and execution progress. Understanding its internal structure is essential for building custom guards, hooks, and persistence layers.
Design Philosophy
AgentState follows two core principles:
-
Immutability. The class is declared
final readonly. Every mutation method (with*,forNextExecution, etc.) returns a new instance, leaving the original untouched. This makes state transitions explicit and safe for concurrent inspection. -
Session vs. Execution separation. Some data persists across executions (identity, context, message history), while other data is transient and scoped to a single execution (step results, timing, continuation signals). This split is represented by the nullable
ExecutionStateproperty.
AgentState Structure
The following diagram shows the complete object graph:Session Data (Persists Across Executions)
Session-level properties survive between executions. When you callforNextExecution(), these fields are preserved while execution is reset to null:
agentId— A typed UUID (AgentId) that uniquely identifies the agent instance. Generated automatically on construction.parentAgentId— Set when the agent is spawned as a subagent. Enables parent-child correlation in event tracing.createdAt/updatedAt— Timestamps for lifecycle tracking.updatedAtis bumped on every mutation viawith().executionCount— Monotonically increasing counter. Incremented byAgentLoop::onBeforeExecution()at the start of each execution. Useful for guards that behave differently on the first execution.llmConfig— OptionalLLMConfigoverride. When set, the driver uses this configuration instead of its default provider settings.context— TheAgentContextcontaining the message history, system prompt, metadata, and response format.
Execution Data (Transient Per Execution)
Theexecution property holds an ExecutionState that is created fresh at the start of each execution and discarded (set to null) when the execution completes:
executionId— A uniqueExecutionIdfor correlation. Generated viaExecutionState::fresh().status— AnExecutionStatusenum tracking the execution lifecycle.stepExecutions— AStepExecutionscollection of completedStepExecutionobjects. Each wraps anAgentSteptogether with its timing and continuation state.continuation— AnExecutionContinuationthat holds stop signals and continuation requests. The agent loop consults this after each step to decide whether to continue or stop.currentStep— TheAgentStepcurrently being processed. Set by the driver viawithCurrentStep(), then archived intostepExecutionswhenwithCurrentStepCompleted()is called.
ExecutionStatus Lifecycle
ExecutionStatus is a string-backed enum with five cases:
| Status | Description |
|---|---|
Pending | Between executions, ready for a fresh start |
InProgress | Execution is actively running |
Completed | Execution finished successfully |
Stopped | Execution was force-stopped by a guard, budget limit, or external request |
Failed | Execution encountered an unrecoverable error |
AgentLoop manages these transitions automatically:
AgentStep Internals
Each step in the execution is represented by anAgentStep — an immutable snapshot of what happened during a single driver invocation:
AgentStep::stepType() inspects the step’s contents to determine its type:
- If the step has errors (including tool execution errors), the type is
AgentStepType::Error. - If the step has requested tool calls, the type is
AgentStepType::ToolExecution. - Otherwise, the type is
AgentStepType::FinalResponse.
StepExecution Wrapper
When a step is completed, it is wrapped in aStepExecution that bundles the step with timing and continuation data:
AgentStep focused on what happened (messages, tools, errors) while StepExecution owns when it happened and whether the loop should continue.
Message Metadata Tagging
When a step’s output messages are appended to the agent context,AgentState::withCurrentStep() automatically tags each message with metadata:
step_id— TheAgentStepIdof the step that produced the message.execution_id— TheExecutionIdof the current execution.agent_id— TheAgentIdof the agent.is_trace— Set totruefor non-final steps (tool execution, error). Final response messages do not carry this flag.
ConversationWithCurrentToolTrace) to filter messages at read-time based on their origin, without modifying the underlying message store.
Key Accessors
AgentState provides a rich set of accessors for inspecting the current state at any point during or after execution:
Identity and Timing
Context
Execution State
Final Output
Continuation and Stop Signals
The agent loop usesExecutionContinuation to decide whether to keep iterating. After each step, the loop calls $state->shouldStop(), which delegates to:
StopReason enum with prioritized cases:
| Priority | StopReason | Description |
|---|---|---|
| 0 (highest) | ErrorForbade | An error prevented continuation |
| 1 | StopRequested | Explicit stop via AgentStopException |
| 2 | StepsLimitReached | Step budget exhausted |
| 3 | TokenLimitReached | Token budget exhausted |
| 4 | TimeLimitReached | Time budget exhausted |
| 5 | RetryLimitReached | Maximum retries exceeded |
| 6 | FinishReasonReceived | LLM signaled completion |
| 7 | UserRequested | External user request |
| 8 | Completed | Normal completion |
| 9 (lowest) | Unknown | Unspecified reason |
wasForceStopped() method on StopReason returns true for all reasons except Completed and FinishReasonReceived, which represent natural completion.
ExecutionBudget
ExecutionBudget declares per-execution resource limits. It is defined on an AgentDefinition and applied as a UseGuards capability when the agent loop is built — it is not stored inside AgentState.
null (or omit) for unlimited. You can check whether a budget has any limits set with isEmpty(), or whether all limits have been exhausted with isExhausted().
The ExecutionBudget::unlimited() factory returns a budget with all limits set to null:
SubagentPolicy (maxDepth), not through the budget.
Debugging
AgentState::debug() returns an associative array summarizing the current state — useful for logging or test assertions:
Serialization
All state objects implementtoArray() and fromArray() for persistence and hydration. This covers the full object graph — AgentState, ExecutionState, AgentStep, StepExecution, ToolExecution, and ExecutionContinuation:
SessionStore implementations use toArray() / fromArray() to save and restore agent state between requests or across process boundaries.
Serialization Scope
| Object | toArray() | fromArray() |
|---|---|---|
AgentState | Full state including context and execution | Restores all fields |
ExecutionState | Execution ID, status, timing, steps, continuation | Restores all fields |
AgentStep | Step ID, messages, inference response, tool executions, errors | Restores all fields |
StepExecution | Step data, continuation, timing | Restores all fields |
ToolExecution | Tool call, result/error, timing | Restores all fields |
ExecutionBudget | All limit values | Restores all limits |
ExecutionContinuation | Stop signals, continuation flag | Restores all fields |
Key Gotcha: ensureExecution() Creates Fresh State
The private ensureExecution() method returns ExecutionState::fresh() with a new UUID when execution is null. This means calling it twice produces different execution IDs. The AgentLoop handles this correctly, but if you are building custom orchestration, be aware that you must capture and reuse the returned state: