Repository navigation
Sessions - #262
Sessions#262
Conversation
5854214 to
ce5f92e
Compare
340090f to
d1c9208
Compare
There was a problem hiding this comment.
Important
Three defects should be fixed before merge. The selector can stall a handler until the inactivity timeout. The agents' sync view returns a new revision alongside stale messages. The pydantic_ai integration can ask the model a second time after its answer was already stored. Smaller items are inline.
Reviewed changes
This is a first review of the whole PR: the native SQL binding, restate.sessions, restate.agents with its LangGraph and pydantic_ai integrations, and the small SDK typing changes.
src/sessions.rs: binds shared-core'ssql::Databaseto PyO3. Calls run on the asyncio loop; writes go directly into the VM, and Python handles waits.restate.sessions:SessionObjectwith exclusive handlers (explicit recovery) and shared views, transactions with an outbox fortx.send, migrations, a single-loopFutureSelectorhooked intoServerInvocationContext, andapp/serve.restate.agents:AgentObjectwith built-in views, asignalscompanion object, durable tools (call,send,workflow,subagent,wait_for_signal), and the conversation log.- LangGraph and pydantic_ai integrations:
SqlCheckpointSaverkeeps checkpoints inctx.db; pydantic_ai stores the conversation before each model request and with each response. - SDK, build and tests: an
_internal.pyistub, annotation fixes,lint-rustadded tojust verify, newserve/langgraphextras, strict type checking for the new packages, and extensive tests and examples.
Specialist reads of the Rust binding (locking, wake-ups, VM borrows, page cache) and of SqlCheckpointSaver's compliance with the LangGraph contract found no defects.
claude-opus-5-5 | 𝕏
There was a problem hiding this comment.
ℹ️ All six earlier findings are fixed. One small follow-up is inline.
Reviewed changes
This run covers the commits since d1c9208: fixes for the earlier review, restate.serve replacing sessions.serve, and a CI job that installs the wheel without extras.
- Selector stall fixed:
FutureSelectornow sets_missedwhen a wait is registered while the loop is between passes. It takes another pass rather than blocking inreceive(), and a new test covers a flush that yields. syncpage now matches its revision: the view reads the revision first, then gets the page from a call to its ownmessagesview, a new invocation that starts after the read.- Stopped turns report calls that returned: both integrations'
_closenow use_calls.outcomesandcut_off_result. A call whose Restate call returned is recorded with its error or withRETURNED_UNRECORDED, notCANCELLED. - pydantic_ai fixes: a retried run that already stored a final text response marks that response as the answer instead of asking the model again. The message adapter now stores bytes as base64, as pydantic-ai's own adapter does.
- Outbox docstring corrected:
Transactionno longer promises that sends repeated by a retried attempt are dropped. restate.servereplacessessions.serve: examples, docstrings and the README now useasyncio.run(restate.serve(sessions.app(...))).- CI job without extras:
no-extrasbuilds the wheel and installs it alone on 3.10 and 3.14.scripts/check_no_extras.pythen checks that nothing else is installed, thatimport restateloads neither new package, and that each optional part raises anImportErrornaming its extra only when used.
claude-opus-5-5 | 𝕏
| returned = await outcomes(ctx, [str(c.get("id")) for c in calls]) | ||
| closing: list[BaseMessage] = [ | ||
| *( | ||
| ToolMessage(cut_off_result(returned, str(c.get("id")), text), tool_call_id=str(c.get("id")), status="error") |
There was a problem hiding this comment.
This line still sets status="error" when cut_off_result returns RETURNED_UNRECORDED. langchain-anthropic turns that status into is_error: true on the tool_result block. So the model is told the call failed, while the text says it happened, which is the mixed signal this change set out to remove. Set "error" only when the result is not RETURNED_UNRECORDED. The pydantic_ai _close has no status flag, so it is unaffected.
restate-sdk-shared-core 8.3.0 with its sql feature: the SQLite-compatible database kept in an object's state, with its page cache (foyer), for restate.sessions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The Python variants of @restatedev/sessions and @restatedev/agents will live here; for now, each is a package with its docstring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The native side of sessions, in restate._internal (src/sessions.rs): an object's database over a key/value store implemented in Python, as sdk-sessions' node.rs binds it to JavaScript. Every request to the store is scheduled on the asyncio loop with call_soon_threadsafe, in issue order; a call that needs nothing from the store answers at once, one that waits goes on in tokio (pyo3-async-runtimes). The process-wide page cache reads the same RESTATE_SQL_CACHE* variables as the Node addon. restate.sessions._database orders the calls and runs transactions, as database.ts does: records committed with a transaction, with_commit (what runs between a commit's writes and its sync), SqlError for what a retry would repeat, and the store's own error for a session it ended. Ported from sqlexperiment's restate_sql, onto shared-core's API (records in place of effects). Nothing uses it yet. uv now rebuilds the extension when the Rust changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
Without journal replay, a handler awaits and combines futures as it likes (asyncio.gather, asyncio.wait, tasks of its own), but the protocol has one await point per invocation: a suspension, the awaiting-on hint and a cancellation each take one unresolved future. The SDK drives each awaited future with a do_progress loop of its own, each reading the input; the selector registers every wait and asks the VM for progress on the first of all of them to complete, as sdk-sessions' selector.ts does. hook(context) makes a ServerInvocationContext's waits go through it, and starts each future when it is made. Ported from sqlexperiment's restate_fns, without its version pin (the context is of this package). Nothing uses it yet: session handlers will. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
_schema.py: the steps that make each key's database, applied once per key, with how far each schema got in the database's record `schema`, so a step added later runs once on each database; drop_all, for reset. As sdk-sessions' schema.ts. _calls.py: ctx.rpc and ctx.send (and a transaction's tx.send), typed by the handler they target: ctx.rpc(handler, key=...)(input) returns the call's awaitable, its result read by the handler's output serde. An object's or a workflow's handler needs its key, a service's has none. generic() calls or sends by name, for a target known only at run time. Nothing uses them yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The Python variant of @restatedev/sessions: a virtual object with a SQL
database per key, ctx.db, kept in the object's state.
notes = sessions.SessionObject("notes", schema=[...])
@notes.handler() # exclusive: SessionContext(key, request, db, rpc, send, cancel)
@notes.view() # shared, read-only: ViewContext(key, request, db, rpc, cancel)
Handlers run with explicit recovery: every attempt runs from its first
line against what the attempts before it committed. Each attempt hooks
the selector, so its waits (SQL, calls) can be awaited together. A
transaction's sends (tx.send) are kept in the outbox record and made with
its commit, each with an idempotency key of the transaction, and made
again by the next attempt if a crash left them there. The outbox format
is sdk-sessions'.
_session.py is session.ts's Attempt; _object.py its object.ts, over the
SDK's VirtualObject (lazy state, metadata hiding the database's entries).
Not yet: preloading only the database's head, explicit cancellation, and
the scope that @restatedev/agents keeps per attempt.
Tests: attempts over an in-memory state (sessions_object.py), and a real
Restate server (sessions_harness.py).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
@notes.handler
async def add(ctx: sessions.SessionContext, text: str) -> None: ...
@notes.handler() and @notes.handler(name=..., ...) work as before; the
overloads keep both typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
- A store that answers with the wrong type fails the request with why, rather than leaving it to fail as dropped when the reply is collected. - The object's store makes its sync (the empty run) and its listing when it is called, in order with the writes, as the Store contract says and sdk-sessions does; as coroutines, they were made a loop turn later. - Ending a session no longer swallows the CancelledError of the task that waits for its open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The VM and the storage are in the same extension, so a request needs no trip between threads, nor, for a write, through Python: - The database's calls run on the thread of the asyncio loop that makes them (SqlTask): a call that waits returns an asyncio future, polled again on the loop as what it waits for wakes it. shared-core's database creates no thread, and runs on whatever polls it. The page cache's disk tier, on foyer's runtime, wakes the loop through a thread of its own, so that it never waits for the GIL. pyo3-async-runtimes goes, and with it the tokio threads every call crossed to and from. - The store's requests are calls of Python's dispatch on that thread, in order, rather than callbacks scheduled from another. - With the invocation's VM (the SDK's context gives it, unless a journal codec encodes the state), the store is the VM (VmStore): writes are its state commands, and a read it can answer at once is answered by it. Only a read the runtime must answer waits through the selector (WAIT), as do the sync and the listing. In-process, over the tests' in-memory store, a single-row commit goes from 182 to 126 us, the loop no longer sleeping between threads. Against a real server a commit is about 5 ms of wall time, its sync's round trip, and about 1.4 ms of CPU, most of it the SDK's HTTP/2 and run machinery: the two stores measure the same there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
Each test runs on the invocation's VM, which the extension writes and reads directly, and on the Python store over the context's state commands, which an endpoint with a journal codec uses; one test checks that the writes went through the store asked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
@obj.handler(explicit_cancellation=True), with sessions.cancelled(ctx), which returns when Restate cancels the invocation, and sessions.cancel_calls(ctx), which cancels the calls the attempt made: as sdk-sessions' cancelled and cancelCalls, which the agents' chat handler uses to stop a turn, a model's request included. The cancellation is one more wait of the selector's, awaited with the handler's others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
sessions.cancelled(ctx), waited for by a task all along a turn (the agents' way), was failed with the SuspendedException of the attempt's end whenever the turn ended uncancelled, and asyncio logged "Task exception was never retrieved" for every such turn. The attempt's end now ends it with asyncio.CancelledError: its task ends cancelled, quietly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The packages written to be fully typed, restate.sessions and (to come) restate.agents, are strict in both type checkers: pyright's strict mode, and mypy with annotations required, no Any returned, no bare generics, no unused ignores. Ruff checks restate.sessions with more rules (python/restate/sessions/ruff.toml): annotations, bugbear, pyupgrade for Python 3.10, sorted imports, simplify, ruff's own, async pitfalls; Any and serde defaults (the SDK's API style) are allowed. What it took: - python/restate/_internal.pyi types the native sessions API (the database, sql_open, the store's ops); the VM's bindings stay untyped behind a module __getattr__, as without the stub. - The internals a package object keeps for another (a context's attempt, a database's attempt, a transaction's reset, an object's entries) are Links (_link.py, as sdk-sessions' link.ts), not hidden fields: the contexts compare by identity. Reads have query_columns, public. - Generic parameters, a typed dispatch, and the SDK's VirtualObject and Workflow name parameters annotated. The rest of the SDK keeps its configuration. All checks pass on Python 3.10 and 3.14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
`just lint-rust`: cargo fmt --check and clippy with warnings as errors, which CI did not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
basedpyright, an editor's, flagged what pyright and mypy let through: - @asynccontextmanager functions annotated -> AsyncIterator[...] (deprecated): -> AsyncGenerator[..., None], here and in the SDK's pydantic integration and tests. - What `# type: ignore` hid from basedpyright: the host's VM and its wait for a read are now part of SqlHost; the store's optional keys and wait are looked up once rather than cast to; the selector's events are mappings, not cast to dicts. - Instance attributes annotated; the abstract reads declared abstract; the store's protocol positional-only; the selector imported as a module of its own (no cycle through the package). [tool.pyright] sets the standard mode, which pyright has by default and basedpyright reads from it (it refuses a [tool.basedpyright] beside it), and reports deprecations: restate.sessions has no error nor warning in either, nor in mypy; Any, serde defaults and @OverRide (typing 3.12) are left to recommended mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
What handlers and views code against is declared in _api.py, as protocols: SessionContext, ViewContext, Database, DatabaseReader, DatabaseWriter, Transaction, Rpc and Send (and Cancel). restate.sessions exports them under the names it had; the implementations are private classes of the modules beside it (AttemptContext, AttemptDatabase, AttemptTransaction, Caller, Sender, ...), which the strict type checkers check against the protocols wherever one is given for the other. What a handler sees is what is declared, and a test can give a handler a fake of its own: @obj.handler and @obj.view now return the function itself, marked as the handler it is (ctx.rpc and the client find it), so a test calls it with a fake context. SessionObject stays a class, as the SDK's VirtualObject; CallFuture, Schema and SqlError stay values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
What restate.agents needs of sessions, as sdk-sessions has it: - ScopeKey (_scope.py, as scope.ts): what a library keeps for an attempt (an agent's turn), by its context; a retry starts without it. Views have a scope too, and definition_of(ctx) gives the session object of a handler or a view, where a library keeps what it keeps per object. - needing(definition, services) and served(...) (_serve.py, as serve.ts): the services a definition needs served beside it (an agent's signals). sessions.app(*services) is restate.app's with them, each once; RESTATE_SERVE_ONLY serves only those it names. The SDK has no serve: the program serves the app (hypercorn, uvicorn). - restate.sessions.internal (as @restatedev/sessions/internal): Schema, database, reset, cancelled, cancel_calls, ScopeKey, definition_of, needing, served and invoke, which libraries and tests use; the package exports what handlers code against, and app. Also a fix, found in this review: a Link holds its value, and an attempt holds its database, so the link from a database to its attempt kept every attempt alive for good. Link(weak=True) holds the value weakly, for the owner to keep it; a test checks an attempt is freed once it ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The first part of the agents' core, ported from sdk-sessions' agents package: - _api.py: what clients read and send, as JSON (ChatMessage and its parts, Messages and MessagesRequest, Turn), with sdk-sessions' field names, so that a client follows an agent of either SDK. - _transcript.py: Transcript, the conversation as a framework builds it (say, call, result); a message given out never changes, so the log stores only what is new. - _log.py: the log in the object's database (sdk-sessions' tables): each message an id that never changes and a seq, the cursor of its last change, so a client reads each change once; a replaced transcript leaves its messages in the log; restart, for clear, goes on from where the log got to. The agents' package is linted and typed as restate.sessions is. Nothing uses it yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The second part of the agents' core: sdk-sessions' signals.ts, a journaled virtual object per agent key (`<agent>:<key>`), with the same handlers, so that it is the same service whichever SDK serves it: - wait / resolve / pending (and listen, forget): a tool's wait for a signal (an approval, say), resolved by its id or the oldest of a name, or kept for the next wait; an agent's own handler cannot wait on an awakeable, so it calls wait, which can, holding no process. - start / awaitTurn / close / status: the message the agent answers; awaitTurn attaches to the turn's invocation, and closes it if it failed without closing itself (killed, out of retries). - publish / notifications / watch (subscribe, unsubscribe): the conversation's revision, which clients wait on for a newer one. - with_companion, companion_of, deliver: how an agent reaches its companion, and sessions.app serves it beside the agent. Tested against a real Restate server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
mypy takes a dict literal passed to the client for a dict, not the handler's TypedDict: the waits, signals and watches are built by typed helpers. (The previous commit went in with these 15 mypy errors.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The third part of the agents' core, from sdk-sessions' object.ts and common.ts: - AgentObject, a SessionObject set up for agents (weeks of retries, its companion served beside it), with the views every agent has: signal, pending, status, stop, notifications and watch, each asking the companion. chat(answer) makes it an agent: the chat handler (a message in, as text or a JSON string, the answer out), the messages view (its log), clear (stop, then the internal erase empties the database, and the log's ids go on), and sync, which a follower reads. - The turn (_turn.py): it starts at the companion, publishes its steps, keeps the framework's transcript in the log (transcribing), and closes; Restate's cancellation (explicit) and the attempt's end stop it (Stop, as AbortSignal, with guard() for a framework's request), and a model provider's refusal ends it with 424 (_common.py), in any handler of an agent. - The tool calls' bookkeeping (_calls.py), which publishing settles. Found by its tests, a fix in sessions: ctx.cancel called a cancel_invocation the SDK's host lacked (typed Any, only the tests' fake host had it); it is now in SqlHost, implemented, and tested from a view (a Stop button). SessionObject gains a protected hook, _attempt_fn, for a subclass to wrap what an attempt runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
The fourth part of the agents' core, from sdk-sessions' tools.ts: - tool: a tool from a plain function, its input from the type hints and its description from the docstring; ctx, call_id and idempotency_key are given, not the model's. A TerminalError is the tool's answer, any other error fails the attempt (ToolError), and arguments that do not fit the input answer the model with what is wrong. - The tools that are Restate itself: call and send (a handler, the model choosing an object's key unless given), workflow, subagent (a task to another agent, keyed by this key and the task's name), and signal (a wait at the companion, which the agent's signal view resolves). Each is made once per tool call (_calls.py): the same Restate call however often a crash runs its tool call again. - run_tool, for a framework's integration: the tool call's start and end are published. Tools take their schemas from pydantic, imported when a tool is made: restate.sessions and restate.agents import without it (a test checks, in a process without pydantic), and an agent framework's extra brings it. restate.agents exports what agents are built with; restate.agents.internal, what a framework's integration needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
loop.add_signal_handler keeps one handler per signal, so the endpoint's replaced the server's: on Python 3.12+ hypercorn no longer stopped on SIGTERM. The endpoint now chains a signal-module handler to the previous one; the loop's own handlers still run through its wakeup fd. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
…serve Ports sdk-sessions' template lessons and sqlexperiment's notes. Each file runs on its own with sessions.serve (hypercorn, imported lazily), which stops on SIGINT/SIGTERM within hypercorn's graceful timeout. tests/examples_agents.py runs every example's commands against a real Restate server; the directory is linted and typechecked with the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
uvicorn 0.53 serves HTTP/2 (cleartext, with prior knowledge, as Restate calls endpoints) through zttp. The new serve extra installs both, on Python 3.11+ (zttp's floor). serve uses uvicorn's own signal handling, with a bounded graceful shutdown. A test runs an example as a program, registers it with Restate, checks Restate speaks HTTP/2.0 to it, and stops it with SIGTERM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
… peer The stub declares ConnectError and TunnelInfo, which restate.connection re-exports. tests/connect.py's fake peer fails futures no test may await any more: their exceptions surfaced, once garbage-collected, in whichever test ran next on the shared loop (tests sorting before connect.py made that disconnect_hotloop). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
state_preload=[HEAD_KEY], as sdk-sessions' sqlOptions: an invocation starts with the head, the open's first read, instead of a round trip for it; the rest of the state is read as needed. Needs restate-server 1.8 (endpoint manifest v5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
… in ctx.db As sdk-sessions' langgraph/checkpointer.ts (and sqlexperiment's Python port): checkpoints, channel values (stored when a step changes them, at unique versions, so forks never clobber) and pending writes in three tables; each put is one transaction; history="latest" keeps only the thread's last checkpoint. The langgraph extra installs LangGraph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
As sdk-sessions' langgraph/index.ts: the usual tool-calling graph (model, tools, answer), each step stored before the next (durability=sync) with the latest checkpoint only; each prompt answered once by id; a crash in a tool resumes it with the same call id, without asking the model again; a stop closes the run as cancelled; a provider's refusal fails with 424. A LangChain tool's terminal error also closes the run, so the next message does not meet it again. Dependencies stay opt-in: the langgraph extra (LangGraph, LangChain's core, pydantic); importing restate.agents.langgraph without it says which extra to install; openai/ and anthropic/ models import langchain-openai or langchain-anthropic only when used, and a missing one is a 424 naming the package. Example: examples-agents/basic/langgraph_agent.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
agent(name, model=, system=, tools=) and prompt(ctx, text, model=, system=, tools=); each of agent()'s options may be a value or a (sync or async) function of the invocation's context. Options and chat() are gone: one way to make an agent. Google-style docstrings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
… ctx.db sqlexperiment's integration on this package: the conversation stored before each model request and with each response (capabilities hooks), each prompt answered once by id, a crash in a tool resumed with the same call id without asking the model again, a stop closing the run as cancelled, a tool function's TerminalError its answer. agent(name, [Agent], model=, system=, tools=) and prompt(ctx, text, ...), as in langgraph; this package's tools are bound to the turn per run (toolsets). Models resolve before the turn: a missing provider extra, API key or unknown provider is a 424 naming it. A provider's ModelHTTPError 400/ 401/403/404 is a refusal (424) too. The pydantic_ai extra names pydantic; importing the integration without it says which extra to install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
From a developer-experience review of restate.sessions and restate.agents:
- SqlError is a TerminalError: still terminal, and now catchable around
a statement (a UNIQUE violation, say).
- Refused when defined: a schema given as one string (it became a step
per character), and a sync function as a handler or view.
- A second handler of a name says so; an agent's built-in names (status,
stop, chat...) say they are built-in and what to name yours instead.
- AgentObject.chat returns its answer function, and works as a
decorator (@agent.chat, @agent.chat(clear=...)).
- Models name their provider with / or : ("anthropic:claude-..."), as
the frameworks do.
- SessionObject.config and with_store_wrapper are private; objects have
a repr.
- No references to modules and extras that do not exist (claude.agent,
restate-sdk[anthropic], restate_sdk[serve]).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
Calls and sends take their input as an argument instead of returning a function of it: await ctx.call(add, 5, key="a"), ctx.send(remind, note, key=k, delay=...), tx.send(deposit, money, key=to). A send can no longer be forgotten by leaving off a trailing call. Overloads keep the typing: the result is the handler's, a wrong input is flagged, and a handler without input takes none. call, generic_call, send, generic_send and cancel are methods of the contexts (and send, generic_send of a transaction), declared by the Caller and Sender protocols; Rpc, Send and Cancel are gone. _api.py's docstrings are PEP 257 / Google style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
- The signal tool is wait_for_signal(signal, description=, input=, name=): no longer shadowing the stdlib's signal next to Signal and signals, and name= is the tool's name, as for every other tool. - Configure is PerSession: a value, or a (sync or async) function of the invocation's context giving it. - Companion, companion_of, deliver, signals, CANCELLED, REFUSED and UNANSWERED move to restate.agents.internal: autocomplete on restate.agents shows what an agent's author uses. - The package's docstring says what it is, with an example, and no references to the TypeScript library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
A session object's migrations=[...] is an append-only list of steps (SQL, or async functions of the transaction), each run once per key; calling it migrations, and its type Migration, makes the rule (never edit a step, add one) obvious. SessionObject, AgentObject and the frameworks' agent() take it; tutorial lesson 04 is 04_migrations.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
Namespaced, so it cannot collide with another tool's MODEL; MODEL is no longer read. The name is internal.MODEL_ENV, and error messages name it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
…comers restate.sessions, restate.agents and the LangGraph and Pydantic AI integrations: an imperative one-line summary for each public name, then Args/Returns/Raises where they help; plain sentences instead of the compressed phrases of the port; no references to the TypeScript library. SessionObject and AgentObject list what they offer (an agent's built-in, reserved views); the integrations say how they differ from restate.ext's langchain and pydantic ones. Docstrings and comments only: the code is unchanged (checked on the ASTs). examples-agents/README.md starts from pip install "restate-sdk[serve]", says sessions.serve needs Python 3.11+, and explains why chat takes plain text while other handlers take JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
… key it used An agent's handler or view named chat, messages, clear or sync was refused only once chat() registered the built-ins; every built-in name is now refused when it is registered. The send tool's confirmation named a key even for a service, which takes none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
server.py is main's again (but for passing explicit_cancellation to the VM). The SDK-wide fix, where install_shutdown_hook(chain=True) keeps the ASGI server's handler, is f14507d's and goes to main separately. sessions.serve no longer needs it: it registers uvicorn's exit through install_shutdown_hook itself, before serving, and uvicorn captures no signals. The endpoint's own SIGTERM hook then joins the same dispatcher on its first request, instead of replacing the server's handler. It stops on SIGINT and SIGTERM after serving requests (tests/examples_agents.py). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
… keys Now that both are options of the SDK's handler decorators, a session object's handler passes them through instead of setting them on the registered handler afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy
A new job builds the wheel, installs it alone into a fresh environment (Python 3.10 and 3.14) and runs scripts/check_no_extras.py from outside the checkout. It fails if the package requires anything beyond its extras, if anything is installed beside it, or if import restate loads restate.sessions or restate.agents. It checks that an ordinary restate.app, and session objects and agents, serve their discovery; and that pydantic tools, the langgraph and pydantic_ai integrations and sessions.serve each fail only when used, naming what to install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session objects and agents are served as any Restate app: asyncio.run(restate.serve(sessions.app(...))). sessions.serve is gone, and the examples, docs and the no-extras check use restate.serve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and three smaller ones - The selector could miss a wait registered while it flushed output before checking the VM: not in that pass's snapshot, and not waking it (it was not waiting yet). If the VM then waited for input, so did the loop, for a run (a commit's sync, say) that only starts once awaited. Such a wait now makes the loop take another pass. - An agent's sync returned a fresh revision with a page from its view's snapshot, taken before (by the whole wait, when it waited). It now reads the page in an invocation of its own (the messages view), after the revision. (A view's snapshot was confirmed against a real Restate.) - Pydantic AI: a final answer stored just before a crash, not yet marked as the prompt's, made the retry ask the model again. It is now marked. - Pydantic AI: messages store bytes as base64, as pydantic-ai does; a tool result of non-UTF-8 bytes failed every save, and so every attempt. - A stopped turn no longer reports a tool call whose Restate call returned as cancelled (the model would repeat it): it gets the error it returned, or a note that it happened. - Transaction's docstring no longer claims a retried body's sends are dropped: only a committed transaction's are made once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… port, and wait until it listens As in tests/serve.py (#266): Docker could map one of the Restate container's ports onto the port chosen for the example before the example bound it. The container now starts first, the test waits until the example accepts connections, and SIGTERM comes while Restate still holds its connections open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Session objects and agents (preview)
Two new packages, shipped in the SDK's wheel and usable only through opt-in extras. Existing users get no new dependencies.
restate.sessions: virtual objects with a SQL database per keyctx.db) lives in the object's state in Restate, so there's no database to run. It comes from shared-core'ssqlfeature (8.3.0), bound with PyO3 insrc/sessions.rs. Its futures run on the asyncio loop thread, and writes go straight into the VM.async with ctx.db.transaction() as tx, withtx.send(...)committed together with the transaction.await ctx.call(handler, arg, key=...)andctx.send(handler, arg, key=..., delay=...).sessions.app(...)gives an ASGI app (with the services agents need beside them), served like any Restate app:asyncio.run(restate.serve(sessions.app(notes)))(Add restate.serve: serve an app over HTTP/2 #266), or any ASGI server._api.py), and decorated handlers stay plain functions, so tests can pass fakes.restate.agents: AI agents in session objectsAgentObject: one conversation per key, with built-in views (status,stop,pending,signal,notifications,watch,messages,sync,clear) and a signals companion object.tool, plus tools that are Restate itself:call,send,workflow,subagent,wait_for_signal. Each tool call is made once.restate.agents.langgraph(langgraphextra): LangGraph's checkpoints live inctx.db(SqlCheckpointSaver). Each step is stored before the next starts.restate.agents.pydantic_ai(pydantic_aiextra): the conversation is stored before each model request and with each response.Changes to the SDK itself
Explicit cancellation (#264), the
explicit_recoverykey (#265) andrestate.serve(#266), which this builds on, are merged. Beside the new packages:_internal.pyi: types the native sessions API.tests/connect.py's fake peer no longer leaks failed futures into later tests, and deprecated context-manager annotations are fixed inext/pydantic.Dependencies
[project] dependenciesis still empty.langgraph;pydantic_ainow also listspydantic. (Theserveextra comes with Add restate.serve: serve an app over HTTP/2 #266.)ImportErrornaming the extra, andtests/agents_imports.pychecks this.Things to look at
sqladds a SQLite-compatible engine and foyer's page cache. A local unstripped release build of the extension is 29 MB (macOS arm64); compare with CI's wheel artifacts.state_preload), which needs restate-server 1.8 or later.API review
A developer-experience review of the public surface, read as an experienced Python developer would read it, led to these changes in this PR:
migrations=instead ofschema=.wait_for_signalandPerSession.restate.agentsnamespace.SqlErrorthat can be caught.migrations, a sync handler, a reserved name).RESTATE_AGENT_MODEL.Left for follow-ups:
restate.sessions.testinghelper with an in-memory host.scalar()read helper.only=in place ofRESTATE_SERVE_ONLY.Testing
sessions/agents), mypy, basedpyright, cargo fmt and clippy.tests/examples_agents.pyruns every example's commands, and runs one example as a program over HTTP/2.scripts/check_no_extras.py:restate-sdk, andimport restateloads neither new package;🤖 Generated with Claude Code