Skip to content

Conversation

pgrayy
Copy link
Member

@pgrayy pgrayy commented Sep 16, 2025

Description

Support interrupting tool calls to ask for human feedback.

  • Users can interrupt from their BeforeToolInvocationEvent hooks and their python tools.
  • Interrupting places the agent in an interrupt state.
  • The result returned from the agent contains an "interrupt" stop reason along with a list of the interrupt instances.
  • Users parse the reasons from the interrupt reasons to construct a resume prompt.
  • Users pass a resume prompt to agent invoke when ready. This resume prompt contains the context needed to continue the tool execution.

For more details, see Usage section below and comments under "Files changed".

Related Issues

#204

Documentation PR

TODO

Usage

import json
from typing import Any

from strands import Agent, tool
from strands.agent import AgentResult
from strands.experimental.hooks import BeforeToolInvocationEvent
from strands.hooks import HookProvider, HookRegistry
from strands.session import FileSessionManager
from strands.types.agent import AgentInput


@tool
def delete_tool(key: str) -> bool:
    print("DELETE_TOOL | deleting")
    return True


class ToolInterruptHook(HookProvider):
    def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
        registry.add_callback(BeforeToolInvocationEvent, self.approve)

    def approve(self, event: BeforeToolInvocationEvent):
        if event.tool_use["name"] != "delete_tool":
            return

        approval = event.interrupt("APPROVAL")
        if approval != "A":
            event.cancel = "approval was not granted"


def server(agent: Agent, prompt: AgentInput) -> AgentResult:
    return agent(prompt)


def client(agent: Agent, key: str) -> dict[str, Any]:
    prompt = f"Can you delete object with key {key}"

    while True:
        result = server(agent, prompt)

        match result.stop_reason:
            case "interrupt":
                interrupts = result.interrupts
                print(f"CLIENT | interrupts=<{interrupts}> | processing interrupts")

                resume = {
                    interrupt.name: input(f"(A)PPROVE or (R)EJECT {interrupt.name}: ")
                    for interrupt in interrupts
                }
                prompt = {"resume": resume}

            case _:
                return json.dumps(result.message, indent=2)


def app() -> None:
    agent = Agent(
        hooks=[ToolInterruptHook()],
        tools=[delete_tool],
        system_prompt="You delete objects given their keys.",
        callback_handler=None,
    )
    response = client(agent, key="X")
    print(f"APP | {response}")


if __name__ == "__main__":
    app()

Type of Change

New feature

Testing

TODO

How have you tested the change? Verify that the changes do not break functionality or introduce warnings in consuming repositories: agents-docs, agents-tools, agents-cli

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@pgrayy pgrayy changed the title interrupt tool interrupt Sep 16, 2025
@pgrayy pgrayy force-pushed the checkpoint_before_tool branch from 7d6bf04 to ec157f0 Compare September 17, 2025 13:22
@pgrayy pgrayy force-pushed the checkpoint_before_tool branch from ec157f0 to dc0f361 Compare September 17, 2025 14:22
@pgrayy pgrayy force-pushed the checkpoint_before_tool branch from dc0f361 to 4df352a Compare September 17, 2025 15:32
@pgrayy pgrayy force-pushed the checkpoint_before_tool branch from 4df352a to 98aabf3 Compare September 17, 2025 15:36
@pgrayy pgrayy force-pushed the checkpoint_before_tool branch from 98aabf3 to 1fe2bc0 Compare September 17, 2025 16:43
@pgrayy pgrayy force-pushed the checkpoint_before_tool branch from 1fe2bc0 to 0450dba Compare September 17, 2025 17:05
Raises:
AttributeError: If the tool doesn't exist.
"""
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still need to figure out how to support interrupts in direct tool calls. I would prefer to allow users to pass in the resume context into the call (e.g., agent.tool.my_tool({"resume": ...})). I don't think though we can add this in a backwards compatible manner.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to support interruptions for direct tool calls? Seems a bit silly IMHO

ConversationManager,
SlidingWindowConversationManager,
)
from .execution_state import ExecutionState
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With interrupts, the agent now enters into a state that requires specific input from the user to continue. To track this, I created an ExecutionState enum. More details below.


self.execution_state = ExecutionState.ASSISTANT

self.interrupts = {}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If in an INTERRUPT execution state, the agent will hold a reference to the interrupts raised by the user. To get things working, I am storing the interrupts in a dictionary in memory. As a follow up, I will think of a more formal mechanism for storing the interrupt state that can also be serialized for session management.


if result.stop_reason == "interrupt":
self.execution_state = ExecutionState.INTERRUPT
self.interrupts = {interrupt.name: interrupt for interrupt in result.interrupts}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Right now we are only supporting interrupts for tools. We use tool names for the interrupt names.
  • We support raising multiple interrupts in a single request to the agent because the agent can execute multiple tools in parallel.
  • Only one interrupt is allowed for each tool. However, users can provide multiple reasons for interrupting a tool. More details on this below.

raise ValueError("<TODO>.")

for interrupt in self.interrupts.values():
interrupt.resume = prompt["resume"][interrupt.name]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users fill in a resume content block that maps interrupt names to the user provided input required for resuming a tool execution after interrupt.

message: Message
metrics: EventLoopMetrics
state: Any
interrupts: Optional[list[Interrupt]] = None
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users can parse the raised interrupts from the AgentResult returned from the agent invoke. interrupts will be populated when stop_reason is "interrupt".

"""

ASSISTANT = "assistant"
INTERRUPT = "interrupt"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking we could add another state called "TOOL" to indicate that the agent is waiting for tool results. Under this state, users would be able to pass in tool result content blocks into agent invoke. This is something to consider for follow up though.

)
invocation_state["event_loop_cycle_span"] = cycle_span

# Create a trace for the stream_messages call
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the red here was moved below into _handle_model_execution. It is a straight copy and paste.

async for event in stream_messages(agent.model, agent.system_prompt, agent.messages, tool_specs):
if not isinstance(event, ModelStopReason):
yield event
if agent.execution_state == ExecutionState.INTERRUPT:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the reason I moved the model execution code. So that I could create this if else condition. If we are in an interrupt state, we need to go straight to tool calling.

) -> AsyncGenerator[TypedEvent, None]:
"""<TODO>."""
# Create a trace for the stream_messages call
stream_trace = Trace("stream_messages", parent_id=cycle_trace.id)
Copy link
Member Author

@pgrayy pgrayy Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this green is a direct copy and paste. No changes were made to the model execution logic.

tool_use: The tool parameters that will be passed to selected_tool.
invocation_state: Keyword arguments that will be passed to the tool.
cancel: A user defined message that when set, will lead to canceling of the tool call.
The message is used to populate a tool result with status "error".
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new complementary feature to tool interrupts. It provides user a mechanism to cancel a tool call if the resume response is not sufficient. For example, a user could interrupt from a BeforeToolInvocationEvent to ask if a user accepts or rejects the call. If rejected, users can then set the cancel message from their event hook.



@dataclass
class Interrupt:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to explore adding an async pause method that will allow users to asynchronously ask for HIL inputs. That would be P1 work. P0 is focused on full on interrupt.

self.activated = False
return self.resume

self.reasons.append(reason)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We allow all hooks registered on a BeforeToolInvocationEvent to run. This allows users to raise multiple interrupts on a single tool use. The reasons are appended.

try:
callback(event)
except InterruptException:
pass
Copy link
Member Author

@pgrayy pgrayy Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We catch the InterruptException to allow all registered hooks to run. The state of the interrupt is stored as an instance variable on the hook event.

result = await asyncio.to_thread(self._tool_func, **validated_input) # type: ignore
yield self._wrap_tool_result(tool_use_id, result)

except InterruptException as e:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users raise interrupts in the same manner in their python tools. The callable interrupt instance is passed through tool context:

@tool(context=True)
def delete_tool(tool_context: "ToolContext", key: str) -> str:
    approval = tool_context.interrupt("APPROVAL")
    if approval != "A":
        return "approval not granted"

    print(f"DELET_TOOL | deleting {key}")
    return f"successfully deleted {key}"

"content": [{"text": f"{tool_name} interrupted"}],
}
after_event = agent.hooks.invoke_callbacks(
AfterToolInvocationEvent(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AfterToolInvocationEvent hooks are allowed to run during a tool interrupt.

)

if before_event.interrupt.activated:
yield ToolInterruptEvent(before_event.interrupt)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea: We should consider allowing users to define tool result content in their BeforeToolInvocationEvent hooks. We can then append the extra content to the final tool result. This would be particularly useful for users to add their HIL responses.

Note, if interrupting from within a tool, users can add the HIL responses themselves to the final output.

task_events[task_id].set()

asyncio.gather(*tasks)
await asyncio.gather(*tasks)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caught this and will fix today in a separate PR.

yield event

if isinstance(event, ToolInterruptEvent):
break
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike concurrent tool execution, for sequential execution, we break early if we see a ToolInterruptEvent.

guardContent: GuardContent
image: ImageContent
reasoningContent: ReasoningContentBlock
resume: dict[str, Any]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not get added to the model messages array.

# String input - convert to user message
messages = [{"role": "user", "content": [{"text": prompt}]}]
elif isinstance(prompt, dict):
messages = [{"role": "user", "content": prompt}] if "resume" not in prompt else []
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not add resume prompts to the model messages array since it is Strands specific.

yield ModelMessageEvent(message=message)


async def _handle_tool_execution(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not shown yet, but when resuming from interrupt, I will skip the tool calls that already succeeded. We just filter out the tool_uses here and add the successful tool results to the tool result message below.

@pgrayy pgrayy changed the title tool interrupt [DRAFT] tool interrupt Oct 6, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants