Skip to content

Commit 80ab955

Browse files
danielmillerpclaude
andcommitted
feat(temporal): opt-in continue-as-new for long-lived agent workflows
Long-lived chat/session agents run as a single Temporal workflow that stays open indefinitely, so their event history grows until it hits Temporal's ~50k-event / 50MB limit and the workflow stalls. This adds an opt-in continue-as-new pattern on BaseWorkflow that recycles the history so a session can stay open forever. It is opt-in by design: an agent gets recycling only by calling run_until_complete from its @workflow.run instead of a bare wait_condition(timeout=None). There is no env flag. BaseWorkflow helpers: - run_until_complete(*args, is_complete, can_recycle=True, timeout=None): keep the workflow open; recycle history when Temporal suggests it. timeout mirrors the old wait_condition(timeout=...); can_recycle lets an agent opt out when a recycle prerequisite is missing. - should_continue_as_new(): recycle when workflow.info().is_continue_as_new_suggested(). - drain_and_continue_as_new(): waits all_handlers_finished (so an in-flight turn is not lost) and re-checks completion before workflow.continue_as_new. - recycling_active(): workflow.patched() gate, so a workflow that started before an agent adopted run_until_complete replays its original command stream unchanged. - is_continued_run(): the hook agents use to gate state rehydration after a recycle. Restoring state after a recycle is framework-specific (rebuild from adk.messages, an adk.state snapshot, or a framework's own memory, e.g. a LangGraph checkpointer) and is intentionally left to follow-up PRs, one per integration. The 000_hello_acp example adopts the pattern; it keeps no cross-turn state, so it needs no rehydration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 936bac6 commit 80ab955

3 files changed

Lines changed: 279 additions & 7 deletions

File tree

‎examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,13 @@ async def on_task_create(self, params: CreateTaskParams) -> None:
6262
),
6363
)
6464

65-
# 2. Wait for the task to be completed indefinitely. If we don't do this the workflow will close as soon as this function returns. Temporal can run hundreds of millions of workflows in parallel, so you don't need to worry about too many workflows running at once.
66-
67-
# Thus, if you want this agent to field events indefinitely (or for a long time) you need to wait for a condition to be met.
68-
await workflow.wait_condition(
69-
lambda: self._complete_task,
70-
timeout=None, # Set a timeout if you want to prevent the task from running indefinitely. Generally this is not needed. Temporal can run hundreds of millions of workflows in parallel and more. Only do this if you have a specific reason to do so.
71-
)
65+
# 2. Keep the workflow open to field events. We use run_until_complete
66+
# instead of a bare wait_condition: it still waits indefinitely, but also
67+
# recycles the Temporal event history via continue-as-new before it hits the
68+
# ~50k-event / 50MB limit, so this chat can stay open forever. Adopting
69+
# run_until_complete IS the opt-in — agents that keep the old wait_condition
70+
# never recycle. This agent keeps no cross-turn state, so nothing needs
71+
# restoring across a recycle and `params` is the only carry-forward. (Agents
72+
# that DO keep state rebuild it at the top of @workflow.run, gated on
73+
# self.is_continued_run() — framework-specific, handled per-agent.)
74+
await self.run_until_complete(params, is_complete=lambda: self._complete_task)

‎src/agentex/lib/core/temporal/workflows/workflow.py‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
from __future__ import annotations
2+
13
from abc import ABC, abstractmethod
4+
from typing import Any, Callable
5+
from datetime import timedelta
26

37
from temporalio import workflow
48

@@ -8,6 +12,15 @@
812

913
logger = make_logger(__name__)
1014

15+
# Patch identifier gating the continue-as-new recycle path. Workflows that were
16+
# already running before this code shipped have no record of this patch in their
17+
# event history; `workflow.patched()` lets the new drain + continue_as_new branch
18+
# be introduced without breaking determinism when those in-flight executions
19+
# replay against the new code. Gate the decision ONCE in the agent's @workflow.run
20+
# (see the 010_agent_chat reference agent) so pre-patch runs keep their old
21+
# behaviour and never spin.
22+
CONTINUE_AS_NEW_PATCH_ID = "agentex-base-workflow-continue-as-new-v1"
23+
1124

1225
class BaseWorkflow(ABC):
1326
def __init__(
@@ -24,3 +37,162 @@ async def on_task_event_send(self, params: SendEventParams) -> None:
2437
@abstractmethod
2538
async def on_task_create(self, params: CreateTaskParams) -> None:
2639
raise NotImplementedError
40+
41+
# ------------------------------------------------------------------ #
42+
# Continue-as-new lifecycle helpers #
43+
# #
44+
# These let a long-lived chat/session workflow recycle its event #
45+
# history so it can stay open indefinitely without hitting Temporal's #
46+
# ~50k-event / 50MB history limit. They are OPT-IN: an agent gets #
47+
# recycling only by calling `run_until_complete` from its #
48+
# `@workflow.run` instead of the usual indefinite `wait_condition`. #
49+
# The SDK owns the hard Temporal mechanics (recycle decision, #
50+
# draining in-flight handlers, the continue_as_new call, patch #
51+
# gating). Restoring state after a recycle is the AGENT's job and is #
52+
# framework-specific (rebuild from `adk.messages`, an `adk.state` #
53+
# snapshot, or a framework's own memory) — see `is_continued_run`. #
54+
# The 000_hello_acp example shows the minimal stateless adoption. #
55+
# ------------------------------------------------------------------ #
56+
57+
def should_continue_as_new(self) -> bool:
58+
"""Whether this run should recycle its event history via continue-as-new.
59+
60+
True when Temporal suggests it: ``is_continue_as_new_suggested()`` fires as
61+
the event history approaches the server's size/count limit, so we let
62+
Temporal own the threshold rather than configuring one ourselves.
63+
64+
This reads only a deterministic ``workflow.info()`` value and emits no
65+
commands, so it is safe to use directly as a ``workflow.wait_condition``
66+
predicate, e.g.::
67+
68+
await workflow.wait_condition(
69+
lambda: self._complete_task or self.should_continue_as_new()
70+
)
71+
"""
72+
return workflow.info().is_continue_as_new_suggested()
73+
74+
async def drain_and_continue_as_new(
75+
self,
76+
*args: Any,
77+
is_complete: Callable[[], bool] | None = None,
78+
) -> None:
79+
"""Drain in-flight signal handlers, then continue-as-new.
80+
81+
Call this from the agent's ``@workflow.run`` once the run loop wakes for a
82+
recycle (see :meth:`should_continue_as_new`). ``args`` are forwarded
83+
verbatim to ``workflow.continue_as_new`` and become the new run's input, so
84+
pass whatever your ``@workflow.run`` signature expects — typically the
85+
original ``CreateTaskParams`` (the new run keeps the same workflow id / task
86+
id and re-hydrates its state from ``adk.state``).
87+
88+
IMPORTANT: keep your data OUTSIDE workflow state BEFORE calling this —
89+
messages in ``adk.messages`` and any other state in ``adk.state``.
90+
In-workflow attributes do NOT survive the recycle; only the forwarded
91+
``args`` do.
92+
93+
Waits on ``all_handlers_finished`` first so an in-flight turn (a signal
94+
handler still running an activity) is never lost or duplicated across the
95+
recycle boundary. ``workflow.continue_as_new`` raises to end the run, so
96+
this never returns normally — EXCEPT when ``is_complete`` is given and
97+
returns True after draining: a completion signal can arrive while we wait
98+
for the drain, and the recycled run would start fresh (losing that
99+
completion), so in that case we return without recycling and let the caller
100+
finish.
101+
"""
102+
# Don't recycle until any signal handler still running has finished, so a
103+
# message mid-flight at the boundary is carried into the next run intact.
104+
await workflow.wait_condition(workflow.all_handlers_finished)
105+
# A completion signal may have landed during the drain — re-check before
106+
# recycling so a workflow that should finish isn't kept open by the recycle.
107+
if is_complete is not None and is_complete():
108+
return
109+
logger.info(
110+
"Recycling workflow via continue-as-new "
111+
f"(history_length={workflow.info().get_current_history_length()}, "
112+
f"run_id={workflow.info().run_id})"
113+
)
114+
workflow.continue_as_new(*args)
115+
116+
async def run_until_complete(
117+
self,
118+
*continue_as_new_args: Any,
119+
is_complete: Callable[[], bool],
120+
can_recycle: bool = True,
121+
timeout: timedelta | None = None,
122+
) -> None:
123+
"""Keep the workflow open to field events, recycling history as needed.
124+
125+
Drop-in replacement for the usual ``await workflow.wait_condition(
126+
lambda: self._complete_task, timeout=None)`` at the end of an agent's
127+
``@workflow.run``. ``is_complete`` is a no-arg predicate (typically
128+
``lambda: self._complete_task``); ``continue_as_new_args`` are forwarded to
129+
continue-as-new on recycle (typically the original ``CreateTaskParams``).
130+
131+
Adopting this method IS the opt-in to recycling — there is no separate
132+
flag. An agent that keeps the old indefinite ``wait_condition`` never
133+
recycles. The recycle path is gated behind ``workflow.patched(...)`` so a
134+
workflow that started before the agent adopted this keeps waiting the old
135+
way and never hits a non-determinism error on replay.
136+
137+
``can_recycle`` lets an agent declare that recycling is unsafe right now and
138+
fall back to the plain wait. Use it when a prerequisite for surviving a
139+
recycle is missing — e.g. an agent that keeps non-message state in
140+
``adk.state`` (keyed by task + agent) has nothing to persist into without an
141+
``AGENT_ID``, so it should pass ``can_recycle=bool(environment_variables.AGENT_ID)``
142+
rather than recycle and silently drop that state on the first
143+
continue-as-new.
144+
145+
``timeout`` optionally bounds how long the workflow waits with no progress
146+
(mirrors the old ``wait_condition(timeout=...)``). Leave it None to wait
147+
indefinitely — generally what you want, Temporal can keep huge numbers of
148+
idle workflows open. Set it only if you have a reason to cap the task; on
149+
expiry ``wait_condition`` raises ``asyncio.TimeoutError`` like before.
150+
151+
Persist anything you need across a recycle OUTSIDE workflow state first —
152+
messages in ``adk.messages``, other state in ``adk.state`` — and rebuild it
153+
at the top of ``@workflow.run``.
154+
"""
155+
if not can_recycle or not self.recycling_active():
156+
await workflow.wait_condition(is_complete, timeout=timeout)
157+
return
158+
while True:
159+
await workflow.wait_condition(
160+
lambda: is_complete() or self.should_continue_as_new(),
161+
timeout=timeout,
162+
)
163+
if is_complete():
164+
return
165+
# Drains in-flight handlers, then continue-as-new (raises; never
166+
# returns) — UNLESS a completion signal arrived during the drain, in
167+
# which case it returns here and the next loop iteration completes.
168+
await self.drain_and_continue_as_new(
169+
*continue_as_new_args, is_complete=is_complete
170+
)
171+
if is_complete():
172+
return
173+
174+
def recycling_active(self) -> bool:
175+
"""Whether continue-as-new machinery should run for this run.
176+
177+
``True`` only when this run is on patched code (``workflow.patched``). This
178+
is the determinism gate: a workflow that started BEFORE an agent adopted
179+
``run_until_complete`` has no patch marker in its history, so this returns
180+
``False`` there and it replays its original command stream unchanged instead
181+
of trying to recycle. Calling it is replay-safe — on a pre-patch run
182+
``workflow.patched`` returns False and emits no command.
183+
"""
184+
return workflow.patched(CONTINUE_AS_NEW_PATCH_ID)
185+
186+
def is_continued_run(self) -> bool:
187+
"""Whether this run was produced by a continue-as-new from a prior run.
188+
189+
True only on a recycled run (``workflow.info().continued_run_id`` is set),
190+
False on the original run a client created. This is the gate for any new,
191+
activity-emitting prologue work in ``@workflow.run`` (re-hydrating state
192+
from ``adk.state`` / ``adk.messages``): such work is only needed on a
193+
continued run, and gating it here means an original run emits no new
194+
commands. That keeps a pre-existing in-flight workflow (started before this
195+
code shipped) deterministic on replay even with continue-as-new enabled,
196+
since its original run skips the new prologue entirely.
197+
"""
198+
return workflow.info().continued_run_id is not None
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Unit tests for BaseWorkflow's continue-as-new lifecycle helpers.
2+
3+
These exercise the pure decision helpers (``should_continue_as_new``,
4+
``recycling_active``, ``is_continued_run``) by faking ``workflow.info()`` /
5+
``workflow.patched()`` so we don't need a running Temporal server. The drain +
6+
``workflow.continue_as_new`` mechanics in ``drain_and_continue_as_new`` /
7+
``run_until_complete`` are best covered by a replay/integration test against a
8+
Temporal test environment (a follow-up).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import override
14+
15+
import pytest
16+
17+
from agentex.lib.core.temporal.workflows import workflow as base_workflow_module
18+
from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow
19+
20+
21+
class _ConcreteWorkflow(BaseWorkflow):
22+
"""Minimal concrete subclass so we can instantiate the ABC in a test."""
23+
24+
def __init__(self) -> None:
25+
self.display_name = "test"
26+
27+
@override
28+
async def on_task_event_send(self, params) -> None: # pragma: no cover - unused
29+
raise NotImplementedError
30+
31+
@override
32+
async def on_task_create(self, params) -> None: # pragma: no cover - unused
33+
raise NotImplementedError
34+
35+
36+
class _FakeInfo:
37+
def __init__(self, *, suggested: bool, continued_run_id: str | None = None) -> None:
38+
self._suggested = suggested
39+
self.continued_run_id = continued_run_id
40+
41+
def is_continue_as_new_suggested(self) -> bool:
42+
return self._suggested
43+
44+
45+
@pytest.fixture
46+
def patch_info(monkeypatch):
47+
"""Patch ``workflow.info`` used inside the BaseWorkflow module."""
48+
49+
def _apply(*, suggested: bool, continued_run_id: str | None = None) -> None:
50+
monkeypatch.setattr(
51+
base_workflow_module.workflow,
52+
"info",
53+
lambda: _FakeInfo(suggested=suggested, continued_run_id=continued_run_id),
54+
)
55+
56+
return _apply
57+
58+
59+
@pytest.fixture
60+
def patch_patched(monkeypatch):
61+
"""Patch ``workflow.patched`` used inside the BaseWorkflow module."""
62+
63+
def _apply(value: bool) -> None:
64+
monkeypatch.setattr(
65+
base_workflow_module.workflow, "patched", lambda _patch_id: value
66+
)
67+
68+
return _apply
69+
70+
71+
def test_recycles_when_temporal_suggests(patch_info):
72+
patch_info(suggested=True)
73+
assert _ConcreteWorkflow().should_continue_as_new() is True
74+
75+
76+
def test_no_recycle_when_not_suggested(patch_info):
77+
patch_info(suggested=False)
78+
assert _ConcreteWorkflow().should_continue_as_new() is False
79+
80+
81+
def test_recycling_active_tracks_patched(patch_patched):
82+
# Pre-patch in-flight workflow (no marker in history) → inactive.
83+
patch_patched(False)
84+
assert _ConcreteWorkflow().recycling_active() is False
85+
# Patched code → active.
86+
patch_patched(True)
87+
assert _ConcreteWorkflow().recycling_active() is True
88+
89+
90+
def test_is_continued_run_false_on_original_run(patch_info):
91+
patch_info(suggested=False, continued_run_id=None)
92+
assert _ConcreteWorkflow().is_continued_run() is False
93+
94+
95+
def test_is_continued_run_true_after_recycle(patch_info):
96+
patch_info(suggested=False, continued_run_id="run-123")
97+
assert _ConcreteWorkflow().is_continued_run() is True

0 commit comments

Comments
 (0)