Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
".": "0.19.0",
"adk": "0.19.0"
".": "0.20.0",
"adk": "0.20.0"
}
8 changes: 4 additions & 4 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -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
config_hash: 5c4e326f797c879e72d6bf32fc1ea49d
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: 593e89b291976a5e84e4c3c3f8324354
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions adk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion adk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
1 change: 1 addition & 0 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ Methods:
- <code title="post /tasks/{task_id}/complete">client.tasks.<a href="./src/agentex/resources/tasks.py">complete</a>(task_id, \*\*<a href="src/agentex/types/task_complete_params.py">params</a>) -> <a href="./src/agentex/types/task.py">Task</a></code>
- <code title="delete /tasks/name/{task_name}">client.tasks.<a href="./src/agentex/resources/tasks.py">delete_by_name</a>(task_name) -> <a href="./src/agentex/types/shared/delete_response.py">DeleteResponse</a></code>
- <code title="post /tasks/{task_id}/fail">client.tasks.<a href="./src/agentex/resources/tasks.py">fail</a>(task_id, \*\*<a href="src/agentex/types/task_fail_params.py">params</a>) -> <a href="./src/agentex/types/task.py">Task</a></code>
- <code title="post /tasks/{task_id}/interrupt">client.tasks.<a href="./src/agentex/resources/tasks.py">interrupt</a>(task_id, \*\*<a href="src/agentex/types/task_interrupt_params.py">params</a>) -> <a href="./src/agentex/types/task.py">Task</a></code>
- <code title="get /tasks/{task_id}/query/{query_name}">client.tasks.<a href="./src/agentex/resources/tasks.py">query_workflow</a>(query_name, \*, task_id) -> <a href="./src/agentex/types/task_query_workflow_response.py">TaskQueryWorkflowResponse</a></code>
- <code title="get /tasks/name/{task_name}">client.tasks.<a href="./src/agentex/resources/tasks.py">retrieve_by_name</a>(task_name, \*\*<a href="src/agentex/types/task_retrieve_by_name_params.py">params</a>) -> <a href="./src/agentex/types/task_retrieve_by_name_response.py">TaskRetrieveByNameResponse</a></code>
- <code title="get /tasks/{task_id}/stream">client.tasks.<a href="./src/agentex/resources/tasks.py">stream_events</a>(task_id) -> object</code>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/agentex/_version.py
Original file line number Diff line number Diff line change
@@ -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
53 changes: 50 additions & 3 deletions src/agentex/lib/adk/_modules/_claude_code_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions src/agentex/lib/adk/_modules/_claude_code_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,31 +118,45 @@ 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

@property
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.
Expand Down
39 changes: 39 additions & 0 deletions src/agentex/lib/adk/_modules/_codex_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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":
<thread_id or None>}``. 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
Expand Down Expand Up @@ -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(
{
Expand Down
22 changes: 18 additions & 4 deletions src/agentex/lib/adk/_modules/_codex_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions src/agentex/lib/adk/_modules/_langgraph_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions src/agentex/lib/adk/_modules/_openai_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading