From 4f1c09348c2ddffeb8c78738fb9b7aa14ed4b752 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 16 Jul 2026 19:17:16 +0000
Subject: [PATCH 1/4] feat(api): add task/interrupt method and INTERRUPTED
status to agents/tasks
---
.stats.yml | 4 ++--
src/agentex/resources/agents/agents.py | 8 ++++----
src/agentex/resources/agents/deployments.py | 4 ++--
src/agentex/resources/tasks.py | 8 ++++++--
src/agentex/types/agent_rpc_by_name_params.py | 17 +++++++++++++++--
src/agentex/types/agent_rpc_params.py | 17 +++++++++++++++--
.../agents/deployment_preview_rpc_params.py | 17 +++++++++++++++--
src/agentex/types/task.py | 4 +++-
src/agentex/types/task_list_params.py | 4 +++-
src/agentex/types/task_list_response.py | 4 +++-
.../types/task_retrieve_by_name_response.py | 4 +++-
src/agentex/types/task_retrieve_response.py | 4 +++-
12 files changed, 74 insertions(+), 21 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 02e3850a9..dd16d1e6f 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 74
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-f3f1b47fa785a0d7b4e1cce1932a2ab0c0b6531d67d499e6188292ea2a00c9d8.yml
-openapi_spec_hash: 77044dc5774eb88f1541ebbe53b413a7
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-03cb3b4d6f3c371f060a90c8baa03c050c20319d7879af0603788cc35f6f0506.yml
+openapi_spec_hash: 132e9efdb0535d9594abadf799431cf5
config_hash: 5c4e326f797c879e72d6bf32fc1ea49d
diff --git a/src/agentex/resources/agents/agents.py b/src/agentex/resources/agents/agents.py
index 2fde20ebc..924fab3fb 100644
--- a/src/agentex/resources/agents/agents.py
+++ b/src/agentex/resources/agents/agents.py
@@ -326,7 +326,7 @@ def rpc(
self,
agent_id: str,
*,
- method: Literal["event/send", "task/create", "message/send", "task/cancel"],
+ method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"],
params: agent_rpc_params.Params,
id: Union[int, str, None] | Omit = omit,
jsonrpc: Literal["2.0"] | Omit = omit,
@@ -374,7 +374,7 @@ def rpc_by_name(
self,
agent_name: str,
*,
- method: Literal["event/send", "task/create", "message/send", "task/cancel"],
+ method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"],
params: agent_rpc_by_name_params.Params,
id: Union[int, str, None] | Omit = omit,
jsonrpc: Literal["2.0"] | Omit = omit,
@@ -987,7 +987,7 @@ async def rpc(
self,
agent_id: str,
*,
- method: Literal["event/send", "task/create", "message/send", "task/cancel"],
+ method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"],
params: agent_rpc_params.Params,
id: Union[int, str, None] | Omit = omit,
jsonrpc: Literal["2.0"] | Omit = omit,
@@ -1035,7 +1035,7 @@ async def rpc_by_name(
self,
agent_name: str,
*,
- method: Literal["event/send", "task/create", "message/send", "task/cancel"],
+ method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"],
params: agent_rpc_by_name_params.Params,
id: Union[int, str, None] | Omit = omit,
jsonrpc: Literal["2.0"] | Omit = omit,
diff --git a/src/agentex/resources/agents/deployments.py b/src/agentex/resources/agents/deployments.py
index 2e02cf8b5..6bb15e61d 100644
--- a/src/agentex/resources/agents/deployments.py
+++ b/src/agentex/resources/agents/deployments.py
@@ -242,7 +242,7 @@ def preview_rpc(
deployment_id: str,
*,
agent_id: str,
- method: Literal["event/send", "task/create", "message/send", "task/cancel"],
+ method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"],
params: deployment_preview_rpc_params.Params,
id: Union[int, str, None] | Omit = omit,
jsonrpc: Literal["2.0"] | Omit = omit,
@@ -542,7 +542,7 @@ async def preview_rpc(
deployment_id: str,
*,
agent_id: str,
- method: Literal["event/send", "task/create", "message/send", "task/cancel"],
+ method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"],
params: deployment_preview_rpc_params.Params,
id: Union[int, str, None] | Omit = omit,
jsonrpc: Literal["2.0"] | Omit = omit,
diff --git a/src/agentex/resources/tasks.py b/src/agentex/resources/tasks.py
index 56ad84d2c..ca9ce24cd 100644
--- a/src/agentex/resources/tasks.py
+++ b/src/agentex/resources/tasks.py
@@ -109,7 +109,9 @@ def list(
order_direction: str | Omit = omit,
page_number: int | Omit = omit,
relationships: List[Literal["agents"]] | Omit = omit,
- status: Optional[Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "TERMINATED", "TIMED_OUT", "DELETED"]]
+ status: Optional[
+ Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"]
+ ]
| Omit = omit,
task_metadata: Optional[str] | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -705,7 +707,9 @@ async def list(
order_direction: str | Omit = omit,
page_number: int | Omit = omit,
relationships: List[Literal["agents"]] | Omit = omit,
- status: Optional[Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "TERMINATED", "TIMED_OUT", "DELETED"]]
+ status: Optional[
+ Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"]
+ ]
| Omit = omit,
task_metadata: Optional[str] | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
diff --git a/src/agentex/types/agent_rpc_by_name_params.py b/src/agentex/types/agent_rpc_by_name_params.py
index 5189926fe..208dfa0c5 100644
--- a/src/agentex/types/agent_rpc_by_name_params.py
+++ b/src/agentex/types/agent_rpc_by_name_params.py
@@ -12,13 +12,14 @@
"Params",
"ParamsCreateTaskRequest",
"ParamsCancelTaskRequest",
+ "ParamsInterruptTaskRequest",
"ParamsSendMessageRequest",
"ParamsSendEventRequest",
]
class AgentRpcByNameParams(TypedDict, total=False):
- method: Required[Literal["event/send", "task/create", "message/send", "task/cancel"]]
+ method: Required[Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"]]
params: Required[Params]
"""The parameters for the agent RPC request"""
@@ -61,6 +62,14 @@ class ParamsCancelTaskRequest(TypedDict, total=False):
"""The name of the task to cancel. Either this or task_id must be provided."""
+class ParamsInterruptTaskRequest(TypedDict, total=False):
+ task_id: Optional[str]
+ """The ID of the task to interrupt. Either this or task_name must be provided."""
+
+ task_name: Optional[str]
+ """The name of the task to interrupt. Either this or task_id must be provided."""
+
+
class ParamsSendMessageRequest(TypedDict, total=False):
content: Required[TaskMessageContentParam]
"""The message that was sent to the agent"""
@@ -90,5 +99,9 @@ class ParamsSendEventRequest(TypedDict, total=False):
Params: TypeAlias = Union[
- ParamsCreateTaskRequest, ParamsCancelTaskRequest, ParamsSendMessageRequest, ParamsSendEventRequest
+ ParamsCreateTaskRequest,
+ ParamsCancelTaskRequest,
+ ParamsInterruptTaskRequest,
+ ParamsSendMessageRequest,
+ ParamsSendEventRequest,
]
diff --git a/src/agentex/types/agent_rpc_params.py b/src/agentex/types/agent_rpc_params.py
index bf13912fd..380001ccf 100644
--- a/src/agentex/types/agent_rpc_params.py
+++ b/src/agentex/types/agent_rpc_params.py
@@ -12,13 +12,14 @@
"Params",
"ParamsCreateTaskRequest",
"ParamsCancelTaskRequest",
+ "ParamsInterruptTaskRequest",
"ParamsSendMessageRequest",
"ParamsSendEventRequest",
]
class AgentRpcParams(TypedDict, total=False):
- method: Required[Literal["event/send", "task/create", "message/send", "task/cancel"]]
+ method: Required[Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"]]
params: Required[Params]
"""The parameters for the agent RPC request"""
@@ -61,6 +62,14 @@ class ParamsCancelTaskRequest(TypedDict, total=False):
"""The name of the task to cancel. Either this or task_id must be provided."""
+class ParamsInterruptTaskRequest(TypedDict, total=False):
+ task_id: Optional[str]
+ """The ID of the task to interrupt. Either this or task_name must be provided."""
+
+ task_name: Optional[str]
+ """The name of the task to interrupt. Either this or task_id must be provided."""
+
+
class ParamsSendMessageRequest(TypedDict, total=False):
content: Required[TaskMessageContentParam]
"""The message that was sent to the agent"""
@@ -90,5 +99,9 @@ class ParamsSendEventRequest(TypedDict, total=False):
Params: TypeAlias = Union[
- ParamsCreateTaskRequest, ParamsCancelTaskRequest, ParamsSendMessageRequest, ParamsSendEventRequest
+ ParamsCreateTaskRequest,
+ ParamsCancelTaskRequest,
+ ParamsInterruptTaskRequest,
+ ParamsSendMessageRequest,
+ ParamsSendEventRequest,
]
diff --git a/src/agentex/types/agents/deployment_preview_rpc_params.py b/src/agentex/types/agents/deployment_preview_rpc_params.py
index ba8e20c71..3d9ade95a 100644
--- a/src/agentex/types/agents/deployment_preview_rpc_params.py
+++ b/src/agentex/types/agents/deployment_preview_rpc_params.py
@@ -12,6 +12,7 @@
"Params",
"ParamsCreateTaskRequest",
"ParamsCancelTaskRequest",
+ "ParamsInterruptTaskRequest",
"ParamsSendMessageRequest",
"ParamsSendEventRequest",
]
@@ -20,7 +21,7 @@
class DeploymentPreviewRpcParams(TypedDict, total=False):
agent_id: Required[str]
- method: Required[Literal["event/send", "task/create", "message/send", "task/cancel"]]
+ method: Required[Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"]]
params: Required[Params]
"""The parameters for the agent RPC request"""
@@ -63,6 +64,14 @@ class ParamsCancelTaskRequest(TypedDict, total=False):
"""The name of the task to cancel. Either this or task_id must be provided."""
+class ParamsInterruptTaskRequest(TypedDict, total=False):
+ task_id: Optional[str]
+ """The ID of the task to interrupt. Either this or task_name must be provided."""
+
+ task_name: Optional[str]
+ """The name of the task to interrupt. Either this or task_id must be provided."""
+
+
class ParamsSendMessageRequest(TypedDict, total=False):
content: Required[TaskMessageContentParam]
"""The message that was sent to the agent"""
@@ -92,5 +101,9 @@ class ParamsSendEventRequest(TypedDict, total=False):
Params: TypeAlias = Union[
- ParamsCreateTaskRequest, ParamsCancelTaskRequest, ParamsSendMessageRequest, ParamsSendEventRequest
+ ParamsCreateTaskRequest,
+ ParamsCancelTaskRequest,
+ ParamsInterruptTaskRequest,
+ ParamsSendMessageRequest,
+ ParamsSendEventRequest,
]
diff --git a/src/agentex/types/task.py b/src/agentex/types/task.py
index 3769d5128..e4348b395 100644
--- a/src/agentex/types/task.py
+++ b/src/agentex/types/task.py
@@ -20,7 +20,9 @@ class Task(BaseModel):
params: Optional[Dict[str, object]] = None
- status: Optional[Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "TERMINATED", "TIMED_OUT", "DELETED"]] = None
+ status: Optional[
+ Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"]
+ ] = None
status_reason: Optional[str] = None
diff --git a/src/agentex/types/task_list_params.py b/src/agentex/types/task_list_params.py
index f3f0ff4d9..e9796daed 100644
--- a/src/agentex/types/task_list_params.py
+++ b/src/agentex/types/task_list_params.py
@@ -23,7 +23,9 @@ class TaskListParams(TypedDict, total=False):
relationships: List[Literal["agents"]]
- status: Optional[Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "TERMINATED", "TIMED_OUT", "DELETED"]]
+ status: Optional[
+ Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"]
+ ]
"""Filter tasks by status (e.g. RUNNING, COMPLETED)."""
task_metadata: Optional[str]
diff --git a/src/agentex/types/task_list_response.py b/src/agentex/types/task_list_response.py
index b8d0ab73d..73ffcf4a4 100644
--- a/src/agentex/types/task_list_response.py
+++ b/src/agentex/types/task_list_response.py
@@ -25,7 +25,9 @@ class TaskListResponseItem(BaseModel):
params: Optional[Dict[str, object]] = None
- status: Optional[Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "TERMINATED", "TIMED_OUT", "DELETED"]] = None
+ status: Optional[
+ Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"]
+ ] = None
status_reason: Optional[str] = None
diff --git a/src/agentex/types/task_retrieve_by_name_response.py b/src/agentex/types/task_retrieve_by_name_response.py
index 557764d04..800fead16 100644
--- a/src/agentex/types/task_retrieve_by_name_response.py
+++ b/src/agentex/types/task_retrieve_by_name_response.py
@@ -25,7 +25,9 @@ class TaskRetrieveByNameResponse(BaseModel):
params: Optional[Dict[str, object]] = None
- status: Optional[Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "TERMINATED", "TIMED_OUT", "DELETED"]] = None
+ status: Optional[
+ Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"]
+ ] = None
status_reason: Optional[str] = None
diff --git a/src/agentex/types/task_retrieve_response.py b/src/agentex/types/task_retrieve_response.py
index bb1398d67..94939ded1 100644
--- a/src/agentex/types/task_retrieve_response.py
+++ b/src/agentex/types/task_retrieve_response.py
@@ -25,7 +25,9 @@ class TaskRetrieveResponse(BaseModel):
params: Optional[Dict[str, object]] = None
- status: Optional[Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "TERMINATED", "TIMED_OUT", "DELETED"]] = None
+ status: Optional[
+ Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"]
+ ] = None
status_reason: Optional[str] = None
From 936a2b1d28a5409bd7548d59b0f18491495c805b Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 16 Jul 2026 19:23:31 +0000
Subject: [PATCH 2/4] feat(api): add interrupt task
---
.stats.yml | 6 +-
api.md | 1 +
src/agentex/resources/tasks.py | 89 ++++++++++++++++++
src/agentex/types/__init__.py | 1 +
src/agentex/types/task_interrupt_params.py | 12 +++
tests/api_resources/test_tasks.py | 102 +++++++++++++++++++++
6 files changed, 208 insertions(+), 3 deletions(-)
create mode 100644 src/agentex/types/task_interrupt_params.py
diff --git a/.stats.yml b/.stats.yml
index dd16d1e6f..2764fd11e 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 74
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-03cb3b4d6f3c371f060a90c8baa03c050c20319d7879af0603788cc35f6f0506.yml
+configured_endpoints: 75
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-b2df5f506330ad5fba5a0d518ab8a4bcf876e8c3684a4fe0d0cc5102fd9c569e.yml
openapi_spec_hash: 132e9efdb0535d9594abadf799431cf5
-config_hash: 5c4e326f797c879e72d6bf32fc1ea49d
+config_hash: 593e89b291976a5e84e4c3c3f8324354
diff --git a/api.md b/api.md
index 284f2bde0..a7515d9d2 100644
--- a/api.md
+++ b/api.md
@@ -126,6 +126,7 @@ Methods:
- client.tasks.complete(task_id, \*\*params) -> Task
- client.tasks.delete_by_name(task_name) -> DeleteResponse
- client.tasks.fail(task_id, \*\*params) -> Task
+- client.tasks.interrupt(task_id, \*\*params) -> Task
- client.tasks.query_workflow(query_name, \*, task_id) -> TaskQueryWorkflowResponse
- client.tasks.retrieve_by_name(task_name, \*\*params) -> TaskRetrieveByNameResponse
- client.tasks.stream_events(task_id) -> object
diff --git a/src/agentex/resources/tasks.py b/src/agentex/resources/tasks.py
index ca9ce24cd..d82b1a7cb 100644
--- a/src/agentex/resources/tasks.py
+++ b/src/agentex/resources/tasks.py
@@ -14,6 +14,7 @@
task_timeout_params,
task_complete_params,
task_retrieve_params,
+ task_interrupt_params,
task_terminate_params,
task_update_by_id_params,
task_update_by_name_params,
@@ -336,6 +337,44 @@ def fail(
cast_to=Task,
)
+ def interrupt(
+ self,
+ task_id: str,
+ *,
+ reason: Optional[str] | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Task:
+ """Stop the in-flight turn without terminating the task.
+
+ Transitions a running task
+ to the non-terminal INTERRUPTED status; the task stays continuable and the next
+ message or event resumes it.
+
+ Args:
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if not task_id:
+ raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}")
+ return self._post(
+ path_template("/tasks/{task_id}/interrupt", task_id=task_id),
+ body=maybe_transform({"reason": reason}, task_interrupt_params.TaskInterruptParams),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=Task,
+ )
+
def query_workflow(
self,
query_name: str,
@@ -934,6 +973,44 @@ async def fail(
cast_to=Task,
)
+ async def interrupt(
+ self,
+ task_id: str,
+ *,
+ reason: Optional[str] | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Task:
+ """Stop the in-flight turn without terminating the task.
+
+ Transitions a running task
+ to the non-terminal INTERRUPTED status; the task stays continuable and the next
+ message or event resumes it.
+
+ Args:
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if not task_id:
+ raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}")
+ return await self._post(
+ path_template("/tasks/{task_id}/interrupt", task_id=task_id),
+ body=await async_maybe_transform({"reason": reason}, task_interrupt_params.TaskInterruptParams),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=Task,
+ )
+
async def query_workflow(
self,
query_name: str,
@@ -1260,6 +1337,9 @@ def __init__(self, tasks: TasksResource) -> None:
self.fail = to_raw_response_wrapper(
tasks.fail,
)
+ self.interrupt = to_raw_response_wrapper(
+ tasks.interrupt,
+ )
self.query_workflow = to_raw_response_wrapper(
tasks.query_workflow,
)
@@ -1311,6 +1391,9 @@ def __init__(self, tasks: AsyncTasksResource) -> None:
self.fail = async_to_raw_response_wrapper(
tasks.fail,
)
+ self.interrupt = async_to_raw_response_wrapper(
+ tasks.interrupt,
+ )
self.query_workflow = async_to_raw_response_wrapper(
tasks.query_workflow,
)
@@ -1362,6 +1445,9 @@ def __init__(self, tasks: TasksResource) -> None:
self.fail = to_streamed_response_wrapper(
tasks.fail,
)
+ self.interrupt = to_streamed_response_wrapper(
+ tasks.interrupt,
+ )
self.query_workflow = to_streamed_response_wrapper(
tasks.query_workflow,
)
@@ -1413,6 +1499,9 @@ def __init__(self, tasks: AsyncTasksResource) -> None:
self.fail = async_to_streamed_response_wrapper(
tasks.fail,
)
+ self.interrupt = async_to_streamed_response_wrapper(
+ tasks.interrupt,
+ )
self.query_workflow = async_to_streamed_response_wrapper(
tasks.query_workflow,
)
diff --git a/src/agentex/types/__init__.py b/src/agentex/types/__init__.py
index 8bb76fa04..674503012 100644
--- a/src/agentex/types/__init__.py
+++ b/src/agentex/types/__init__.py
@@ -56,6 +56,7 @@
from .message_create_params import MessageCreateParams as MessageCreateParams
from .message_list_response import MessageListResponse as MessageListResponse
from .message_update_params import MessageUpdateParams as MessageUpdateParams
+from .task_interrupt_params import TaskInterruptParams as TaskInterruptParams
from .task_terminate_params import TaskTerminateParams as TaskTerminateParams
from .tool_response_content import ToolResponseContent as ToolResponseContent
from .tracker_list_response import TrackerListResponse as TrackerListResponse
diff --git a/src/agentex/types/task_interrupt_params.py b/src/agentex/types/task_interrupt_params.py
new file mode 100644
index 000000000..5f0b1a358
--- /dev/null
+++ b/src/agentex/types/task_interrupt_params.py
@@ -0,0 +1,12 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing import Optional
+from typing_extensions import TypedDict
+
+__all__ = ["TaskInterruptParams"]
+
+
+class TaskInterruptParams(TypedDict, total=False):
+ reason: Optional[str]
diff --git a/tests/api_resources/test_tasks.py b/tests/api_resources/test_tasks.py
index 766175fa4..37df8f0f3 100644
--- a/tests/api_resources/test_tasks.py
+++ b/tests/api_resources/test_tasks.py
@@ -357,6 +357,57 @@ def test_path_params_fail(self, client: Agentex) -> None:
task_id="",
)
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_interrupt(self, client: Agentex) -> None:
+ task = client.tasks.interrupt(
+ task_id="task_id",
+ )
+ assert_matches_type(Task, task, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_interrupt_with_all_params(self, client: Agentex) -> None:
+ task = client.tasks.interrupt(
+ task_id="task_id",
+ reason="reason",
+ )
+ assert_matches_type(Task, task, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_raw_response_interrupt(self, client: Agentex) -> None:
+ response = client.tasks.with_raw_response.interrupt(
+ task_id="task_id",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ task = response.parse()
+ assert_matches_type(Task, task, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_streaming_response_interrupt(self, client: Agentex) -> None:
+ with client.tasks.with_streaming_response.interrupt(
+ task_id="task_id",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ task = response.parse()
+ assert_matches_type(Task, task, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_path_params_interrupt(self, client: Agentex) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"):
+ client.tasks.with_raw_response.interrupt(
+ task_id="",
+ )
+
@pytest.mark.skip(reason="Mock server tests are disabled")
@parametrize
def test_method_query_workflow(self, client: Agentex) -> None:
@@ -1086,6 +1137,57 @@ async def test_path_params_fail(self, async_client: AsyncAgentex) -> None:
task_id="",
)
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_interrupt(self, async_client: AsyncAgentex) -> None:
+ task = await async_client.tasks.interrupt(
+ task_id="task_id",
+ )
+ assert_matches_type(Task, task, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_interrupt_with_all_params(self, async_client: AsyncAgentex) -> None:
+ task = await async_client.tasks.interrupt(
+ task_id="task_id",
+ reason="reason",
+ )
+ assert_matches_type(Task, task, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_raw_response_interrupt(self, async_client: AsyncAgentex) -> None:
+ response = await async_client.tasks.with_raw_response.interrupt(
+ task_id="task_id",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ task = await response.parse()
+ assert_matches_type(Task, task, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_streaming_response_interrupt(self, async_client: AsyncAgentex) -> None:
+ async with async_client.tasks.with_streaming_response.interrupt(
+ task_id="task_id",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ task = await response.parse()
+ assert_matches_type(Task, task, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_path_params_interrupt(self, async_client: AsyncAgentex) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"):
+ await async_client.tasks.with_raw_response.interrupt(
+ task_id="",
+ )
+
@pytest.mark.skip(reason="Mock server tests are disabled")
@parametrize
async def test_method_query_workflow(self, async_client: AsyncAgentex) -> None:
From eaa3dd526b88c454fc604d0e617f7191b66952bb Mon Sep 17 00:00:00 2001
From: Declan Brady
Date: Thu, 16 Jul 2026 16:14:05 -0400
Subject: [PATCH 3/4] feat(interrupt): task/interrupt hook + protocol +
resume-safe session capture (AGX1-391) (#462)
---
.../lib/adk/_modules/_claude_code_sync.py | 53 +++++-
.../lib/adk/_modules/_claude_code_turn.py | 26 ++-
src/agentex/lib/adk/_modules/_codex_sync.py | 39 ++++
src/agentex/lib/adk/_modules/_codex_turn.py | 22 ++-
.../lib/adk/_modules/_langgraph_sync.py | 21 +++
src/agentex/lib/adk/_modules/_openai_sync.py | 19 ++
.../lib/adk/_modules/_pydantic_ai_sync.py | 21 +++
src/agentex/lib/adk/_modules/tasks.py | 47 +++++
src/agentex/lib/core/services/adk/tasks.py | 19 ++
.../lib/core/temporal/activities/__init__.py | 1 +
.../activities/adk/tasks_activities.py | 10 +
.../services/temporal_task_service.py | 19 +-
.../lib/core/temporal/types/workflow.py | 5 +
.../lib/core/temporal/workflows/workflow.py | 31 ++-
.../lib/sdk/fastacp/base/base_acp_server.py | 15 ++
.../lib/sdk/fastacp/impl/temporal_acp.py | 19 ++
src/agentex/lib/types/acp.py | 1 +
src/agentex/protocol/acp.py | 24 +++
tests/lib/adk/test_claude_code_turn.py | 68 +++++++
tests/lib/adk/test_codex_turn.py | 59 ++++++
tests/test_acp_interrupt.py | 178 ++++++++++++++++++
21 files changed, 682 insertions(+), 15 deletions(-)
create mode 100644 tests/test_acp_interrupt.py
diff --git a/src/agentex/lib/adk/_modules/_claude_code_sync.py b/src/agentex/lib/adk/_modules/_claude_code_sync.py
index 93a639118..e8daa44a3 100644
--- a/src/agentex/lib/adk/_modules/_claude_code_sync.py
+++ b/src/agentex/lib/adk/_modules/_claude_code_sync.py
@@ -74,6 +74,35 @@ def _extract_summary(text: str, max_len: int = 300) -> str:
async def convert_claude_code_to_agentex_events(
lines: AsyncIterator[str | dict[str, Any]],
on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
+ on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
+) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
+ """Public tap: convert a claude-code ``stream-json`` line stream to events.
+
+ Thin wrapper over :func:`_convert_claude_code_impl` that owns the
+ cancellation backstop: a ``finally`` closes the underlying ``lines`` iterator
+ (when it exposes ``aclose``) whenever this generator is closed — including on
+ the ``GeneratorExit``/``CancelledError`` raised when a consuming task is
+ cancelled mid-turn by an interrupt. This terminates the CLI stdout handle /
+ subprocess instead of leaking it. Kept as a wrapper (rather than a
+ ``try/finally`` inside the impl) so the large parser body stays untouched.
+ """
+ inner = _convert_claude_code_impl(lines, on_result=on_result, on_init=on_init)
+ try:
+ async for event in inner:
+ yield event
+ finally:
+ inner_aclose = getattr(inner, "aclose", None)
+ if inner_aclose is not None:
+ await inner_aclose()
+ aclose = getattr(lines, "aclose", None)
+ if aclose is not None:
+ await aclose()
+
+
+async def _convert_claude_code_impl(
+ lines: AsyncIterator[str | dict[str, Any]],
+ on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
+ on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
"""Convert a claude-code ``stream-json`` line stream into Agentex ``StreamTaskMessage*`` events.
@@ -85,7 +114,20 @@ async def convert_claude_code_to_agentex_events(
caller can capture usage and cost. It is awaited before the generator
continues. When ``None``, the result envelope is silently dropped.
+ ``on_init`` is called with the ``system``/``init`` envelope the moment it
+ arrives (the FIRST envelope of a claude-code stream), so the caller can
+ capture ``session_id`` EARLY — before the turn completes. This is what makes
+ an interrupted-before-completion turn resumable: the terminal ``result``
+ envelope (which ``on_result`` reads) never arrives when a turn is cut short,
+ so relying on it alone loses the session id. Mirrors how the inline
+ ``claude_agents`` activity captures session_id from its SystemMessage init.
+ When ``None``, the init envelope's session metadata is not surfaced early.
+
Envelope → canonical mapping is documented in this module's docstring.
+
+ A ``finally`` closes the underlying ``lines`` iterator (when it exposes
+ ``aclose``) as a backstop, so a cancellation mid-turn (e.g. an interrupt that
+ cancels the consuming task) does not leak the CLI stdout handle / subprocess.
"""
next_index = 0
tool_call_count = 0
@@ -355,9 +397,14 @@ async def convert_claude_code_to_agentex_events(
# system / init — session metadata (ignored at this layer)
# -----------------------------------------------------------------------
elif evt_type == "system":
- # Session ID tracking and MCP status logging are provider concerns.
- # This pure parser layer intentionally emits nothing for system events.
- pass
+ # Session ID tracking and MCP status logging are provider concerns:
+ # this pure parser layer emits no StreamTaskMessage for system events.
+ # It DOES surface the init envelope's session metadata early via
+ # on_init so a caller can capture session_id before the turn's
+ # terminal ``result`` arrives — required for resuming a turn that was
+ # interrupted before completion (no ``result`` is ever emitted then).
+ if on_init is not None and evt.get("subtype") == "init":
+ await on_init(evt)
# -----------------------------------------------------------------------
# result — carries usage + cost; fired to on_result, not emitted as msgs
diff --git a/src/agentex/lib/adk/_modules/_claude_code_turn.py b/src/agentex/lib/adk/_modules/_claude_code_turn.py
index 6c052976a..d41afafe7 100644
--- a/src/agentex/lib/adk/_modules/_claude_code_turn.py
+++ b/src/agentex/lib/adk/_modules/_claude_code_turn.py
@@ -118,17 +118,28 @@ class ClaudeCodeTurn:
def __init__(self, lines: AsyncIterator[str | dict[str, Any]]) -> None:
self._lines = lines
self._result_envelope: dict[str, Any] | None = None
+ # session_id captured from the early system/init envelope. This is what
+ # keeps an interrupted-before-completion turn resumable: the terminal
+ # ``result`` envelope never arrives when a turn is cut short, so we must
+ # capture the session id up front (from init) rather than only at the end.
+ self._init_session_id: str | None = None
self._events_stream: AsyncIterator[StreamTaskMessage] | None = None
async def _on_result(self, envelope: dict[str, Any]) -> None:
self._result_envelope = envelope
+ async def _on_init(self, envelope: dict[str, Any]) -> None:
+ sid = envelope.get("session_id")
+ if sid:
+ self._init_session_id = sid
+
@property
def events(self) -> AsyncIterator[StreamTaskMessage]:
if self._events_stream is None:
self._events_stream = convert_claude_code_to_agentex_events(
self._lines,
on_result=self._on_result,
+ on_init=self._on_init,
)
return self._events_stream
@@ -136,13 +147,16 @@ def events(self) -> AsyncIterator[StreamTaskMessage]:
def session_id(self) -> str | None:
"""The Claude Code session id, for resuming a multi-turn session.
- Valid only after ``events`` has been fully consumed (populated by the
- ``result`` envelope). Returns ``None`` if the stream was truncated or
- Claude Code reported no session id.
+ Prefers the id from the terminal ``result`` envelope (fully-complete
+ turn), then falls back to the id captured from the early ``system/init``
+ envelope. The init fallback is what makes a turn that was interrupted
+ before completion still resumable (no ``result`` is emitted then).
+ Returns ``None`` only if neither envelope was seen (e.g. the stream was
+ truncated before init) or Claude Code reported no session id.
"""
- if not self._result_envelope:
- return None
- return self._result_envelope.get("session_id")
+ if self._result_envelope and self._result_envelope.get("session_id"):
+ return self._result_envelope.get("session_id")
+ return self._init_session_id
def usage(self) -> TurnUsage:
"""Return normalised usage for this turn.
diff --git a/src/agentex/lib/adk/_modules/_codex_sync.py b/src/agentex/lib/adk/_modules/_codex_sync.py
index 5a951d57e..951622b13 100644
--- a/src/agentex/lib/adk/_modules/_codex_sync.py
+++ b/src/agentex/lib/adk/_modules/_codex_sync.py
@@ -527,6 +527,34 @@ def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMe
async def convert_codex_to_agentex_events(
events: AsyncIterator[str | dict[str, Any]],
on_result: Callable[[dict[str, Any]], None] | None = None,
+ on_init: Callable[[dict[str, Any]], None] | None = None,
+) -> AsyncIterator[StreamTaskMessage]:
+ """Public tap: convert a ``codex exec --json`` event stream to events.
+
+ Thin wrapper over :func:`_convert_codex_impl` that owns the cancellation
+ backstop: a ``finally`` closes the underlying ``events`` iterator (when it
+ exposes ``aclose``) whenever this generator is closed — including on the
+ ``GeneratorExit``/``CancelledError`` raised when a consuming task is
+ cancelled mid-turn by an interrupt. This terminates the CLI stdout handle /
+ subprocess instead of leaking it.
+ """
+ inner = _convert_codex_impl(events, on_result=on_result, on_init=on_init)
+ try:
+ async for event in inner:
+ yield event
+ finally:
+ inner_aclose = getattr(inner, "aclose", None)
+ if inner_aclose is not None:
+ await inner_aclose()
+ aclose = getattr(events, "aclose", None)
+ if aclose is not None:
+ await aclose()
+
+
+async def _convert_codex_impl(
+ events: AsyncIterator[str | dict[str, Any]],
+ on_result: Callable[[dict[str, Any]], None] | None = None,
+ on_init: Callable[[dict[str, Any]], None] | None = None,
) -> AsyncIterator[StreamTaskMessage]:
"""Convert a ``codex exec --json`` event stream into Agentex stream events.
@@ -546,6 +574,12 @@ async def convert_codex_to_agentex_events(
``reasoning_count`` — int
Use this to record turn-level metrics / usage in the caller's span
without coupling this module to span/tracing APIs.
+ on_init: Optional callback invoked once when the ``thread.started`` event
+ is seen (the first event of a codex stream). Receives ``{"session_id":
+ }``. This surfaces the session id EARLY — before
+ the turn completes — so a turn interrupted before completion is still
+ resumable (``turn.completed`` / ``on_result`` never fires then). The
+ codex counterpart of the claude-code ``system/init`` early capture.
Yields:
Canonical ``StreamTaskMessage*`` events (Start/Delta/Full/Done) with
@@ -590,6 +624,11 @@ async def convert_codex_to_agentex_events(
for msg in messages:
yield msg
+ # Surface session_id early (processor sets it while handling
+ # thread.started) so an interrupted turn is still resumable.
+ if on_init is not None and evt.get("type") == "thread.started":
+ on_init({"session_id": processor.session_id})
+
if on_result is not None:
on_result(
{
diff --git a/src/agentex/lib/adk/_modules/_codex_turn.py b/src/agentex/lib/adk/_modules/_codex_turn.py
index e7fa1d929..05429bce5 100644
--- a/src/agentex/lib/adk/_modules/_codex_turn.py
+++ b/src/agentex/lib/adk/_modules/_codex_turn.py
@@ -160,6 +160,10 @@ def __init__(
# Populated by the on_result callback once the stream is exhausted.
self._result: dict[str, Any] | None = None
+ # Populated by the on_init callback when thread.started arrives (early),
+ # so session_id is available even if the turn is interrupted before
+ # completion (turn.completed / on_result never fires then).
+ self._init_session_id: str | None = None
# The events generator is created at most once: ``_raw_events`` is a
# single-consumption AsyncIterator, so re-wrapping it would yield an
# already-exhausted stream that fires on_result with zeros and clobbers
@@ -179,21 +183,31 @@ def events(self) -> AsyncIterator[StreamTaskMessage]:
self._events_gen = convert_codex_to_agentex_events(
self._raw_events,
on_result=self._on_result,
+ on_init=self._on_init,
)
return self._events_gen
def _on_result(self, result: dict[str, Any]) -> None:
self._result = result
+ def _on_init(self, init: dict[str, Any]) -> None:
+ sid = init.get("session_id")
+ if sid:
+ self._init_session_id = sid
+
@property
def session_id(self) -> str | None:
"""The codex session id, for resuming a multi-turn session.
- Valid only after ``events`` has been fully consumed (populated by the
- ``on_result`` callback). Returns ``None`` if the stream is not yet
- exhausted or codex reported no session id.
+ Prefers the id from the terminal ``turn.completed`` result (fully-complete
+ turn), then falls back to the id captured early from ``thread.started``.
+ The early fallback keeps a turn interrupted before completion resumable
+ (no ``turn.completed`` / ``on_result`` fires then). Returns ``None`` only
+ if neither was seen or codex reported no session id.
"""
- return self._result.get("session_id") if self._result else None
+ if self._result and self._result.get("session_id"):
+ return self._result.get("session_id")
+ return self._init_session_id
def usage(self) -> TurnUsage:
"""Return normalized ``TurnUsage`` for this turn.
diff --git a/src/agentex/lib/adk/_modules/_langgraph_sync.py b/src/agentex/lib/adk/_modules/_langgraph_sync.py
index 9d7b73847..02f2ba416 100644
--- a/src/agentex/lib/adk/_modules/_langgraph_sync.py
+++ b/src/agentex/lib/adk/_modules/_langgraph_sync.py
@@ -32,6 +32,27 @@
async def convert_langgraph_to_agentex_events(
stream: Any,
on_final_ai_message: Optional[Callable[..., None]] = None,
+) -> AsyncGenerator[Any, None]:
+ """Public LangGraph tap: convert events, closing the source stream on exit.
+
+ Thin wrapper over ``_convert_langgraph_impl`` that adds a cancellation-safe
+ ``finally`` so an interrupted turn tears down the LangGraph ``astream`` source
+ instead of leaking it.
+ """
+ inner = _convert_langgraph_impl(stream, on_final_ai_message=on_final_ai_message)
+ try:
+ async for event in inner:
+ yield event
+ finally:
+ for _src in (inner, stream):
+ _aclose = getattr(_src, "aclose", None)
+ if _aclose is not None:
+ await _aclose()
+
+
+async def _convert_langgraph_impl(
+ stream: Any,
+ on_final_ai_message: Optional[Callable[..., None]] = None,
) -> AsyncGenerator[Any, None]:
"""Convert LangGraph streaming events to Agentex TaskMessageUpdate events.
diff --git a/src/agentex/lib/adk/_modules/_openai_sync.py b/src/agentex/lib/adk/_modules/_openai_sync.py
index ac404bef1..b857a5be6 100644
--- a/src/agentex/lib/adk/_modules/_openai_sync.py
+++ b/src/agentex/lib/adk/_modules/_openai_sync.py
@@ -145,6 +145,25 @@ def _extract_tool_response_info(tool_map: dict[str, Any], tool_output_item: Any)
async def convert_openai_to_agentex_events(stream_response):
+ """Public OpenAI tap: parse the event stream, closing the source on exit.
+
+ Thin wrapper over ``_convert_openai_impl`` that adds a cancellation-safe
+ ``finally`` so an interrupted turn tears down the source event stream instead
+ of leaking it. (Resume state is carried by the OpenAI Agents SDK input list,
+ not a session id, so there is no early-session_id capture like the CLI taps.)
+ """
+ inner = _convert_openai_impl(stream_response)
+ try:
+ async for event in inner:
+ yield event
+ finally:
+ for _src in (inner, stream_response):
+ _aclose = getattr(_src, "aclose", None)
+ if _aclose is not None:
+ await _aclose()
+
+
+async def _convert_openai_impl(stream_response):
"""Convert OpenAI streaming events to AgentEx TaskMessageUpdate events with reasoning support.
This is an enhanced version of the base converter that includes support for:
diff --git a/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py b/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py
index 0f9aaeb55..75bfb64db 100644
--- a/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py
+++ b/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py
@@ -120,6 +120,27 @@ def _tool_return_content(result: ToolReturnPart | Any) -> Any:
async def convert_pydantic_ai_to_agentex_events(
stream_response: AsyncIterator[Any],
on_result: Callable[[AgentRunResultEvent], Any] | None = None,
+) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
+ """Public Pydantic AI tap: convert events, closing the source stream on exit.
+
+ Thin wrapper over ``_convert_pydantic_ai_impl`` that adds a cancellation-safe
+ ``finally`` so an interrupted turn tears down the source stream instead of
+ leaking it.
+ """
+ inner = _convert_pydantic_ai_impl(stream_response, on_result=on_result)
+ try:
+ async for event in inner:
+ yield event
+ finally:
+ for _src in (inner, stream_response):
+ _aclose = getattr(_src, "aclose", None)
+ if _aclose is not None:
+ await _aclose()
+
+
+async def _convert_pydantic_ai_impl(
+ stream_response: AsyncIterator[Any],
+ on_result: Callable[[AgentRunResultEvent], Any] | None = None,
) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
"""Convert a Pydantic AI agent event stream into Agentex stream events.
diff --git a/src/agentex/lib/adk/_modules/tasks.py b/src/agentex/lib/adk/_modules/tasks.py
index 7b304a656..d842f23ee 100644
--- a/src/agentex/lib/adk/_modules/tasks.py
+++ b/src/agentex/lib/adk/_modules/tasks.py
@@ -174,6 +174,53 @@ async def cancel(
parent_span_id=parent_span_id,
)
+ async def interrupt(
+ self,
+ *,
+ task_id: str,
+ reason: str | None = None,
+ trace_id: str | None = None,
+ parent_span_id: str | None = None,
+ start_to_close_timeout: timedelta = timedelta(seconds=5),
+ heartbeat_timeout: timedelta = timedelta(seconds=5),
+ retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
+ ) -> Task:
+ """
+ Mark a running task as interrupted (non-terminal).
+
+ Interrupt is cooperative: an agent calls this from its interrupt handler,
+ after it has actually stopped its in-flight turn, to record INTERRUPTED.
+ The task stays continuable; the control plane resumes it to RUNNING on the
+ next turn.
+ Args:
+ task_id: The ID of the task to interrupt.
+ reason: Optional reason for the interrupt.
+ Returns:
+ The updated task entry.
+ """
+ params = TaskStatusTransitionParams(
+ task_id=task_id,
+ reason=reason,
+ trace_id=trace_id,
+ parent_span_id=parent_span_id,
+ )
+ if in_temporal_workflow():
+ return await ActivityHelpers.execute_activity(
+ activity_name=TasksActivityName.INTERRUPT_TASK,
+ request=params,
+ response_type=Task,
+ start_to_close_timeout=start_to_close_timeout,
+ retry_policy=retry_policy,
+ heartbeat_timeout=heartbeat_timeout,
+ )
+ else:
+ return await self._tasks_service.interrupt_task(
+ task_id=task_id,
+ reason=reason,
+ trace_id=trace_id,
+ parent_span_id=parent_span_id,
+ )
+
async def complete(
self,
*,
diff --git a/src/agentex/lib/core/services/adk/tasks.py b/src/agentex/lib/core/services/adk/tasks.py
index 7748799e4..f1dba08bd 100644
--- a/src/agentex/lib/core/services/adk/tasks.py
+++ b/src/agentex/lib/core/services/adk/tasks.py
@@ -98,6 +98,25 @@ async def cancel_task(
span.output = task_model.model_dump()
return task_model
+ async def interrupt_task(
+ self,
+ task_id: str,
+ reason: str | None = None,
+ trace_id: str | None = None,
+ parent_span_id: str | None = None,
+ ) -> Task:
+ trace = self._tracer.trace(trace_id)
+ async with trace.span(
+ parent_id=parent_span_id,
+ name="interrupt_task",
+ input={"task_id": task_id, "reason": reason},
+ ) as span:
+ heartbeat_if_in_workflow("interrupt task")
+ task_model = await self._agentex_client.tasks.interrupt(task_id=task_id, reason=reason)
+ if span:
+ span.output = task_model.model_dump()
+ return task_model
+
async def complete_task(
self,
task_id: str,
diff --git a/src/agentex/lib/core/temporal/activities/__init__.py b/src/agentex/lib/core/temporal/activities/__init__.py
index 4660afdde..93e6b69e6 100644
--- a/src/agentex/lib/core/temporal/activities/__init__.py
+++ b/src/agentex/lib/core/temporal/activities/__init__.py
@@ -181,6 +181,7 @@ def get_all_activities(sgp_client=None):
tasks_activities.get_task,
tasks_activities.delete_task,
tasks_activities.cancel_task,
+ tasks_activities.interrupt_task,
tasks_activities.complete_task,
tasks_activities.fail_task,
tasks_activities.terminate_task,
diff --git a/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py b/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py
index 38eecd447..bd1c430c4 100644
--- a/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py
+++ b/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py
@@ -18,6 +18,7 @@ class TasksActivityName(str, Enum):
GET_TASK = "get-task"
DELETE_TASK = "delete-task"
CANCEL_TASK = "cancel-task"
+ INTERRUPT_TASK = "interrupt-task"
COMPLETE_TASK = "complete-task"
FAIL_TASK = "fail-task"
TERMINATE_TASK = "terminate-task"
@@ -83,6 +84,15 @@ async def cancel_task(self, params: TaskStatusTransitionParams) -> Task:
parent_span_id=params.parent_span_id,
)
+ @activity.defn(name=TasksActivityName.INTERRUPT_TASK)
+ async def interrupt_task(self, params: TaskStatusTransitionParams) -> Task:
+ return await self._tasks_service.interrupt_task(
+ task_id=params.task_id,
+ reason=params.reason,
+ trace_id=params.trace_id,
+ parent_span_id=params.parent_span_id,
+ )
+
@activity.defn(name=TasksActivityName.COMPLETE_TASK)
async def complete_task(self, params: TaskStatusTransitionParams) -> Task:
return await self._tasks_service.complete_task(
diff --git a/src/agentex/lib/core/temporal/services/temporal_task_service.py b/src/agentex/lib/core/temporal/services/temporal_task_service.py
index cb7a181d6..5f6c0c381 100644
--- a/src/agentex/lib/core/temporal/services/temporal_task_service.py
+++ b/src/agentex/lib/core/temporal/services/temporal_task_service.py
@@ -6,7 +6,7 @@
from agentex.types.task import Task
from agentex.types.agent import Agent
from agentex.types.event import Event
-from agentex.protocol.acp import SendEventParams, CreateTaskParams
+from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams
from agentex.lib.environment_variables import EnvironmentVariables
from agentex.lib.core.clients.temporal.types import WorkflowState
from agentex.lib.core.temporal.types.workflow import SignalName
@@ -74,6 +74,23 @@ async def send_event(self, agent: Agent, task: Task, event: Event, request: dict
).model_dump(),
)
+ async def interrupt(self, agent: Agent, task: Task, request: dict | None = None) -> None:
+ """Forward a task/interrupt to the running workflow as a dedicated signal.
+
+ Non-terminal: unlike ``cancel``/``terminate`` this does NOT tear down the
+ workflow. It signals ``interrupt_turn`` so the workflow's ``on_interrupt``
+ hook can stop the in-flight turn while leaving the task continuable.
+ """
+ return await self._temporal_client.send_signal(
+ workflow_id=task.id,
+ signal=SignalName.INTERRUPT_TURN.value,
+ payload=InterruptTaskParams(
+ agent=agent,
+ task=task,
+ request=request,
+ ).model_dump(),
+ )
+
async def cancel(self, task_id: str) -> None:
return await self._temporal_client.cancel_workflow(
workflow_id=task_id,
diff --git a/src/agentex/lib/core/temporal/types/workflow.py b/src/agentex/lib/core/temporal/types/workflow.py
index 973bb52c2..62624832e 100644
--- a/src/agentex/lib/core/temporal/types/workflow.py
+++ b/src/agentex/lib/core/temporal/types/workflow.py
@@ -3,3 +3,8 @@
class SignalName(str, Enum):
RECEIVE_EVENT = "receive_event"
+ # Dedicated non-terminal "stop the current turn" signal (design doc section 7).
+ # Routed to the overridable BaseWorkflow.on_interrupt hook. Kept separate from
+ # RECEIVE_EVENT so the interrupt handler can interleave with (and cancel) the
+ # in-flight turn WITHOUT waiting on the turn lock the running turn holds.
+ INTERRUPT_TURN = "interrupt_turn"
diff --git a/src/agentex/lib/core/temporal/workflows/workflow.py b/src/agentex/lib/core/temporal/workflows/workflow.py
index a353bea6e..e47fd9a5c 100644
--- a/src/agentex/lib/core/temporal/workflows/workflow.py
+++ b/src/agentex/lib/core/temporal/workflows/workflow.py
@@ -6,7 +6,7 @@
from temporalio import workflow
-from agentex.protocol.acp import SendEventParams, CreateTaskParams
+from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams
from agentex.lib.utils.logging import make_logger
from agentex.lib.core.temporal.types.workflow import SignalName
@@ -160,3 +160,32 @@ def is_continued_run(self) -> bool:
gated here runs again on each history rollover.
"""
return workflow.info().continued_run_id is not None
+
+ @workflow.signal(name=SignalName.INTERRUPT_TURN)
+ async def on_interrupt(self, params: InterruptTaskParams) -> None:
+ """Handle a task/interrupt: stop the in-flight turn, keep the task continuable.
+
+ This is the durable transport for the platform's ``task/interrupt`` verb
+ (design doc section 7). The control plane forwards ``task/interrupt`` to the
+ agent pod over HTTP JSON-RPC; the ACP/Temporal layer routes it to the
+ ``interrupt_turn`` signal, which invokes this hook.
+
+ Temporal runs signal handlers on the same deterministic event loop as the
+ workflow ``run`` method, interleaving at ``await`` points. This handler runs
+ CONCURRENTLY with the in-flight turn coroutine, so it MUST NOT acquire the
+ turn lock (the running turn already holds it) — doing so would deadlock: the
+ interrupt could never fire because it would be waiting on the very turn it is
+ meant to stop.
+
+ The base implementation is a no-op so existing agents keep working (and the
+ signal is still accepted, avoiding "unhandled signal" warnings). Interruptible
+ agents (the golden agent is the reference) override this to actually stop the
+ in-flight model / CLI subprocess — e.g. SIGINT the leased sandbox CLI by pid,
+ or cancel the inline model ``asyncio.Task`` — while preserving partial output
+ and resume state so the next turn continues the conversation.
+ """
+ logger.info(
+ "on_interrupt (no-op base impl) for task %s; override to make this agent "
+ "interruptible",
+ params.task.id,
+ )
diff --git a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py
index b3dac487e..1ea8e82e6 100644
--- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py
+++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py
@@ -22,6 +22,7 @@
CancelTaskParams,
CreateTaskParams,
SendMessageParams,
+ InterruptTaskParams,
)
from agentex.lib.utils.logging import make_logger, ctx_var_request_id
from agentex.protocol.json_rpc import JSONRPCError, JSONRPCRequest, JSONRPCResponse
@@ -68,6 +69,7 @@ class BaseACPServer(FastAPI):
Available methods:
- event/send → Send a message to a task
- task/cancel → Cancel a task
+ - task/interrupt → Interrupt the in-flight turn (non-terminal; task stays continuable)
- task/approve → Approve a task
"""
@@ -324,6 +326,7 @@ async def _process_request(
- on_task_create
- on_task_event_send
- on_task_cancel
+ - on_task_interrupt
ACP Type: Sync
Decorators:
@@ -362,6 +365,18 @@ def on_task_cancel(self, fn: Callable[[CancelTaskParams], Awaitable[Any]]):
self._handlers[RPCMethod.TASK_CANCEL] = wrapped
return fn
+ # Type: Async
+ def on_task_interrupt(self, fn: Callable[[InterruptTaskParams], Awaitable[Any]]):
+ """Handle task/interrupt method.
+
+ Non-terminal counterpart to ``on_task_cancel``: forwards the interrupt to
+ the agent so it can stop the in-flight turn while leaving the task
+ continuable. See the interrupt-and-queue design doc, section 7.
+ """
+ wrapped = self._wrap_handler(fn)
+ self._handlers[RPCMethod.TASK_INTERRUPT] = wrapped
+ return fn
+
# Type: Sync
def on_message_send(
self,
diff --git a/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py b/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py
index 69d843720..1a9cce7a8 100644
--- a/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py
+++ b/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py
@@ -10,6 +10,7 @@
SendEventParams,
CancelTaskParams,
CreateTaskParams,
+ InterruptTaskParams,
)
from agentex.lib.utils.logging import make_logger
from agentex.lib.environment_variables import EnvironmentVariables
@@ -132,3 +133,21 @@ async def handle_cancel(params: CancelTaskParams) -> None:
except Exception as e:
logger.error(f"Failed to cancel task: {e}")
raise
+
+ @self.on_task_interrupt
+ async def handle_interrupt(params: InterruptTaskParams) -> None:
+ """Forward task/interrupt to the running workflow via TaskService.
+
+ Non-terminal: signals the workflow's ``interrupt_turn`` handler rather
+ than tearing the workflow down, so the task stays continuable.
+ """
+ try:
+ if self._temporal_task_service is not None:
+ await self._temporal_task_service.interrupt(
+ agent=params.agent,
+ task=params.task,
+ request=params.request,
+ )
+ except Exception as e:
+ logger.error(f"Failed to interrupt task: {e}")
+ raise
diff --git a/src/agentex/lib/types/acp.py b/src/agentex/lib/types/acp.py
index 74295ef88..e86ff7ddf 100644
--- a/src/agentex/lib/types/acp.py
+++ b/src/agentex/lib/types/acp.py
@@ -12,4 +12,5 @@
CancelTaskParams,
CreateTaskParams,
SendMessageParams,
+ InterruptTaskParams,
)
diff --git a/src/agentex/protocol/acp.py b/src/agentex/protocol/acp.py
index d719b4fd5..7e310cd89 100644
--- a/src/agentex/protocol/acp.py
+++ b/src/agentex/protocol/acp.py
@@ -18,6 +18,7 @@ class RPCMethod(str, Enum):
MESSAGE_SEND = "message/send"
TASK_CANCEL = "task/cancel"
TASK_CREATE = "task/create"
+ TASK_INTERRUPT = "task/interrupt"
class CreateTaskParams(BaseModel):
@@ -104,6 +105,28 @@ class CancelTaskParams(BaseModel):
)
+class InterruptTaskParams(BaseModel):
+ """Parameters for task/interrupt method.
+
+ Non-terminal counterpart to :class:`CancelTaskParams`. The control plane
+ forwards ``task/interrupt`` to the agent so it can stop the in-flight turn
+ while leaving the task continuable (status ``INTERRUPTED``, not a terminal
+ status). See the interrupt-and-queue design doc, sections 5-7.
+
+ Attributes:
+ agent: The agent that the task was sent to.
+ task: The task that was interrupted.
+ request: Additional request context including headers forwarded to this agent.
+ """
+
+ agent: Agent = Field(..., description="The agent that the task was sent to")
+ task: Task = Field(..., description="The task that was interrupted")
+ request: dict[str, Any] | None = Field(
+ default=None,
+ description="Additional request context including headers forwarded to this agent",
+ )
+
+
RPC_SYNC_METHODS = [
RPCMethod.MESSAGE_SEND,
]
@@ -113,4 +136,5 @@ class CancelTaskParams(BaseModel):
RPCMethod.TASK_CANCEL: CancelTaskParams,
RPCMethod.MESSAGE_SEND: SendMessageParams,
RPCMethod.TASK_CREATE: CreateTaskParams,
+ RPCMethod.TASK_INTERRUPT: InterruptTaskParams,
}
diff --git a/tests/lib/adk/test_claude_code_turn.py b/tests/lib/adk/test_claude_code_turn.py
index 4fbb2f913..be80dfe1f 100644
--- a/tests/lib/adk/test_claude_code_turn.py
+++ b/tests/lib/adk/test_claude_code_turn.py
@@ -281,3 +281,71 @@ async def test_events_property_returns_same_iterator(self):
it1 = turn.events
it2 = turn.events
assert it1 is it2
+
+
+# ---------------------------------------------------------------------------
+# Early session_id capture (resume after interrupt)
+# ---------------------------------------------------------------------------
+
+
+class TestClaudeCodeTurnSessionIdCapture:
+ async def test_session_id_from_result_when_turn_completes(self):
+ envelopes = [
+ {"type": "system", "subtype": "init", "session_id": "sess-init"},
+ {"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}},
+ {"type": "result", "session_id": "sess-final", "usage": {}},
+ ]
+ turn = ClaudeCodeTurn(_aiter(envelopes))
+ await _drain(turn)
+ # Terminal result wins for a fully-completed turn.
+ assert turn.session_id == "sess-final"
+
+ async def test_session_id_from_init_when_interrupted_before_result(self):
+ """No `result` envelope (turn cut short) must still yield a session_id.
+
+ This is the resume fix: capture session_id from the early system/init
+ envelope so an interrupted-before-completion turn stays resumable.
+ """
+ envelopes = [
+ {"type": "system", "subtype": "init", "session_id": "sess-init"},
+ {"type": "assistant", "message": {"content": [{"type": "text", "text": "partial"}]}},
+ # stream ends here (interrupted) — no result envelope
+ ]
+ turn = ClaudeCodeTurn(_aiter(envelopes))
+ await _drain(turn)
+ assert turn.session_id == "sess-init"
+
+ async def test_session_id_none_when_no_init_or_result(self):
+ envelopes = [
+ {"type": "assistant", "message": {"content": [{"type": "text", "text": "x"}]}},
+ ]
+ turn = ClaudeCodeTurn(_aiter(envelopes))
+ await _drain(turn)
+ assert turn.session_id is None
+
+ async def test_guarded_lines_closes_source_on_early_break(self):
+ """The converter's finally-backstop closes the source stdout iterator.
+
+ Breaking out of the event loop early (as a cancellation/interrupt would)
+ must trigger aclose() on the underlying lines iterator so the CLI stdout
+ handle is not leaked.
+ """
+ closed = {"value": False}
+
+ async def _lines():
+ try:
+ yield {"type": "system", "subtype": "init", "session_id": "sess-init"}
+ yield {"type": "assistant", "message": {"content": [{"type": "text", "text": "a"}]}}
+ yield {"type": "assistant", "message": {"content": [{"type": "text", "text": "b"}]}}
+ finally:
+ closed["value"] = True
+
+ turn = ClaudeCodeTurn(_lines())
+ events = turn.events
+ # Consume only the first event, then close the stream early.
+ async for _ in events:
+ break
+ events_aclose = getattr(events, "aclose", None)
+ assert events_aclose is not None
+ await events_aclose()
+ assert closed["value"] is True
diff --git a/tests/lib/adk/test_codex_turn.py b/tests/lib/adk/test_codex_turn.py
index f6a046478..c2843e90e 100644
--- a/tests/lib/adk/test_codex_turn.py
+++ b/tests/lib/adk/test_codex_turn.py
@@ -280,3 +280,62 @@ async def test_reasoning_tokens_propagated(self) -> None:
turn = CodexTurn(_aiter(events), model="o4-mini")
await _collect(turn)
assert turn.usage().reasoning_tokens == 40
+
+
+# ---------------------------------------------------------------------------
+# Early session_id capture (resume after interrupt)
+# ---------------------------------------------------------------------------
+
+
+class TestCodexTurnSessionIdCapture:
+ async def test_session_id_from_result_when_turn_completes(self) -> None:
+ events = [
+ {"type": "thread.started", "thread_id": "thread-abc"},
+ {"type": "turn.completed", "usage": None},
+ ]
+ turn = CodexTurn(_aiter(events), model="o4-mini")
+ await _collect(turn)
+ # on_result carries session_id from processor.session_id at end of stream.
+ assert turn.session_id == "thread-abc"
+
+ async def test_session_id_from_init_when_interrupted_before_completion(self) -> None:
+ """thread.started must yield session_id even with no turn.completed.
+
+ Mirrors the claude-code early-capture fix: an interrupted-before-completion
+ codex turn never emits turn.completed, so the early thread.started capture
+ is what keeps it resumable.
+ """
+ events = [
+ {"type": "thread.started", "thread_id": "thread-abc"},
+ # stream ends here (interrupted) — no turn.completed
+ ]
+ turn = CodexTurn(_aiter(events), model="o4-mini")
+ await _collect(turn)
+ assert turn.session_id == "thread-abc"
+
+ async def test_session_id_none_without_thread_started(self) -> None:
+ turn = CodexTurn(_aiter([{"type": "turn.completed", "usage": None}]), model="o4-mini")
+ await _collect(turn)
+ assert turn.session_id is None
+
+ async def test_guarded_events_closes_source_on_early_break(self) -> None:
+ closed = {"value": False}
+
+ async def _events():
+ try:
+ yield {"type": "thread.started", "thread_id": "thread-abc"}
+ yield {
+ "type": "item.completed",
+ "item": {"id": "m1", "type": "agent_message", "text": "hi"},
+ }
+ finally:
+ closed["value"] = True
+
+ turn = CodexTurn(_events(), model="o4-mini")
+ gen = turn.events
+ async for _ in gen:
+ break
+ gen_aclose = getattr(gen, "aclose", None)
+ assert gen_aclose is not None
+ await gen_aclose()
+ assert closed["value"] is True
diff --git a/tests/test_acp_interrupt.py b/tests/test_acp_interrupt.py
new file mode 100644
index 000000000..f53cfa6b4
--- /dev/null
+++ b/tests/test_acp_interrupt.py
@@ -0,0 +1,178 @@
+"""Unit tests for the hand-editable task/interrupt additions.
+
+Covers the regeneration-safe surfaces added for the interrupt-and-queue design
+(design doc sections 6, 7, 9.2):
+
+1. Protocol (``agentex.protocol.acp``): ``RPCMethod.TASK_INTERRUPT``, the
+ ``InterruptTaskParams`` model (mirror of ``CancelTaskParams``), and the
+ ``PARAMS_MODEL_BY_METHOD`` entry, plus the back-compat shim re-export.
+2. ACP server routing: ``BaseACPServer.on_task_interrupt`` registers a handler
+ under ``RPCMethod.TASK_INTERRUPT``.
+3. Temporal transport: ``BaseWorkflow.on_interrupt`` is a ``@workflow.signal``
+ named ``interrupt_turn`` (and is NOT abstract, so existing agents keep
+ working), and ``TemporalTaskService.interrupt`` forwards that signal without
+ tearing the workflow down.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import Mock, AsyncMock
+
+import pytest
+
+from agentex.types.task import Task
+from agentex.types.agent import Agent
+from agentex.protocol.acp import (
+ PARAMS_MODEL_BY_METHOD,
+ RPCMethod,
+ CancelTaskParams,
+ InterruptTaskParams,
+)
+
+
+def _agent() -> Agent:
+ return Agent(
+ id="test-agent-456",
+ name="test-agent",
+ description="test-agent",
+ acp_type="async",
+ created_at="2023-01-01T00:00:00Z",
+ updated_at="2023-01-01T00:00:00Z",
+ )
+
+
+def _task() -> Task:
+ return Task(id="test-task-123", status="RUNNING")
+
+
+# ---------------------------------------------------------------------------
+# Protocol additions
+# ---------------------------------------------------------------------------
+
+
+class TestInterruptProtocol:
+ def test_rpc_method_value(self) -> None:
+ assert RPCMethod.TASK_INTERRUPT.value == "task/interrupt"
+ # Constructible from the wire string (the ACP server does RPCMethod(str)).
+ assert RPCMethod("task/interrupt") is RPCMethod.TASK_INTERRUPT
+
+ def test_params_model_registered(self) -> None:
+ assert PARAMS_MODEL_BY_METHOD[RPCMethod.TASK_INTERRUPT] is InterruptTaskParams
+
+ def test_params_mirror_cancel_shape(self) -> None:
+ """InterruptTaskParams mirrors CancelTaskParams field-for-field."""
+ assert set(InterruptTaskParams.model_fields) == set(CancelTaskParams.model_fields)
+ assert set(InterruptTaskParams.model_fields) == {"agent", "task", "request"}
+
+ def test_params_validate_round_trip(self) -> None:
+ params = InterruptTaskParams(agent=_agent(), task=_task())
+ assert params.task.id == "test-task-123"
+ assert params.request is None
+ # Header forwarding path (BaseACPServer populates params.request).
+ with_headers = InterruptTaskParams.model_validate(
+ {
+ "agent": _agent().model_dump(),
+ "task": _task().model_dump(),
+ "request": {"headers": {"x-foo": "bar"}},
+ }
+ )
+ assert with_headers.request == {"headers": {"x-foo": "bar"}}
+
+ def test_shim_reexports_interrupt_params(self) -> None:
+ """The back-compat shim must re-export the new model as the same object."""
+ from agentex.protocol import acp as canon
+ from agentex.lib.types import acp as shim
+
+ assert shim.InterruptTaskParams is canon.InterruptTaskParams
+
+
+# ---------------------------------------------------------------------------
+# ACP server routing
+# ---------------------------------------------------------------------------
+
+
+class TestACPServerInterruptRouting:
+ def test_on_task_interrupt_registers_handler(self) -> None:
+ from unittest.mock import patch
+
+ from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer
+
+ with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}):
+ server = BaseACPServer()
+
+ assert RPCMethod.TASK_INTERRUPT not in server._handlers
+
+ @server.on_task_interrupt
+ async def _handle(params: InterruptTaskParams) -> None: # noqa: ARG001
+ return None
+
+ assert RPCMethod.TASK_INTERRUPT in server._handlers
+ assert server._handlers[RPCMethod.TASK_INTERRUPT] is not None
+
+ def test_temporal_acp_wires_interrupt_handler(self) -> None:
+ from unittest.mock import patch
+
+ from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP
+
+ with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}):
+ server = TemporalACP.create(temporal_address="localhost:7233")
+
+ assert RPCMethod.TASK_INTERRUPT in server._handlers
+
+
+# ---------------------------------------------------------------------------
+# Temporal transport: signal + service forwarding
+# ---------------------------------------------------------------------------
+
+
+class TestWorkflowInterruptSignal:
+ def test_on_interrupt_is_signal_named_interrupt_turn(self) -> None:
+ from agentex.lib.core.temporal.types.workflow import SignalName
+ from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow
+
+ # getattr avoids the dunder name-mangling that would otherwise rewrite
+ # this to _TestWorkflowInterruptSignal__temporal_signal_definition.
+ sd = getattr(BaseWorkflow.on_interrupt, "__temporal_signal_definition")
+ assert sd is not None
+ # str-enum: equal by value to the wire string "interrupt_turn".
+ assert sd.name == SignalName.INTERRUPT_TURN
+ assert sd.name == "interrupt_turn"
+
+ def test_on_interrupt_not_abstract(self) -> None:
+ """A default no-op keeps existing (non-interruptible) workflows valid."""
+ from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow
+
+ assert "on_interrupt" not in BaseWorkflow.__abstractmethods__
+
+
+class TestTemporalTaskServiceInterrupt:
+ async def test_interrupt_sends_signal_not_cancel(self) -> None:
+ from agentex.lib.core.temporal.types.workflow import SignalName
+ from agentex.lib.core.temporal.services.temporal_task_service import (
+ TemporalTaskService,
+ )
+
+ temporal_client = Mock()
+ temporal_client.send_signal = AsyncMock()
+ temporal_client.cancel_workflow = AsyncMock()
+ temporal_client.terminate_workflow = AsyncMock()
+
+ service = TemporalTaskService(temporal_client=temporal_client, env_vars=Mock())
+
+ await service.interrupt(agent=_agent(), task=_task(), request={"headers": {"x-a": "b"}})
+
+ # Non-terminal: it signals, it does NOT cancel or terminate the workflow.
+ temporal_client.cancel_workflow.assert_not_called()
+ temporal_client.terminate_workflow.assert_not_called()
+ temporal_client.send_signal.assert_awaited_once()
+
+ kwargs = temporal_client.send_signal.await_args.kwargs
+ assert kwargs["workflow_id"] == "test-task-123"
+ assert kwargs["signal"] == SignalName.INTERRUPT_TURN.value == "interrupt_turn"
+ # Payload is a serialized InterruptTaskParams (task/agent/request).
+ assert kwargs["payload"]["task"]["id"] == "test-task-123"
+ assert kwargs["payload"]["request"] == {"headers": {"x-a": "b"}}
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(pytest.main([__file__, "-v"]))
From 44dea4f00a6e0c677cef1c089d54b93dedf3cb4d Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:14:28 +0000
Subject: [PATCH 4/4] chore: release main
---
.release-please-manifest.json | 4 ++--
CHANGELOG.md | 10 ++++++++++
adk/CHANGELOG.md | 8 ++++++++
adk/pyproject.toml | 2 +-
pyproject.toml | 2 +-
src/agentex/_version.py | 2 +-
6 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 06a2374d3..fa12be2ab 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,4 +1,4 @@
{
- ".": "0.19.0",
- "adk": "0.19.0"
+ ".": "0.20.0",
+ "adk": "0.20.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ad5decdf..3fd212e16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,16 @@
* **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``.
+## 0.20.0 (2026-07-16)
+
+Full Changelog: [agentex-client-v0.19.0...agentex-client-v0.20.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.19.0...agentex-client-v0.20.0)
+
+### Features
+
+* **api:** add interrupt task ([936a2b1](https://github.com/scaleapi/scale-agentex-python/commit/936a2b1d28a5409bd7548d59b0f18491495c805b))
+* **api:** add task/interrupt method and INTERRUPTED status to agents/tasks ([4f1c093](https://github.com/scaleapi/scale-agentex-python/commit/4f1c09348c2ddffeb8c78738fb9b7aa14ed4b752))
+* **interrupt:** task/interrupt hook + protocol + resume-safe session capture (AGX1-391) ([#462](https://github.com/scaleapi/scale-agentex-python/issues/462)) ([eaa3dd5](https://github.com/scaleapi/scale-agentex-python/commit/eaa3dd526b88c454fc604d0e617f7191b66952bb))
+
## 0.19.0 (2026-07-14)
Full Changelog: [agentex-client-v0.18.0...agentex-client-v0.19.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.18.0...agentex-client-v0.19.0)
diff --git a/adk/CHANGELOG.md b/adk/CHANGELOG.md
index 033b192e0..76e5d8779 100644
--- a/adk/CHANGELOG.md
+++ b/adk/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.20.0 (2026-07-16)
+
+Full Changelog: [agentex-sdk-v0.19.0...agentex-sdk-v0.20.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.19.0...agentex-sdk-v0.20.0)
+
+### Chores
+
+* **agentex-sdk:** Synchronize agentex versions
+
## 0.19.0 (2026-07-14)
Full Changelog: [agentex-sdk-v0.18.0...agentex-sdk-v0.19.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.18.0...agentex-sdk-v0.19.0)
diff --git a/adk/pyproject.toml b/adk/pyproject.toml
index 09e045bf9..5fca1d619 100644
--- a/adk/pyproject.toml
+++ b/adk/pyproject.toml
@@ -4,7 +4,7 @@
# (agentex/{__init__.py, _*.py, types/, resources/}) ships from the slim
# sibling package `agentex-client` which is pinned as a runtime dep.
name = "agentex-sdk"
-version = "0.19.0"
+version = "0.20.0"
description = "Agent Development Kit (ADK) overlay for the Agentex API — FastACP server, Temporal workflows, LLM provider integrations, observability"
license = "Apache-2.0"
authors = [
diff --git a/pyproject.toml b/pyproject.toml
index 16bbd74e2..4a3fea3f2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@
# overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships
# as the sibling `agentex-sdk` package — see `adk/pyproject.toml`.
name = "agentex-client"
-version = "0.19.0"
+version = "0.20.0"
description = "The official Python REST client for the Agentex API"
dynamic = ["readme"]
license = "Apache-2.0"
diff --git a/src/agentex/_version.py b/src/agentex/_version.py
index 1dc6d97f8..a6c7a942b 100644
--- a/src/agentex/_version.py
+++ b/src/agentex/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "agentex"
-__version__ = "0.19.0" # x-release-please-version
+__version__ = "0.20.0" # x-release-please-version