You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ak...@apache.org on 2026/06/05 17:44:07 UTC
(tooling-gofannon) 03/06: make AgentStopped a BaseException so user code cant swallow stops
This is an automated email from the ASF dual-hosted git repository.
andrewmusselman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-gofannon.git
commit 8ab8956420cbfb341ded53d9d521ae6e10af4421
Author: Andrew Musselman <ak...@apache.org>
AuthorDate: Fri Jun 5 16:42:35 2026 +0000
make AgentStopped a BaseException so user code cant swallow stops
The asvs agents log warnings like
WARNING: Could not load ASVS levels: Run was stopped by user
WARNING: dropping 345 unknown section ID(s)
... (repeated for every chapter)
and roll through to a 0-section output after Stop is pressed. The
'Run was stopped by user' string is the AgentStopped exception
message; the agent has a broad
try:
load_asvs_levels()
except Exception as e:
print(f' WARNING: Could not load ASVS levels: {e}', flush=True)
around the ASVS load call. check_should_stop raises AgentStopped,
the except Exception swallows it, the agent logs a warning, then
continues to the next chapter where check_should_stop raises
again, and again, and again until the agent reaches a 0-section
'completion' that's effectively garbage.
This is the same shape as Python before 3.8 when asyncio
.CancelledError was Exception and broad except clauses would
quietly absorb cancellation requests. PEP 622-era discussion led to
reparenting CancelledError onto BaseException so it would
propagate through user code regardless of try/except hygiene.
Apply the same fix to AgentStopped: cancellation signals must not
be catchable by code that's only meant to recover from ordinary
errors.
routes.py's run_agent_task needs the matching update: with
AgentStopped no longer an Exception, the streaming endpoints
cleanup-and-mark-complete handler doesn't cover it via
'except Exception' anymore. Broaden to '(Exception, AgentStopped)'
explicitly. Same handler logic for both, just named for clarity
rather than catching BaseException unscoped.
The asvs_* agent files dont need editing -- their broad except
clauses are now harmless for cancellation, since AgentStopped
bypasses them and reaches the worker's catch directly.
Adds a regression test: an agent that wraps check_should_stop in
'except Exception' must still propagate AgentStopped to its
caller once the cancel bit is set.
---
webapp/packages/api/user-service/routes.py | 7 +++-
.../api/user-service/services/cancel_token.py | 16 ++++++++--
.../tests/unit/services/test_agent_executor.py | 37 ++++++++++++++++++++++
3 files changed, 57 insertions(+), 3 deletions(-)
diff --git a/webapp/packages/api/user-service/routes.py b/webapp/packages/api/user-service/routes.py
index b0387f3..2e923fe 100644
--- a/webapp/packages/api/user-service/routes.py
+++ b/webapp/packages/api/user-service/routes.py
@@ -809,7 +809,12 @@ async def run_agent_code_stream(
"Agent code executed successfully (streaming).",
metadata={"request": get_sanitized_request_data(req)},
)
- except Exception as e:
+ except (Exception, AgentStopped) as e:
+ # AgentStopped now inherits from BaseException so user code
+ # can't swallow it via except Exception; we have to name it
+ # explicitly here to keep our cleanup-and-mark-complete
+ # handler covering it. Same handler logic for error and
+ # stop cases below.
final["error"] = f"{type(e).__name__}: {e}"
# Distinguish a user-initiated stop from a genuine error.
# AgentStopped is raised by check_should_stop() when the
diff --git a/webapp/packages/api/user-service/services/cancel_token.py b/webapp/packages/api/user-service/services/cancel_token.py
index a8ffaaa..8a1078a 100644
--- a/webapp/packages/api/user-service/services/cancel_token.py
+++ b/webapp/packages/api/user-service/services/cancel_token.py
@@ -22,8 +22,20 @@ from dataclasses import dataclass
from typing import Optional
-class AgentStopped(Exception):
- """Raised when a run's cancel token is set and a structural check fires."""
+class AgentStopped(BaseException):
+ """Raised when a run's cancel token is set and a structural check fires.
+
+ Inherits from BaseException (not Exception) deliberately, mirroring
+ asyncio.CancelledError, KeyboardInterrupt, and SystemExit:
+ cancellation signals must propagate through user code unimpeded. If
+ AgentStopped were Exception, a single ``except Exception`` anywhere
+ in the agent code would swallow the cancel and the agent would
+ continue. The asvs_* agents in particular do exactly this -- broad
+ try/except around data store loads -- and a stop request would log
+ a warning per chapter and roll on regardless. Making AgentStopped
+ BaseException forces it to bubble past those handlers to the
+ streaming endpoint's explicit catch where mark_complete fires.
+ """
@dataclass
diff --git a/webapp/packages/api/user-service/tests/unit/services/test_agent_executor.py b/webapp/packages/api/user-service/tests/unit/services/test_agent_executor.py
index f977da0..c6bffa9 100644
--- a/webapp/packages/api/user-service/tests/unit/services/test_agent_executor.py
+++ b/webapp/packages/api/user-service/tests/unit/services/test_agent_executor.py
@@ -133,3 +133,40 @@ async def test_stop_interrupts_mid_await():
assert elapsed < 1.5, (
f"stop should take effect mid-await; took {elapsed:.2f}s"
)
+
+
+@pytest.mark.asyncio
+async def test_agent_stopped_escapes_except_exception():
+ """Regression: agents' broad `except Exception as e: log; continue`
+ patterns must not swallow AgentStopped. The asvs_orchestrate agent
+ in particular wraps an ASVS-load call in try/except Exception; with
+ AgentStopped as Exception the agent caught it, logged a warning per
+ chapter, dropped sections it couldn't classify, and rolled through
+ to a useless 0-section output instead of stopping. Making
+ AgentStopped a BaseException (mirroring asyncio.CancelledError's
+ Python 3.8 reparenting) makes broad except clauses pass it through."""
+ from services.cancel_token import (
+ AgentStopped, CancelToken, bind_token, check_should_stop,
+ )
+
+ cancel = CancelToken()
+
+ async def agent_with_broad_except():
+ bind_token(cancel)
+ survivors = 0
+ for _ in range(5):
+ try:
+ check_should_stop()
+ survivors += 1
+ except Exception: # the asvs_* pattern
+ survivors += 100 # marker: if this fires, test fails
+ return survivors
+
+ # Sanity: with bit not set, agent runs 5 iters normally.
+ assert await agent_with_broad_except() == 5
+
+ # Flip the bit. AgentStopped should escape the except Exception
+ # and abort the agent.
+ cancel.request_stop()
+ with pytest.raises(AgentStopped):
+ await agent_with_broad_except()
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org