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