The bug
Tool.call() in guidance/_tools.py catches BaseException instead of Exception, which has two concrete, reproducible consequences:
1. KeyboardInterrupt gets silently swallowed and fed back to the model as a tool result.
If a user hits Ctrl+C while inside a tool's callable (e.g. a slow network call, a long-running computation), the interrupt is caught, formatted into a traceback string via exc_formatter (or the default traceback.format_exception), and returned as the tool's output - exactly as if the tool had raised a normal exception. The model then receives that traceback as "tool output" and keeps generating instead of the process actually stopping. SystemExit (e.g. a sys.exit() call inside a tool) is swallowed the same way.
def raises_keyboard_interrupt():
raise KeyboardInterrupt()
result = tool.call() # tool.callable = raises_keyboard_interrupt
# result is a formatted traceback string; KeyboardInterrupt never propagates,
# the guidance program keeps running instead of stopping
2. A non-callable Tool.callable triggers a bare, unhelpful AssertionError instead of a clear error.
def call(self, *args, **kwargs) -> Any:
try:
return self.callable(*args, **kwargs)
except BaseException as e: # noqa: BLE001
tb = e.__traceback__.tb_next
assert tb is not None # must exist
...
The assert tb is not None assumes the exception always originates from inside self.callable's own frame. That's true when self.callable executes at least one line before raising. It's not true when self.callable itself isn't actually callable - in that case the TypeError ('X' object is not callable) is raised at the call expression itself, inside call()'s own frame, so e.__traceback__.tb_next is None and the assert fails with a bare, message-less AssertionError instead of surfacing anything about the actual problem (a bad callable value on the Tool).
Reproduction
import traceback
class NotActuallyCallable:
pass
def call(callable_obj, *args, **kwargs):
try:
return callable_obj(*args, **kwargs)
except BaseException as e:
tb = e.__traceback__.tb_next
assert tb is not None # <-- fails here
return "".join(traceback.format_exception(type(e), e, tb))
call(NotActuallyCallable())
# AssertionError (no message)
def raises_keyboard_interrupt():
raise KeyboardInterrupt()
result = call(raises_keyboard_interrupt)
print(result)
# Traceback (most recent call last):
# File "...", line ..., in raises_keyboard_interrupt
# KeyboardInterrupt
# --- returned as a normal string, not re-raised ---
Expected behavior
except BaseException should be except Exception, so KeyboardInterrupt/SystemExit/GeneratorExit propagate normally instead of being converted into tool output. Separately, the tb_next assumption should either handle the tb is None case explicitly (e.g. fall back to formatting without skipping a frame, since there's no frame to skip) or raise a clearer error identifying that Tool.callable isn't actually callable, rather than an unlabeled AssertionError.
System info
Reproduced against current main (_tools.py, Tool.call).
The bug
Tool.call()inguidance/_tools.pycatchesBaseExceptioninstead ofException, which has two concrete, reproducible consequences:1.
KeyboardInterruptgets silently swallowed and fed back to the model as a tool result.If a user hits Ctrl+C while inside a tool's callable (e.g. a slow network call, a long-running computation), the interrupt is caught, formatted into a traceback string via
exc_formatter(or the defaulttraceback.format_exception), and returned as the tool's output - exactly as if the tool had raised a normal exception. The model then receives that traceback as "tool output" and keeps generating instead of the process actually stopping.SystemExit(e.g. asys.exit()call inside a tool) is swallowed the same way.2. A non-callable
Tool.callabletriggers a bare, unhelpfulAssertionErrorinstead of a clear error.The
assert tb is not Noneassumes the exception always originates from insideself.callable's own frame. That's true whenself.callableexecutes at least one line before raising. It's not true whenself.callableitself isn't actually callable - in that case theTypeError('X' object is not callable) is raised at the call expression itself, insidecall()'s own frame, soe.__traceback__.tb_nextisNoneand theassertfails with a bare, message-lessAssertionErrorinstead of surfacing anything about the actual problem (a badcallablevalue on theTool).Reproduction
Expected behavior
except BaseExceptionshould beexcept Exception, soKeyboardInterrupt/SystemExit/GeneratorExitpropagate normally instead of being converted into tool output. Separately, thetb_nextassumption should either handle thetb is Nonecase explicitly (e.g. fall back to formatting without skipping a frame, since there's no frame to skip) or raise a clearer error identifying thatTool.callableisn't actually callable, rather than an unlabeledAssertionError.System info
Reproduced against current
main(_tools.py,Tool.call).