Skip to content

Commit 1ea35bd

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. Two things killed them: event history grows past Temporal's ~50k-event / 50MB limit, and the 24h execution-timeout default terminated the whole chain. This adds an opt-in continue-as-new pattern on BaseWorkflow and defaults the execution timeout to infinite. Opt-in by adoption: an agent gets recycling only by calling run_until_complete from its @workflow.run instead of a bare wait_condition(timeout=None). No flag, no patch gate (no in-flight long-running workflows to preserve; Worker Versioning + upgrade-on-continue-as-new is the path for evolving these later). BaseWorkflow helpers: - run_until_complete(*args, is_complete, can_recycle=True, timeout=None): keep the workflow open; recycle history when Temporal suggests it. - should_continue_as_new(): recycle when workflow.info().is_continue_as_new_suggested(). - drain_and_continue_as_new(): drain all_handlers_finished and re-check completion before workflow.continue_as_new. - is_continued_run(): hook agents use to gate state rehydration after a recycle. Execution timeout: WORKFLOW_EXECUTION_TIMEOUT_SECONDS now defaults to None (no execution timeout; None/0/negative -> execution_timeout=None). It is chain-wide (continue-as-new does not reset it), so leaving it unset is required for a forever-chat. Idle bounding is a durable timer (run_until_complete's timeout). State restoration after a recycle is framework-specific and left to follow-up PRs, one per integration. 000_hello_acp adopts the pattern (no cross-turn state, so no rehydration needed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 936bac6 commit 1ea35bd

6 files changed

Lines changed: 249 additions & 15 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/clients/temporal/temporal_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ async def start_workflow(
153153
duplicate_policy: DuplicateWorkflowPolicy = DuplicateWorkflowPolicy.ALLOW_DUPLICATE,
154154
retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
155155
task_timeout: timedelta = timedelta(seconds=10),
156-
execution_timeout: timedelta = timedelta(seconds=86400),
156+
execution_timeout: timedelta | None = None,
157157
**kwargs: Any,
158158
) -> str:
159159
temporal_retry_policy = TemporalRetryPolicy(**retry_policy.model_dump(exclude_unset=True))

src/agentex/lib/core/temporal/services/temporal_task_service.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
3333
3434
returns the workflow ID of the temporal workflow
3535
"""
36+
# None / 0 / negative => no execution timeout (workflow can stay open
37+
# indefinitely, which long-lived chat/session agents rely on). A positive
38+
# value bounds the whole continue-as-new chain's wall-clock lifetime.
39+
timeout_seconds = self._env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS
40+
execution_timeout = (
41+
timedelta(seconds=timeout_seconds)
42+
if timeout_seconds and timeout_seconds > 0
43+
else None
44+
)
3645
return await self._temporal_client.start_workflow(
3746
workflow=self._env_vars.WORKFLOW_NAME,
3847
arg=CreateTaskParams(
@@ -42,7 +51,7 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
4251
),
4352
id=task.id,
4453
task_queue=self._env_vars.WORKFLOW_TASK_QUEUE,
45-
execution_timeout=timedelta(seconds=self._env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS),
54+
execution_timeout=execution_timeout,
4655
)
4756

4857
async def get_state(self, task_id: str) -> WorkflowState:

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

Lines changed: 145 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

@@ -24,3 +28,144 @@ async def on_task_event_send(self, params: SendEventParams) -> None:
2428
@abstractmethod
2529
async def on_task_create(self, params: CreateTaskParams) -> None:
2630
raise NotImplementedError
31+
32+
# ------------------------------------------------------------------ #
33+
# Continue-as-new lifecycle helpers #
34+
# #
35+
# These let a long-lived chat/session workflow recycle its event #
36+
# history so it can stay open indefinitely without hitting Temporal's #
37+
# ~50k-event / 50MB history limit. They are OPT-IN: an agent gets #
38+
# recycling only by calling `run_until_complete` from its #
39+
# `@workflow.run` instead of the usual indefinite `wait_condition`. #
40+
# The SDK owns the hard Temporal mechanics (recycle decision and #
41+
# draining in-flight handlers before the continue_as_new call). #
42+
# Restoring state after a recycle is the AGENT's job and is #
43+
# framework-specific (rebuild from `adk.messages`, an `adk.state` #
44+
# snapshot, or a framework's own memory) — see `is_continued_run`. #
45+
# The 000_hello_acp example shows the minimal stateless adoption. #
46+
# ------------------------------------------------------------------ #
47+
48+
def should_continue_as_new(self) -> bool:
49+
"""Whether this run should recycle its event history via continue-as-new.
50+
51+
True when Temporal suggests it: ``is_continue_as_new_suggested()`` fires as
52+
the event history approaches the server's size/count limit, so we let
53+
Temporal own the threshold rather than configuring one ourselves.
54+
55+
This reads only a deterministic ``workflow.info()`` value and emits no
56+
commands, so it is safe to use directly as a ``workflow.wait_condition``
57+
predicate, e.g.::
58+
59+
await workflow.wait_condition(
60+
lambda: self._complete_task or self.should_continue_as_new()
61+
)
62+
"""
63+
return workflow.info().is_continue_as_new_suggested()
64+
65+
async def drain_and_continue_as_new(
66+
self,
67+
*args: Any,
68+
is_complete: Callable[[], bool] | None = None,
69+
) -> None:
70+
"""Drain in-flight signal handlers, then continue-as-new.
71+
72+
Call this from the agent's ``@workflow.run`` once the run loop wakes for a
73+
recycle (see :meth:`should_continue_as_new`). ``args`` are forwarded
74+
verbatim to ``workflow.continue_as_new`` and become the new run's input, so
75+
pass whatever your ``@workflow.run`` signature expects — typically the
76+
original ``CreateTaskParams`` (the new run keeps the same workflow id / task
77+
id and re-hydrates its state from ``adk.state``).
78+
79+
IMPORTANT: keep your data OUTSIDE workflow state BEFORE calling this —
80+
messages in ``adk.messages`` and any other state in ``adk.state``.
81+
In-workflow attributes do NOT survive the recycle; only the forwarded
82+
``args`` do.
83+
84+
Waits on ``all_handlers_finished`` first so an in-flight turn (a signal
85+
handler still running an activity) is never lost or duplicated across the
86+
recycle boundary. ``workflow.continue_as_new`` raises to end the run, so
87+
this never returns normally — EXCEPT when ``is_complete`` is given and
88+
returns True after draining: a completion signal can arrive while we wait
89+
for the drain, and the recycled run would start fresh (losing that
90+
completion), so in that case we return without recycling and let the caller
91+
finish.
92+
"""
93+
# Don't recycle until any signal handler still running has finished, so a
94+
# message mid-flight at the boundary is carried into the next run intact.
95+
await workflow.wait_condition(workflow.all_handlers_finished)
96+
# A completion signal may have landed during the drain — re-check before
97+
# recycling so a workflow that should finish isn't kept open by the recycle.
98+
if is_complete is not None and is_complete():
99+
return
100+
logger.info(
101+
"Recycling workflow via continue-as-new "
102+
f"(history_length={workflow.info().get_current_history_length()}, "
103+
f"run_id={workflow.info().run_id})"
104+
)
105+
workflow.continue_as_new(*args)
106+
107+
async def run_until_complete(
108+
self,
109+
*continue_as_new_args: Any,
110+
is_complete: Callable[[], bool],
111+
can_recycle: bool = True,
112+
timeout: timedelta | None = None,
113+
) -> None:
114+
"""Keep the workflow open to field events, recycling history as needed.
115+
116+
Drop-in replacement for the usual ``await workflow.wait_condition(
117+
lambda: self._complete_task, timeout=None)`` at the end of an agent's
118+
``@workflow.run``. ``is_complete`` is a no-arg predicate (typically
119+
``lambda: self._complete_task``); ``continue_as_new_args`` are forwarded to
120+
continue-as-new on recycle (typically the original ``CreateTaskParams``).
121+
122+
Adopting this method IS the opt-in to recycling — there is no flag. An agent
123+
that keeps the old indefinite ``wait_condition`` never recycles.
124+
125+
``can_recycle`` lets an agent declare that recycling is unsafe right now and
126+
fall back to the plain wait. Use it when a prerequisite for surviving a
127+
recycle is missing — e.g. an agent that keeps non-message state in
128+
``adk.state`` (keyed by task + agent) has nothing to persist into without an
129+
``AGENT_ID``, so it should pass ``can_recycle=bool(environment_variables.AGENT_ID)``
130+
rather than recycle and silently drop that state on the first
131+
continue-as-new.
132+
133+
``timeout`` optionally bounds how long the workflow waits with no progress
134+
(mirrors the old ``wait_condition(timeout=...)``). Leave it None to wait
135+
indefinitely — generally what you want, Temporal can keep huge numbers of
136+
idle workflows open. Set it only if you have a reason to cap the task; on
137+
expiry ``wait_condition`` raises ``asyncio.TimeoutError`` like before.
138+
139+
Persist anything you need across a recycle OUTSIDE workflow state first —
140+
messages in ``adk.messages``, other state in ``adk.state`` — and rebuild it
141+
at the top of ``@workflow.run``.
142+
"""
143+
if not can_recycle:
144+
await workflow.wait_condition(is_complete, timeout=timeout)
145+
return
146+
while True:
147+
await workflow.wait_condition(
148+
lambda: is_complete() or self.should_continue_as_new(),
149+
timeout=timeout,
150+
)
151+
if is_complete():
152+
return
153+
# Drains in-flight handlers, then continue-as-new (raises; never
154+
# returns) — UNLESS a completion signal arrived during the drain, in
155+
# which case it returns here and the next loop iteration completes.
156+
await self.drain_and_continue_as_new(
157+
*continue_as_new_args, is_complete=is_complete
158+
)
159+
if is_complete():
160+
return
161+
162+
def is_continued_run(self) -> bool:
163+
"""Whether this run was produced by a continue-as-new from a prior run.
164+
165+
True only on a recycled run (``workflow.info().continued_run_id`` is set),
166+
False on the original run a client created. Agents use this to gate state
167+
restoration in ``@workflow.run``: rehydrating from ``adk.state`` /
168+
``adk.messages`` is only needed on a continued run — the original run starts
169+
fresh and has nothing to restore, so it should skip that work.
170+
"""
171+
return workflow.info().continued_run_id is not None

src/agentex/lib/environment_variables.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from pathlib import Path
77

88
from dotenv import load_dotenv
9-
from pydantic import Field
109

1110
from agentex.lib.utils.logging import make_logger
1211
from agentex.lib.utils.model_utils import BaseModel
@@ -76,11 +75,14 @@ class EnvironmentVariables(BaseModel):
7675
# Workflow Configuration
7776
WORKFLOW_TASK_QUEUE: str | None = None
7877
WORKFLOW_NAME: str | None = None
79-
# Maximum total time (in seconds) a workflow execution can run, including
80-
# retries and continue-as-new. Defaults to 24h to bound runaway workflows;
81-
# agents with longer-running tasks should override this. Must be > 0 — a
82-
# zero or negative timedelta would cause every submitted workflow to fail.
83-
WORKFLOW_EXECUTION_TIMEOUT_SECONDS: int = Field(default=86400, gt=0)
78+
# Maximum total wall-clock time (in seconds) a workflow execution can run,
79+
# INCLUDING retries and the entire continue-as-new chain (Temporal does not
80+
# reset it on continue-as-new). Defaults to None = no execution timeout, so
81+
# long-lived chat/session workflows can stay open indefinitely. None / 0 /
82+
# negative are all treated as "no timeout" at the start_workflow call site.
83+
# To bound idle workflows, use an explicit durable timer inside the workflow
84+
# (e.g. run_until_complete's `timeout`), not this chain-wide ceiling.
85+
WORKFLOW_EXECUTION_TIMEOUT_SECONDS: int | None = None
8486
# Temporal Worker Configuration
8587
HEALTH_CHECK_PORT: int = 80
8688
# Auth Configuration
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Unit tests for BaseWorkflow's continue-as-new lifecycle helpers.
2+
3+
These exercise the pure decision helpers (``should_continue_as_new``,
4+
``is_continued_run``) by faking ``workflow.info()`` so we don't need a running
5+
Temporal server. The drain + ``workflow.continue_as_new`` mechanics in
6+
``drain_and_continue_as_new`` / ``run_until_complete`` are best covered by a
7+
replay/integration test against a Temporal test environment (a follow-up).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from typing import override
13+
14+
import pytest
15+
16+
from agentex.lib.core.temporal.workflows import workflow as base_workflow_module
17+
from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow
18+
19+
20+
class _ConcreteWorkflow(BaseWorkflow):
21+
"""Minimal concrete subclass so we can instantiate the ABC in a test."""
22+
23+
def __init__(self) -> None:
24+
self.display_name = "test"
25+
26+
@override
27+
async def on_task_event_send(self, params) -> None: # pragma: no cover - unused
28+
raise NotImplementedError
29+
30+
@override
31+
async def on_task_create(self, params) -> None: # pragma: no cover - unused
32+
raise NotImplementedError
33+
34+
35+
class _FakeInfo:
36+
def __init__(self, *, suggested: bool, continued_run_id: str | None = None) -> None:
37+
self._suggested = suggested
38+
self.continued_run_id = continued_run_id
39+
40+
def is_continue_as_new_suggested(self) -> bool:
41+
return self._suggested
42+
43+
44+
@pytest.fixture
45+
def patch_info(monkeypatch):
46+
"""Patch ``workflow.info`` used inside the BaseWorkflow module."""
47+
48+
def _apply(*, suggested: bool, continued_run_id: str | None = None) -> None:
49+
monkeypatch.setattr(
50+
base_workflow_module.workflow,
51+
"info",
52+
lambda: _FakeInfo(suggested=suggested, continued_run_id=continued_run_id),
53+
)
54+
55+
return _apply
56+
57+
58+
def test_recycles_when_temporal_suggests(patch_info):
59+
patch_info(suggested=True)
60+
assert _ConcreteWorkflow().should_continue_as_new() is True
61+
62+
63+
def test_no_recycle_when_not_suggested(patch_info):
64+
patch_info(suggested=False)
65+
assert _ConcreteWorkflow().should_continue_as_new() is False
66+
67+
68+
def test_is_continued_run_false_on_original_run(patch_info):
69+
patch_info(suggested=False, continued_run_id=None)
70+
assert _ConcreteWorkflow().is_continued_run() is False
71+
72+
73+
def test_is_continued_run_true_after_recycle(patch_info):
74+
patch_info(suggested=False, continued_run_id="run-123")
75+
assert _ConcreteWorkflow().is_continued_run() is True

0 commit comments

Comments
 (0)