Skip to content

Commit eaa3dd5

Browse files
authored
feat(interrupt): task/interrupt hook + protocol + resume-safe session capture (AGX1-391) (#462)
1 parent 936a2b1 commit eaa3dd5

21 files changed

Lines changed: 682 additions & 15 deletions

src/agentex/lib/adk/_modules/_claude_code_sync.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ def _extract_summary(text: str, max_len: int = 300) -> str:
7474
async def convert_claude_code_to_agentex_events(
7575
lines: AsyncIterator[str | dict[str, Any]],
7676
on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
77+
on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
78+
) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
79+
"""Public tap: convert a claude-code ``stream-json`` line stream to events.
80+
81+
Thin wrapper over :func:`_convert_claude_code_impl` that owns the
82+
cancellation backstop: a ``finally`` closes the underlying ``lines`` iterator
83+
(when it exposes ``aclose``) whenever this generator is closed — including on
84+
the ``GeneratorExit``/``CancelledError`` raised when a consuming task is
85+
cancelled mid-turn by an interrupt. This terminates the CLI stdout handle /
86+
subprocess instead of leaking it. Kept as a wrapper (rather than a
87+
``try/finally`` inside the impl) so the large parser body stays untouched.
88+
"""
89+
inner = _convert_claude_code_impl(lines, on_result=on_result, on_init=on_init)
90+
try:
91+
async for event in inner:
92+
yield event
93+
finally:
94+
inner_aclose = getattr(inner, "aclose", None)
95+
if inner_aclose is not None:
96+
await inner_aclose()
97+
aclose = getattr(lines, "aclose", None)
98+
if aclose is not None:
99+
await aclose()
100+
101+
102+
async def _convert_claude_code_impl(
103+
lines: AsyncIterator[str | dict[str, Any]],
104+
on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
105+
on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
77106
) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
78107
"""Convert a claude-code ``stream-json`` line stream into Agentex ``StreamTaskMessage*`` events.
79108
@@ -85,7 +114,20 @@ async def convert_claude_code_to_agentex_events(
85114
caller can capture usage and cost. It is awaited before the generator
86115
continues. When ``None``, the result envelope is silently dropped.
87116
117+
``on_init`` is called with the ``system``/``init`` envelope the moment it
118+
arrives (the FIRST envelope of a claude-code stream), so the caller can
119+
capture ``session_id`` EARLY — before the turn completes. This is what makes
120+
an interrupted-before-completion turn resumable: the terminal ``result``
121+
envelope (which ``on_result`` reads) never arrives when a turn is cut short,
122+
so relying on it alone loses the session id. Mirrors how the inline
123+
``claude_agents`` activity captures session_id from its SystemMessage init.
124+
When ``None``, the init envelope's session metadata is not surfaced early.
125+
88126
Envelope → canonical mapping is documented in this module's docstring.
127+
128+
A ``finally`` closes the underlying ``lines`` iterator (when it exposes
129+
``aclose``) as a backstop, so a cancellation mid-turn (e.g. an interrupt that
130+
cancels the consuming task) does not leak the CLI stdout handle / subprocess.
89131
"""
90132
next_index = 0
91133
tool_call_count = 0
@@ -355,9 +397,14 @@ async def convert_claude_code_to_agentex_events(
355397
# system / init — session metadata (ignored at this layer)
356398
# -----------------------------------------------------------------------
357399
elif evt_type == "system":
358-
# Session ID tracking and MCP status logging are provider concerns.
359-
# This pure parser layer intentionally emits nothing for system events.
360-
pass
400+
# Session ID tracking and MCP status logging are provider concerns:
401+
# this pure parser layer emits no StreamTaskMessage for system events.
402+
# It DOES surface the init envelope's session metadata early via
403+
# on_init so a caller can capture session_id before the turn's
404+
# terminal ``result`` arrives — required for resuming a turn that was
405+
# interrupted before completion (no ``result`` is ever emitted then).
406+
if on_init is not None and evt.get("subtype") == "init":
407+
await on_init(evt)
361408

362409
# -----------------------------------------------------------------------
363410
# result — carries usage + cost; fired to on_result, not emitted as msgs

src/agentex/lib/adk/_modules/_claude_code_turn.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,31 +118,45 @@ class ClaudeCodeTurn:
118118
def __init__(self, lines: AsyncIterator[str | dict[str, Any]]) -> None:
119119
self._lines = lines
120120
self._result_envelope: dict[str, Any] | None = None
121+
# session_id captured from the early system/init envelope. This is what
122+
# keeps an interrupted-before-completion turn resumable: the terminal
123+
# ``result`` envelope never arrives when a turn is cut short, so we must
124+
# capture the session id up front (from init) rather than only at the end.
125+
self._init_session_id: str | None = None
121126
self._events_stream: AsyncIterator[StreamTaskMessage] | None = None
122127

123128
async def _on_result(self, envelope: dict[str, Any]) -> None:
124129
self._result_envelope = envelope
125130

131+
async def _on_init(self, envelope: dict[str, Any]) -> None:
132+
sid = envelope.get("session_id")
133+
if sid:
134+
self._init_session_id = sid
135+
126136
@property
127137
def events(self) -> AsyncIterator[StreamTaskMessage]:
128138
if self._events_stream is None:
129139
self._events_stream = convert_claude_code_to_agentex_events(
130140
self._lines,
131141
on_result=self._on_result,
142+
on_init=self._on_init,
132143
)
133144
return self._events_stream
134145

135146
@property
136147
def session_id(self) -> str | None:
137148
"""The Claude Code session id, for resuming a multi-turn session.
138149
139-
Valid only after ``events`` has been fully consumed (populated by the
140-
``result`` envelope). Returns ``None`` if the stream was truncated or
141-
Claude Code reported no session id.
150+
Prefers the id from the terminal ``result`` envelope (fully-complete
151+
turn), then falls back to the id captured from the early ``system/init``
152+
envelope. The init fallback is what makes a turn that was interrupted
153+
before completion still resumable (no ``result`` is emitted then).
154+
Returns ``None`` only if neither envelope was seen (e.g. the stream was
155+
truncated before init) or Claude Code reported no session id.
142156
"""
143-
if not self._result_envelope:
144-
return None
145-
return self._result_envelope.get("session_id")
157+
if self._result_envelope and self._result_envelope.get("session_id"):
158+
return self._result_envelope.get("session_id")
159+
return self._init_session_id
146160

147161
def usage(self) -> TurnUsage:
148162
"""Return normalised usage for this turn.

src/agentex/lib/adk/_modules/_codex_sync.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,34 @@ def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMe
527527
async def convert_codex_to_agentex_events(
528528
events: AsyncIterator[str | dict[str, Any]],
529529
on_result: Callable[[dict[str, Any]], None] | None = None,
530+
on_init: Callable[[dict[str, Any]], None] | None = None,
531+
) -> AsyncIterator[StreamTaskMessage]:
532+
"""Public tap: convert a ``codex exec --json`` event stream to events.
533+
534+
Thin wrapper over :func:`_convert_codex_impl` that owns the cancellation
535+
backstop: a ``finally`` closes the underlying ``events`` iterator (when it
536+
exposes ``aclose``) whenever this generator is closed — including on the
537+
``GeneratorExit``/``CancelledError`` raised when a consuming task is
538+
cancelled mid-turn by an interrupt. This terminates the CLI stdout handle /
539+
subprocess instead of leaking it.
540+
"""
541+
inner = _convert_codex_impl(events, on_result=on_result, on_init=on_init)
542+
try:
543+
async for event in inner:
544+
yield event
545+
finally:
546+
inner_aclose = getattr(inner, "aclose", None)
547+
if inner_aclose is not None:
548+
await inner_aclose()
549+
aclose = getattr(events, "aclose", None)
550+
if aclose is not None:
551+
await aclose()
552+
553+
554+
async def _convert_codex_impl(
555+
events: AsyncIterator[str | dict[str, Any]],
556+
on_result: Callable[[dict[str, Any]], None] | None = None,
557+
on_init: Callable[[dict[str, Any]], None] | None = None,
530558
) -> AsyncIterator[StreamTaskMessage]:
531559
"""Convert a ``codex exec --json`` event stream into Agentex stream events.
532560
@@ -546,6 +574,12 @@ async def convert_codex_to_agentex_events(
546574
``reasoning_count`` — int
547575
Use this to record turn-level metrics / usage in the caller's span
548576
without coupling this module to span/tracing APIs.
577+
on_init: Optional callback invoked once when the ``thread.started`` event
578+
is seen (the first event of a codex stream). Receives ``{"session_id":
579+
<thread_id or None>}``. This surfaces the session id EARLY — before
580+
the turn completes — so a turn interrupted before completion is still
581+
resumable (``turn.completed`` / ``on_result`` never fires then). The
582+
codex counterpart of the claude-code ``system/init`` early capture.
549583
550584
Yields:
551585
Canonical ``StreamTaskMessage*`` events (Start/Delta/Full/Done) with
@@ -590,6 +624,11 @@ async def convert_codex_to_agentex_events(
590624
for msg in messages:
591625
yield msg
592626

627+
# Surface session_id early (processor sets it while handling
628+
# thread.started) so an interrupted turn is still resumable.
629+
if on_init is not None and evt.get("type") == "thread.started":
630+
on_init({"session_id": processor.session_id})
631+
593632
if on_result is not None:
594633
on_result(
595634
{

src/agentex/lib/adk/_modules/_codex_turn.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ def __init__(
160160

161161
# Populated by the on_result callback once the stream is exhausted.
162162
self._result: dict[str, Any] | None = None
163+
# Populated by the on_init callback when thread.started arrives (early),
164+
# so session_id is available even if the turn is interrupted before
165+
# completion (turn.completed / on_result never fires then).
166+
self._init_session_id: str | None = None
163167
# The events generator is created at most once: ``_raw_events`` is a
164168
# single-consumption AsyncIterator, so re-wrapping it would yield an
165169
# already-exhausted stream that fires on_result with zeros and clobbers
@@ -179,21 +183,31 @@ def events(self) -> AsyncIterator[StreamTaskMessage]:
179183
self._events_gen = convert_codex_to_agentex_events(
180184
self._raw_events,
181185
on_result=self._on_result,
186+
on_init=self._on_init,
182187
)
183188
return self._events_gen
184189

185190
def _on_result(self, result: dict[str, Any]) -> None:
186191
self._result = result
187192

193+
def _on_init(self, init: dict[str, Any]) -> None:
194+
sid = init.get("session_id")
195+
if sid:
196+
self._init_session_id = sid
197+
188198
@property
189199
def session_id(self) -> str | None:
190200
"""The codex session id, for resuming a multi-turn session.
191201
192-
Valid only after ``events`` has been fully consumed (populated by the
193-
``on_result`` callback). Returns ``None`` if the stream is not yet
194-
exhausted or codex reported no session id.
202+
Prefers the id from the terminal ``turn.completed`` result (fully-complete
203+
turn), then falls back to the id captured early from ``thread.started``.
204+
The early fallback keeps a turn interrupted before completion resumable
205+
(no ``turn.completed`` / ``on_result`` fires then). Returns ``None`` only
206+
if neither was seen or codex reported no session id.
195207
"""
196-
return self._result.get("session_id") if self._result else None
208+
if self._result and self._result.get("session_id"):
209+
return self._result.get("session_id")
210+
return self._init_session_id
197211

198212
def usage(self) -> TurnUsage:
199213
"""Return normalized ``TurnUsage`` for this turn.

src/agentex/lib/adk/_modules/_langgraph_sync.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@
3232
async def convert_langgraph_to_agentex_events(
3333
stream: Any,
3434
on_final_ai_message: Optional[Callable[..., None]] = None,
35+
) -> AsyncGenerator[Any, None]:
36+
"""Public LangGraph tap: convert events, closing the source stream on exit.
37+
38+
Thin wrapper over ``_convert_langgraph_impl`` that adds a cancellation-safe
39+
``finally`` so an interrupted turn tears down the LangGraph ``astream`` source
40+
instead of leaking it.
41+
"""
42+
inner = _convert_langgraph_impl(stream, on_final_ai_message=on_final_ai_message)
43+
try:
44+
async for event in inner:
45+
yield event
46+
finally:
47+
for _src in (inner, stream):
48+
_aclose = getattr(_src, "aclose", None)
49+
if _aclose is not None:
50+
await _aclose()
51+
52+
53+
async def _convert_langgraph_impl(
54+
stream: Any,
55+
on_final_ai_message: Optional[Callable[..., None]] = None,
3556
) -> AsyncGenerator[Any, None]:
3657
"""Convert LangGraph streaming events to Agentex TaskMessageUpdate events.
3758

src/agentex/lib/adk/_modules/_openai_sync.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,25 @@ def _extract_tool_response_info(tool_map: dict[str, Any], tool_output_item: Any)
145145

146146

147147
async def convert_openai_to_agentex_events(stream_response):
148+
"""Public OpenAI tap: parse the event stream, closing the source on exit.
149+
150+
Thin wrapper over ``_convert_openai_impl`` that adds a cancellation-safe
151+
``finally`` so an interrupted turn tears down the source event stream instead
152+
of leaking it. (Resume state is carried by the OpenAI Agents SDK input list,
153+
not a session id, so there is no early-session_id capture like the CLI taps.)
154+
"""
155+
inner = _convert_openai_impl(stream_response)
156+
try:
157+
async for event in inner:
158+
yield event
159+
finally:
160+
for _src in (inner, stream_response):
161+
_aclose = getattr(_src, "aclose", None)
162+
if _aclose is not None:
163+
await _aclose()
164+
165+
166+
async def _convert_openai_impl(stream_response):
148167
"""Convert OpenAI streaming events to AgentEx TaskMessageUpdate events with reasoning support.
149168
150169
This is an enhanced version of the base converter that includes support for:

src/agentex/lib/adk/_modules/_pydantic_ai_sync.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ def _tool_return_content(result: ToolReturnPart | Any) -> Any:
120120
async def convert_pydantic_ai_to_agentex_events(
121121
stream_response: AsyncIterator[Any],
122122
on_result: Callable[[AgentRunResultEvent], Any] | None = None,
123+
) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
124+
"""Public Pydantic AI tap: convert events, closing the source stream on exit.
125+
126+
Thin wrapper over ``_convert_pydantic_ai_impl`` that adds a cancellation-safe
127+
``finally`` so an interrupted turn tears down the source stream instead of
128+
leaking it.
129+
"""
130+
inner = _convert_pydantic_ai_impl(stream_response, on_result=on_result)
131+
try:
132+
async for event in inner:
133+
yield event
134+
finally:
135+
for _src in (inner, stream_response):
136+
_aclose = getattr(_src, "aclose", None)
137+
if _aclose is not None:
138+
await _aclose()
139+
140+
141+
async def _convert_pydantic_ai_impl(
142+
stream_response: AsyncIterator[Any],
143+
on_result: Callable[[AgentRunResultEvent], Any] | None = None,
123144
) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
124145
"""Convert a Pydantic AI agent event stream into Agentex stream events.
125146

src/agentex/lib/adk/_modules/tasks.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,53 @@ async def cancel(
174174
parent_span_id=parent_span_id,
175175
)
176176

177+
async def interrupt(
178+
self,
179+
*,
180+
task_id: str,
181+
reason: str | None = None,
182+
trace_id: str | None = None,
183+
parent_span_id: str | None = None,
184+
start_to_close_timeout: timedelta = timedelta(seconds=5),
185+
heartbeat_timeout: timedelta = timedelta(seconds=5),
186+
retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
187+
) -> Task:
188+
"""
189+
Mark a running task as interrupted (non-terminal).
190+
191+
Interrupt is cooperative: an agent calls this from its interrupt handler,
192+
after it has actually stopped its in-flight turn, to record INTERRUPTED.
193+
The task stays continuable; the control plane resumes it to RUNNING on the
194+
next turn.
195+
Args:
196+
task_id: The ID of the task to interrupt.
197+
reason: Optional reason for the interrupt.
198+
Returns:
199+
The updated task entry.
200+
"""
201+
params = TaskStatusTransitionParams(
202+
task_id=task_id,
203+
reason=reason,
204+
trace_id=trace_id,
205+
parent_span_id=parent_span_id,
206+
)
207+
if in_temporal_workflow():
208+
return await ActivityHelpers.execute_activity(
209+
activity_name=TasksActivityName.INTERRUPT_TASK,
210+
request=params,
211+
response_type=Task,
212+
start_to_close_timeout=start_to_close_timeout,
213+
retry_policy=retry_policy,
214+
heartbeat_timeout=heartbeat_timeout,
215+
)
216+
else:
217+
return await self._tasks_service.interrupt_task(
218+
task_id=task_id,
219+
reason=reason,
220+
trace_id=trace_id,
221+
parent_span_id=parent_span_id,
222+
)
223+
177224
async def complete(
178225
self,
179226
*,

src/agentex/lib/core/services/adk/tasks.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ async def cancel_task(
9898
span.output = task_model.model_dump()
9999
return task_model
100100

101+
async def interrupt_task(
102+
self,
103+
task_id: str,
104+
reason: str | None = None,
105+
trace_id: str | None = None,
106+
parent_span_id: str | None = None,
107+
) -> Task:
108+
trace = self._tracer.trace(trace_id)
109+
async with trace.span(
110+
parent_id=parent_span_id,
111+
name="interrupt_task",
112+
input={"task_id": task_id, "reason": reason},
113+
) as span:
114+
heartbeat_if_in_workflow("interrupt task")
115+
task_model = await self._agentex_client.tasks.interrupt(task_id=task_id, reason=reason)
116+
if span:
117+
span.output = task_model.model_dump()
118+
return task_model
119+
101120
async def complete_task(
102121
self,
103122
task_id: str,

0 commit comments

Comments
 (0)