Skip to content

Sessions - #262

Merged
igalshilman merged 45 commits into
mainfrom
sessions
Nov 4, 2026
Merged

igalshilman merged 45 commits into
mainfrom
sessions

Conversation

@igalshilman

@igalshilman igalshilman commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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 key

from restate import sessions

notes = sessions.SessionObject("notes", migrations=["CREATE TABLE notes (text TEXT NOT NULL)"])

@notes.handler
async def add(ctx: sessions.SessionContext, text: str) -> None:
    await ctx.db.execute("INSERT INTO notes (text) VALUES (?)", [text])

@notes.view(name="list")
async def list_notes(ctx: sessions.ViewContext) -> list[str]:
    return [row["text"] for row in await ctx.db.query("SELECT text FROM notes ORDER BY rowid")]
  • Storage: each key's SQLite-compatible database (ctx.db) lives in the object's state in Restate, so there's no database to run. It comes from shared-core's sql feature (8.3.0), bound with PyO3 in src/sessions.rs. Its futures run on the asyncio loop thread, and writes go straight into the VM.
  • Handlers and views: handlers run one at a time per key; views run beside them. Transactions are async with ctx.db.transaction() as tx, with tx.send(...) committed together with the transaction.
  • Recovery and cancellation: handlers run with explicit recovery (explicit_recovery as a handler and service key #265): a retried attempt runs again from the start, against what was committed, instead of replaying a journal. Explicit cancellation (Add explicit cancellation (preview) #264) is opt-in per handler; the agents use it to stop a turn.
  • Calls: typed calls and sends, await ctx.call(handler, arg, key=...) and ctx.send(handler, arg, key=..., delay=...).
  • Serving: 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/implementation split: the API is declared as Protocols (_api.py), and decorated handlers stay plain functions, so tests can pass fakes.

restate.agents: AI agents in session objects

  • AgentObject: one conversation per key, with built-in views (status, stop, pending, signal, notifications, watch, messages, sync, clear) and a signals companion object.
  • Tools for any framework: tool, plus tools that are Restate itself: call, send, workflow, subagent, wait_for_signal. Each tool call is made once.
  • restate.agents.langgraph (langgraph extra): LangGraph's checkpoints live in ctx.db (SqlCheckpointSaver). Each step is stored before the next starts.
  • restate.agents.pydantic_ai (pydantic_ai extra): the conversation is stored before each model request and with each response.
  • Shared behaviour: a crash in a tool resumes it with the same call id without asking the model again; a stop ends the turn as cancelled; a provider's refusal fails with 424 and names what to fix.

Changes to the SDK itself

Explicit cancellation (#264), the explicit_recovery key (#265) and restate.serve (#266), which this builds on, are merged. Beside the new packages:

  • _internal.pyi: types the native sessions API.
  • Small fixes: tests/connect.py's fake peer no longer leaks failed futures into later tests, and deprecated context-manager annotations are fixed in ext/pydantic.

Dependencies

  • [project] dependencies is still empty.
  • New extras: langgraph; pydantic_ai now also lists pydantic. (The serve extra comes with Add restate.serve: serve an app over HTTP/2 #266.)
  • Lazy imports: each integration imports its framework only when used. Importing one without its extra raises an ImportError naming the extra, and tests/agents_imports.py checks this.

Things to look at

  • Wheel size: sql adds 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.
  • Restate version: session objects preload only their database's head (state_preload), which needs restate-server 1.8 or later.
  • Preview status: the API is a preview. Anthropic and OpenAI integrations, structured output and a testing helper follow in later PRs.

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:

  • Flat calls instead of curried ones.
  • migrations= instead of schema=.
  • wait_for_signal and PerSession.
  • A smaller restate.agents namespace.
  • SqlError that can be caught.
  • Errors at definition time (a lone string as migrations, a sync handler, a reserved name).
  • RESTATE_AGENT_MODEL.
  • PEP 257 / Google-style docstrings throughout.

Left for follow-ups:

  • A restate.sessions.testing helper with an in-memory host.
  • A scalar() read helper.
  • An explicit only= in place of RESTATE_SERVE_ONLY.
  • Tool parameters injected by annotation.

Testing

  • Static checks: ruff, pyright (strict for sessions/agents), mypy, basedpyright, cargo fmt and clippy.
  • The full suite passes on 3.14 and 3.10, including end-to-end tests against a real Restate server.
  • The agent integrations are tested on scripted models: crashes mid-tool, stop and cancel, refusals, wrong tool arguments, and per-user options.
  • tests/examples_agents.py runs every example's commands, and runs one example as a program over HTTP/2.
  • A new CI job, "Install without extras", installs the built wheel alone into a fresh environment (Python 3.10 and 3.14) and runs scripts/check_no_extras.py:
    • nothing is installed beside restate-sdk, and import restate loads neither new package;
    • an ordinary app, and session objects and agents, serve;
    • each optional part fails only when used, naming its extra.

🤖 Generated with Claude Code

@igalshilman
igalshilman force-pushed the sessions branch 2 times, most recently from 5854214 to ce5f92e Compare November 4, 2026 16:38
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Test Results

  8 files  ±0    8 suites  ±0   3m 51s ⏱️ -4s
 71 tests ±0   71 ✅ ±0  0 💤 ±0  0 ❌ ±0 
322 runs  ±0  322 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 16ed3ec. ± Comparison against base commit 39bb159.

♻️ This comment has been updated with latest results.

@igalshilman
igalshilman force-pushed the sessions branch 2 times, most recently from 340090f to d1c9208 Compare November 4, 2026 18:46
@igalshilman
igalshilman marked this pull request as ready for review November 4, 2026 18:47

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's sql::Database to PyO3. Calls run on the asyncio loop; writes go directly into the VM, and Python handles waits.
  • restate.sessions: SessionObject with exclusive handlers (explicit recovery) and shared views, transactions with an outbox for tx.send, migrations, a single-loop FutureSelector hooked into ServerInvocationContext, and app/serve.
  • restate.agents: AgentObject with built-in views, a signals companion object, durable tools (call, send, workflow, subagent, wait_for_signal), and the conversation log.
  • LangGraph and pydantic_ai integrations: SqlCheckpointSaver keeps checkpoints in ctx.db; pydantic_ai stores the conversation before each model request and with each response.
  • SDK, build and tests: an _internal.pyi stub, annotation fixes, lint-rust added to just verify, new serve/langgraph extras, 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread python/restate/sessions/_selector.py Resolved
Comment thread python/restate/agents/_object.py Resolved
Comment thread python/restate/sessions/_api.py Resolved

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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: FutureSelector now sets _missed when a wait is registered while the loop is between passes. It takes another pass rather than blocking in receive(), and a new test covers a flush that yields.
  • sync page now matches its revision: the view reads the revision first, then gets the page from a call to its own messages view, a new invocation that starts after the read.
  • Stopped turns report calls that returned: both integrations' _close now use _calls.outcomes and cut_off_result. A call whose Restate call returned is recorded with its error or with RETURNED_UNRECORDED, not CANCELLED.
  • 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: Transaction no longer promises that sends repeated by a retried attempt are dropped.
  • restate.serve replaces sessions.serve: examples, docstrings and the README now use asyncio.run(restate.serve(sessions.app(...))).
  • CI job without extras: no-extras builds the wheel and installs it alone on 3.10 and 3.14. scripts/check_no_extras.py then checks that nothing else is installed, that import restate loads neither new package, and that each optional part raises an ImportError naming its extra only when used.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

igalshilman and others added 19 commits November 4, 2026 20:29
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
igalshilman and others added 26 commits November 4, 2026 20:29
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
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>
@igalshilman
igalshilman merged commit 4730b10 into main Nov 4, 2026
11 of 12 checks passed
@igalshilman
igalshilman deleted the sessions branch November 4, 2026 19:34
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 4, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant