Skip to content

feat: remote agents honor interrupt/stop/play control signals#442

Open
amit-gazal-thenvoi wants to merge 6 commits into
devfrom
int-829-agentflow-remote-agents-honor-interrupt-and-stopresume
Open

feat: remote agents honor interrupt/stop/play control signals#442
amit-gazal-thenvoi wants to merge 6 commits into
devfrom
int-829-agentflow-remote-agents-honor-interrupt-and-stopresume

Conversation

@amit-gazal-thenvoi

Copy link
Copy Markdown
Collaborator

Summary

  • SDK joins agent_control:{agent_id} and handles interrupt/stop/play preemptively, out-of-band from the message queue, so a signal can abort a reasoning cycle already in flight (ExecutionContext/AgentRuntime).
  • interrupt aborts the in-flight cycle and consumes the message (nothing sent, no replay). stop does the same plus durably suppresses new cycles in that room until play, which triggers a rehydration-style /next catch-up.
  • Bridge (band-bridge) honors the same signals for one-shot/bridged agents: interrupt/stop cancel whatever forward call is currently in flight for a room; play proactively nudges the room via /next instead of waiting for the next natural event.
  • Fix: the ReconnectedEvent sync path now short-circuits locally on _stopped, consistent with the other stopped-guards, instead of making a /next call already known to return empty.

Test plan

  • uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ — full unit suite green
  • uv run pytest tests/runtime/ tests/platform/ tests/websocket/ tests/bridge/ tests/test_platform_runtime.py — control-signal suites green (851 passed)
  • uv run ruff check . / uv run ruff format --check . / uv run pyrefly check — clean
  • Live integration tests (tests/integration/test_control_signals.py) — require a live platform + BAND_API_KEY/BAND_API_KEY_USER; not run in CI

🤖 Generated with Claude Code

Wires agent_control into the bridge's AgentRunner: interrupt/stop cancel
whatever forward task is currently in flight for a room, and play proactively
nudges the room via /next instead of waiting for the next natural event. The
bridge holds no Band lifecycle logic, so interrupt and stop are handled
identically here — the consume-vs-replay distinction and durable per-room
suppression already happen correctly downstream via OneShotInvoker's /next
claim check and the platform's stop gate.
The ReconnectedEvent path in ExecutionContext._process_event always ran the
full /next sync, unlike the idle-timeout and resync-sentinel paths which
already short-circuit on _stopped. The platform gate made this safe (a
stopped room's /next is guaranteed empty), but it wasted a call on every
reconnect while stopped. Skip locally for consistency with the other
_stopped short-circuits.
@linear-code

linear-code Bot commented Jul 16, 2026

Copy link
Copy Markdown

INT-829

amit-gazal-thenvoi and others added 2 commits July 16, 2026 16:11
… sync

A stop control signal landing mid-cycle during _synchronize_with_next or
_resync_pending_messages left the message in 'processing' for replay, but
the enclosing loop kept polling /next, which returns 'processing' messages
same as before. The very next iteration re-claimed and fully re-ran the
cycle stop just aborted. Both loops now check self._stopped after
processing a backlog message and stop polling until play resumes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…y budget

A cycle cancelled by our own stop/interrupt control signal never actually
ran the handler, but record_attempt() was still charging it as a real
attempt. At the default max_message_retries=1, a message stopped once and
replayed via /next on resume would immediately land in permanently_failed
before ever reaching the adapter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@amit-gazal-thenvoi
amit-gazal-thenvoi requested a review from a team July 16, 2026 14:46
…te-agents-honor-interrupt-and-stopresume

# Conflicts:
#	src/band/client/streaming/client.py
#	src/band/platform/link.py
#	src/band/runtime/execution.py
#	src/band/runtime/platform_runtime.py
#	uv.lock
room_id = getattr(event, "room_id", None)
task = asyncio.current_task()
if room_id and task is not None:
self._active_room_tasks[room_id] = task

@AlexanderZ-Band AlexanderZ-Band Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_active_room_tasks is set before the semaphore and per-room lock. A second same-room task can overwrite the active forward while it waits, so interrupt cancels the waiter and the real forward continues. Register the task only while it owns the room lock, or track all tasks per room.


Best-effort: this stops the bridge from waiting and releases the room
lock immediately. It does not guarantee the remote invocation itself
stops — ``AgentCoreForwarder``'s underlying boto3 call runs in a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Cancelling this await does not cancel asyncio.to_thread() or the AgentCore invocation. The remote cycle can keep consuming tokens and running tools, so this does not meet the in-flight halt requirement. Add remote cancellation or exclude this transport from that guarantee.

for room_id in room_ids:
self._cancel_active_forward(room_id)
elif payload.mode == "play":
await asyncio.gather(*(self._nudge_room(rid) for rid in room_ids))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

play awaits the full /next forward on the WebSocket callback task. While that forward runs, this receive path cannot process a following stop or interrupt. Schedule the nudge as a tracked background task.

else:
logger.warning(
"ExecutionContext %s: durable mark_processed failed for "
"interrupted message %s; idle resync may re-deliver it",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If this ack fails, the interrupted message remains locally replayable and /next can run it again. Record _remember_processed_ack_pending(msg_id) so the interrupt stays consumed while the durable ack retries.

# _run_cycle and we send nothing further. Only message-driven
# cycles report the working signal (see _invoke_handler);
# participant add/remove events are housekeeping and skip it.
if not await self._run_cycle(event, msg_id):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A control signal during hydration or mark_processing() sees no _active_cycle_task: interrupt is lost, and stop can still reach this call without another _stopped check. Cover the full claimed-message lifecycle or re-check control state immediately before starting the handler.

A cycle cancelled by interrupt/stop never actually ran the handler, so
it must not count against the message's retry budget.
"""
self._attempts.pop(msg_id, None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

pop() removes every prior failed attempt, not just the control-cancelled attempt. Decrement by one so interrupting a retry does not reset the message's entire retry budget.


# --- Control signals (interrupt/stop/play) ---

async def _handle_control(self, payload: AgentControlPayload) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This method does three things: dedup, room resolution, mode dispatch. Split out _is_duplicate_control(cid) and _resolve_control_rooms(payload) to make it easier to read.

cid,
)

if payload.mode in ("interrupt", "stop"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Use match/case here (repo convention in CLAUDE.md). Add a case _: that logs unknown modes — right now they are silently ignored.

@@ -382,20 +403,33 @@ def _backlog_event(self, msg: PlatformMessage) -> MessageEvent:
)

async def _safe_handle_event(self, event: object) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Too many nested levels. Move the inner try/except up to the outer try — same behavior, flatter code.

@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator

AgentRunner does forwarding, rehydration, and control signals in one ~800-line file. The control-signal part is self-contained — consider moving it to its own module as a follow-up.

import httpx
import pytest
import pytest_asyncio
from thenvoi_rest.human_api_chats.types.create_my_chat_room_request_chat import (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This import fails — the package was renamed to band_rest (your conftest already uses it). The file doesn't collect: ModuleNotFoundError: No module named 'thenvoi_rest'.

return await client.get(f"{get_base_url()}{path}", headers=_user_headers())


async def _send_user_mention(chat_id: str, agent_id: str, text: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No need for raw httpx here — the typed client has human_api_messages.send_my_chat_message with mentions, and user_api_client is already available. Raw httpx is only justified for the control endpoints.

return resp.json()["data"]["id"]


async def _wait_until(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Generic polling helper, nothing control-specific — put it in tests/conftest_integration.py so the next integration test doesn't grow its own.

return pred()


class _ControllableHandler:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same blocking-handler pattern (started/cancelled events + hang until cancelled) is hand-rolled many times in tests/runtime/test_execution_interrupt.py. Extract one shared fake and use it in both places.



@pytest_asyncio.fixture
async def control_room(user_api_client, session_api_client, shared_agent1_info):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This repeats the reuse-or-create + is_room_alive pattern from shared_room in conftest_integration.py. Make it a shared user-owned room fixture there — future control/permission tests will need it too.

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

See inline comments — main blockers: the in-flight cancellation gaps in the bridge (task registration ordering, play blocking the receive path), the interrupt/stop lifecycle windows in execution.py, and tests/integration/test_control_signals.py failing to import (thenvoi_rest).

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