Skip to content

Commit 98cf744

Browse files
feat(temporal): opt-in continue-as-new for long-lived agent workflows (#447)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 936bac6 commit 98cf744

6 files changed

Lines changed: 253 additions & 23 deletions

File tree

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

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,27 @@ async def on_task_event_send(self, params: SendEventParams) -> None:
5353
async def on_task_create(self, params: CreateTaskParams) -> None:
5454
logger.info(f"Received task create params: {params}")
5555

56-
# 1. Acknowledge that the task has been created.
57-
await adk.messages.create(
58-
task_id=params.task.id,
59-
content=TextContent(
60-
author="agent",
61-
content=f"Hello! I've received your task. Normally you can do some state initialization here, or just pass and do nothing until you get your first event. For now I'm just acknowledging that I've received a task with the following params:\n\n{json.dumps(params.params, indent=2)}.\n\nYou should only see this message once, when the task is created. All subsequent events will be handled by the `on_task_event_send` handler.",
62-
),
63-
)
56+
# 1. Acknowledge that the task has been created. Gate this one-time prologue
57+
# on is_continued_run(): run_until_complete below recycles the workflow via
58+
# continue-as-new, which re-enters on_task_create from the top — without this
59+
# guard the "you should only see this once" welcome would re-fire on every
60+
# recycle. Original run -> emit; continued (recycled) run -> skip.
61+
if not self.is_continued_run():
62+
await adk.messages.create(
63+
task_id=params.task.id,
64+
content=TextContent(
65+
author="agent",
66+
content=f"Hello! I've received your task. Normally you can do some state initialization here, or just pass and do nothing until you get your first event. For now I'm just acknowledging that I've received a task with the following params:\n\n{json.dumps(params.params, indent=2)}.\n\nYou should only see this message once, when the task is created. All subsequent events will be handled by the `on_task_event_send` handler.",
67+
),
68+
)
6469

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-
)
70+
# 2. Keep the workflow open to field events. We use run_until_complete
71+
# instead of a bare wait_condition: it still waits indefinitely, but also
72+
# recycles the Temporal event history via continue-as-new before it hits the
73+
# ~50k-event / 50MB limit, so this chat can stay open forever. Adopting
74+
# run_until_complete IS the opt-in — agents that keep the old wait_condition
75+
# never recycle. This agent keeps no cross-turn state, so nothing needs
76+
# restoring across a recycle and `params` is the only carry-forward. (Agents
77+
# that DO keep state restore it at the top of @workflow.run on a recycled
78+
# run — framework-specific, landing per-integration in follow-up PRs.)
79+
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: 136 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,135 @@ 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); that lands per-integration #
45+
# in follow-up PRs. The 000_hello_acp example shows the minimal #
46+
# stateless adoption that needs no restoration. #
47+
# ------------------------------------------------------------------ #
48+
49+
def should_continue_as_new(self) -> bool:
50+
"""Whether this run should recycle its event history via continue-as-new.
51+
52+
True when Temporal suggests it: ``is_continue_as_new_suggested()`` fires as
53+
the event history approaches the server's size/count limit, so we let
54+
Temporal own the threshold rather than configuring one ourselves.
55+
56+
This reads only a deterministic ``workflow.info()`` value and emits no
57+
commands, so it is safe to use directly as a ``workflow.wait_condition``
58+
predicate, e.g.::
59+
60+
await workflow.wait_condition(
61+
lambda: self._complete_task or self.should_continue_as_new()
62+
)
63+
"""
64+
return workflow.info().is_continue_as_new_suggested()
65+
66+
async def drain_and_continue_as_new(
67+
self,
68+
*args: Any,
69+
is_complete: Callable[[], bool] | None = None,
70+
) -> None:
71+
"""Drain in-flight signal handlers, then continue-as-new.
72+
73+
Call this from the agent's ``@workflow.run`` once the run loop wakes for a
74+
recycle (see :meth:`should_continue_as_new`). ``args`` are forwarded
75+
verbatim to ``workflow.continue_as_new`` and become the new run's input, so
76+
pass whatever your ``@workflow.run`` signature expects — typically the
77+
original ``CreateTaskParams`` (the new run keeps the same workflow id / task
78+
id and re-hydrates its state from ``adk.state``).
79+
80+
IMPORTANT: keep your data OUTSIDE workflow state BEFORE calling this —
81+
messages in ``adk.messages`` and any other state in ``adk.state``.
82+
In-workflow attributes do NOT survive the recycle; only the forwarded
83+
``args`` do.
84+
85+
Waits on ``all_handlers_finished`` first so an in-flight turn (a signal
86+
handler still running an activity) is never lost or duplicated across the
87+
recycle boundary. ``workflow.continue_as_new`` raises to end the run, so
88+
this never returns normally — EXCEPT when ``is_complete`` is given and
89+
returns True after draining: a completion signal can arrive while we wait
90+
for the drain, and the recycled run would start fresh (losing that
91+
completion), so in that case we return without recycling and let the caller
92+
finish.
93+
"""
94+
# Don't recycle until any signal handler still running has finished, so a
95+
# message mid-flight at the boundary is carried into the next run intact.
96+
await workflow.wait_condition(workflow.all_handlers_finished)
97+
# A completion signal may have landed during the drain — re-check before
98+
# recycling so a workflow that should finish isn't kept open by the recycle.
99+
if is_complete is not None and is_complete():
100+
return
101+
logger.info(
102+
"Recycling workflow via continue-as-new "
103+
f"(history_length={workflow.info().get_current_history_length()}, "
104+
f"run_id={workflow.info().run_id})"
105+
)
106+
workflow.continue_as_new(*args)
107+
108+
async def run_until_complete(
109+
self,
110+
*continue_as_new_args: Any,
111+
is_complete: Callable[[], bool],
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+
``timeout`` is an optional cap on how long to wait with no progress; it
126+
defaults to None = wait indefinitely (the usual case — Temporal can keep huge
127+
numbers of idle workflows open). The broader workflow-level lifetime cap is
128+
the execution timeout (``WORKFLOW_EXECUTION_TIMEOUT_SECONDS``, also infinite
129+
by default). On ``timeout`` expiry ``wait_condition`` raises
130+
``asyncio.TimeoutError`` like before.
131+
132+
Persist anything you need across a recycle OUTSIDE workflow state first —
133+
messages in ``adk.messages``, other state in ``adk.state`` — and rebuild it
134+
at the top of ``@workflow.run``.
135+
"""
136+
while True:
137+
await workflow.wait_condition(
138+
lambda: is_complete() or self.should_continue_as_new(),
139+
timeout=timeout,
140+
)
141+
if is_complete():
142+
return
143+
# Drains in-flight handlers, then continue-as-new (raises; never
144+
# returns) — UNLESS a completion signal arrived during the drain, in
145+
# which case it returns here and the next loop iteration completes.
146+
await self.drain_and_continue_as_new(
147+
*continue_as_new_args, is_complete=is_complete
148+
)
149+
if is_complete():
150+
return
151+
152+
def is_continued_run(self) -> bool:
153+
"""Whether this run was produced by a continue-as-new from a prior run.
154+
155+
True only on a recycled run (``workflow.info().continued_run_id`` is set),
156+
False on the original run a client created. Use it in ``@workflow.run`` to
157+
gate one-time prologue work that must NOT repeat on every recycle — e.g. a
158+
welcome message, or rehydrating state only when there's something to restore.
159+
The recycled run re-enters ``@workflow.run`` from the top, so anything not
160+
gated here runs again on each history rollover.
161+
"""
162+
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`` and
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 = False, 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(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(continued_run_id="run-123")
75+
assert _ConcreteWorkflow().is_continued_run() is True

0 commit comments

Comments
 (0)