Repository navigation
Agents: fewer database commits and companion calls per turn - #308
Conversation
A turn made 2r+1 commits of the key's database (r model requests): its prompt, each reply and each batch of tool results, and then the final reply and the answer one after the other, with nothing but memory between them. Each commit is two state writes, a run and a round trip. In `answer_once`'s run, a step that no tool waits for (a reply without tool calls, ADK's end-of-agent event) is now held in memory, and written by the next write in its transaction: so the final reply commits with the turn's answer. Replies with tool calls still commit before their tools run, and tool results before the model is asked again. What is held is written before a publish, before a call record and LangGraph's nodes' calls, and when the attempt fails; a crash loses it, and the retried attempt asks the model again. A turn makes one commit fewer in every framework, and two fewer with ADK: a plain turn 3 to 2 (ADK 4 to 2), a turn with one round of tool calls 5 to 4 (ADK 6 to 4). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… call's start and end A followed turn told its companion to publish at its start and at every tool call's start and end: 3 + 2k calls a turn (k tool calls), each an invocation of the companion. A publish carries nothing: it wakes the followers, who read the conversation again. Pydantic AI, OpenAI and ADK store a reply's results together, after the tools' end and before the next model request, and no publish followed that commit: a follower saw the results only when the next tool call started or the turn ended, a model request late. Of the 8 revisions of a turn with one reply of 3 tool calls, 6 showed a follower nothing new. Now the turn publishes once after each write of its steps that commits (a reply with tool calls, the tools' results), and waits for it: a follower sees the calls as their tools start and the results before the model is asked again, and the messages steered to the turn are those of that publish. The companion's `start` makes the turn's first publish and returns what `publish` returns (`Started.publishes`); a companion that returns nothing from `start` gets a publish from the turn, as before. `tool_call_started` only forgets the stored calls, and `tool_call_ended` does nothing. The companion no longer clears an empty `watching` at each publish. Companion calls of a turn with one reply of 3 tool calls go from 9 to 4, and of a plain turn from 3 to 2; that turn's journal, on Restate 1.8.1 with Pydantic AI, from 46-48 entries to 31-33. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…rn's own A provider may give a tool call the id of an earlier turn's call. The integrations asked whether the window knew the result by call id, found the earlier turn's, and never stored the new one: later turns told the model the call was interrupted (Pydantic AI), or sent a call without output that a strict provider rejects (OpenAI). `Conversation.new_results` now takes a result for a call the turn being answered has open as the turn's own once it comes after the latest call of that id in the run's items; one before it is an earlier turn's, handed back, and is not stored again. Pydantic AI's `store` finds results through it. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`Stop.guard` cancels the run's task. A write of the ledger cancelled while it waited for its commit left its rows committed but the ledger's next position, `seq` and held rows as they were, so the turn's close wrote over them: the cancellation surfaced as a unique constraint error and the turn stayed open. As the steer store already was, every other ledger write (`Turn._insert`, `answered`, `snapshot`, `drop`, `close`, `Conversation.ask`, `snapshot_between`, `flush`) is now shielded (`_shielded`): it finishes with its bookkeeping while the caller gets the cancellation. `close` takes the lock before it reads the turn's open calls and status, so that it closes after a write under way, with the calls it made, or not at all once it stored the answer. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The plan store journaled each read in a tool call in a commit of its own, though a read's row matters only if a write follows it in the call. The reads are now kept on the call and written in its next write's transaction: a call that only reads commits nothing, and a call that runs again replays its reads as before, since one that crashed before its next write had not changed the plan since them. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A turn reads the tool call records as it ends (`settle`, from `answering`), whether or not a tool ran, so a key's first turn applied the calls table's schema in a commit of its own after the conversation's. `restate.sessions.internal.ensure_schemas` applies several schemas, those missing in one transaction (the sessions layer already did so for a list), and `answer_once` ensures the conversation's and the calls' with it. The call records still ensure their own schema, for callers without a conversation: within an attempt that already applied it, that costs nothing. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…most The design of the Pydantic AI integration said `_Restate` runs innermost and stores the snapshots. `_Snapshots` stores them, innermost but for `_Steering`, which it wraps; `_Restate` comes first of the run's capabilities and stores the results, the enqueued messages and the responses. The text and the sequence diagram now say so. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues. One minor suggestion inline.
Reviewed changes
I reviewed all 29 files of the PR at 5de21bf, covering the held-row ledger, publish-after-commit, the companion's start, shielded writes, the call-id reuse fix, deferred plan reads and ensure_schemas.
- Held steps commit with the next write. In
answer_once, steps with no tool call or result keep their positions but stay in memory. The next ledger write, a publish, a call record, a LangGraph node's Restate call or a failing attempt writes them first. The database therefore always holds a prefix of the turn's positions, so a crash can only produce a state the old code could also reach. - Publishes follow commits.
_Turn._storedawaits a publish after each write that commits.tool_call_endedbecomes a no-op, and the companion'sstartpublishes and returnsPublishedwhen the turn passesStarted.publishes. Older and TypeScript companions still get a publish from the turn. - Shielded ledger writes.
_insert,answered,close,drop,snapshot,ask,snapshot_betweenandflushfinish once started, andclosetakes the lock before checking its status. A cancelled write can no longer leave the ledger's positions out of step with the database. - Reused call ids across turns.
new_resultstreats a result as the turn's own when its id matches an open call and the result comes after the latest call with that id. Pydantic AI now uses it too. - Deferred plan reads and one schema commit. A plan read inside a tool call is journaled with that call's next write.
answer_onceapplies both schemas in one transaction.
I ran tests/agents_conversation.py, tests/agents_publishes.py and the in-process tests in the Pydantic AI, OpenAI, ADK, stores and LangGraph files locally, and all pass. The Docker-harness tests can't run here. I also checked that id reuse within a single turn isn't a remaining gap: the OpenAI Agents SDK itself rejects it (ModelBehaviorError: Model reused a completed tool call ID).
claude-opus-5-5 | 𝕏
A graph of your own copied each ToolMessage of a step in a write of its own: since each write that commits now publishes, a step of N tools made N commits and N publishes, where the built-in agent makes one of each. Consecutive tool messages are now copied in one write. Also: an annotation pyright needed in `new_results`, and ADK's request and response types imported from their own modules in the publishes test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
There was a problem hiding this comment.
✅ No new issues found. The batching suggestion from the last review is addressed.
Reviewed changes
I reviewed the one commit pushed since the previous Pullfrog review (5de21bf → 7c37cfb).
- Batched a graph step's tool results.
_copiednow collects consecutiveToolMessages and stores them with a singleturn.append(*results). It writes them before the nextAIMessageand again after the loop, so a parallel tool step costs one commit and one publish. The explicitstepped(ctx)call is gone, becauseTurn._storedalready publishes after each write that commits.knownis still updated for every copied message. - Added
test_a_steps_tool_messages_are_copied_in_one_write. It checks the write grouping andknown, both with and without a final reply after the results. I ran it locally along with the neighbouring checkpoint test, and both pass.ruffis clean. - Fixed typing for CI. This adds an annotation on
open_idsinnew_resultsand moves the ADK imports intests/agents_publishes.pyto their concrete modules. Neither changes behavior.
claude-opus-5-5 | 𝕏

Summary
A review of what one turn of
restate.agentscosts in Restate: the commits of the key's database (ctx.db) and the calls to the companion. It makes a turn cheaper in every framework, and fixes the bugs the review confirmed along the way.Measured on Restate 1.8.1 with Pydantic AI, a warm key, for a turn of one reply with 3 parallel tool calls and then an answer:
chatinvocationA plain turn goes from 3 commits and 3 companion calls to 2 and 2 (24 journal entries to 17). A key's first turn makes one schema commit instead of two.
1. A turn's final reply commits with its answer
A turn made 2r + 1 commits (r model requests): its prompt, each reply and each batch of tool results, then the final reply and the answer one after the other, with only memory between them. Each commit is two state writes, a run and a round trip.
answer_once's run, a step that no tool waits for (a reply without tool calls, ADK's end-of-agent event) is held in memory. The next write writes it in its own transaction, so the final reply commits with the answer.Commits per warm turn:
2. A turn publishes after each step it commits
A followed turn published at its start and at every tool call's start and end: 3 + 2k companion calls (k tool calls). A publish carries nothing; it wakes followers, who read the conversation again.
This fixes a visibility bug. Pydantic AI, OpenAI and ADK store a reply's results together, after the tools end and before the next model request, and no publish followed that commit. A follower saw tool results only when the next tool call started or the turn ended, a model request late. Of the 8 revisions of the turn above, 6 showed a follower nothing new.
startmakes the turn's first publish and returns whatpublishreturns (Started.publishes). A companion that returns nothing fromstart(an earlier deployment, or the TypeScript SDK's) still gets a publish from the turn.tool_call_startedonly forgets the stored call records;tool_call_endeddoes nothing and is kept for callers ofrestate.agents.internal.watchingat each publish.3. Bugs the review confirmed
docs/agents/frameworks.mdnamed the wrong Pydantic AI capability as innermost and as the one that stores snapshots.4. Commits that nothing needed
restate.sessions.internal.ensure_schemasapplies several schemas in one transaction, andanswer_onceuses it.Docs
docs/agents/turns.md,access-patterns.md,learnings.md,conversation.mdandframeworks.mdsay what is true now;porting.mdsays what the TypeScript port must change (starttakingStarted.publishesand returning whatpublishreturns).Tests
startreturns nothing; reused call ids, with a provider that refuses a call without output; a write cancelled while it commits; a plan read that commits nothing; one schema commit on a key's first turn.Not in this PR
🤖 Generated with Claude Code