feat(interrupt): task/interrupt hook + protocol + resume-safe session capture (AGX1-391)#462
Merged
Merged
Conversation
…apture (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>
…AGX1-391)
Agents call adk.tasks.interrupt(task_id=...) from their interrupt handler, after stopping the in-flight turn, to record the non-terminal INTERRUPTED status (the control plane forwards task/interrupt but no longer writes status). Mirrors adk.tasks.cancel across the facade / activity / service layers and registers the interrupt-task activity. The service uses a generic POST /tasks/{id}/interrupt for now; switches to client.tasks.interrupt once Stainless regenerates the typed method.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
declan-scale
force-pushed
the
declan-scale/agx1-391-interrupt-hook
branch
from
July 16, 2026 19:35
2b0611b to
3c8f58a
Compare
… (AGX1-391) scale-agentex#365 merged and Stainless regenerated client.tasks.interrupt, so drop the interim generic POST in the adk interrupt service and call the typed method (matches cancel_task). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
declan-scale
force-pushed
the
declan-scale/agx1-391-interrupt-hook
branch
from
July 16, 2026 19:35
3c8f58a to
226789c
Compare
The resume backstop and its tests called .aclose() directly on streams typed AsyncIterator (only AsyncGenerator exposes aclose), failing the pyright lint. Guard via getattr like the existing source-stream close, keeping runtime behavior identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
declan-scale
marked this pull request as ready for review
July 16, 2026 19:45
… taps (AGX1-391) Mirror the claude-code/codex tap hardening across the other harness converters: wrap each convert_*_to_agentex_events in a thin public wrapper with a cancellation-safe finally that closes the source stream (defensive getattr aclose), so an interrupted turn tears the source down instead of leaking it. Parser bodies moved untouched into _convert_*_impl. The early-session_id capture is NOT ported: these frameworks carry conversation state via the input list, not a resumable session id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The agent-runtime (hand-authored) half of AGX1-391 (stop an agent run mid-stream). scale-agentex#365 is merged, so the generated client method
client.tasks.interrupt(...)and theTaskINTERRUPTEDstatus already regenerated via Stainless; this PR adds theagentex.libruntime support that makes an agent actually interruptible.What's included (all in the hand-editable runtime, not generated code)
SignalName.INTERRUPT_TURNand an overridableBaseWorkflow.on_interrupt(params)hook (no-op default, so existing agents are unaffected and the signal is still accepted).on_task_interruptACP-server decorator +handle_interrupt;TemporalTaskService.interrupt()signalsinterrupt_turn(never cancels/terminates the workflow).adk.tasks.interrupt(task_id=...)across the facade → activity → service → worker-registration layers (mirrorsadk.tasks.cancel), calling the generatedclient.tasks.interrupt. Agents call this from their interrupt handler, after stopping the turn, to record the non-terminalINTERRUPTEDstatus.RPCMethod.TASK_INTERRUPT+InterruptTaskParamsinprotocol/acp.py.session_id: captured from the earlysystem/init(claude) /thread.started(codex) envelopes, with a cancellation-safefinally, so a turn interrupted before completion is still resumable.Model
Interrupt is cooperative: the control plane forwards
task/interrupt(courier) and ownsRUNNING(task creation + auto-resume); the agent stops its own turn and recordsINTERRUPTEDviaadk.tasks.interrupt. An agent that does not implement the hook simply staysRUNNING. Reference consumer: the golden agent (agentex-agents).Base / testing
Targets
next(Stainless convention). lint / build / conformance and the agent-SDK matrix (claude-code / codex / openai / langgraph, sync+async+temporal) are green.Greptile Summary
This PR adds the agent-runtime half of the cooperative interrupt feature (AGX1-391): a dedicated
interrupt_turnTemporal signal, an overridableBaseWorkflow.on_interrupthook, ACP routing viatask/interrupt, and theadk.tasks.interrupt(...)call chain (facade → activity → service → generated client). It also fixes resume-safety by capturingsession_idfrom the earlysystem/init(claude-code) andthread.started(codex) envelopes via a newon_initcallback, so a turn interrupted before its terminal result envelope arrives remains resumable._claude_code_sync.py,_codex_sync.py,_langgraph_sync.py,_openai_sync.py,_pydantic_ai_sync.py) are each split into a public wrapper + private impl, where the wrapper owns a cancellation-safefinallythat callsaclose()on both the inner generator and the underlying source iterator to prevent subprocess/handle leaks on mid-turn cancellation.RPCMethod.TASK_INTERRUPT,InterruptTaskParams,SignalName.INTERRUPT_TURN, andTemporalTaskService.interrupt()are added — the signal path deliberately avoidscancel_workflow/terminate_workflowso the task stays continuable.BaseWorkflow.on_interruptis non-abstract with a logging no-op default, so agents that don't implement the hook keep working and the unhandled-signal warning is suppressed.Confidence Score: 5/5
Safe to merge. The interrupt path is non-terminal (signal only, never cancel/terminate), the no-op base implementation preserves backward compatibility, and all new call-chain layers mirror the well-tested cancel pattern.
The implementation is internally consistent across all five stream-converter modules and all four call-chain layers (facade → activity → service → client). The cooperative-interrupt model correctly avoids the turn-lock deadlock, the session_id early-capture logic is sound, and the cleanup backstops correctly propagate aclose() through wrapper → impl → source. The only gap is that the codex interrupted-before-completion unit test accidentally validates its assertion via the on_result path rather than the _init_session_id fallback it claims to exercise.
tests/lib/adk/test_codex_turn.py — the session-id fallback test for the interrupted path does not isolate the _init_session_id mechanism.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant CP as Control Plane participant ACP as TemporalACP participant TTS as TemporalTaskService participant TW as Temporal Workflow participant Agent as Agent turn coroutine participant ADK as adk.tasks.interrupt participant API as client.tasks.interrupt CP->>ACP: POST task/interrupt ACP->>TTS: interrupt(agent, task, request) TTS->>TW: send_signal interrupt_turn Note over TW: on_interrupt fires concurrently with running turn TW->>Agent: stop in-flight turn Agent-->>TW: turn stopped, partial output preserved Agent->>ADK: interrupt(task_id) ADK->>API: tasks.interrupt(task_id) API-->>Agent: "Task status=INTERRUPTED" Note over TW: Task stays continuable for next turn%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant CP as Control Plane participant ACP as TemporalACP participant TTS as TemporalTaskService participant TW as Temporal Workflow participant Agent as Agent turn coroutine participant ADK as adk.tasks.interrupt participant API as client.tasks.interrupt CP->>ACP: POST task/interrupt ACP->>TTS: interrupt(agent, task, request) TTS->>TW: send_signal interrupt_turn Note over TW: on_interrupt fires concurrently with running turn TW->>Agent: stop in-flight turn Agent-->>TW: turn stopped, partial output preserved Agent->>ADK: interrupt(task_id) ADK->>API: tasks.interrupt(task_id) API-->>Agent: "Task status=INTERRUPTED" Note over TW: Task stays continuable for next turnReviews (2): Last reviewed commit: "fix(adk): cancellation-safe teardown for..." | Re-trigger Greptile