feat(bidi): support cancellation from custom tools (#4664)

This commit is contained in:
Patrick Gray
2026-09-29 12:06:47 -04:00
committed by GitHub
parent 43479b8da2
commit 3e5e89e265
18 changed files with 160 additions and 228 deletions
@@ -177,7 +177,9 @@ Each grouped tool-use message stays adjacent to its matching result message, eve
Tool-result messages use `metadata["custom"]["bidi"]["kind"]` to distinguish `tool_dispatch` acknowledgements from `tool_result` messages.
After the group finishes, the agent loop checks `request_state["stop_event_loop"]` to trigger graceful shutdown instead of sending tool results back to the model. Any tool can set this flag to stop the conversation. The SDK's experimental `stop` tool uses this mechanism.
To let a tool end the conversation, call `tool_context.agent.cancel()`. Cancellation takes effect only after the tool group completes. Requests from other contexts remain pending until then.
See [Graceful shutdown](./quickstart.mdx#graceful-shutdown) for a custom tool example.
### Connection Lifecycle
@@ -225,8 +225,7 @@ Emitted when the streaming connection is closed.
- `"timeout"`: Connection timed out
- `"error"`: Error occurred
- `"complete"`: Conversation completed normally
- `"user_request"`: User requested closure (via the SDK's experimental `stop`
tool or any tool that sets `request_state["stop_event_loop"]`)
- `"user_request"`: Cancellation requested through `agent.cancel()`
### Response Lifecycle Events
@@ -88,12 +88,10 @@ Pass your I/O streams to the agent's `run()` method to connect them to the agent
import asyncio
from strands.experimental.bidi.agent import BidiAgent
from strands.experimental.tools import stop
async def main():
# stop tool allows user to verbally stop agent execution.
agent = BidiAgent(tools=[stop])
agent = BidiAgent()
await agent.run(inputs=[MyInputStream()], outputs=[MyOutputStream()])
@@ -104,6 +102,8 @@ The `run()` method handles startup, execution, and shutdown for the agent and it
streams. Inputs and outputs run concurrently, so you can mix and match implementations.
If an I/O task fails, `run()` cancels the remaining tasks, stops the streams, and
re-raises the exception.
For a tool that lets users end the conversation, see
[Graceful shutdown](./quickstart.mdx#graceful-shutdown).
## Audio I/O
@@ -126,12 +126,10 @@ import asyncio
from strands.experimental.bidi.agent import BidiAgent
from strands.experimental.bidi.io import AudioIO
from strands.experimental.tools import stop
async def main():
# stop tool allows user to verbally stop agent execution.
agent = BidiAgent(tools=[stop])
agent = BidiAgent()
audio_io = AudioIO(input_device_index=1)
await agent.run(
@@ -232,7 +230,6 @@ import asyncio
from strands.experimental.bidi.agent import BidiAgent
from strands.experimental.bidi.io import ConsoleIO
from strands.experimental.bidi.models import OpenAIRealtimeModel
from strands.experimental.tools import stop
async def main():
@@ -241,7 +238,7 @@ async def main():
transcription_model_id=None,
params={"output_modalities": ["text"]},
)
agent = BidiAgent(model=model, tools=[stop])
agent = BidiAgent(model=model)
console_io = ConsoleIO()
await agent.run(
@@ -280,11 +277,10 @@ import asyncio
from strands.experimental.bidi.agent import BidiAgent
from strands.experimental.bidi.io import AudioIO, ConsoleIO
from strands.experimental.tools import stop
async def main():
agent = BidiAgent(tools=[stop])
agent = BidiAgent()
console_io = ConsoleIO(placeholder="Type or speak…")
audio_io = AudioIO(console=console_io)
@@ -49,7 +49,6 @@ import asyncio
from strands.experimental.bidi.agent import BidiAgent
from strands.experimental.bidi.io import AudioIO
from strands.experimental.bidi.models import BedrockNovaSonicModel
from strands.experimental.tools import stop
from strands.vended_tools import notebook
@@ -59,7 +58,7 @@ async def main() -> None:
region="us-east-1",
voice="tiffany",
)
agent = BidiAgent(model=model, tools=[notebook, stop])
agent = BidiAgent(model=model, tools=[notebook])
audio_io = AudioIO()
await agent.run(inputs=[audio_io.input()], outputs=[audio_io.output()])
@@ -45,7 +45,6 @@ import asyncio
from strands.experimental.bidi.agent import BidiAgent
from strands.experimental.bidi.io import AudioIO
from strands.experimental.bidi.models import GoogleGeminiLiveModel
from strands.experimental.tools import stop
from strands.vended_tools import notebook
@@ -55,8 +54,7 @@ async def main() -> None:
voice="Kore",
client_args={"api_key": "<GOOGLE_API_KEY>"},
)
# stop tool allows user to verbally stop agent execution.
agent = BidiAgent(model=model, tools=[notebook, stop])
agent = BidiAgent(model=model, tools=[notebook])
audio_io = AudioIO()
await agent.run(inputs=[audio_io.input()], outputs=[audio_io.output()])
@@ -44,7 +44,6 @@ import asyncio
from strands.experimental.bidi.agent import BidiAgent
from strands.experimental.bidi.io import AudioIO
from strands.experimental.bidi.models import OpenAIRealtimeModel
from strands.experimental.tools import stop
from strands.vended_tools import notebook
@@ -55,8 +54,7 @@ async def main() -> None:
voice="coral",
api_key="<OPENAI_API_KEY>",
)
# stop tool allows user to verbally stop agent execution.
agent = BidiAgent(model=model, tools=[notebook, stop])
agent = BidiAgent(model=model, tools=[notebook])
audio_io = AudioIO()
await agent.run(inputs=[audio_io.input()], outputs=[audio_io.output()])
@@ -406,22 +406,28 @@ See [Controlling Conversation Lifecycle](#controlling-conversation-lifecycle) fo
## Graceful Shutdown
Use the SDK's experimental `stop` tool to allow users to end conversations
naturally. It sets `request_state["stop_event_loop"]`, which the agent loop checks
to trigger a graceful shutdown:
To let users end a conversation by voice, define a tool that calls `agent.cancel()`:
```python
import asyncio
from strands import LocalAgent, ToolContext, tool
from strands.experimental.bidi.agent import BidiAgent
from strands.experimental.bidi.io import AudioIO
from strands.experimental.bidi.models import BedrockNovaSonicModel
from strands.experimental.tools import stop
@tool(context=True)
def end_conversation(tool_context: ToolContext[LocalAgent]) -> str:
"""End the conversation when the user asks to stop."""
tool_context.agent.cancel()
return "Ending conversation"
model = BedrockNovaSonicModel(model_id="amazon.nova-2-sonic-v1:0")
agent = BidiAgent(
model=model,
tools=[stop],
system_prompt="You are a helpful assistant. When the user says 'stop conversation', use the stop tool."
tools=[end_conversation],
system_prompt="You are a helpful assistant.",
)
audio_io = AudioIO()
@@ -431,24 +437,12 @@ async def main():
inputs=[audio_io.input()],
outputs=[audio_io.output()]
)
# Conversation ends when user says "stop conversation"
# run() returns after the agent calls end_conversation.
asyncio.run(main())
```
You can also create custom stop tools using the `request_state["stop_event_loop"]` flag:
```python
from strands import tool
@tool
def end_session(request_state: dict) -> str:
request_state["stop_event_loop"] = True
return "Goodbye!"
```
The agent will gracefully close the connection when any tool sets `request_state["stop_event_loop"] = True`.
Bidi checks for cancellation after a tool group completes and its results are recorded. Requests from other contexts remain pending until that checkpoint.
## Debug Logs