Skip to content

Commit d5b7035

Browse files
authored
feat(core): Add AgentCancel exception for control-flow in callbacks (#894)
* feat(core): Add AgentCancel exception for control-flow in callbacks Callbacks can now raise exceptions inheriting from AgentCancel to stop agent execution. These exceptions propagate directly to the caller without being wrapped in AgentRunError, allowing users to catch them by their specific type. - Add AgentCancel ABC with trace property for accessing spans - Modify run_async to preserve AgentCancel exceptions - Update AgentRunError docstring for clarity * test(unit): Add tests for AgentCancel exception - Test AgentCancel ABC cannot be instantiated directly - Test subclasses can be instantiated and caught - Test trace property and message preservation - Test run_async preserves AgentCancel without wrapping - Test regular exceptions are wrapped in AgentRunError * test(integration): Add AgentCancel integration test Verify AgentCancel subclasses propagate without being wrapped in AgentRunError across all agent frameworks. * docs: Document AgentCancel for stopping agent execution - Add "Stopping Execution" section to callbacks documentation - Document AgentCancel vs regular exception patterns - Add examples for both exception types - Add AgentCancel to API reference * fix(tools): Update type: ignore for isinstance tuple type narrowing The isinstance check using a runtime-constructed tuple doesn't allow mypy to narrow the type, causing a no-any-return error instead of the original return-value error. * fix(openai): Remove type: ignore now that any-llm-sdk 1.7.0 supports xhigh
1 parent 9aa5e40 commit d5b7035

File tree

8 files changed

+381
-17
lines changed

8 files changed

+381
-17
lines changed

docs/agents/callbacks.md

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,90 @@ class CountSearchWeb(Callback):
4848
return context
4949
```
5050

51-
Callbacks can raise exceptions to stop agent execution. This is useful for implementing safety guardrails or validation logic:
51+
## Stopping Execution
52+
53+
Callbacks can raise exceptions to stop agent execution. This is useful for implementing safety guardrails or validation logic.
54+
55+
!!! warning "Exceptions act as a circuit breaker"
56+
57+
Raising any exception from a callback immediately halts the agent loop. Use this intentionally to enforce limits or abort on invalid states.
58+
59+
### Using `AgentCancel` (Recommended)
60+
61+
For intentional cancellation (rate limits, guardrails, validation), subclass [`AgentCancel`][any_agent.AgentCancel]. These exceptions propagate directly to your code, allowing you to catch them by their specific type:
5262

5363
```python
64+
from any_agent import AgentCancel, AgentConfig, AnyAgent
65+
from any_agent.callbacks import Callback
66+
from any_agent.callbacks.context import Context
67+
68+
class SearchLimitReached(AgentCancel):
69+
"""Raised when the search limit is exceeded."""
70+
5471
class LimitSearchWeb(Callback):
5572
def __init__(self, max_calls: int):
5673
self.max_calls = max_calls
5774

5875
def before_tool_execution(self, context: Context, *args, **kwargs) -> Context:
59-
if context.shared["search_web_count"] > self.max_calls:
60-
raise RuntimeError("Reached limit of `search_web` calls.")
76+
if context.shared.get("search_web_count", 0) > self.max_calls:
77+
raise SearchLimitReached(f"Exceeded {self.max_calls} search calls")
78+
return context
79+
80+
# In your application code:
81+
agent = AnyAgent.create(
82+
"tinyagent",
83+
AgentConfig(
84+
model_id="gpt-4.1-nano",
85+
callbacks=[LimitSearchWeb(max_calls=3)],
86+
),
87+
)
88+
try:
89+
trace = agent.run("Find information about Python")
90+
except SearchLimitReached as e:
91+
print(f"Search limit reached: {e}")
92+
print(f"Trace: {e.trace}") # Access spans collected before cancellation
93+
```
94+
95+
### Using Regular Exceptions
96+
97+
Regular exceptions (like `RuntimeError`) are automatically wrapped in [`AgentRunError`][any_agent.AgentRunError] by the framework, which provides access to the execution trace but requires you to inspect the wrapped exception:
98+
99+
```python
100+
from any_agent import AgentConfig, AgentRunError, AnyAgent
101+
from any_agent.callbacks import Callback
102+
from any_agent.callbacks.context import Context
103+
104+
class LimitSearchWeb(Callback):
105+
def __init__(self, max_calls: int):
106+
self.max_calls = max_calls
107+
108+
def before_tool_execution(self, context: Context, *args, **kwargs) -> Context:
109+
if context.shared.get("search_web_count", 0) > self.max_calls:
110+
msg = "Reached limit of `search_web` calls."
111+
raise RuntimeError(msg)
112+
return context
113+
114+
# In your application code:
115+
agent = AnyAgent.create(
116+
"tinyagent",
117+
AgentConfig(
118+
model_id="gpt-4.1-nano",
119+
callbacks=[LimitSearchWeb(max_calls=3)],
120+
),
121+
)
122+
try:
123+
trace = agent.run("Find information about Python")
124+
except AgentRunError as e:
125+
print(f"Error: {e.original_exception}")
126+
print(f"Trace: {e.trace}")
61127
```
62-
!!! warning
63128

64-
Raising an exception is the standard way to halt execution. This effectively acts as a 'circuit breaker' for your agent.
129+
!!! tip "Choosing the right exception type"
130+
131+
- **`AgentCancel`**: Use when cancellation is expected behavior and you want to handle it distinctly (e.g., rate limits, safety guardrails).
132+
- **Regular exceptions**: Use when something unexpected goes wrong and you want consistent error handling via `AgentRunError`.
133+
134+
Both expose the execution trace via `.trace` for debugging and inspection.
65135

66136
## Inspecting Data (`Context.current_span`)
67137

@@ -264,46 +334,57 @@ You can find a working example in the [Callbacks Cookbook](../cookbook/callbacks
264334

265335
### Limit the number of steps
266336

267-
Some agent frameworks allow to limit how many steps an agent can take and some don't. In addition,
268-
each framework defines a `step` differently: some count the llm calls, some the tool executions,
337+
Some agent frameworks allow you to limit how many steps an agent can take and some don't. In addition,
338+
each framework defines a `step` differently: some count the LLM calls, some the tool executions,
269339
and some the sum of both.
270340

271341
You can use callbacks to limit how many steps an agent can take, and you can decide what to count
272342
as a `step`:
273343

274344
```python
345+
from any_agent import AgentCancel
275346
from any_agent.callbacks.base import Callback
276347
from any_agent.callbacks.context import Context
277348

349+
350+
class LLMCallLimitReached(AgentCancel):
351+
"""Raised when the LLM call limit is exceeded."""
352+
353+
354+
class ToolExecutionLimitReached(AgentCancel):
355+
"""Raised when the tool execution limit is exceeded."""
356+
357+
278358
class LimitLLMCalls(Callback):
279359
def __init__(self, max_llm_calls: int) -> None:
280360
self.max_llm_calls = max_llm_calls
281361

282362
def before_llm_call(self, context: Context, *args, **kwargs) -> Context:
283-
284363
if "n_llm_calls" not in context.shared:
285364
context.shared["n_llm_calls"] = 0
286365

287366
context.shared["n_llm_calls"] += 1
288367

289368
if context.shared["n_llm_calls"] > self.max_llm_calls:
290-
raise RuntimeError("Reached limit of LLM Calls")
369+
raise LLMCallLimitReached(f"Exceeded {self.max_llm_calls} LLM calls")
291370

292371
return context
293372

373+
294374
class LimitToolExecutions(Callback):
295375
def __init__(self, max_tool_executions: int) -> None:
296376
self.max_tool_executions = max_tool_executions
297377

298378
def before_tool_execution(self, context: Context, *args, **kwargs) -> Context:
299-
300379
if "n_tool_executions" not in context.shared:
301380
context.shared["n_tool_executions"] = 0
302381

303382
context.shared["n_tool_executions"] += 1
304383

305384
if context.shared["n_tool_executions"] > self.max_tool_executions:
306-
raise RuntimeError("Reached limit of Tool Executions")
385+
raise ToolExecutionLimitReached(
386+
f"Exceeded {self.max_tool_executions} tool executions"
387+
)
307388

308389
return context
309390
```

docs/api/agent.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22

33
::: any_agent.AnyAgent
44

5+
::: any_agent.AgentCancel
6+
57
::: any_agent.AgentRunError

src/any_agent/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from importlib.metadata import PackageNotFoundError, version
22

33
from .config import AgentConfig, AgentFramework
4-
from .frameworks.any_agent import AgentRunError, AnyAgent
4+
from .frameworks.any_agent import AgentCancel, AgentRunError, AnyAgent
55
from .tracing.agent_trace import AgentTrace
66

77
try:
@@ -12,6 +12,7 @@
1212
__version__ = "0.0.0-dev"
1313

1414
__all__ = [
15+
"AgentCancel",
1516
"AgentConfig",
1617
"AgentFramework",
1718
"AgentRunError",

src/any_agent/frameworks/any_agent.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,111 @@
3535
INSIDE_NOTEBOOK = hasattr(builtins, "__IPYTHON__")
3636

3737

38+
class AgentCancel(ABC, Exception): # noqa: N818
39+
"""Abstract base class for control-flow exceptions raised in callbacks.
40+
41+
Within a callback, raise an exception inherited from AgentCancel when you
42+
want to intentionally stop agent execution and handle that specific case in
43+
your application code.
44+
45+
Unlike regular exceptions (which are wrapped in AgentRunError), AgentCancel
46+
subclasses propagate directly to the caller, allowing you to catch them by
47+
their specific type.
48+
49+
When to use AgentCancel vs regular exceptions:
50+
- Use AgentCancel: When stopping execution is expected behavior
51+
(rate limits, safety guardrails, validation failures) and you
52+
want to handle it distinctly in your application.
53+
- Use regular exceptions: When something unexpected goes wrong,
54+
and you want consistent error handling via AgentRunError.
55+
56+
Example:
57+
class StopOnLimit(AgentCancel):
58+
pass
59+
60+
class LimitCallsCallback(Callback):
61+
def before_tool_execution(self, context, *args, **kwargs):
62+
if context.shared.get("call_count", 0) > 10:
63+
raise StopOnLimit("Exceeded call limit")
64+
return context
65+
66+
try:
67+
agent.run("prompt")
68+
except StopOnLimit as e:
69+
# Handle the expected cancellation.
70+
print(f"Canceled: {e}")
71+
print(f"Collected {len(e.trace.spans)} spans")
72+
except AgentRunError as e:
73+
# Handle unexpected errors.
74+
print(f"Unexpected error: {e.original_exception}")
75+
76+
"""
77+
78+
_trace: AgentTrace | None
79+
80+
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
81+
if cls is AgentCancel:
82+
msg = "AgentCancel cannot be instantiated directly; subclass it instead"
83+
raise TypeError(msg)
84+
return super().__new__(cls)
85+
86+
def __init__(self, *args: Any, **kwargs: Any) -> None:
87+
super().__init__(*args, **kwargs)
88+
self._trace = None
89+
90+
@property
91+
def trace(self) -> AgentTrace | None:
92+
"""Execution trace collected before cancellation.
93+
94+
Returns None if accessed before the framework processes the exception.
95+
"""
96+
return self._trace
97+
98+
3899
class AgentRunError(Exception):
39-
"""Error that wraps underlying framework specific errors and carries spans."""
100+
"""Wrapper for unexpected exceptions that occur during agent execution.
101+
102+
When an unexpected exception is raised during agent execution (from
103+
callbacks, tools, or the underlying framework), it is caught and
104+
wrapped in AgentRunError.
105+
106+
Note: Exceptions that inherit from AgentCancel are not wrapped,
107+
they propagate directly to the caller.
108+
109+
AgentRunError ensures:
110+
111+
* The execution trace is preserved - you can inspect what happened
112+
before the error via the `trace` property.
113+
* Consistent error handling - all unexpected errors are wrapped in
114+
the same type, regardless of the underlying framework.
115+
* Original exception access - the wrapped exception is available
116+
via `original_exception` for debugging.
117+
118+
Example:
119+
try:
120+
agent.run("prompt")
121+
except AgentRunError as e:
122+
print(f"Error: {e.original_exception}")
123+
print(f"Trace had {len(e.trace.spans)} spans before failure")
124+
125+
"""
40126

41127
_trace: AgentTrace
42128
_original_exception: Exception
43129

44130
def __init__(self, trace: AgentTrace, original_exception: Exception):
45131
self._trace = trace
46132
self._original_exception = original_exception
47-
# Set the exception message to be the original exception's message
48133
super().__init__(str(original_exception))
49134

50135
@property
51136
def trace(self) -> AgentTrace:
137+
"""The execution trace collected up to the point of failure."""
52138
return self._trace
53139

54140
@property
55141
def original_exception(self) -> Exception:
142+
"""The underlying exception that was caught."""
56143
return self._original_exception
57144

58145
def __str__(self) -> str:
@@ -262,6 +349,12 @@ async def run_async(self, prompt: str, **kwargs: Any) -> AgentTrace:
262349
)
263350

264351
trace.add_span(invoke_span)
352+
353+
# Preserve control-flow exceptions without wrapping.
354+
if isinstance(e, AgentCancel):
355+
e._trace = trace
356+
raise
357+
265358
raise AgentRunError(trace, e) from e
266359

267360
async with self._lock:

src/any_agent/frameworks/openai.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,7 @@ async def _fetch_response(
333333
parallel_tool_calls=parallel_tool_calls,
334334
stream=stream,
335335
stream_options=stream_options,
336-
# TODO: Remove type: ignore after any-llm adds xhigh support.
337-
reasoning_effort=reasoning_effort, # type: ignore[arg-type]
336+
reasoning_effort=reasoning_effort,
338337
top_logprobs=model_settings.top_logprobs,
339338
**extra_kwargs, # type: ignore[arg-type]
340339
)

src/any_agent/tools/wrappers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def _wrap_tool_openai(tool: "Tool | AgentTool") -> "AgentTool":
5050
)
5151

5252
if isinstance(tool, agent_tool_types):
53-
return tool # type: ignore[return-value]
53+
return tool # type: ignore[no-any-return]
5454

5555
# Enabling strict mode required else
5656
# throws error "Only strict function tools can be auto-parsed"

tests/integration/frameworks/test_error_handling.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
from any_agent import (
7+
AgentCancel,
78
AgentConfig,
89
AgentFramework,
910
AgentRunError,
@@ -122,3 +123,40 @@ def search_web(query: str) -> str:
122123
and exception_reason in getattr(span.status, "description", "")
123124
for span in agent_trace.spans
124125
)
126+
127+
128+
class StopExecution(AgentCancel):
129+
"""Test exception for cancelling agent execution."""
130+
131+
132+
class StopBeforeFirstLLMCall(Callback):
133+
"""Callback that raises StopExecution before the first LLM call."""
134+
135+
def before_llm_call(self, context: Context, *args: Any, **kwargs: Any) -> Context:
136+
msg = "Stopped by callback"
137+
raise StopExecution(msg)
138+
139+
140+
def test_agent_cancel_not_wrapped(
141+
agent_framework: AgentFramework,
142+
) -> None:
143+
"""AgentCancel subclasses should propagate without being wrapped in AgentRunError.
144+
145+
When a callback raises an exception that inherits from AgentCancel,
146+
the exception should propagate directly to the caller without being
147+
wrapped in AgentRunError, and the trace should be attached.
148+
"""
149+
agent_config = AgentConfig(
150+
model_id=DEFAULT_SMALL_MODEL_ID,
151+
tools=[],
152+
callbacks=[StopBeforeFirstLLMCall()],
153+
model_args=get_default_agent_model_args(agent_framework),
154+
)
155+
agent = AnyAgent.create(agent_framework, agent_config)
156+
157+
with pytest.raises(StopExecution) as exc_info:
158+
agent.run("test")
159+
160+
# The exception should have a trace attached.
161+
assert exc_info.value.trace is not None
162+
assert len(exc_info.value.trace.spans) > 0

0 commit comments

Comments
 (0)