diff --git a/src/askui/tools/askui/askui_controller.py b/src/askui/tools/askui/askui_controller.py index 6bb879e2..e0e0f1ad 100644 --- a/src/askui/tools/askui/askui_controller.py +++ b/src/askui/tools/askui/askui_controller.py @@ -28,6 +28,7 @@ ) from askui.tools.askui.askui_ui_controller_grpc.desktop_agent_os_error import ( DesktopAgentOsError, + DesktopAgentOsException, ) from askui.tools.askui.askui_ui_controller_grpc.generated import ( Controller_V1_pb2 as controller_v1_pbs, @@ -1442,7 +1443,7 @@ def get_file_names(self, absolute_directory_path: str) -> list[str]: message = f"unexpected response type: {res}" raise DesktopAgentOsError(message) if res.error is not None: - raise DesktopAgentOsError(res.error) + raise DesktopAgentOsException(res.error) if res.response is None: message = f"{type(res).__name__} is missing both error and response" raise DesktopAgentOsError(message) @@ -1472,7 +1473,10 @@ def get_file(self, path: str) -> Image.Image | PdfSource | str: Image.Image | PdfSource | str: The decoded file contents. Raises: - DesktopAgentOsError: If the file cannot be read or the response is invalid. + DesktopAgentOsException: If the file cannot be read (e.g. the path + does not exist or its contents cannot be decoded). + DesktopAgentOsError: If the controller response violates the expected + protocol. """ self._reporter.add_message(self._REPORTER_SOURCE, f"get_file({path})") command = GetFileCommand(parameters=[path]) @@ -1481,7 +1485,7 @@ def get_file(self, path: str) -> Image.Image | PdfSource | str: message = f"unexpected response type: {res}" raise DesktopAgentOsError(message) if res.error is not None: - raise DesktopAgentOsError(res.error) + raise DesktopAgentOsException(res.error) if res.response is None: message = f"{type(res).__name__} is missing both error and response" raise DesktopAgentOsError(message) @@ -1535,7 +1539,7 @@ def _decode_file_payload(base64_data: str) -> Image.Image | PdfSource | str: except UnicodeDecodeError: pass message = "File contents are neither a supported image, PDF, nor UTF-8 text" - raise DesktopAgentOsError(message) + raise DesktopAgentOsException(message) AskUiControllerClient = MultiComputerTargetAgentOS diff --git a/src/askui/tools/askui/askui_ui_controller_grpc/desktop_agent_os_error.py b/src/askui/tools/askui/askui_ui_controller_grpc/desktop_agent_os_error.py index 14a66aba..2ac37161 100644 --- a/src/askui/tools/askui/askui_ui_controller_grpc/desktop_agent_os_error.py +++ b/src/askui/tools/askui/askui_ui_controller_grpc/desktop_agent_os_error.py @@ -1,5 +1,30 @@ -class DesktopAgentOsError(BaseException): - """Base class for Desktop Agent OS errors. +from askui.models.exceptions import AutomationError - This error is raised when an error occurs in the Desktop Agent OS. + +class DesktopAgentOsError(AutomationError): + """Unfixable error raised by the Desktop Agent OS. + + Raised when the Desktop Agent OS returns a response that violates the + expected protocol (e.g. an unexpected response type or a response missing + both an error and a payload). These indicate a broken controller or + connection rather than something the agent can recover from, so - like + other `AutomationError`s - they are re-raised by the tool-calling loop and + terminate the run. + + For failures the agent can react to and work around (e.g. a path that does + not exist), raise `DesktopAgentOsException` instead. + """ + + +class DesktopAgentOsException(Exception): # noqa: N818 + """Recoverable error raised by the Desktop Agent OS. + + Raised when an operation on the Desktop Agent OS fails in a way the agent + can react to and work around - for example, reading a file or directory + that does not exist, or a file whose contents cannot be decoded. Because it + derives from `Exception` (and not `AutomationError`), the tool-calling loop + catches it and surfaces it to the agent as a tool error result instead of + terminating the run. + + For unfixable protocol violations, raise `DesktopAgentOsError` instead. """ diff --git a/tests/unit/models/shared/test_desktop_agent_os_error_handling.py b/tests/unit/models/shared/test_desktop_agent_os_error_handling.py new file mode 100644 index 00000000..6454cecb --- /dev/null +++ b/tests/unit/models/shared/test_desktop_agent_os_error_handling.py @@ -0,0 +1,77 @@ +"""Tests that Desktop Agent OS errors are routed by recoverability. + +The Desktop Agent OS raises two error types: + +- `DesktopAgentOsException` for failures the agent can react to (e.g. reading a + path that does not exist). The tool-calling loop catches it and surfaces it to + the agent as a tool error result so the run can continue. +- `DesktopAgentOsError` for unfixable protocol violations. It derives from + `AutomationError` and is re-raised by the tool-calling loop, terminating the + run instead of being fed back to the agent. +""" + +import pytest + +from askui.models.exceptions import AutomationError +from askui.models.shared.agent_message_param import ( + ToolResultBlockParam, + ToolUseBlockParam, +) +from askui.models.shared.tools import Tool, ToolCollection +from askui.tools.askui.askui_ui_controller_grpc.desktop_agent_os_error import ( + DesktopAgentOsError, + DesktopAgentOsException, +) + +_RECOVERABLE_MESSAGE = ( + "directory_iterator::directory_iterator: The system cannot find the " + 'path specified.: "FrontEnd\\Traces"' +) +_FATAL_MESSAGE = "unexpected response type: " + + +class _RaisingTool(Tool): + """A tool whose `__call__` raises the exception it was constructed with.""" + + _error: BaseException + + def __init__(self, error: BaseException) -> None: + super().__init__( + name="raising_tool", + description="Raises a preconfigured Desktop Agent OS error.", + ) + self._error = error + + def __call__(self) -> str: + raise self._error + + +def _run(tool: Tool) -> list: + collection = ToolCollection(tools=[tool]) + tool_use = ToolUseBlockParam(id="tool_use_1", input={}, name=tool.name) + return collection.run([tool_use]) + + +class TestDesktopAgentOsErrorHierarchy: + def test_error_is_an_automation_error(self) -> None: + assert issubclass(DesktopAgentOsError, AutomationError) + + def test_exception_is_a_plain_exception_not_automation_error(self) -> None: + assert issubclass(DesktopAgentOsException, Exception) + assert not issubclass(DesktopAgentOsException, AutomationError) + + +class TestDesktopAgentOsErrorHandling: + def test_recoverable_exception_returns_error_result(self) -> None: + results = _run(_RaisingTool(DesktopAgentOsException(_RECOVERABLE_MESSAGE))) + + assert len(results) == 1 + result = results[0] + assert isinstance(result, ToolResultBlockParam) + assert result.is_error is True + assert result.tool_use_id == "tool_use_1" + assert "FrontEnd\\Traces" in str(result.content) + + def test_fatal_error_propagates_and_terminates(self) -> None: + with pytest.raises(DesktopAgentOsError): + _run(_RaisingTool(DesktopAgentOsError(_FATAL_MESSAGE))) diff --git a/tests/unit/tools/askui/test_decode_file_payload.py b/tests/unit/tools/askui/test_decode_file_payload.py index 6d898e83..53206245 100644 --- a/tests/unit/tools/askui/test_decode_file_payload.py +++ b/tests/unit/tools/askui/test_decode_file_payload.py @@ -14,7 +14,7 @@ from askui.tools.askui.askui_controller import ( AskUiControllerClient, - DesktopAgentOsError, + DesktopAgentOsException, ) from askui.utils.pdf_utils import PdfSource @@ -47,5 +47,5 @@ def test_decodes_utf8_text(self) -> None: assert result == "hello world" def test_rejects_unsupported_binary(self) -> None: - with pytest.raises(DesktopAgentOsError): + with pytest.raises(DesktopAgentOsException): AskUiControllerClient._decode_file_payload(_b64(b"\x00\x01\x02\x03"))