Problem Statement
When a caller wants to abort an in-flight agent interaction, GatewayClient.cancel_task() requires the caller to know the specific message_id of the task (or one of its ancestors) they want to cancel, then walks the parent_message_id tree from that node outward via BFS to find the sub-tree to cancel. This works well when the caller knows exactly which task/turn to target, but there's no way to say "stop everything currently running in this session" without first looking up a message_id to seed the walk. Callers that just want to abandon a session outright (e.g. the end user closed the chat, or a new turn should supersede an old one) have to reconstruct or guess a message_id before they can cancel anything.
Solution
Add a GatewayClient.cancel_session(session_id, reason="") method that cancels every non-terminal execution registered under a given session_id, without requiring a starting message_id. Since WorkerRegistry.get_all_session_executions(session_id) already returns every execution record for a session in one flat call (it's the same data cancel_task's BFS reconstructs a tree from), cancel_session skips the tree-walking step entirely: it partitions the flat list into terminal vs. non-terminal executions and applies the same per-node cancel/flag actions cancel_task already applies to its to_cancel and terminal_ancestors buckets — just without needing a target node to seed the walk.
User Stories
- As a backend developer integrating
by-framework, I want to cancel every active task in a session with one call, so that I don't have to look up or track a message_id just to abandon a session.
- As a backend developer, I want
cancel_session to cancel every node in a multi-agent call chain (A→B→C→...) currently active under that session, so that abandoning a conversation turn doesn't leave orphaned sub-agent tasks running.
- As a backend developer, I want executions that are already
COMPLETED/FAILED/CANCELLED to be left alone (not re-marked as cancelling) but still flagged so that a still-running descendant can't wake them back up via callback, so that stopping a session doesn't resurrect finished work.
- As a backend developer, I want tasks that are queued but not yet claimed by any worker (no
worker_id assigned yet) to still be cancelled, so that in-flight-but-unstarted work doesn't slip through and start after I asked for the session to stop.
- As a backend developer, I want a clear response telling me how many executions were actively cancelled vs. how many were already finished, so that I can log/report the outcome without re-querying the registry.
- As a backend developer, I want
cancel_session to behave predictably when the session has no executions at all (never existed, or fully expired), so that I can distinguish "nothing to cancel" from a real failure.
- As a backend developer, I want
cancel_session to raise the same error cancel_task raises when the client was constructed without a WorkerRegistry, so that the failure mode is consistent across the client's cancel APIs.
- As a backend developer using
ByaiGatewayClient, I want cancel_session to work without any extra setup, so that I get the same session-cancel capability regardless of which client subclass I use.
- As a backend developer, I want to pass a
reason and cancel_mode through to every CancelTaskCommand dispatched by cancel_session, so that downstream plugins/traces/logs see a consistent cancellation reason across the whole session, just like they would with an individual cancel_task call.
- As a backend developer, I want to know that
cancel_session is intentionally broader than cancel_task — it cancels all active executions in the session, not just one task's sub-tree — so that I don't reach for it when I actually mean to cancel one conversation turn while others continue in the background.
- As a maintainer, I want
cancel_session to reuse the existing CancelTaskCommand protocol and worker-side handle_cancel_task handling unchanged, so that this feature requires no worker-side or wire-protocol changes.
Implementation Decisions
- New method:
GatewayClient.cancel_session(self, session_id: str, reason: str = "", requested_by: str = "client", cancel_mode: str = CancelMode.GRACEFUL) -> CancelSessionResponse, defined alongside cancel_task in by_framework/client/client.py.
- Registry guard: same precondition as
cancel_task — if self.registry is None, raise the same error cancel_task raises today (ValueError("GatewayClient requires a WorkerRegistry to cancel tasks")) so the two cancel APIs fail the same way under the same misconfiguration.
- No tree walk: fetch
all_executions = await self.registry.get_all_session_executions(session_id) once. Do not reconstruct a parent_message_id tree and do not BFS — the full session's executions are already flat in this result, which is everything cancel_task's BFS was assembling piece by piece.
- Partitioning: split
all_executions into terminal (status in {"COMPLETED", "FAILED", "CANCELLED"}) and non_terminal using the same terminal-state set cancel_task uses.
- Non-terminal executions: for each, call
registry.mark_execution_cancelling(execution_id, session_id, reason); if the execution record has a non-empty worker_id, build a CancelTaskCommand (same fields cancel_task populates: target_message_id, target_execution_id, target_worker_id, reason, requested_by, cancel_mode) and xadd it to RedisKeys.worker_ctrl_stream(worker_id). If worker_id is empty (task queued but not yet claimed), skip sending the command — rely on the existing runner-side cancel_requested pre-check that already short-circuits unclaimed tasks before they start processing.
- Terminal executions: for each, call
registry.mark_cancel_requested(execution_id, session_id, reason) only — do not change their status. This is the same protective flagging cancel_task applies to "terminal ancestors" of its BFS target, applied here to every terminal execution in the session (there's no single target node to compute ancestry from, so it's unconditional rather than ancestry-scoped).
- New response type: add
CancelSessionResponse (frozen dataclass) to by_framework/core/protocol/responses.py:
@dataclass(frozen=True)
class CancelSessionResponse:
success: bool
session_id: str
status: str
timestamp: int
cancelled_count: int = 0
already_finished_count: int = 0
error: str = ""
Reuse ExecutionStatus.NOT_FOUND, ExecutionStatus.ALREADY_FINISHED, and ExecutionStatus.CANCEL_REQUESTED for status — no new status constants needed.
- Result semantics:
all_executions empty → success=False, status=NOT_FOUND, cancelled_count=0, already_finished_count=0.
all_executions non-empty but non_terminal empty (everything already terminal) → success=False, status=ALREADY_FINISHED, cancelled_count=0, already_finished_count=len(terminal).
- Otherwise →
success=True, status=CANCEL_REQUESTED, cancelled_count=len(non_terminal), already_finished_count=len(terminal).
ByaiGatewayClient: no override needed. It only overrides send_message today; cancel_task is inherited as-is and cancel_session will be too.
- No worker-side changes:
handle_cancel_task and the CancelTaskCommand wire format are untouched — cancel_session is purely a client-side/registry-read change that dispatches the existing command.
Testing Decisions
- Tests only observe external behavior: given a mocked
registry.get_all_session_executions return value, assert on (a) the CancelSessionResponse returned, (b) which registry.mark_execution_cancelling / registry.mark_cancel_requested calls were made and with what arguments, and (c) which redis.xadd calls were made and to which worker_id streams — not on any internal partitioning helper.
- Location:
tests/client/test_client.py, next to the existing cancel_task tests. Same seam, same fixtures (AsyncMock() for registry and redis_client, constructing a bare GatewayClient(redis_client=..., registry=...)).
- Prior art to follow directly:
test_client_cascading_cancel_a_b_c (mixed terminal/non-terminal partitioning and per-worker xadd assertions), test_client_cancel_task_marks_registry_when_worker_not_assigned (empty worker_id → registry marked but no xadd), test_client_cancel_task_returns_already_finished (all-terminal → success=False).
- Cases to cover:
- Mixed session (some terminal, some non-terminal, e.g. an A→B→C chain plus one unrelated completed root) → correct split, correct
xadd targets, correct counts.
- All executions non-terminal →
cancelled_count == len(all_executions), one xadd per execution with a worker_id.
- All executions terminal →
success=False, status=ALREADY_FINISHED, zero xadd calls, mark_cancel_requested called once per execution.
- Empty session (
get_all_session_executions returns []) → success=False, status=NOT_FOUND.
- Non-terminal execution with empty
worker_id (queued, unclaimed) → mark_execution_cancelling called, no xadd for that node.
registry is None → raises the same error cancel_task raises.
ByaiGatewayClient instance calling cancel_session behaves identically to GatewayClient (confirms no override is required).
Out of Scope
- Porting
cancel_session to the TypeScript (by-framework-ts) or Java (by-framework-java) SDKs — those are separate repos with their own issue trackers and would need their own issues per the workspace's cross-SDK convention.
- Fixing the pre-existing gap where
cancel_mode (GRACEFUL vs FORCE) is threaded through CancelTaskCommand but never actually changes worker-side cancellation behavior (handle_cancel_task always does an immediate task.cancel() regardless of mode). cancel_session inherits this exact same pre-existing limitation from cancel_task; not this PRD's job to fix it.
- Locking or atomicity guarantees against tasks dispatched concurrently with
cancel_session (e.g. a node calling call_agent to spawn a new child in the window between the registry snapshot and the cancel commands being sent). cancel_task's BFS has the identical limitation today; this PRD does not change that.
- Any worker-side or wire-protocol changes —
CancelTaskCommand and handle_cancel_task are reused unmodified.
- A REST/HTTP-facing endpoint for session cancellation — this PRD only covers the
GatewayClient Python API surface.
Further Notes
cancel_session is deliberately coarser-grained than cancel_task: it will cancel every active execution in a session, including unrelated concurrent top-level tasks that share the same session_id but aren't part of the same conversation turn (a session can have more than one root execution over its lifetime). Callers that only want to abandon one turn while leaving other in-flight work alone should keep using cancel_task with that turn's root message_id.
- This was scoped out of a conversation about how
cancel_task's BFS cascade already works for multi-hop call_agent chains (A→B→C→D→E); the observation that motivated this PRD is that get_all_session_executions already returns the flattened data the BFS reconstructs, so a session-wide cancel is strictly simpler to implement than the existing per-task cascade, not harder.
Problem Statement
When a caller wants to abort an in-flight agent interaction,
GatewayClient.cancel_task()requires the caller to know the specificmessage_idof the task (or one of its ancestors) they want to cancel, then walks theparent_message_idtree from that node outward via BFS to find the sub-tree to cancel. This works well when the caller knows exactly which task/turn to target, but there's no way to say "stop everything currently running in this session" without first looking up amessage_idto seed the walk. Callers that just want to abandon a session outright (e.g. the end user closed the chat, or a new turn should supersede an old one) have to reconstruct or guess amessage_idbefore they can cancel anything.Solution
Add a
GatewayClient.cancel_session(session_id, reason="")method that cancels every non-terminal execution registered under a givensession_id, without requiring a startingmessage_id. SinceWorkerRegistry.get_all_session_executions(session_id)already returns every execution record for a session in one flat call (it's the same datacancel_task's BFS reconstructs a tree from),cancel_sessionskips the tree-walking step entirely: it partitions the flat list into terminal vs. non-terminal executions and applies the same per-node cancel/flag actionscancel_taskalready applies to itsto_cancelandterminal_ancestorsbuckets — just without needing a target node to seed the walk.User Stories
by-framework, I want to cancel every active task in a session with one call, so that I don't have to look up or track amessage_idjust to abandon a session.cancel_sessionto cancel every node in a multi-agent call chain (A→B→C→...) currently active under that session, so that abandoning a conversation turn doesn't leave orphaned sub-agent tasks running.COMPLETED/FAILED/CANCELLEDto be left alone (not re-marked as cancelling) but still flagged so that a still-running descendant can't wake them back up via callback, so that stopping a session doesn't resurrect finished work.worker_idassigned yet) to still be cancelled, so that in-flight-but-unstarted work doesn't slip through and start after I asked for the session to stop.cancel_sessionto behave predictably when the session has no executions at all (never existed, or fully expired), so that I can distinguish "nothing to cancel" from a real failure.cancel_sessionto raise the same errorcancel_taskraises when the client was constructed without aWorkerRegistry, so that the failure mode is consistent across the client's cancel APIs.ByaiGatewayClient, I wantcancel_sessionto work without any extra setup, so that I get the same session-cancel capability regardless of which client subclass I use.reasonandcancel_modethrough to everyCancelTaskCommanddispatched bycancel_session, so that downstream plugins/traces/logs see a consistent cancellation reason across the whole session, just like they would with an individualcancel_taskcall.cancel_sessionis intentionally broader thancancel_task— it cancels all active executions in the session, not just one task's sub-tree — so that I don't reach for it when I actually mean to cancel one conversation turn while others continue in the background.cancel_sessionto reuse the existingCancelTaskCommandprotocol and worker-sidehandle_cancel_taskhandling unchanged, so that this feature requires no worker-side or wire-protocol changes.Implementation Decisions
GatewayClient.cancel_session(self, session_id: str, reason: str = "", requested_by: str = "client", cancel_mode: str = CancelMode.GRACEFUL) -> CancelSessionResponse, defined alongsidecancel_taskinby_framework/client/client.py.cancel_task— ifself.registry is None, raise the same errorcancel_taskraises today (ValueError("GatewayClient requires a WorkerRegistry to cancel tasks")) so the two cancel APIs fail the same way under the same misconfiguration.all_executions = await self.registry.get_all_session_executions(session_id)once. Do not reconstruct aparent_message_idtree and do not BFS — the full session's executions are already flat in this result, which is everythingcancel_task's BFS was assembling piece by piece.all_executionsintoterminal(status in {"COMPLETED", "FAILED", "CANCELLED"}) andnon_terminalusing the same terminal-state setcancel_taskuses.registry.mark_execution_cancelling(execution_id, session_id, reason); if the execution record has a non-emptyworker_id, build aCancelTaskCommand(same fieldscancel_taskpopulates:target_message_id,target_execution_id,target_worker_id,reason,requested_by,cancel_mode) andxaddit toRedisKeys.worker_ctrl_stream(worker_id). Ifworker_idis empty (task queued but not yet claimed), skip sending the command — rely on the existing runner-sidecancel_requestedpre-check that already short-circuits unclaimed tasks before they start processing.registry.mark_cancel_requested(execution_id, session_id, reason)only — do not change their status. This is the same protective flaggingcancel_taskapplies to "terminal ancestors" of its BFS target, applied here to every terminal execution in the session (there's no single target node to compute ancestry from, so it's unconditional rather than ancestry-scoped).CancelSessionResponse(frozen dataclass) toby_framework/core/protocol/responses.py:ExecutionStatus.NOT_FOUND,ExecutionStatus.ALREADY_FINISHED, andExecutionStatus.CANCEL_REQUESTEDforstatus— no new status constants needed.all_executionsempty →success=False,status=NOT_FOUND,cancelled_count=0,already_finished_count=0.all_executionsnon-empty butnon_terminalempty (everything already terminal) →success=False,status=ALREADY_FINISHED,cancelled_count=0,already_finished_count=len(terminal).success=True,status=CANCEL_REQUESTED,cancelled_count=len(non_terminal),already_finished_count=len(terminal).ByaiGatewayClient: no override needed. It only overridessend_messagetoday;cancel_taskis inherited as-is andcancel_sessionwill be too.handle_cancel_taskand theCancelTaskCommandwire format are untouched —cancel_sessionis purely a client-side/registry-read change that dispatches the existing command.Testing Decisions
registry.get_all_session_executionsreturn value, assert on (a) theCancelSessionResponsereturned, (b) whichregistry.mark_execution_cancelling/registry.mark_cancel_requestedcalls were made and with what arguments, and (c) whichredis.xaddcalls were made and to whichworker_idstreams — not on any internal partitioning helper.tests/client/test_client.py, next to the existingcancel_tasktests. Same seam, same fixtures (AsyncMock()forregistryandredis_client, constructing a bareGatewayClient(redis_client=..., registry=...)).test_client_cascading_cancel_a_b_c(mixed terminal/non-terminal partitioning and per-workerxaddassertions),test_client_cancel_task_marks_registry_when_worker_not_assigned(emptyworker_id→ registry marked but noxadd),test_client_cancel_task_returns_already_finished(all-terminal →success=False).xaddtargets, correct counts.cancelled_count == len(all_executions), onexaddper execution with aworker_id.success=False,status=ALREADY_FINISHED, zeroxaddcalls,mark_cancel_requestedcalled once per execution.get_all_session_executionsreturns[]) →success=False,status=NOT_FOUND.worker_id(queued, unclaimed) →mark_execution_cancellingcalled, noxaddfor that node.registry is None→ raises the same errorcancel_taskraises.ByaiGatewayClientinstance callingcancel_sessionbehaves identically toGatewayClient(confirms no override is required).Out of Scope
cancel_sessionto the TypeScript (by-framework-ts) or Java (by-framework-java) SDKs — those are separate repos with their own issue trackers and would need their own issues per the workspace's cross-SDK convention.cancel_mode(GRACEFULvsFORCE) is threaded throughCancelTaskCommandbut never actually changes worker-side cancellation behavior (handle_cancel_taskalways does an immediatetask.cancel()regardless of mode).cancel_sessioninherits this exact same pre-existing limitation fromcancel_task; not this PRD's job to fix it.cancel_session(e.g. a node callingcall_agentto spawn a new child in the window between the registry snapshot and the cancel commands being sent).cancel_task's BFS has the identical limitation today; this PRD does not change that.CancelTaskCommandandhandle_cancel_taskare reused unmodified.GatewayClientPython API surface.Further Notes
cancel_sessionis deliberately coarser-grained thancancel_task: it will cancel every active execution in a session, including unrelated concurrent top-level tasks that share the samesession_idbut aren't part of the same conversation turn (a session can have more than one root execution over its lifetime). Callers that only want to abandon one turn while leaving other in-flight work alone should keep usingcancel_taskwith that turn's rootmessage_id.cancel_task's BFS cascade already works for multi-hopcall_agentchains (A→B→C→D→E); the observation that motivated this PRD is thatget_all_session_executionsalready returns the flattened data the BFS reconstructs, so a session-wide cancel is strictly simpler to implement than the existing per-task cascade, not harder.