Skip to content

Agents: a companion per agent, <agent>.prebuilt.restate, and the agent's own contexts - #311

Merged
igalshilman merged 16 commits into
mainfrom
claude/prebuilt-object
Nov 13, 2026
Merged

igalshilman merged 16 commits into
mainfrom
claude/prebuilt-object

Conversation

@igalshilman

@igalshilman igalshilman commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Three pieces of work on restate.agents, in the order they landed. The TypeScript side is restatedev/sdk-sessions#24; the two SDKs must agree on the companion's name before either releases.

Each agent brings its own companion

signals was one virtual object for the whole endpoint, keyed <agent>:<key>. There is one service of that name in a Restate cluster, so a Python endpoint and a TypeScript endpoint registered against the same cluster take the name from each other: whichever registered last serves the companion for both SDKs' agents, with its own handler set and payload details, and nothing warns about it. Serving one agent per image has the same problem inside one language, since every image also serves signals.

Now each agent brings its own, <agent>.prebuilt.restate, keyed as the agent and served beside it through __restate_services__. A companion is as unique as its agent, and an agent's name is already unique in a cluster. agent in the suffix said nothing the agent's own name does not, so the marker is .prebuilt.restate; Restate refuses names that start with restate, so it stays a suffix.

The companion holds what it always held: the tools' waits, the message being answered, the steering inbox, and the conversation's revision. Its handler names and payloads are unchanged, including the camelCase awaitTurn that is wire-shared with the TypeScript SDK.

No migration. The shared signals and the per-agent companion are both preview, and the state does not move: the new object starts empty for every key. The conversation itself is untouched — it lives in the key's SQL database, and the companion holds none of it.

docs/agents/prebuilt.md records the decision and why the shared name could not stay. The companion's name is not guarded: an agent named support.prebuilt.restate would own agent support's companion name, and Endpoint.bind catches that only within one endpoint. The note says so.

The chat turn publishes; the conversation stores

Publishing belongs to the agent's chat turn rather than to answer_once: an agent's own handlers publish their steps and the turn's end, with one settle per commit. A retry that finds the turn already answered publishes its end too, so an attempt that died between the commit storing the answer and the publish does not leave followers waiting for a watch timeout. A signal wait from a handler of a plain session object now fails for good with NeedsAgent, as it does in TypeScript, rather than calling a service nobody serves and being retried until the turn hangs. docs/agents/turns.md says which layer stores and which publishes.

AgentContext and AgentViewContext

The agents' API and a session object's took the same context, so a handler reaching up a layer was only found out while the turn ran, after a model round-trip, looking like a tool failure.

The two protocols name the layer in the signature. They are a signpost, not a fence: the marker member is declared and never exists at runtime, nothing reads it, restate/sessions/ does not change, and NeedsAgent stays the guard that actually stops the wait. They are applied to the agent's registration surface — chat()'s answer, the built-in views, the private handlers (erase, settle, compact, steered, clear, sync, messages, steer), and the turn's publish helpers.

chat's TypeVar bound moves with it, which is the one strict signature on the public surface. Everything else is additive: parameter types are contravariant, so a handler annotated SessionContext still registers on an agent and still type-checks, and no existing user code breaks.

The conversation stays on SessionContext, deliberately. A handler of a plain session object drives answer_once — tests/agents_conversation.py registers it on a sessions.SessionObject, and each framework's public prompt(ctx: SessionContext, ...), the documented "for a handler of your own", funnels into it. The same holds for wait_for, take_steers, settings_of, companion_at and the LangGraph signal node, which are reached from a framework's prompt() or are the probes that find out which tier they are on. docs/agents/contexts.md says what narrowed and what could not, and why.

The tools (tool, call, send, workflow, subagent) and ToolCall.ctx stay on SessionContext too: a tool is a value a framework may run from either tier, so the type cannot reach through it.

Merging main's #310

main gained journal timing and the hiding of an agent's bookkeeping calls (ENTRY_NAME, hide_entries) while this branch was open, written against the shared signals and its <agent>:<key> addressing. The merge keeps this branch's per-agent companion calls and carries #310's entry name onto them, so every call main names is named here: the views, _stopped, steer, erase's reset, sync's reads, the turn's start/publish/close, and the taken send. main's naming test is kept whole, with its expected handlers re-pointed from signals/... to <agent>.prebuilt.restate/....

Two things left as main has them, worth a decision separately: ENTRY_NAME is still the literal "signals", now a stale label for an object of another name that deployed agents' metadata already carries; and the companion's wait calls are the one kind that stays unnamed, so a tool's wait is visible in the UI while every other bookkeeping call is hidden.

Verified

ruff check clean, pyright python/ 0 errors, pyright tests 0 errors, mypy clean. The agents and sessions suites run 560 passed, 0 failed.

The server-backed tests ran against a local restate-server binary rather than the Docker harness, since this environment has no Docker daemon; CI runs them the usual way.

Nothing under restate/sessions/ changed: git diff main...HEAD -- python/restate/sessions/ is empty.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV

…state

Whatever makes an agent (a framework's agent(), AgentObject, with_companion)
now also makes its companion: a virtual object of its own, named
`<agent>.agentprebuild.restate` (COMPANION_SUFFIX) and keyed as the agent
(`alice`, not `assistant:alice`). The agent serves it alongside itself; the
shared `signals` object is gone, and nothing is served or reserved under that
name. The companion knows its agent statically, and is ingress-private as a
whole: clients reach it through the agent's views.

make_companion(agent) builds it, with the same handlers, names and payloads
as before; Companion holds the object and typed references to its handlers.
Callers address it with ctx.call(companion.<handler>, ..., key=ctx.key):
views through their agent, a turn's code through companion_in(ctx), others
through companion_of(agent), which replaces `signals` in
restate.agents.internal.

Shims for a companion of another SDK or an older deployment are removed:
Started.settles (every dead turn is sent to settle), the turn's own publish
when start returns nothing, and Inbox.saw taking a publish without a
revision. Started.steers and Started.publishes stay: a framework that cannot
take steered messages, and a background agent, are real cases.

Upgrading leaves the old `signals` object's state behind: turns in flight,
pending waits and the revision do not carry over (docs/agents/turns.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
`answer_once` no longer publishes after each commit of a turn's steps:
`chat_turn` records what follows a commit (`after_steps`, a per-attempt
scope value, set only for a followed turn), and `answer_once` hands it to
`Conversation.hold` as an opaque callback, after forgetting the calls whose
results the commit stored (`settled`, which no longer rides on a publish).
So a plain session object's handler that prompts stores its turn and makes
no publish nor companion lookup.

`publish` from a context with no companion now raises (`companion_in`)
instead of silently skipping. Waiting for a signal (`wait_for_signal`,
`wait_for`, LangGraph's interrupts and `nodes.wait_for_signal`) from a plain
session object fails with a terminal error that says to use an agent,
instead of a TypeError that Restate retried forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
The conversation stores; the agent's chat turn publishes; a handler of your
own that prompts stores its turn and publishes nothing; waits and steering
need an agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
…ommit

- Every handler of an `AgentObject` (not only `chat`) has the steps its
  turns store publish, through `publish_steps`, as before 8f7ad14: a
  handler you add that prompts publishes each committed step again (no
  start nor close: only `chat` is a turn in the companion). A background
  agent's publish nothing, as before.
- `publish` no longer forgets settled calls: `answer_once` does it once
  per commit, before what follows it; the `settle` handler publishes
  outside any turn, where it did nothing.
- Tests: LangGraph's `nodes.wait_for_signal` and `interrupt()` from a
  plain session object fail the turn terminally with the needs-agent
  message.
- resurrect.py: re-point `pydantic-denial-approves` at today's
  `approval_of`; it is caught by three tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
…in TypeScript

`needs_agent` now raises `NeedsAgent`, a `TerminalError` that `Tool.run`
(and Pydantic AI's tool error hook) never turns into the tool's result for
the model: every wait from a session object that is no agent, the signal
tool included, fails the turn once, for good, closed `failed`. A tool's
other terminal errors still go to the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
A turn that runs in a handler other than `chat`, on an agent object,
published after each commit of its steps but never at the end: a follower
saw the steps arrive and then heard nothing, because only `chat_turn` calls
the companion's `close`, which is what wakes followers as a `chat` turn
ends.

It now publishes once more, after the commit that stores the turn's answer,
never before it: a follower told earlier would read a conversation that has
no answer yet. A turn closed for good (failed, refused, stopped) publishes
its end too, since its close stores the turn's answer; a turn whose attempt
failed publishes none, as it has not ended and the next attempt carries it
on. A turn nobody follows (a background agent's) publishes neither its steps
nor its end, as before.

The seam is `after_answer`, beside `after_steps`: every handler of an
`AgentObject` sets it (`publish_end`, guarded by the same `_unfollowed`
check as `publish_steps`), and `answer_once` runs it as an opaque callback
once the answer is stored. A `chat` turn sets nothing there: its end is the
companion's `close`, which publishes its last change with it, so its
publishes are what they were (`start`, one per commit of its steps,
`close`) and it gains no round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
…s `signals`

Both SDKs ship one `signals` object for a whole endpoint, keyed
`<agent>:<key>`. A cluster has one service of that name, so a Python
endpoint and a TypeScript endpoint registered against it take the name
from each other: one SDK's companion then serves the other's agents.

The design note records the move to one object per agent, named
`<agent>.prebuilt.restate` and keyed as the agent: why the shared name
cannot stay, why the marker is a suffix, that only agents have one, what
the rename touches in both repos, how a deployment on `signals` migrates
(drain first; the revision restarts per key), and what must hold
afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
The shared `signals` and `<agent>.agentprebuilt.restate` are both preview,
and neither per-agent companion has been released: the new object starts
empty for every key, and what a running `signals` holds is dropped with
it. The conversation is untouched either way, since the companion holds
none of it.

So the note drops the upgrade path, with the alias and the revision
tolerance it asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
An agent's companion is already its own object, keyed as the agent: the
name is what is left to settle. `agent` in the suffix says nothing the
agent's own name does not, so the marker is `.prebuilt.restate`, and an
agent's companion is as unique as the agent itself.

Restate refuses names that start with `restate`, so it stays a suffix.
The design note keeps the old spelling where it describes what came
before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
The script still counted a service named `signals` as a companion, for the
object every agent shared before. Nothing serves it any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
An agent's APIs and a session object's take the same context today, so a
handler reaching up a layer — a signal wait from a plain session object —
is only found out while the turn runs.

The note names the two contexts, as protocols in Python with a marker
that never exists at runtime and as plain `extends` in TypeScript: a
signpost in the signature, with `NeedsAgent` left as the guard that
actually stops the wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
An agent's handler and a session object's take the same context, so a
handler that reaches up a layer is only found out while the turn runs.
The two protocols extend the session contexts with a private marker that
is declared and never present, so a plain session context is not one of
them for a type checker, and the layer is in the signature where the
person writing the handler reads it.

Nothing of them exists at runtime: the session tier goes on building its
attempt contexts, nothing reads the marker, and `NeedsAgent` stays the
guard that stops a wait from a session object.

What takes them is the agent's own surface: `chat`'s `answer` and the
bound it is registered under, the built-in views, the private handlers,
and what only an agent does with a context, which reaches its companion
(`chat_turn`, `publish` and the publishes around a turn, `cleared`).

The tier below keeps `SessionContext`, and that is wider than the design
note's table says. A plain session object drives the conversation: the
tests register `answer_once` on one, and each framework's `prompt` is
documented for a handler of your own and takes a session context, so
`answer_once`, `settle` and `compact`, the turn's `settings_of` and
`stop_signal_of`, `take_steers`, `wait_for` and `companion_in` are all
reached from there and stay as they are. The langgraph signal node and
the interrupt resume read their context from the run's, and guard it
with `waits_in`, which raises `NeedsAgent`: narrowing them would need a
cast that says the opposite of what the guard is for.

Two tests pass a plain context to `publish` on purpose, to show it still
raises; they now say so with a cast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
The note's first draft asked for the conversation, the turn's helpers, the
signal waits and the langgraph nodes to take an agent's context. None of
them can: a handler of a plain session object drives `answer_once`, each
framework's public `prompt` funnels into it, and `companion_at` and
`waits_in` are the probes that find out which tier they are on. Narrowing
them is a type error in the SDK's own tests.

What narrowed is the agent's registration surface, which is the honest
reach of a name that is only a signpost. The note now says so, and says
why TypeScript, which is barred from nothing, holds the same line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV

@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.

ℹ️ No critical issues. Two rough edges are flagged inline.

Reviewed changes

This review covers the full PR at e3ede15: the per-agent companion, the split between storing and publishing, and the new context protocols. The in-process suites agents_companion, agents_conversation and agents_publishes pass locally (81 passed).

  • A companion for each agent: make_companion(agent) builds an ingress-private <agent>.prebuilt.restate virtual object keyed as the agent, and it replaces the shared signals. AgentObject.__restate_services__ serves it beside the agent, and Started.settles is gone because only chat_turn starts turns.
  • The conversation only stores: answer_once now runs whatever its caller set through after_steps / after_answer. The AgentObject handler wrapper sets both. chat_turn keeps the per-step publishes but clears the end publish, because close already publishes the end. A plain SessionObject neither publishes nor looks up a companion.
  • NeedsAgent: a signal wait from a plain session object now fails the turn for good. Tool.run and Pydantic AI's on_tool_execute_error let it propagate instead of returning it to the model as a tool result.
  • AgentContext / AgentViewContext: these protocols mark the agent layer in signatures using a phantom _agent member. They now type chat()'s TypeVar bound, the built-in views, the private handlers and the turn's publish helpers.
  • API changes in _signals and internal: companion_of(agent) no longer takes a key and returns the handlers. companion_at, companion_in and waits_in replace signals_of. signals is removed from internal.__all__.

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

Comment thread python/restate/agents/_conversation.py Resolved
Comment thread python/restate/agents/_object.py Resolved
An agent's own handler sets a publish for the commit that stores its
turn's answer, and `answer_once` read it only after the check for an
answer already stored. An attempt that died between that commit and the
publish therefore lost the end: the retry found the turn answered,
returned, and nobody told the followers, which heard nothing until their
watch timed out. A `chat` turn was never affected, since its `close`
publishes the end on the retry too.

The hook is read before the answer, and a retry that finds one runs it.
Publishing an end twice costs a revision and tells a follower what it may
already know.

The companion's own name stays unguarded, which the note now says rather
than requiring a refusal that nothing implements: an agent named
`support.prebuilt.restate` takes agent `support`'s companion name across
endpoints, and `Endpoint.bind` catches it only within one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV

@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.

✅ No new issues found. Both earlier findings are addressed.

Reviewed changes

This review covers f6d4d49, the one commit pushed since the last Pullfrog review. I re-ran the in-process agents_publishes, agents_conversation, agents_companion and agents_object suites: 82 passed. The 12 errors were Docker-harness tests, and there is no Docker in this environment. I also reproduced the retry directly. When ask is invoked again with the same invocation id, the turn's answer is already stored. The handler returns "done" and publishes exactly once, and that publish already shows the answer.

  • A retry that finds the turn answered now publishes its end: answer_once reads after_answer_of(ctx) before the answer_in early return and awaits it there. An attempt that dies between committing the answer and publishing it no longer leaves followers waiting. chat is unaffected, because chat_turn clears the hook. The publish_end docstring now describes this path.
  • docs/agents/prebuilt.md no longer describes a name guard: the doc now says that an agent named <x>.prebuilt.restate is deliberately not refused. Endpoint.bind catches the clash only within one endpoint. The "what must hold" list no longer includes the refusal.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

This branch gave each agent a companion of its own, `<agent>.prebuilt.restate`,
keyed as the agent: its calls go through typed handler references, with the
agent's own key, and the shared `signals` object and its `<agent>:<key>` key
are gone. Main timed model requests and tool calls in the journal, and hid the
agent's bookkeeping from Restate's UI: every call and send to the companion is
named `_signals.ENTRY_NAME`, and the agent's metadata hides the entries of that
name (`hide_entries`).

Both are kept. The companion's calls stay typed and keyed as the agent, and
each carries main's journal name: `ctx.call(companion.reset, key=ctx.key,
name=_signals.ENTRY_NAME)`. Main's `_companion` helper is not kept: it existed
to cast away the types of a call addressed by name, which the typed handlers
made unnecessary, so each view passes `name=` at its call site instead. The
name's value is still `signals`, a stale label for an object no longer called
that; renaming it is a change of its own.

The turn's publishes are this branch's (a start that publishes, a publish per
committed step, a close), and the test that main added for the names asks the
agent for its companion rather than for the shared object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV

@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.

✅ No new issues found. The merge of main carries #310's entry naming over to the per-agent companion completely.

Reviewed changes

This review covers 86f7556, the merge of upstream main (with #310) into the branch. I checked that the merge resolved the agents code correctly. The in-process agents_publishes, agents_conversation, agents_companion, agents_object and sessions_object suites pass (111 passed). The 12 errors are Docker-harness tests, which cannot run in this environment. ruff check is clean.

  • Named the companion calls: every agent call to the companion that main names signals is named on the branch too. That covers the views (signal, pending, status, notifications, watch), _stopped, steer, the reset in erase, sync's watch and reads, the turn's start/publish/close, and the taken send. Tool waits stay unnamed, as they are on main.
  • Merged the hiding metadata: AgentObject puts hide_entries(_signals.ENTRY_NAME) under any metadata the user passes, so an agent's own metadata still wins.
  • Adapted the naming test: the new test_the_calls_to_the_companion_are_named_for_the_agents_metadata_to_hide_them now expects <agent>.prebuilt.restate/... handlers, not signals/....

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@github-actions

Copy link
Copy Markdown

Test Results

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

Results for commit 86f7556. ± Comparison against base commit 0cd30df.

@igalshilman
igalshilman merged commit 4a440ff into main Nov 13, 2026
12 checks passed
@igalshilman
igalshilman deleted the claude/prebuilt-object branch November 13, 2026 15:02
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 13, 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.

2 participants