Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/askui/tools/askui/askui_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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])
Expand All @@ -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)
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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.
"""
77 changes: 77 additions & 0 deletions tests/unit/models/shared/test_desktop_agent_os_error_handling.py
Original file line number Diff line number Diff line change
@@ -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: <broken>"


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)))
4 changes: 2 additions & 2 deletions tests/unit/tools/askui/test_decode_file_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from askui.tools.askui.askui_controller import (
AskUiControllerClient,
DesktopAgentOsError,
DesktopAgentOsException,
)
from askui.utils.pdf_utils import PdfSource

Expand Down Expand Up @@ -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"))
Loading