Skip to content

Commit bbdbc83

Browse files
declan-scaleclaude
andcommitted
feat(interrupt): task/interrupt hook, protocol, resume-safe session capture (AGX1-391)
Hand-editable SDK support for the platform's non-terminal task/interrupt verb (the generated client.tasks.interrupt + param/response types + RPC unions regenerate from the scale-agentex OpenAPI change via Stainless; see the TODO in protocol/acp.py). - protocol/acp.py: RPCMethod.TASK_INTERRUPT, InterruptTaskParams, PARAMS_MODEL_BY_METHOD - Temporal ACP layer: SignalName.INTERRUPT_TURN and an overridable BaseWorkflow.on_interrupt hook (no-op default so existing agents are unaffected), TemporalTaskService.interrupt() that signals interrupt_turn (never cancels/terminates), on_task_interrupt ACP-server decorator + handle_interrupt forwarding - resume fix: capture session_id from the early system/init (claude) and thread.started (codex) envelopes with a cancellation-safe finally, so a turn interrupted before completion stays resumable - unit tests Part of the Stop-mid-stream + queue-at-boundary effort (AGX1-391). Pairs with scaleapi/scale-agentex#365. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 90ca889 commit bbdbc83

14 files changed

Lines changed: 545 additions & 15 deletions

File tree

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

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,33 @@ 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+
await inner.aclose()
95+
aclose = getattr(lines, "aclose", None)
96+
if aclose is not None:
97+
await aclose()
98+
99+
100+
async def _convert_claude_code_impl(
101+
lines: AsyncIterator[str | dict[str, Any]],
102+
on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
103+
on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
77104
) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
78105
"""Convert a claude-code ``stream-json`` line stream into Agentex ``StreamTaskMessage*`` events.
79106
@@ -85,7 +112,20 @@ async def convert_claude_code_to_agentex_events(
85112
caller can capture usage and cost. It is awaited before the generator
86113
continues. When ``None``, the result envelope is silently dropped.
87114
115+
``on_init`` is called with the ``system``/``init`` envelope the moment it
116+
arrives (the FIRST envelope of a claude-code stream), so the caller can
117+
capture ``session_id`` EARLY — before the turn completes. This is what makes
118+
an interrupted-before-completion turn resumable: the terminal ``result``
119+
envelope (which ``on_result`` reads) never arrives when a turn is cut short,
120+
so relying on it alone loses the session id. Mirrors how the inline
121+
``claude_agents`` activity captures session_id from its SystemMessage init.
122+
When ``None``, the init envelope's session metadata is not surfaced early.
123+
88124
Envelope → canonical mapping is documented in this module's docstring.
125+
126+
A ``finally`` closes the underlying ``lines`` iterator (when it exposes
127+
``aclose``) as a backstop, so a cancellation mid-turn (e.g. an interrupt that
128+
cancels the consuming task) does not leak the CLI stdout handle / subprocess.
89129
"""
90130
next_index = 0
91131
tool_call_count = 0
@@ -355,9 +395,14 @@ async def convert_claude_code_to_agentex_events(
355395
# system / init — session metadata (ignored at this layer)
356396
# -----------------------------------------------------------------------
357397
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
398+
# Session ID tracking and MCP status logging are provider concerns:
399+
# this pure parser layer emits no StreamTaskMessage for system events.
400+
# It DOES surface the init envelope's session metadata early via
401+
# on_init so a caller can capture session_id before the turn's
402+
# terminal ``result`` arrives — required for resuming a turn that was
403+
# interrupted before completion (no ``result`` is ever emitted then).
404+
if on_init is not None and evt.get("subtype") == "init":
405+
await on_init(evt)
361406

362407
# -----------------------------------------------------------------------
363408
# 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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,32 @@ 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+
await inner.aclose()
547+
aclose = getattr(events, "aclose", None)
548+
if aclose is not None:
549+
await aclose()
550+
551+
552+
async def _convert_codex_impl(
553+
events: AsyncIterator[str | dict[str, Any]],
554+
on_result: Callable[[dict[str, Any]], None] | None = None,
555+
on_init: Callable[[dict[str, Any]], None] | None = None,
530556
) -> AsyncIterator[StreamTaskMessage]:
531557
"""Convert a ``codex exec --json`` event stream into Agentex stream events.
532558
@@ -546,6 +572,12 @@ async def convert_codex_to_agentex_events(
546572
``reasoning_count`` — int
547573
Use this to record turn-level metrics / usage in the caller's span
548574
without coupling this module to span/tracing APIs.
575+
on_init: Optional callback invoked once when the ``thread.started`` event
576+
is seen (the first event of a codex stream). Receives ``{"session_id":
577+
<thread_id or None>}``. This surfaces the session id EARLY — before
578+
the turn completes — so a turn interrupted before completion is still
579+
resumable (``turn.completed`` / ``on_result`` never fires then). The
580+
codex counterpart of the claude-code ``system/init`` early capture.
549581
550582
Yields:
551583
Canonical ``StreamTaskMessage*`` events (Start/Delta/Full/Done) with
@@ -590,6 +622,11 @@ async def convert_codex_to_agentex_events(
590622
for msg in messages:
591623
yield msg
592624

625+
# Surface session_id early (processor sets it while handling
626+
# thread.started) so an interrupted turn is still resumable.
627+
if on_init is not None and evt.get("type") == "thread.started":
628+
on_init({"session_id": processor.session_id})
629+
593630
if on_result is not None:
594631
on_result(
595632
{

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/core/temporal/services/temporal_task_service.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from agentex.types.task import Task
77
from agentex.types.agent import Agent
88
from agentex.types.event import Event
9-
from agentex.protocol.acp import SendEventParams, CreateTaskParams
9+
from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams
1010
from agentex.lib.environment_variables import EnvironmentVariables
1111
from agentex.lib.core.clients.temporal.types import WorkflowState
1212
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
7474
).model_dump(),
7575
)
7676

77+
async def interrupt(self, agent: Agent, task: Task, request: dict | None = None) -> None:
78+
"""Forward a task/interrupt to the running workflow as a dedicated signal.
79+
80+
Non-terminal: unlike ``cancel``/``terminate`` this does NOT tear down the
81+
workflow. It signals ``interrupt_turn`` so the workflow's ``on_interrupt``
82+
hook can stop the in-flight turn while leaving the task continuable.
83+
"""
84+
return await self._temporal_client.send_signal(
85+
workflow_id=task.id,
86+
signal=SignalName.INTERRUPT_TURN.value,
87+
payload=InterruptTaskParams(
88+
agent=agent,
89+
task=task,
90+
request=request,
91+
).model_dump(),
92+
)
93+
7794
async def cancel(self, task_id: str) -> None:
7895
return await self._temporal_client.cancel_workflow(
7996
workflow_id=task_id,

src/agentex/lib/core/temporal/types/workflow.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@
33

44
class SignalName(str, Enum):
55
RECEIVE_EVENT = "receive_event"
6+
# Dedicated non-terminal "stop the current turn" signal (design doc section 7).
7+
# Routed to the overridable BaseWorkflow.on_interrupt hook. Kept separate from
8+
# RECEIVE_EVENT so the interrupt handler can interleave with (and cancel) the
9+
# in-flight turn WITHOUT waiting on the turn lock the running turn holds.
10+
INTERRUPT_TURN = "interrupt_turn"

src/agentex/lib/core/temporal/workflows/workflow.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from temporalio import workflow
88

9-
from agentex.protocol.acp import SendEventParams, CreateTaskParams
9+
from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams
1010
from agentex.lib.utils.logging import make_logger
1111
from agentex.lib.core.temporal.types.workflow import SignalName
1212

@@ -160,3 +160,32 @@ def is_continued_run(self) -> bool:
160160
gated here runs again on each history rollover.
161161
"""
162162
return workflow.info().continued_run_id is not None
163+
164+
@workflow.signal(name=SignalName.INTERRUPT_TURN)
165+
async def on_interrupt(self, params: InterruptTaskParams) -> None:
166+
"""Handle a task/interrupt: stop the in-flight turn, keep the task continuable.
167+
168+
This is the durable transport for the platform's ``task/interrupt`` verb
169+
(design doc section 7). The control plane forwards ``task/interrupt`` to the
170+
agent pod over HTTP JSON-RPC; the ACP/Temporal layer routes it to the
171+
``interrupt_turn`` signal, which invokes this hook.
172+
173+
Temporal runs signal handlers on the same deterministic event loop as the
174+
workflow ``run`` method, interleaving at ``await`` points. This handler runs
175+
CONCURRENTLY with the in-flight turn coroutine, so it MUST NOT acquire the
176+
turn lock (the running turn already holds it) — doing so would deadlock: the
177+
interrupt could never fire because it would be waiting on the very turn it is
178+
meant to stop.
179+
180+
The base implementation is a no-op so existing agents keep working (and the
181+
signal is still accepted, avoiding "unhandled signal" warnings). Interruptible
182+
agents (the golden agent is the reference) override this to actually stop the
183+
in-flight model / CLI subprocess — e.g. SIGINT the leased sandbox CLI by pid,
184+
or cancel the inline model ``asyncio.Task`` — while preserving partial output
185+
and resume state so the next turn continues the conversation.
186+
"""
187+
logger.info(
188+
"on_interrupt (no-op base impl) for task %s; override to make this agent "
189+
"interruptible",
190+
params.task.id,
191+
)

src/agentex/lib/sdk/fastacp/base/base_acp_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
CancelTaskParams,
2323
CreateTaskParams,
2424
SendMessageParams,
25+
InterruptTaskParams,
2526
)
2627
from agentex.lib.utils.logging import make_logger, ctx_var_request_id
2728
from agentex.protocol.json_rpc import JSONRPCError, JSONRPCRequest, JSONRPCResponse
@@ -68,6 +69,7 @@ class BaseACPServer(FastAPI):
6869
Available methods:
6970
- event/send → Send a message to a task
7071
- task/cancel → Cancel a task
72+
- task/interrupt → Interrupt the in-flight turn (non-terminal; task stays continuable)
7173
- task/approve → Approve a task
7274
"""
7375

@@ -324,6 +326,7 @@ async def _process_request(
324326
- on_task_create
325327
- on_task_event_send
326328
- on_task_cancel
329+
- on_task_interrupt
327330
328331
ACP Type: Sync
329332
Decorators:
@@ -362,6 +365,18 @@ def on_task_cancel(self, fn: Callable[[CancelTaskParams], Awaitable[Any]]):
362365
self._handlers[RPCMethod.TASK_CANCEL] = wrapped
363366
return fn
364367

368+
# Type: Async
369+
def on_task_interrupt(self, fn: Callable[[InterruptTaskParams], Awaitable[Any]]):
370+
"""Handle task/interrupt method.
371+
372+
Non-terminal counterpart to ``on_task_cancel``: forwards the interrupt to
373+
the agent so it can stop the in-flight turn while leaving the task
374+
continuable. See the interrupt-and-queue design doc, section 7.
375+
"""
376+
wrapped = self._wrap_handler(fn)
377+
self._handlers[RPCMethod.TASK_INTERRUPT] = wrapped
378+
return fn
379+
365380
# Type: Sync
366381
def on_message_send(
367382
self,

src/agentex/lib/sdk/fastacp/impl/temporal_acp.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
SendEventParams,
1111
CancelTaskParams,
1212
CreateTaskParams,
13+
InterruptTaskParams,
1314
)
1415
from agentex.lib.utils.logging import make_logger
1516
from agentex.lib.environment_variables import EnvironmentVariables
@@ -132,3 +133,21 @@ async def handle_cancel(params: CancelTaskParams) -> None:
132133
except Exception as e:
133134
logger.error(f"Failed to cancel task: {e}")
134135
raise
136+
137+
@self.on_task_interrupt
138+
async def handle_interrupt(params: InterruptTaskParams) -> None:
139+
"""Forward task/interrupt to the running workflow via TaskService.
140+
141+
Non-terminal: signals the workflow's ``interrupt_turn`` handler rather
142+
than tearing the workflow down, so the task stays continuable.
143+
"""
144+
try:
145+
if self._temporal_task_service is not None:
146+
await self._temporal_task_service.interrupt(
147+
agent=params.agent,
148+
task=params.task,
149+
request=params.request,
150+
)
151+
except Exception as e:
152+
logger.error(f"Failed to interrupt task: {e}")
153+
raise

src/agentex/lib/types/acp.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
CancelTaskParams,
1313
CreateTaskParams,
1414
SendMessageParams,
15+
InterruptTaskParams,
1516
)

0 commit comments

Comments
 (0)