Keep and restore conversations#
Each request starts with fresh context. A history extension restores previous dialogue, so you decide how long a conversation lives and where it is stored. Default clients remember turns in memory; they do not persist them to disk.
Identity is not storage#
AgentRunConfig names the conversation:
from zett_agent.agent import AgentRunConfig
config = AgentRunConfig(session_id="project-notes")
The same session_id across requests is what lets a history extension find the
right messages. request_id is only a correlation label for one request; it is
not an idempotency key, and reusing it does not deduplicate anything.
Pick a history extension#
Extension |
Lifetime |
|---|---|
One process; the default when you do not pass |
|
Survives restarts; stores raw history and compaction checkpoints. |
|
Your own implementation |
Keep history in a script#
from zett_agent.client import create_agent
client = await create_agent(model)
await client.run("My preferred language is Python.")
reply = await client.run("What language did I mention?")
With a real model, the answer wording varies. The important contract is that the second request receives the earlier user and assistant messages. Reuse the client or the same accumulator instance to retain that memory. Creating a new client with a new default accumulator starts fresh, even if you reuse the session ID. A different ID selects an independent history.
Configure a durable conversation#
from zett_agent.agent import AgentRunConfig
from zett_agent.extensions.sqlite import SQLiteSessionExtension
from zett_agent.extensions.tool_guidelines import ToolGuidelinesExtension
storage = SQLiteSessionExtension("sessions.sqlite")
config = AgentRunConfig(session_id="project-notes")
try:
client = await create_agent(
model,
config=config,
extensions=[storage, ToolGuidelinesExtension()],
)
await client.run("Remember my preference for Python.")
finally:
await storage.close()
This creates or updates a real file relative to the process working directory.
Choose an application-owned path and keep it stable across restarts. The
extension also accepts ":memory:" for an ephemeral SQLite database.
Omitting the path uses its user-data default; use explicit paths in tests and
multi-user applications.
After reopening the same database, create a client with the same config
and send the next user message. Current system instructions and tools come
from the new client; persisted system prompts do not accumulate in model
context. Close the provider separately when it is no longer needed.
Restore across processes#
async def main() -> None:
# No demo touches the default database under the user's home directory.
with TemporaryDirectory(prefix="zett-docs-") as directory:
path = Path(directory) / "sessions.sqlite"
session_id = "project-notes"
config = AgentRunConfig(session_id=session_id)
first_storage = SQLiteSessionExtension(path)
try:
first = await create_agent(TurnModel(), config=config, extensions=[first_storage])
print((await first.run("Remember Python", metadata={"origin": "tutorial"})).content)
finally:
await first_storage.close()
restored_storage = SQLiteSessionExtension(path)
try:
restored = await create_agent(TurnModel(), config=config, extensions=[restored_storage])
print((await restored.run("Continue")).content)
rows = await restored_storage.list_raw_messages(session_id)
assert [row.message.role.value for row in rows] == [
"system",
"user",
"assistant",
"system",
"user",
"assistant",
]
assert rows[0].metadata == {"origin": "tutorial"}
assert len(await restored_storage.list_raw_messages(session_id, limit=2, offset=2)) == 2
view = await restored_storage.storage.load(session_id)
assert view.snapshot is None and len(view.raw_tail) == 6
await restored_storage.update_session(session_id, title="Project notes")
renamed = await restored_storage.get_session(session_id)
assert renamed is not None and renamed.title == "Project notes"
assert await restored_storage.delete_session(session_id)
assert await restored_storage.list_raw_messages(session_id) == []
print("Raw roles: system, user, assistant, system, user, assistant")
print("Session restored, renamed, and deleted in a temporary database")
finally:
await restored_storage.close()
This program writes to a temporary database, closes it, opens a new client, and
restores the first turn — the pattern to copy for a real application. Close the
extension when finished with await storage.close().
Warning
Use one history-restoring extension per agent. Combining SQLite and in-memory restore makes it unclear which messages the model sees.
Two views of the same conversation#
You will usually want history for a UI and history for the model, and they are not identical once compaction runs.
await storage.list_raw_messages(session_id)returns the original records in order — what you show in a transcript.await storage.storage.load(session_id)returns the model context: the latest checkpoint plus the raw messages after it.
Compaction adds a checkpoint; it never rewrites the raw log. Pagination and
listing helpers on the storage extension cover the rest (after_sequence is
exclusive, through_sequence is inclusive).
The raw log includes system messages and tool-only assistant messages, not just visible chat bubbles. A UI should select the roles and fields it wants to render instead of displaying every record as user-facing text.
records = await storage.list_raw_messages(
"project-notes", after_sequence=0, limit=50,
)
for record in records:
print(record.sequence, record.message.role, record.message)
storage.storage.load returns a SessionView with snapshot,
raw_tail, and a messages projection. During restore, the persistence
extension additionally omits incomplete assistant/tool batches left by an
interrupted process, so an endpoint never receives an orphan tool result.
The original records stay available in the raw log for diagnosis.
List, label, and forget conversations#
sessions = await storage.list_sessions(limit=20, offset=0)
await storage.update_session("project-notes", title="Project notes")
summary = await storage.get_session("project-notes")
create_session can create an empty session before the first run.
parent_session_id links a delegated conversation to its parent;
session_type is an application-defined integer classification, not a
permission or runtime mode.
To intentionally delete the stored session and its log/checkpoints, call
await storage.delete_session(session_id). This is deletion, not archival;
stop active requests first and apply your retention/backup policy. In-memory
history has accumulator.clear(session_id) instead. Session IDs do not
control access — authenticate the owner before any history operation.
Interrupted runs are not transactions#
Persistence appends completed message records as the request proceeds. A later error or cancellation does not undo a user message, finished tool result, or external side effect. Partial text deltas are not final assistant records. After a restart, let the restore projection select valid context rather than reconstructing it from UI fragments. For retry and idempotency decisions, see Integrate an application.
Running out of context#
Add CompactionExtension to summarize
older turns when the conversation approaches the model’s limit. Recent turns are
kept whole, including their tool calls and results, and a summary that would not
be smaller is discarded. See Compact without losing history.
Next: understand What the model sees, or move to Extend the runtime to build your own behavior.