Repository navigation
fix: propagate cancellation across active turns #308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -545,6 +545,9 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None | |
| while True: | ||
| attempt += 1 | ||
| with self._delegation_tool_access(action, execution_args): | ||
| if self.execution_context.cancelled is not None and self.execution_context.cancelled.is_set(): | ||
| outcome = tool_failure("cancelled", "Tool call was cancelled before execution.") | ||
|
Comment on lines
547
to
+549
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This cancellation check runs only after Useful? React with 👍 / 👎. |
||
| break | ||
| if callable(_pre_execution_check) and not _pre_execution_check(): | ||
| outcome = tool_failure("cancelled", "Tool retry stopped because its task is no longer active.") | ||
| break | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import asyncio | ||
| from typing import Any | ||
| from openkyrozen.tasks.engine import is_complete, is_terminal | ||
| from openkyrozen.agent.modes import InteractionError, is_plan_acceptance | ||
|
|
@@ -131,9 +132,31 @@ def _chat_turn(self, user_input: str, clear_tasks: bool = False, profile: str | | |
| ) | ||
| if backend := self._fast_used_backend.get(): | ||
| reply = f"Applied routing advice from {'Jev' if backend == 'jev' else 'Kev'}.\n\n{reply}" | ||
| except Exception as exc: | ||
| except (Exception, KeyboardInterrupt, asyncio.CancelledError) as exc: | ||
| from openkyrozen.providers.errors import ProviderError, ProviderErrorKind | ||
| cancelled = (self.execution_context.cancelled is not None | ||
| and self.execution_context.cancelled.is_set()) or isinstance( | ||
| exc, (KeyboardInterrupt, asyncio.CancelledError) | ||
| ) or (isinstance(exc, ProviderError) and exc.kind == ProviderErrorKind.CANCELLED) | ||
| if cancelled and active_plan: | ||
| for task_id in active_plan.get("working_task_ids", []): | ||
| index = next((i for i, task in enumerate(self.tasks.tasks) | ||
| if task.get("id") == task_id), None) | ||
| if index is not None and self.tasks.tasks[index].get("status") not in {"succeeded", "cancelled"}: | ||
| self.tasks.set_status(index, "cancelled") | ||
| try: | ||
| self._interaction_controller.complete_plan( | ||
| active_plan, status="cancelled", task_manager=self.tasks, | ||
| ) | ||
| except Exception: | ||
| pass | ||
| if cancelled: | ||
| self._publish_runtime_event("turn.effect_uncertain", { | ||
| "reason": "cancelled_during_execution", | ||
| "message": "Remote or already-dispatched side effects may still complete.", | ||
| }) | ||
|
Comment on lines
+153
to
+157
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| self._record_conversation_entry("turn.outcome", { | ||
| "status": "failed", "error_type": type(exc).__name__, | ||
| "status": "cancelled" if cancelled else "failed", "error_type": type(exc).__name__, | ||
| }) | ||
| from openkyrozen.agent.compaction import is_context_digest, retain_context_digests | ||
| display = conversation.display_messages() | ||
|
|
@@ -142,9 +165,10 @@ def _chat_turn(self, user_input: str, clear_tasks: bool = False, profile: str | | |
| projected = ([digest] if digest else []) + display | ||
| self.short_term_memory = retain_context_digests(projected, limit) | ||
| self.current_session.messages_compacted = bool(digest) or len(projected) > limit | ||
| if active_plan: | ||
| if active_plan and not cancelled: | ||
| self._interaction_controller.complete_plan(active_plan, status="failed") | ||
| self._track_fix_outcome(original_user_input, f"Turn failed: {type(exc).__name__}: {exc}") | ||
| if not cancelled: | ||
| self._track_fix_outcome(original_user_input, f"Turn failed: {type(exc).__name__}: {exc}") | ||
| runtime.turn_end(reply="", success=False, | ||
| error=f"{type(exc).__name__}: {exc}", **context) | ||
| raise | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| import asyncio | ||
| import json | ||
| import uuid | ||
| import threading | ||
| from contextlib import contextmanager | ||
| from contextvars import ContextVar | ||
| from .models import AgentSession, WorkspaceState | ||
|
|
@@ -283,6 +284,8 @@ def __init__(self, memory, tasks, interaction, workspace): | |
| self._default_session = AgentSession(memory.session_id or "surface:cli", memory, tasks, interaction, workspace) | ||
| self._default_turn = ExecutionContext() | ||
| self._turn_context = ContextVar("agent_turn", default=None) | ||
| self._active_turns = {} | ||
| self._active_turns_lock = threading.Lock() | ||
| self._session_context = ContextVar("agent_session", default=None) | ||
| self._surface_capability_override = ContextVar("agent_surface_capabilities", default=None) | ||
| self._conversation_turn_id = ContextVar("conversation_turn_id", default=None) | ||
|
|
@@ -405,6 +408,14 @@ def chat(self, session, message, *, clear_tasks=False, profile=None, memory_cont | |
| kind, payload if isinstance(payload, dict) else {"data": payload}, task_id=task_id, | ||
| ) | ||
| ) if task_event_context is not None else None | ||
| cancellation = threading.Event() | ||
| turn_context = copy.copy(self.execution_context) | ||
| from openkyrozen.agent.turn import CancellationEvents | ||
| turn_context.cancelled = CancellationEvents(turn_context.cancelled, cancellation) | ||
| turn_context_token = self._turn_context.set(turn_context) | ||
|
Comment on lines
+411
to
+415
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The inner Useful? React with 👍 / 👎. |
||
| session_key = (str(getattr(getattr(session, "memory", None), "user_id", "local")), str(session.session_id)) | ||
| with self._active_turns_lock: | ||
| self._active_turns.setdefault(session_key, set()).add(cancellation) | ||
| try: | ||
| publisher.publish("turn.started", {}) | ||
| try: | ||
|
|
@@ -428,6 +439,13 @@ def chat(self, session, message, *, clear_tasks=False, profile=None, memory_cont | |
| publisher.publish("turn.completed", {"status": self._turn_completion_status.get()}) | ||
| return reply | ||
| finally: | ||
| with self._active_turns_lock: | ||
| active = self._active_turns.get(session_key) | ||
| if active is not None: | ||
| active.discard(cancellation) | ||
| if not active: | ||
| self._active_turns.pop(session_key, None) | ||
| self._turn_context.reset(turn_context_token) | ||
| self._stream_event_callback.reset(events) | ||
| self._last_response_is_native.reset(native_response) | ||
| self._turn_failed.reset(turn_failed) | ||
|
|
@@ -442,6 +460,16 @@ def chat(self, session, message, *, clear_tasks=False, profile=None, memory_cont | |
| self._context_history_seen.reset(history_seen) | ||
| self._context_history_projection_ids.reset(history_projection) | ||
|
|
||
| def cancel_turn(self, session_id: str, *, user_id: str = "local") -> bool: | ||
| """Request cancellation of the active foreground turn for one session.""" | ||
| with self._active_turns_lock: | ||
| active = tuple(self._active_turns.get((str(user_id), str(session_id)), ())) | ||
| if not active: | ||
| return False | ||
| for cancellation in active: | ||
| cancellation.set() | ||
| return True | ||
|
|
||
| @property | ||
| def conversation_history(self): | ||
| from openkyrozen.agent.conversation import ConversationHistory | ||
|
|
@@ -543,7 +571,10 @@ def run_command(self, args): | |
| except (OSError, RuntimeError, ValueError) as exc: | ||
| return CommandResult(f"Error starting background process: {exc}", False, | ||
| failure="execution_error") | ||
| return self.current_session.workspace.adapters.run_command(args) | ||
| if self.execution_context.cancelled is not None and self.execution_context.cancelled.is_set(): | ||
| from openkyrozen.tools.models import CommandResult | ||
| return CommandResult("Command cancelled before it started.", False, failure="cancelled") | ||
| return self.current_session.workspace.adapters.run_command(args, cancelled=self.execution_context.cancelled) | ||
|
|
||
| @legacy_text_api | ||
| def _read_process(self, args): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,11 @@ def _command(self, name: str, args: Any, request_id: str) -> None: | |
| else: | ||
| command = raw.lower().replace("-", "_") | ||
| arg_text = args if isinstance(args, str) else "" | ||
| if command in {"/quit", "/exit", "quit", "exit", "shutdown"}: | ||
| if command in {"/cancel", "cancel"}: | ||
| session = runtime.current_session | ||
| cancelled = runtime.cancel_turn(session.session_id, user_id=session.memory.user_id) | ||
| self.emit("response", request_id, text="Cancellation requested." if cancelled else "No active turn to cancel.") | ||
|
Comment on lines
+21
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a TUI turn is waiting at a high-impact tool approval, Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Once Useful? React with 👍 / 👎. |
||
| elif command in {"/quit", "/exit", "quit", "exit", "shutdown"}: | ||
| self.stop() | ||
| elif command in {"/provider", "provider"}: | ||
| if isinstance(args, Mapping) and args.get("provider"): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -434,6 +434,14 @@ async def api_chat(self, request: Request): | |
| "interaction": session["interaction"], "context": session.get("context")} | ||
|
|
||
|
|
||
| async def api_cancel_chat(self, session_id: str, request: Request): | ||
| service = self | ||
| session_id = service._normalise_session_id(session_id) | ||
| user_id = service._actor_for_request(request) | ||
| return {"session_id": session_id, | ||
| "cancelled": service._agent.cancel_turn(session_id, user_id=user_id)} | ||
|
Comment on lines
+437
to
+442
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a turn started through Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| async def api_chat_stream(self, request: Request): | ||
| service = self | ||
| """SSE streaming chat endpoint.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,7 +63,7 @@ def _validated_shell_options(self, args: str | dict): | |
| return cmd, timeout, str(working_directory), env, background | ||
|
|
||
|
|
||
| def run_command(self, args: str | dict) -> CommandResult: | ||
| def run_command(self, args: str | dict, *, cancelled=None) -> CommandResult: | ||
| """ | ||
| Execute a shell command and retain its machine-readable outcome. | ||
| """ | ||
|
|
@@ -87,7 +87,38 @@ def run_command(self, args: str | dict) -> CommandResult: | |
| start_new_session=(os.name != "nt"), | ||
| ) | ||
| try: | ||
| out, err = process.communicate(timeout=timeout) | ||
| deadline = started + timeout | ||
| while True: | ||
| if cancelled is not None and cancelled.is_set(): | ||
| raise InterruptedError | ||
| try: | ||
| out, err = process.communicate(timeout=min(0.1, max(0.01, deadline - time.monotonic()))) | ||
| break | ||
| except subprocess.TimeoutExpired: | ||
| if time.monotonic() >= deadline: | ||
| raise | ||
| except InterruptedError: | ||
| if os.name == "nt": | ||
| subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], | ||
| capture_output=True, timeout=5, check=False) | ||
| else: | ||
| try: | ||
| os.killpg(process.pid, signal.SIGTERM) | ||
| except ProcessLookupError: | ||
| pass | ||
| try: | ||
| out, err = process.communicate(timeout=2) | ||
| except subprocess.TimeoutExpired: | ||
| if os.name != "nt": | ||
| try: | ||
| os.killpg(process.pid, signal.SIGKILL) | ||
| except ProcessLookupError: | ||
| pass | ||
| out, err = process.communicate() | ||
| return CommandResult("Error: command cancelled", False, process.returncode, | ||
| "cancelled", out, err, time.monotonic() - started, True, | ||
| sandbox_backend=invocation.backend, | ||
|
Comment on lines
+118
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The positional Useful? React with 👍 / 👎. |
||
| sandbox_reason=invocation.unavailable_reason) | ||
| except subprocess.TimeoutExpired: | ||
| if os.name == "nt": | ||
| subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a parent is cancelled while
_complete_turnis waiting for delegated work, this combined signal can stop a child's provider/tool operation, but the delegation coordinator determines cancellation solely from its own per-run event. Because that event was not set, it records the child as failed, and_complete_turntreats the terminal failure as an ordinary unresolved result and returns normally, causing the cancelled parent turn to emit completion instead of entering_chat_turn's cancellation cleanup. Propagate inherited cancellation into the coordinator run state and make the waiting parent raise cancellation rather than synthesize a normal result.Useful? React with 👍 / 👎.