State¶
Learn: Keep the conversation. Concepts: State and history. Guide: Go back or try another path.
parent_id, parent_call_id, child and achild are about subagents: States that a tool
call of this State started. Guide: Use subagents.
State
¶
State(*args: Never, id: str | None = None, messages: Iterable[Message] = (), extra_data: Mapping[str, Any] | None = None, history: Iterable[HistoryEntry] | None = None, **kwargs: Never)
One conversation's record: the messages the model sees (messages) and everything that happened
(history).
Create it with the messages to start from, pass it to agent.run to keep it after the run, or to continue it
after Ctrl+C. Its methods are the only way to change it, and every one is thread-safe, so tools may call
them from worker threads. Read values with the properties, or take snapshot() for several values that must
belong together.
Example
state = State(messages=[Message.user("Find the bug in this repo")], extra_data={"repo": "api"})
agent.run(state)
print(state.answer, state.stopped, state.usage.cost)
Start a State, empty or from messages, extra data or a history. Everything is keyword-only.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
id
|
str | None
|
The name a store saves this State under: letters, digits, |
None
|
messages
|
Iterable[Message]
|
The conversation to start from, as |
()
|
extra_data
|
Mapping[str, Any] | None
|
Your own values (JSON: dicts, lists, str, int, float, bool, None). The model never sees
them. Recorded as one |
None
|
history
|
Iterable[HistoryEntry] | None
|
Entries to rebuild a State from ( |
None
|
Raises:
| Type | Description |
|---|---|
TypeError
|
A positional argument, a keyword that does not exist (the 0.4 ones, |
ValueError
|
|
parent_id
property
¶
parent_id: str | None
The id of the State whose tool call started this one as a subagent, or None for a State that no
tool call started. It is the first link in the history (RunStartEntry.parent_id), so it is the same
while it runs and after store.load.
parent_call_id
property
¶
parent_call_id: str | None
The ToolCall.id of the parent's tool call that started this State, or None together with
parent_id.
history
property
¶
history: tuple[HistoryEntry, ...]
Everything that happened, oldest first: messages, model requests and replies, tool results, errors, context changes, runs and stops. Entries are only ever added, so shrinking the context never removes anything from here.
Each entry is one of the HistoryEntry classes (ModelReplyEntry, ToolResultEntry, ...). Checking
kind tells which, so type checkers know what content is.
Values in history are read-only and in their JSON form from the moment they are recorded, on a live State
as well as a loaded one: a tool call's args and a recorded answer are frozen dicts and lists (indexing,
json.dumps and == work, a change raises TypeError; dict(value) makes an editable copy), a
tuple becomes a list, and a dataclass or Pydantic model becomes a dict. A value that is not JSON raises
TypeError when it is recorded.
Example
for entry in state.history:
if entry.kind == "model_reply":
print(entry.content.tool_calls) # a Reply
elif entry.kind == "tool_result":
print(entry.call.name, entry.outcome)
messages
property
¶
messages: tuple[Message, ...]
The messages the model will see at the next think.
Results of this turn's tool calls go in together once the last call has a result. Messages added in the
meantime, or while think waits on the model, go in after them.
pending_calls
property
¶
pending_calls: tuple[ToolCall, ...]
Tool calls the model requested this turn that have no result yet, in request order.
A call leaves this tuple when it gets a result, is denied or cancelled by a permission, or is closed by an exception.
Every tool call needs a result before the model is asked again. While calls are pending, think,
compact, clear_tool_results and restore raise ValueError, add_message waits (the message
goes into messages right after the results), and finish works as usual.
turn
property
¶
turn: int
Number of turns so far: each think adds 1 while it waits on the model, and a think that fails
takes it back.
usage
property
¶
usage: Usage
Total tokens, requests and cost of every think, ask and compact on this State, and of the
subagents its tool calls started (added when the call closes). restore does not lower it.
extra_data
property
¶
extra_data: FrozenDict[str, Any]
A read-only dict for your own values (JSON). The model never sees it.
Change it with edit_extra_data(): state.extra_data["x"] = 1 raises TypeError.
finished
property
¶
finished: bool
Whether finish() was called. A finished State stops its loop before the next turn, and cannot run
again.
answer
property
¶
answer: Any
The run's answer.
The value given to finish(answer) if there is one, otherwise the text of the latest model reply
without tool calls, otherwise None. A think that fails does not change it.
It is in JSON form even before any save: a dataclass or Pydantic model given to finish comes back as
a read-only dict (Review(**state.answer) rebuilds it). Use agent.ask(..., returns=Type) to get the
object itself.
stopped
property
¶
stopped: Stopped | None
Why this run is set to stop, or None until something decides that.
StoppedByFinish(answer):finish()was called (set right away)StoppedByPermission(call, permission): a permission deniedcallwithstop=True(set right away, whenuse_toolsrecords the results)StoppedByUntil(name): anuntilfunction returned true, e.g.StoppedByUntil("waiting_for_user")(set when@loopchecks it before a turn)StoppedByLimit(turns): the loop ranlimitturns in this call;turnsis the limit (set when@loopchecks it before a turn)
run/arun reset it to None at the start, and it is None after a run that ended with an
exception. A @loop stops before its next turn once it is set. A loop written without @loop checks
it itself. A @loop called inside such a loop leaves its until/limit reason set, so a loop without
@loop that calls one checks only for StoppedByFinish/StoppedByPermission.
Example
agent.run(state)
if isinstance(state.stopped, StoppedByLimit):
print(f"gave up after {state.stopped.turns} turns")
def my_loop(agent, state):
while state.stopped is None and not state.finished:
agent.think(state)
if state.pending_calls:
agent.use_tools(state)
created_at
property
¶
created_at: datetime | None
When the first thing was recorded: the at of the first history entry, in UTC. None for a State
with no history yet.
updated_at
property
¶
updated_at: datetime | None
When something was last recorded: the at of the latest history entry, in UTC. None for a State
with no history yet.
Anything added to history moves it, including changes to extra_data.
snapshot
¶
snapshot() -> StateSnapshot
The current value of this State: a frozen StateSnapshot that never changes, and that
restore can go back to.
Example
before = state.snapshot()
state.add_message(Message.user("Try the other approach"))
state.restore(before)
add_message
¶
add_message(message: Message) -> None
Add a user message or a notice to the conversation, for example the next request in a chat loop.
While tool calls are pending, or while think waits on the model, the message is held and goes into
messages right after the results or the reply. A loop waiting for the user stays waiting until the
model answers it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
|
required |
Raises:
| Type | Description |
|---|---|
TypeError
|
|
ValueError
|
|
Example
state.add_message(Message.user("Now fix it"))
state.add_message(Message.notice("The tests failed"))
finish
¶
finish(answer: Any = None) -> None
End the run. state.stopped becomes StoppedByFinish(answer) right away, and the loop stops
before its next turn.
Tools may call it (a submit tool, for example), even while other calls are pending. Calling it again
is allowed; the last answer given wins.
It ends the State for good: afterwards think, use_tools, ask and run raise ValueError,
and only restore to a snapshot taken before it takes it back. If a tool calls it, end the loop body
without another think. A stop by a permission does not do this: the State is not finished, so add a
message and run again.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
answer
|
Any
|
If not |
None
|
Raises:
| Type | Description |
|---|---|
TypeError
|
|
compact
¶
compact(summary: str) -> None
Replace the messages with the first user message and summary. History keeps everything.
Use it with a summary you wrote or got from agent.ask. agent.compact(state) asks the model for the
summary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
summary
|
str
|
What the model should know about the work so far. |
required |
Raises:
| Type | Description |
|---|---|
TypeError
|
|
ValueError
|
Tool calls are pending, or |
clear_tool_results
¶
clear_tool_results(keep_last: int = 5) -> None
Shrink the context by blanking old tool results. History keeps the full results.
Every tool result except the last keep_last is replaced with "(cleared: kept in history)". Images
in a result are cleared with its text. Results that are already cleared still count toward keep_last.
Nothing is recorded when nothing changes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
keep_last
|
int
|
How many of the most recent tool results to keep. |
5
|
Raises:
| Type | Description |
|---|---|
ValueError
|
Tool calls are pending, or |
edit_extra_data
¶
edit_extra_data() -> Iterator[dict[str, Any]]
Change extra_data inside a with block. It gives you an editable copy (plain dicts and lists), and
when the block ends normally the changes are recorded as one history entry.
Edits take turns: a lock held for the whole block makes two threads' edits run one after the other, so
data["n"] = data.get("n", 0) + 1 never loses an update. If the block raises, nothing changes and the
exception propagates. If nothing changed, nothing is recorded.
Raises:
| Type | Description |
|---|---|
TypeError
|
At the end of the block, a value is not JSON (a set, an object, a non-string key, NaN). The message names the key. |
Example
with state.edit_extra_data() as data:
data.setdefault("notes", {})["bug"] = "b.py"
data.pop("draft", None)
fork
¶
fork() -> State
A new State with the same history and so the same content, a new id, and nothing shared with this one: no store, no running Agent. Use it to try something on a copy.
The copy is the same State at that point, so a fork of a finished State is finished too (start a new State
from state.messages to continue). Fork between turns: while a model request is being waited on there is
no think() behind the copy, so fork() refuses (fork before think, or after it returns, e.g. in
Reporter.on_think_end). State(history=snapshot.history) of such a snapshot is still allowed: it is
a plain replay.
The copy is independent: running or changing it leaves this State as it was, and
fork().snapshot() == state.snapshot(). With Agent(store=...), the copy is saved under its own id the
first time the Agent runs it. The one thing it shares is the subagent States this State ran, which it can
open with child too (they do not change because of the copy): the ones in memory, and, for a State from
store.load, the ones in that store.
Example
attempt = state.fork()
agent.run(attempt) # `state` is untouched
Raises:
| Type | Description |
|---|---|
ValueError
|
The model is being waited on. |
child
¶
child(child_id: str) -> State
The subagent State with id child_id, one that a tool call of this State started (one of the
child_ids of a ToolResultEntry in the history). A grandchild is state.child(a).child(b).
While the process that ran the subagents is alive, this is the very State object the subagent ran with
(state.child(a) is state.child(a)). After store.load, or in a State that did not run them, it is
loaded from the store this State is bound to. That State is not cached: every call loads a new one, so
state.child(a) is state.child(a) is False then. A subagent whose Agent has a store of its own is not
in this State's store, so it cannot be opened from here: open it with that store's load(child_id).
Example
for entry in state.history:
if entry.kind == "tool_result":
for child_id in entry.child_ids:
print(entry.call.name, state.child(child_id).answer)
Raises:
| Type | Description |
|---|---|
KeyError
|
|
ValueError
|
It is a child, but this State has no way to open it: it is not in memory and no store is bound to this State. |
TypeError
|
The store works only asynchronously. Use |
LookupError
|
The store has no State with this id (a subagent with a store of its own). |
achild
async
¶
achild(child_id: str) -> State
The async version of child: the same lookup and the same errors, but a child that is not in memory
is loaded with await store.aload(child_id). Use it with a store that works only asynchronously. Like
child, it does not cache what it loads from the store.
Example
state = await store.aload("bug-hunt-1")
researcher_state = await state.achild(child_id)
restore
¶
restore(snapshot: StateSnapshot) -> None
Go back to snapshot, a state.snapshot() taken earlier from this State.
Every value goes back to what it was then (messages, turn, extra_data, finished,
answer, stopped), except usage, which stays cumulative (the tokens were spent), and
history, which keeps everything and gets one context_change entry saying the State went back.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snapshot
|
StateSnapshot
|
A snapshot of this State, taken between turns. |
required |
Raises:
| Type | Description |
|---|---|
TypeError
|
|
ValueError
|
|
StateSnapshot¶
What state.snapshot() returns: the frozen value of a State at one moment. Pass it to state.restore(snapshot).
StateSnapshot
dataclass
¶
StateSnapshot
The value of a State at one moment: what state.snapshot() returns, and what every state.<property>
reads. It is frozen, so it never changes while you hold it, and it has no methods that change anything.
Every field is a pure function of history: building a State from the same history (State(history=...))
gives an equal snapshot. == compares the public fields, so equal histories give equal snapshots. Pass a
snapshot to state.restore(snapshot) to go back to that point.
A snapshot lives in memory. To keep one across processes, keep its history: State(history=snapshot.history)
rebuilds the same value, and so does a store.
Example
before = state.snapshot()
agent.run(state)
if before.usage != state.usage:
state.restore(before) # undo the run's effect on the context
history
class-attribute
instance-attribute
¶
history: tuple[HistoryEntry, ...] = ()
Everything that happened, oldest first. Entries are only ever added.
messages
class-attribute
instance-attribute
¶
messages: tuple[Message, ...] = ()
What the model will see at the next think. Results of this turn's tool calls go in together once the
last call has a result. Messages added in the meantime, or while the model is being waited on, go in after
them.
pending_calls
class-attribute
instance-attribute
¶
pending_calls: tuple[ToolCall, ...] = ()
The tool calls of the open turn that have no result yet, in request order.
turn
class-attribute
instance-attribute
¶
turn: int = 0
Completed model replies, plus one while a request is waiting on the model.
usage
class-attribute
instance-attribute
¶
Tokens, requests and cost of every think, ask and compact. It is cumulative: restore does not
lower it, because the tokens were spent.
extra_data
class-attribute
instance-attribute
¶
extra_data: FrozenDict[str, Any] = field(default_factory=FrozenDict)
Your own values (JSON), which the model never sees. Read-only: change them with
state.edit_extra_data().
finished
class-attribute
instance-attribute
¶
finished: bool = False
Whether finish() was called (and not undone by restore). A finished State cannot run again.
answer
class-attribute
instance-attribute
¶
answer: Any = None
The value given to finish(answer) if there is one, otherwise the text of the latest reply without tool
calls, otherwise None.
stopped
class-attribute
instance-attribute
¶
stopped: Stopped | None = None
Why the current or last run is set to stop, or None (a new run clears it).
created_at
class-attribute
instance-attribute
¶
created_at: datetime | None = field(default=None, compare=False)
The at of the first history entry, or None while the history is empty. Not compared by ==.
updated_at
class-attribute
instance-attribute
¶
updated_at: datetime | None = field(default=None, compare=False)
The at of the latest history entry, or None while the history is empty. Not compared by ==.