Skip to content

Tool.call() catches BaseException, silently swallowing KeyboardInterrupt/SystemExit and masking non-callable errors #1485

Description

@ErenAta16

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions