Skip to content

uipath-claude-sdk: expose a public way to detect the resume pass inside a tool body #370

Description

@radu-mocanu

Ask

Export a public way to ask "is this the resume pass?" from inside a tool body, so an agent can perform a side effect on the suspend pass only.

Why

interrupts.py documents the constraint plainly (lines 52-54):

Across a suspension the body therefore runs twice, exactly as a LangGraph node does. Work performed BEFORE the interrupt call happens twice, so keep side effects after the call or make them idempotent.

Both suggested options fail for a real class of side effect: one that has to happen before the suspension, precisely because the run is about to stop.

The concrete case is notifying a person that the agent is now blocked. A gated tool suspends on CreateEscalation, and the human who asked for the work is somewhere the escalation is not visible (a chat surface, a ticket, an email thread). The only moment the agent can tell them is before it suspends. Doing it after the interrupt is too late, the approval has already been given. Making it idempotent means an extra round trip per gated call to discover whether the notification already went out.

There is no way to express this today, even though the library computes the answer.

The information already exists

interrupt() decides between raising and returning on exactly this predicate (line 444):

call = _ACTIVE_CALL.get()
if call is None:
    raise InterruptOutsideRunError(_OUTSIDE_RUN_MESSAGE)
if call.channel.is_resolved(call.tool_use_id):
    return call.channel.resolved_for(call.tool_use_id)
raise _InterruptRaised(value)

SuspendChannel.is_resolved is public and in __all__. What is missing is a way to reach the current call: _ACTIVE_CALL is a private ContextVar (line 383) and is absent from __all__, which exports active_channel (a setter) but no getter for either the channel or the call.

So the only way to ask the question is to import a private module attribute, which will break on any refactor.

Suggested change

Three lines next to interrupt(), exported in __all__:

def is_resuming() -> bool:
    """Whether this tool body is replaying after a suspension.

    False on the first pass, when a call to interrupt() would suspend the run.
    True on the resume pass, when interrupt() returns the resolved payload.
    False outside a UiPath run.
    """
    call = _ACTIVE_CALL.get()
    return call is not None and call.channel.is_resolved(call.tool_use_id)

It adds no state, cannot disagree with interrupt() because it is the same predicate, and is per tool_use_id, so two identical gated calls in one turn are tracked independently.

Usage this enables:

if is_resuming():
    action = await interrupt(None)
else:
    task = await sdk.tasks.create_async(...)
    await notify(f"Waiting for approval: {url_for(task)}")
    action = await interrupt(WaitEscalation(action=task))

Note the second useful property, which is worth documenting alongside it: on the resume path interrupt() returns before reading its argument, so a body that already knows it is resuming does not have to reconstruct the suspend value. Verified against the installed package:

--- suspend pass ---
  is_resuming(): False
  interrupt raised, carried: {'marker': 'real value'}
--- resume pass ---
  is_resuming(): True
  interrupt returned: {'Answer': True}   (argument ignored on this path)

If a broader surface is preferred over a single boolean, a public accessor for the active call or channel would cover this and other cases. The boolean is the smallest thing that unblocks it.

Environment

  • uipath-claude-sdk as on main, byte-identical to the installed build I tested against
  • Python 3.14, macOS
  • Line numbers above are against main

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions