diff --git a/pdm.lock b/pdm.lock index 083f4ee2..d626dff4 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,14 +2,25 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "all", "android", "chat", "dev", "pynput", "test", "web"] +groups = ["default", "all", "android", "chat", "dev", "pynput", "web"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:8d52f2c4c18cacc1197a60c1e72602933fecfd465384d5c74083f535c093afae" +content_hash = "sha256:9f37e762e6289c4df583809586d54ba8d3714b62b21bbb652caa1b25e9822794" [[metadata.targets]] requires_python = ">=3.10" +[[package]] +name = "aiofiles" +version = "24.1.0" +requires_python = ">=3.8" +summary = "File support for asyncio." +groups = ["default"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -128,7 +139,7 @@ name = "backports-asyncio-runner" version = "1.2.0" requires_python = "<3.11,>=3.8" summary = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." -groups = ["test"] +groups = ["dev"] marker = "python_version < \"3.11\"" files = [ {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, @@ -375,7 +386,7 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["default", "all", "chat", "dev", "test"] +groups = ["default", "all", "chat", "dev"] marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -402,7 +413,7 @@ name = "coverage" version = "7.8.0" requires_python = ">=3.9" summary = "Code coverage measurement for Python" -groups = ["test"] +groups = ["dev"] files = [ {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, @@ -465,7 +476,7 @@ version = "7.8.0" extras = ["toml"] requires_python = ">=3.9" summary = "Code coverage measurement for Python" -groups = ["test"] +groups = ["dev"] dependencies = [ "coverage==7.8.0", "tomli; python_full_version <= \"3.11.0a6\"", @@ -715,7 +726,7 @@ name = "exceptiongroup" version = "1.2.2" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" -groups = ["default", "all", "chat", "test"] +groups = ["default", "all", "chat", "dev"] files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -726,7 +737,7 @@ name = "execnet" version = "2.1.1" requires_python = ">=3.8" summary = "execnet: rapid multi-Python deployment" -groups = ["test"] +groups = ["dev"] files = [ {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, @@ -886,7 +897,7 @@ name = "greenlet" version = "3.2.3" requires_python = ">=3.9" summary = "Lightweight in-process concurrent programming" -groups = ["all", "chat", "test", "web"] +groups = ["all", "chat", "dev", "web"] files = [ {file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"}, {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"}, @@ -939,7 +950,7 @@ name = "grpc-stubs" version = "1.53.0.6" requires_python = ">=3.6" summary = "Mypy stubs for gRPC" -groups = ["test"] +groups = ["dev"] dependencies = [ "grpcio", ] @@ -953,7 +964,7 @@ name = "grpcio" version = "1.73.1" requires_python = ">=3.9" summary = "HTTP/2-based RPC framework" -groups = ["default", "dev", "test"] +groups = ["default", "dev"] files = [ {file = "grpcio-1.73.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:2d70f4ddd0a823436c2624640570ed6097e40935c9194482475fe8e3d9754d55"}, {file = "grpcio-1.73.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3841a8a5a66830261ab6a3c2a3dc539ed84e4ab019165f77b3eeb9f0ba621f26"}, @@ -1179,7 +1190,7 @@ name = "iniconfig" version = "2.1.0" requires_python = ">=3.8" summary = "brain-dead simple config-ini parsing" -groups = ["test"] +groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -1702,7 +1713,7 @@ name = "mypy" version = "1.15.0" requires_python = ">=3.9" summary = "Optional static typing for Python" -groups = ["test"] +groups = ["dev"] dependencies = [ "mypy-extensions>=1.0.0", "tomli>=1.1.0; python_version < \"3.11\"", @@ -1742,7 +1753,7 @@ name = "mypy-extensions" version = "1.0.0" requires_python = ">=3.5" summary = "Type system extensions for programs checked with the mypy type checker." -groups = ["dev", "test"] +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -1958,7 +1969,7 @@ name = "packaging" version = "24.2" requires_python = ">=3.8" summary = "Core utilities for Python packages" -groups = ["default", "dev", "test"] +groups = ["default", "dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -2133,7 +2144,7 @@ name = "playwright" version = "1.53.0" requires_python = ">=3.9" summary = "A high-level API to automate web browsers" -groups = ["all", "chat", "test", "web"] +groups = ["all", "chat", "dev", "web"] dependencies = [ "greenlet<4.0.0,>=3.1.1", "pyee<14,>=13", @@ -2154,7 +2165,7 @@ name = "pluggy" version = "1.5.0" requires_python = ">=3.8" summary = "plugin and hook calling mechanisms for python" -groups = ["test"] +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -2378,7 +2389,7 @@ name = "pyee" version = "13.0.0" requires_python = ">=3.8" summary = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" -groups = ["all", "chat", "test", "web"] +groups = ["all", "chat", "dev", "web"] dependencies = [ "typing-extensions", ] @@ -2551,7 +2562,7 @@ name = "pytest" version = "8.3.5" requires_python = ">=3.8" summary = "pytest: simple powerful testing with Python" -groups = ["test"] +groups = ["dev"] dependencies = [ "colorama; sys_platform == \"win32\"", "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", @@ -2570,7 +2581,7 @@ name = "pytest-asyncio" version = "1.1.0" requires_python = ">=3.9" summary = "Pytest support for asyncio" -groups = ["test"] +groups = ["dev"] dependencies = [ "backports-asyncio-runner<2,>=1.1; python_version < \"3.11\"", "pytest<9,>=8.2", @@ -2586,7 +2597,7 @@ name = "pytest-cov" version = "6.1.1" requires_python = ">=3.9" summary = "Pytest plugin for measuring coverage." -groups = ["test"] +groups = ["dev"] dependencies = [ "coverage[toml]>=7.5", "pytest>=4.6", @@ -2601,7 +2612,7 @@ name = "pytest-mock" version = "3.14.0" requires_python = ">=3.8" summary = "Thin-wrapper around the mock package for easier use with pytest" -groups = ["test"] +groups = ["dev"] dependencies = [ "pytest>=6.2.5", ] @@ -2615,7 +2626,7 @@ name = "pytest-timeout" version = "2.4.0" requires_python = ">=3.7" summary = "pytest plugin to abort hanging tests" -groups = ["test"] +groups = ["dev"] dependencies = [ "pytest>=7.0.0", ] @@ -2629,7 +2640,7 @@ name = "pytest-xdist" version = "3.6.1" requires_python = ">=3.8" summary = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -groups = ["test"] +groups = ["dev"] dependencies = [ "execnet>=2.1", "pytest>=7.0.0", @@ -2994,7 +3005,7 @@ name = "ruff" version = "0.11.3" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." -groups = ["test"] +groups = ["dev"] files = [ {file = "ruff-0.11.3-py3-none-linux_armv6l.whl", hash = "sha256:cb893a5eedff45071d52565300a20cd4ac088869e156b25e0971cb98c06f5dd7"}, {file = "ruff-0.11.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:58edd48af0e201e2f494789de80f5b2f2b46c9a2991a12ea031254865d5f6aa3"}, @@ -3137,7 +3148,7 @@ name = "tomli" version = "2.2.1" requires_python = ">=3.8" summary = "A lil' TOML parser" -groups = ["dev", "test"] +groups = ["dev"] marker = "python_version <= \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, @@ -3203,12 +3214,23 @@ files = [ {file = "typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74"}, ] +[[package]] +name = "types-aiofiles" +version = "24.1.0.20250822" +requires_python = ">=3.9" +summary = "Typing stubs for aiofiles" +groups = ["dev"] +files = [ + {file = "types_aiofiles-24.1.0.20250822-py3-none-any.whl", hash = "sha256:0ec8f8909e1a85a5a79aed0573af7901f53120dd2a29771dd0b3ef48e12328b0"}, + {file = "types_aiofiles-24.1.0.20250822.tar.gz", hash = "sha256:9ab90d8e0c307fe97a7cf09338301e3f01a163e39f3b529ace82466355c84a7b"}, +] + [[package]] name = "types-pillow" version = "10.2.0.20240822" requires_python = ">=3.8" summary = "Typing stubs for Pillow" -groups = ["test"] +groups = ["dev"] files = [ {file = "types-Pillow-10.2.0.20240822.tar.gz", hash = "sha256:559fb52a2ef991c326e4a0d20accb3bb63a7ba8d40eb493e0ecb0310ba52f0d3"}, {file = "types_Pillow-10.2.0.20240822-py3-none-any.whl", hash = "sha256:d9dab025aba07aeb12fd50a6799d4eac52a9603488eca09d7662543983f16c5d"}, @@ -3219,7 +3241,7 @@ name = "types-protobuf" version = "5.29.1.20250403" requires_python = ">=3.9" summary = "Typing stubs for protobuf" -groups = ["test"] +groups = ["dev"] files = [ {file = "types_protobuf-5.29.1.20250403-py3-none-any.whl", hash = "sha256:c71de04106a2d54e5b2173d0a422058fae0ef2d058d70cf369fb797bf61ffa59"}, {file = "types_protobuf-5.29.1.20250403.tar.gz", hash = "sha256:7ff44f15022119c9d7558ce16e78b2d485bf7040b4fadced4dd069bb5faf77a2"}, @@ -3230,7 +3252,7 @@ name = "types-pynput" version = "1.8.1.20250318" requires_python = ">=3.9" summary = "Typing stubs for pynput" -groups = ["test"] +groups = ["dev"] files = [ {file = "types_pynput-1.8.1.20250318-py3-none-any.whl", hash = "sha256:0c1038aa1550941633114a2728ad85e392f67dfba970aebf755e369ab57aca70"}, {file = "types_pynput-1.8.1.20250318.tar.gz", hash = "sha256:13d4df97843a7d1e7cddccbf9987aca7f0d463b214a8a35b4f53275d2c5a3576"}, @@ -3241,7 +3263,7 @@ name = "types-pyperclip" version = "1.9.0.20250218" requires_python = ">=3.9" summary = "Typing stubs for pyperclip" -groups = ["test"] +groups = ["dev"] files = [ {file = "types_pyperclip-1.9.0.20250218-py3-none-any.whl", hash = "sha256:305afab7efb6fbcc77d9690bf660cf24baa06e30c2db7d12dd1f1602b2094a27"}, {file = "types_pyperclip-1.9.0.20250218.tar.gz", hash = "sha256:8c03a16c17fae2b1e527e4b3505d711a77d9aa9961763026cc75fba4d32469e5"}, @@ -3252,7 +3274,7 @@ name = "types-python-dateutil" version = "2.9.0.20241206" requires_python = ">=3.8" summary = "Typing stubs for python-dateutil" -groups = ["test"] +groups = ["dev"] files = [ {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, @@ -3263,7 +3285,7 @@ name = "types-requests" version = "2.32.0.20250328" requires_python = ">=3.9" summary = "Typing stubs for requests" -groups = ["test"] +groups = ["dev"] dependencies = [ "urllib3>=2", ] @@ -3277,7 +3299,7 @@ name = "typing-extensions" version = "4.14.1" requires_python = ">=3.9" summary = "Backported and Experimental Type Hints for Python 3.9+" -groups = ["default", "all", "chat", "dev", "test", "web"] +groups = ["default", "all", "chat", "dev", "web"] files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, @@ -3313,7 +3335,7 @@ name = "urllib3" version = "2.3.0" requires_python = ">=3.9" summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["default", "test"] +groups = ["default", "dev"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, diff --git a/pyproject.toml b/pyproject.toml index b6f34094..0ac8f6ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "markitdown[xls,xlsx,docx]>=0.1.2", "asyncer==0.0.8", "bson>=0.5.10", + "aiofiles>=24.1.0", ] requires-python = ">=3.10" readme = "README.md" @@ -45,6 +46,7 @@ path = "src/askui/__init__.py" + [tool.pdm] distribution = true @@ -73,7 +75,7 @@ typecheck = "mypy" "json:gen" = "datamodel-codegen --output-model-type pydantic_v2.BaseModel --input src/askui/tools/askui/askui_ui_controller_grpc/json_schema/ --input-file-type jsonschema --output src/askui/tools/askui/askui_ui_controller_grpc/generated/" [dependency-groups] -test = [ +dev = [ "pytest>=8.3.4", "ruff>=0.9.5", "pytest-mock>=3.14.0", @@ -90,10 +92,9 @@ test = [ "types-pynput>=1.8.1.20250318", "playwright>=1.41.0", "pytest-asyncio>=1.1.0", -] -dev = [ "datamodel-code-generator>=0.31.2", "grpcio-tools>=1.73.1", + "types-aiofiles>=24.1.0.20250822", ] diff --git a/src/askui/chat/api/runs/dependencies.py b/src/askui/chat/api/runs/dependencies.py index ca8b1075..bca36b42 100644 --- a/src/askui/chat/api/runs/dependencies.py +++ b/src/askui/chat/api/runs/dependencies.py @@ -9,9 +9,12 @@ from askui.chat.api.mcp_clients.manager import McpClientManagerManager from askui.chat.api.messages.chat_history_manager import ChatHistoryManager from askui.chat.api.messages.dependencies import ChatHistoryManagerDep +from askui.chat.api.runs.models import RunListQuery from .service import RunService +RunListQueryDep = Depends(RunListQuery) + def get_runs_service( workspace_dir: Path = WorkspaceDirDep, diff --git a/src/askui/chat/api/runs/events/__init__.py b/src/askui/chat/api/runs/events/__init__.py new file mode 100644 index 00000000..56b48f30 --- /dev/null +++ b/src/askui/chat/api/runs/events/__init__.py @@ -0,0 +1,15 @@ +from askui.chat.api.runs.events.done_events import DoneEvent +from askui.chat.api.runs.events.error_events import ErrorEvent +from askui.chat.api.runs.events.event_base import EventBase +from askui.chat.api.runs.events.events import Event +from askui.chat.api.runs.events.message_events import MessageEvent +from askui.chat.api.runs.events.run_events import RunEvent + +__all__ = [ + "DoneEvent", + "ErrorEvent", + "EventBase", + "Event", + "MessageEvent", + "RunEvent", +] diff --git a/src/askui/chat/api/runs/runner/events/done_events.py b/src/askui/chat/api/runs/events/done_events.py similarity index 66% rename from src/askui/chat/api/runs/runner/events/done_events.py rename to src/askui/chat/api/runs/events/done_events.py index 64be93fd..458daa8a 100644 --- a/src/askui/chat/api/runs/runner/events/done_events.py +++ b/src/askui/chat/api/runs/events/done_events.py @@ -1,6 +1,6 @@ from typing import Literal -from askui.chat.api.runs.runner.events.event_base import EventBase +from askui.chat.api.runs.events.event_base import EventBase class DoneEvent(EventBase): diff --git a/src/askui/chat/api/runs/runner/events/error_events.py b/src/askui/chat/api/runs/events/error_events.py similarity index 80% rename from src/askui/chat/api/runs/runner/events/error_events.py rename to src/askui/chat/api/runs/events/error_events.py index 82688d54..98107479 100644 --- a/src/askui/chat/api/runs/runner/events/error_events.py +++ b/src/askui/chat/api/runs/events/error_events.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from askui.chat.api.runs.runner.events.event_base import EventBase +from askui.chat.api.runs.events.event_base import EventBase class ErrorEventDataError(BaseModel): diff --git a/src/askui/chat/api/runs/runner/events/event_base.py b/src/askui/chat/api/runs/events/event_base.py similarity index 100% rename from src/askui/chat/api/runs/runner/events/event_base.py rename to src/askui/chat/api/runs/events/event_base.py diff --git a/src/askui/chat/api/runs/events/events.py b/src/askui/chat/api/runs/events/events.py new file mode 100644 index 00000000..94ecb9d2 --- /dev/null +++ b/src/askui/chat/api/runs/events/events.py @@ -0,0 +1,10 @@ +from pydantic import TypeAdapter + +from askui.chat.api.runs.events.done_events import DoneEvent +from askui.chat.api.runs.events.error_events import ErrorEvent +from askui.chat.api.runs.events.message_events import MessageEvent +from askui.chat.api.runs.events.run_events import RunEvent + +Event = DoneEvent | ErrorEvent | MessageEvent | RunEvent + +EventAdapter: TypeAdapter[Event] = TypeAdapter(Event) diff --git a/src/askui/chat/api/runs/runner/events/message_events.py b/src/askui/chat/api/runs/events/message_events.py similarity index 72% rename from src/askui/chat/api/runs/runner/events/message_events.py rename to src/askui/chat/api/runs/events/message_events.py index 54a5c802..f8eb374e 100644 --- a/src/askui/chat/api/runs/runner/events/message_events.py +++ b/src/askui/chat/api/runs/events/message_events.py @@ -1,7 +1,7 @@ from typing import Literal from askui.chat.api.messages.models import Message -from askui.chat.api.runs.runner.events.event_base import EventBase +from askui.chat.api.runs.events.event_base import EventBase class MessageEvent(EventBase): diff --git a/src/askui/chat/api/runs/runner/events/run_events.py b/src/askui/chat/api/runs/events/run_events.py similarity index 85% rename from src/askui/chat/api/runs/runner/events/run_events.py rename to src/askui/chat/api/runs/events/run_events.py index 84952e34..66a83517 100644 --- a/src/askui/chat/api/runs/runner/events/run_events.py +++ b/src/askui/chat/api/runs/events/run_events.py @@ -1,7 +1,7 @@ from typing import Literal +from askui.chat.api.runs.events.event_base import EventBase from askui.chat.api.runs.models import Run -from askui.chat.api.runs.runner.events.event_base import EventBase class RunEvent(EventBase): diff --git a/src/askui/chat/api/runs/events/service.py b/src/askui/chat/api/runs/events/service.py new file mode 100644 index 00000000..3e07d8c3 --- /dev/null +++ b/src/askui/chat/api/runs/events/service.py @@ -0,0 +1,332 @@ +import asyncio +import logging +import types +from abc import ABC, abstractmethod +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, AsyncIterator, Type + +import aiofiles + +if TYPE_CHECKING: + from aiofiles.threadpool.text import AsyncTextIOWrapper + +from askui.chat.api.models import RunId, ThreadId +from askui.chat.api.runs.events.done_events import DoneEvent +from askui.chat.api.runs.events.error_events import ( + ErrorEvent, + ErrorEventData, + ErrorEventDataError, +) +from askui.chat.api.runs.events.events import Event, EventAdapter +from askui.chat.api.runs.events.run_events import RunEvent +from askui.chat.api.runs.models import Run + +logger = logging.getLogger(__name__) + + +class EventFileManager: + """Manages the lifecycle of a single event file with reference counting.""" + + def __init__(self, file_path: Path) -> None: + self.file_path = file_path + self.readers_count = 0 + self.writer_active = False + self._lock = asyncio.Lock() + self._file_created_event = asyncio.Event() + self._new_event_event = asyncio.Event() + + async def add_reader(self) -> None: + """Add a reader reference.""" + async with self._lock: + self.readers_count += 1 + + async def remove_reader(self) -> None: + """Remove a reader reference and cleanup if no refs remain.""" + async with self._lock: + self.readers_count -= 1 + await self._cleanup_if_needed() + + async def set_writer_active(self, active: bool) -> None: + """Set writer active status.""" + async with self._lock: + self.writer_active = active + if not active: + await self._cleanup_if_needed() + + async def _cleanup_if_needed(self) -> None: + """Delete file if no active connections remain.""" + if not self.writer_active and self.readers_count == 0: + try: + if self.file_path.exists(): + self.file_path.unlink() + # we keep the parent directory + except FileNotFoundError: + pass # Already deleted + + async def notify_file_created(self) -> None: + """Signal that the file has been created.""" + self._file_created_event.set() + + async def wait_for_file(self, timeout: float = 30.0) -> None: + """Wait for the file to be created. + + Args: + timeout: Timeout in seconds. + + Raises: + TimeoutError: If the file is not created within the timeout. + """ + await asyncio.wait_for(self._file_created_event.wait(), timeout) + + async def notify_new_event(self) -> None: + """Signal that a new event has been written to the file.""" + self._new_event_event.set() + + async def wait_for_new_event( + self, timeout: float = 30.0, clear: bool = False + ) -> None: + """Wait for a new event to be written to the file.""" + await asyncio.wait_for(self._new_event_event.wait(), timeout) + if clear: + self._new_event_event.clear() + + +class RetrieveRunService(ABC): + @abstractmethod + def retrieve(self, thread_id: ThreadId, run_id: RunId) -> Run: + raise NotImplementedError + + +class EventWriter: + """Writer for appending events to a JSONL file.""" + + def __init__(self, manager: EventFileManager): + self._manager = manager + self._file: "AsyncTextIOWrapper | None" = None + + async def write_event(self, event: Event) -> None: + """Write an event to the file.""" + if self._file is None: + self._file = await aiofiles.open( + self._manager.file_path, "a", encoding="utf-8" + ).__aenter__() + await self._manager.notify_file_created() + + event_json = event.model_dump_json() + await self._file.write(f"{event_json}\n") + await self._file.flush() + await self._manager.notify_new_event() + + async def __aenter__(self) -> "EventWriter": + return self + + async def __aexit__( + self, + exc_type: Type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + if self._file: + await self._file.close() + + +class EventReader: + """Reader for streaming events from a JSONL file.""" + + def __init__( + self, + manager: EventFileManager, + run_service: RetrieveRunService, + start_index: int, + thread_id: ThreadId, + run_id: RunId, + ): + self._manager = manager + self._run_service = run_service + self._start_index = start_index + self._thread_id = thread_id + self._run_id = run_id + + async def _iter_final_events(self, run: Run) -> AsyncIterator[Event]: + match run.status: + case "completed": + yield RunEvent(data=run, event="thread.run.completed") + yield DoneEvent() + case "failed": + yield ErrorEvent( + data=ErrorEventData( + error=ErrorEventDataError( + message=run.last_error.message + if run.last_error + else "Unknown error" + ) + ) + ) + case "cancelled": + yield RunEvent(data=run, event="thread.run.cancelled") + yield DoneEvent() + case "expired": + yield RunEvent(data=run, event="thread.run.expired") + yield DoneEvent() + case _: + pass + + async def read_events(self) -> AsyncIterator[Event]: # noqa: C901 + """ + Stream events from the file starting at the specified index. + Continues reading until a terminal event (DoneEvent or ErrorEvent) is found. + + Yields: + Event objects parsed from the JSONL file. + """ + while True: + try: + await self._manager.wait_for_file() + break + except asyncio.exceptions.TimeoutError: + logger.warning( + "Timeout waiting for file %s to be created", + self._manager.file_path, + ) + if run := self._run_service.retrieve(self._thread_id, self._run_id): + if run.status not in ("queued", "in_progress"): + async for event in self._iter_final_events(run): + yield event + return + + line_index = -1 + current_position = 0 + async with aiofiles.open( + self._manager.file_path, "r", encoding="utf-8" + ) as file: + while True: + if await file.tell() != current_position: + await file.seek(current_position) + async for line in file: + line_index += 1 + if line_index < self._start_index: + continue + + if stripped_line := line.strip(): + event = EventAdapter.validate_json(stripped_line) + yield event + if isinstance(event, (DoneEvent, ErrorEvent)): + return + await asyncio.sleep(0.25) + current_position = await file.tell() + while True: + try: + await self._manager.wait_for_new_event(clear=True) + break + except asyncio.exceptions.TimeoutError: + logger.warning( + "Timeout waiting for file %s to have a new event", + self._manager.file_path, + ) + if run := self._run_service.retrieve( + self._thread_id, self._run_id + ): + if run.status not in ( + "queued", + "in_progress", + "cancelling", + ): + async for event in self._iter_final_events(run): + yield event + return + + +class EventService: + """ + Service for managing event files with concurrent read/write access. + + Features: + - Single writer, multiple readers per file + - Automatic file cleanup when all connections close + - Thread-safe operations + - Performant streaming reads + """ + + _file_managers: dict[RunId, EventFileManager] = {} + _lock = asyncio.Lock() + + def __init__(self, base_dir: Path, run_service: RetrieveRunService) -> None: + self._base_dir = base_dir + self._run_service = run_service + + def _get_event_path(self, thread_id: ThreadId, run_id: RunId) -> Path: + """Get the file path for an event.""" + return self._base_dir / "events" / thread_id / f"{run_id}.jsonl" + + async def _get_or_create_manager( + self, thread_id: ThreadId, run_id: RunId + ) -> EventFileManager: + """Get or create a file manager for the session.""" + async with self._lock: + if run_id not in self._file_managers: + events_file = self._get_event_path(thread_id, run_id) + events_file.parent.mkdir(parents=True, exist_ok=True) + self._file_managers[run_id] = EventFileManager(events_file) + return self._file_managers[run_id] + + @asynccontextmanager + async def create_writer( + self, thread_id: ThreadId, run_id: RunId + ) -> AsyncIterator["EventWriter"]: + """ + Create a writer context manager for appending events to a file. + + Args: + thread_id: Thread ID of the file to write. + run_id: Run ID of the file to write. + + Yields: + EventWriter instance for writing events. + """ + manager = await self._get_or_create_manager(thread_id, run_id) + await manager.set_writer_active(True) + + try: + writer = EventWriter(manager) + yield writer + finally: + await manager.set_writer_active(False) + # Cleanup manager reference if file was deleted + async with self._lock: + if not manager.file_path.exists(): + self._file_managers.pop(run_id, None) + + @asynccontextmanager + async def create_reader( + self, thread_id: ThreadId, run_id: RunId, start_index: int = 0 + ) -> AsyncIterator["EventReader"]: + """ + Create a reader context manager for reading events from a file. + + Args: + thread_id: Thread ID of the file to read. + run_id: Run ID of the file to read. + start_index: Index to start reading from (0-based). + + Yields: + EventReader instance for reading events. + """ + manager = await self._get_or_create_manager(thread_id, run_id) + await manager.add_reader() + + try: + reader = EventReader( + manager=manager, + run_service=self._run_service, + start_index=start_index, + thread_id=thread_id, + run_id=run_id, + ) + yield reader + finally: + await manager.remove_reader() + # Cleanup manager reference if file was deleted + async with self._lock: + if not manager.file_path.exists(): + self._file_managers.pop(run_id, None) diff --git a/src/askui/chat/api/runs/models.py b/src/askui/chat/api/runs/models.py index 4a085c17..96220ffd 100644 --- a/src/askui/chat/api/runs/models.py +++ b/src/askui/chat/api/runs/models.py @@ -1,11 +1,13 @@ +from dataclasses import dataclass from datetime import timedelta -from typing import Literal +from typing import Annotated, Literal +from fastapi import Query from pydantic import BaseModel, computed_field from askui.chat.api.models import AssistantId, RunId, ThreadId from askui.chat.api.threads.models import ThreadCreateParams -from askui.utils.api_utils import Resource +from askui.utils.api_utils import ListQuery, Resource from askui.utils.datetime_utils import UnixDatetime, now from askui.utils.id_utils import generate_time_ordered_id @@ -101,3 +103,9 @@ def cancel(self) -> None: def fail(self, error: RunError) -> None: self.failed_at = now() self.last_error = error + + +@dataclass(kw_only=True) +class RunListQuery(ListQuery): + thread: Annotated[ThreadId | None, Query()] = None + status: Annotated[list[RunStatus] | None, Query()] = None diff --git a/src/askui/chat/api/runs/router.py b/src/askui/chat/api/runs/router.py index eae47e21..bca81eb2 100644 --- a/src/askui/chat/api/runs/router.py +++ b/src/askui/chat/api/runs/router.py @@ -1,7 +1,16 @@ from collections.abc import AsyncGenerator from typing import Annotated -from fastapi import APIRouter, BackgroundTasks, Header, Path, Response, status +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + Header, + Path, + Query, + Response, + status, +) from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel @@ -12,8 +21,8 @@ from askui.chat.api.threads.facade import ThreadFacade from askui.utils.api_utils import ListQuery, ListResponse -from .dependencies import RunServiceDep -from .models import Run, ThreadAndRunCreateParams +from .dependencies import RunListQueryDep, RunServiceDep +from .models import Run, RunListQuery, ThreadAndRunCreateParams from .service import RunService router = APIRouter(tags=["runs"]) @@ -93,21 +102,38 @@ async def _run_async_generator() -> None: @router.get("/threads/{thread_id}/runs/{run_id}") -def retrieve_run( +async def retrieve_run( thread_id: Annotated[ThreadId, Path(...)], run_id: Annotated[RunId, Path(...)], + stream: Annotated[bool, Query()] = False, run_service: RunService = RunServiceDep, -) -> Run: - return run_service.retrieve(thread_id, run_id) +) -> Response: + if not stream: + return JSONResponse( + content=run_service.retrieve(thread_id, run_id).model_dump(), + ) + async def sse_event_stream() -> AsyncGenerator[str, None]: + async for event in run_service.retrieve_stream(thread_id, run_id): + data = ( + event.data.model_dump_json() + if isinstance(event.data, BaseModel) + else event.data + ) + yield f"event: {event.event}\ndata: {data}\n\n" + + return StreamingResponse( + content=sse_event_stream(), + media_type="text/event-stream", + ) -@router.get("/threads/{thread_id}/runs") -def list_runs( - thread_id: Annotated[ThreadId, Path(...)], - query: ListQuery = ListQueryDep, + +@router.get("/runs") +async def list_runs( + query: RunListQuery = RunListQueryDep, thread_facade: ThreadFacade = ThreadFacadeDep, ) -> ListResponse[Run]: - return thread_facade.list_runs(thread_id, query=query) + return thread_facade.list_runs(query=query) @router.post("/threads/{thread_id}/runs/{run_id}/cancel") diff --git a/src/askui/chat/api/runs/runner/events/__init__.py b/src/askui/chat/api/runs/runner/events/__init__.py deleted file mode 100644 index 11475a34..00000000 --- a/src/askui/chat/api/runs/runner/events/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from askui.chat.api.runs.runner.events.done_events import DoneEvent -from askui.chat.api.runs.runner.events.error_events import ErrorEvent -from askui.chat.api.runs.runner.events.event_base import EventBase -from askui.chat.api.runs.runner.events.events import Events -from askui.chat.api.runs.runner.events.message_events import MessageEvent -from askui.chat.api.runs.runner.events.run_events import RunEvent - -__all__ = [ - "DoneEvent", - "ErrorEvent", - "EventBase", - "Events", - "MessageEvent", - "RunEvent", -] diff --git a/src/askui/chat/api/runs/runner/events/events.py b/src/askui/chat/api/runs/runner/events/events.py deleted file mode 100644 index dac1d80f..00000000 --- a/src/askui/chat/api/runs/runner/events/events.py +++ /dev/null @@ -1,6 +0,0 @@ -from askui.chat.api.runs.runner.events.done_events import DoneEvent -from askui.chat.api.runs.runner.events.error_events import ErrorEvent -from askui.chat.api.runs.runner.events.message_events import MessageEvent -from askui.chat.api.runs.runner.events.run_events import RunEvent - -Events = DoneEvent | ErrorEvent | MessageEvent | RunEvent diff --git a/src/askui/chat/api/runs/runner/runner.py b/src/askui/chat/api/runs/runner/runner.py index 5f5b6a61..d4ea97ca 100644 --- a/src/askui/chat/api/runs/runner/runner.py +++ b/src/askui/chat/api/runs/runner/runner.py @@ -7,35 +7,31 @@ from asyncer import asyncify, syncify from askui.chat.api.assistants.models import Assistant -from askui.chat.api.assistants.seeds import ANDROID_AGENT from askui.chat.api.mcp_clients.manager import McpClientManagerManager from askui.chat.api.messages.chat_history_manager import ChatHistoryManager -from askui.chat.api.models import RunId, ThreadId, WorkspaceId -from askui.chat.api.runs.models import Run, RunError -from askui.chat.api.runs.runner.events.done_events import DoneEvent -from askui.chat.api.runs.runner.events.error_events import ( +from askui.chat.api.models import WorkspaceId +from askui.chat.api.runs.events.done_events import DoneEvent +from askui.chat.api.runs.events.error_events import ( ErrorEvent, ErrorEventData, ErrorEventDataError, ) -from askui.chat.api.runs.runner.events.events import Events -from askui.chat.api.runs.runner.events.message_events import MessageEvent -from askui.chat.api.runs.runner.events.run_events import RunEvent +from askui.chat.api.runs.events.events import Event +from askui.chat.api.runs.events.message_events import MessageEvent +from askui.chat.api.runs.events.run_events import RunEvent +from askui.chat.api.runs.events.service import RetrieveRunService +from askui.chat.api.runs.models import Run, RunError from askui.custom_agent import CustomAgent from askui.models.models import ModelName from askui.models.shared.agent_message_param import MessageParam from askui.models.shared.agent_on_message_cb import OnMessageCbParam from askui.models.shared.settings import ActSettings, MessageSettings -from askui.models.shared.tools import Tool, ToolCollection +from askui.models.shared.tools import ToolCollection logger = logging.getLogger(__name__) -class RunnerRunService(ABC): - @abstractmethod - def retrieve(self, thread_id: ThreadId, run_id: RunId) -> Run: - raise NotImplementedError - +class RunnerRunService(RetrieveRunService, ABC): @abstractmethod def save(self, run: Run, new: bool = False) -> None: raise NotImplementedError @@ -103,7 +99,7 @@ def _build_system(self) -> list[BetaTextBlockParam]: async def _run_agent( self, - send_stream: ObjectStream[Events], + send_stream: ObjectStream[Event], ) -> None: async def async_on_message( on_message_cb_param: OnMessageCbParam, @@ -167,7 +163,7 @@ def _run_agent_inner() -> None: async def run( self, - send_stream: ObjectStream[Events], + send_stream: ObjectStream[Event], ) -> None: try: self._mark_run_as_started() diff --git a/src/askui/chat/api/runs/service.py b/src/askui/chat/api/runs/service.py index 58488eb4..31f8bbb2 100644 --- a/src/askui/chat/api/runs/service.py +++ b/src/askui/chat/api/runs/service.py @@ -1,6 +1,7 @@ from collections.abc import AsyncGenerator from datetime import datetime, timezone from pathlib import Path +from typing import Callable import anyio from typing_extensions import override @@ -9,23 +10,27 @@ from askui.chat.api.mcp_clients.manager import McpClientManagerManager from askui.chat.api.messages.chat_history_manager import ChatHistoryManager from askui.chat.api.models import RunId, ThreadId, WorkspaceId -from askui.chat.api.runs.models import Run, RunCreateParams -from askui.chat.api.runs.runner.events.events import ( - DoneEvent, - ErrorEvent, - Events, - RunEvent, -) +from askui.chat.api.runs.events.events import DoneEvent, ErrorEvent, Event, RunEvent +from askui.chat.api.runs.events.service import EventService +from askui.chat.api.runs.models import Run, RunCreateParams, RunListQuery from askui.chat.api.runs.runner.runner import Runner, RunnerRunService from askui.utils.api_utils import ( ConflictError, - ListQuery, ListResponse, NotFoundError, list_resources, ) +def _build_run_filter_fn(query: RunListQuery) -> Callable[[Run], bool]: + def filter_fn(run: Run) -> bool: + return (query.thread is None or run.thread_id == query.thread) and ( + query.status is None or run.status in query.status + ) + + return filter_fn + + class RunService(RunnerRunService): """Service for managing Run resources with filesystem persistence.""" @@ -40,6 +45,7 @@ def __init__( self._assistant_service = assistant_service self._mcp_client_manager_manager = mcp_client_manager_manager self._chat_history_manager = chat_history_manager + self._event_service = EventService(base_dir, self) def get_runs_dir(self, thread_id: ThreadId) -> Path: return self._base_dir / "runs" / thread_id @@ -67,12 +73,12 @@ async def create( workspace_id: WorkspaceId, thread_id: ThreadId, params: RunCreateParams, - ) -> tuple[Run, AsyncGenerator[Events, None]]: + ) -> tuple[Run, AsyncGenerator[Event, None]]: assistant = self._assistant_service.retrieve( workspace_id=workspace_id, assistant_id=params.assistant_id ) run = self._create(thread_id, params) - send_stream, receive_stream = anyio.create_memory_object_stream[Events]() + send_stream, receive_stream = anyio.create_memory_object_stream[Event]() runner = Runner( workspace_id=workspace_id, assistant=assistant, @@ -82,36 +88,44 @@ async def create( run_service=self, ) - async def event_generator() -> AsyncGenerator[Events, None]: + async def event_generator() -> AsyncGenerator[Event, None]: try: - yield RunEvent( - data=run, - event="thread.run.created", - ) - yield RunEvent( - data=run, - event="thread.run.queued", - ) - - async def run_runner() -> None: - try: - await runner.run(send_stream) # type: ignore[arg-type] - finally: - await send_stream.aclose() - - async with anyio.create_task_group() as tg: - tg.start_soon(run_runner) - - while True: + async with self._event_service.create_writer( + thread_id, run.id + ) as event_writer: + run_created_event = RunEvent( + data=run, + event="thread.run.created", + ) + await event_writer.write_event(run_created_event) + yield run_created_event + run_queued_event = RunEvent( + data=run, + event="thread.run.queued", + ) + await event_writer.write_event(run_queued_event) + yield run_queued_event + + async def run_runner() -> None: try: - event = await receive_stream.receive() - yield event - if isinstance(event, DoneEvent) or isinstance( - event, ErrorEvent - ): + await runner.run(send_stream) # type: ignore[arg-type] + finally: + await send_stream.aclose() + + async with anyio.create_task_group() as tg: + tg.start_soon(run_runner) + + while True: + try: + event = await receive_stream.receive() + await event_writer.write_event(event) + yield event + if isinstance(event, DoneEvent) or isinstance( + event, ErrorEvent + ): + break + except anyio.EndOfStream: break - except anyio.EndOfStream: - break finally: await send_stream.aclose() @@ -126,9 +140,27 @@ def retrieve(self, thread_id: ThreadId, run_id: RunId) -> Run: error_msg = f"Run {run_id} not found in thread {thread_id}" raise NotFoundError(error_msg) from e - def list_(self, thread_id: ThreadId, query: ListQuery) -> ListResponse[Run]: - runs_dir = self.get_runs_dir(thread_id) - return list_resources(runs_dir, query, Run) + async def retrieve_stream( + self, thread_id: ThreadId, run_id: RunId + ) -> AsyncGenerator[Event, None]: + async with self._event_service.create_reader(thread_id, run_id) as event_reader: + async for event in event_reader.read_events(): + yield event + + def list_(self, query: RunListQuery) -> ListResponse[Run]: + if query.thread: + runs_dir = self.get_runs_dir(query.thread) + pattern = "*.json" + else: + runs_dir = self._base_dir / "runs" + pattern = "*/*.json" + return list_resources( + runs_dir, + query, + Run, + filter_fn=_build_run_filter_fn(query), + pattern=pattern, + ) def cancel(self, thread_id: ThreadId, run_id: RunId) -> Run: run = self.retrieve(thread_id, run_id) diff --git a/src/askui/chat/api/threads/facade.py b/src/askui/chat/api/threads/facade.py index c258869f..de836dd4 100644 --- a/src/askui/chat/api/threads/facade.py +++ b/src/askui/chat/api/threads/facade.py @@ -3,8 +3,13 @@ from askui.chat.api.messages.models import Message, MessageCreateParams from askui.chat.api.messages.service import MessageService from askui.chat.api.models import ThreadId, WorkspaceId -from askui.chat.api.runs.models import Run, RunCreateParams, ThreadAndRunCreateParams -from askui.chat.api.runs.runner.events.events import Events +from askui.chat.api.runs.events.events import Event +from askui.chat.api.runs.models import ( + Run, + RunCreateParams, + RunListQuery, + ThreadAndRunCreateParams, +) from askui.chat.api.runs.service import RunService from askui.chat.api.threads.service import ThreadService from askui.utils.api_utils import ListQuery, ListResponse @@ -38,7 +43,7 @@ def create_message( async def create_run( self, workspace_id: WorkspaceId, thread_id: ThreadId, params: RunCreateParams - ) -> tuple[Run, AsyncGenerator[Events, None]]: + ) -> tuple[Run, AsyncGenerator[Event, None]]: """Create a run, ensuring the thread exists first.""" self._ensure_thread_exists(thread_id) return await self._run_service.create( @@ -49,7 +54,7 @@ async def create_run( async def create_thread_and_run( self, workspace_id: WorkspaceId, params: ThreadAndRunCreateParams - ) -> tuple[Run, AsyncGenerator[Events, None]]: + ) -> tuple[Run, AsyncGenerator[Event, None]]: """Create a thread and a run, ensuring the thread exists first.""" thread = self._thread_service.create(params.thread) return await self._run_service.create( @@ -65,7 +70,8 @@ def list_messages( self._ensure_thread_exists(thread_id) return self._message_service.list_(thread_id, query) - def list_runs(self, thread_id: ThreadId, query: ListQuery) -> ListResponse[Run]: + def list_runs(self, query: RunListQuery) -> ListResponse[Run]: """List runs, ensuring the thread exists first.""" - self._ensure_thread_exists(thread_id) - return self._run_service.list_(thread_id, query) + if query.thread: + self._ensure_thread_exists(query.thread) + return self._run_service.list_(query) diff --git a/src/askui/models/openrouter/model.py b/src/askui/models/openrouter/model.py index 60db2099..0111719e 100644 --- a/src/askui/models/openrouter/model.py +++ b/src/askui/models/openrouter/model.py @@ -157,7 +157,7 @@ def _predict( response_json = json.loads(model_response) except json.JSONDecodeError: error_msg = f"Expected JSON, but model {self._settings.model} returned: {model_response}" # noqa: E501 - logger.error(error_msg) + logger.exception(error_msg, exc_info=True) raise ValueError(error_msg) from None validated_response = _response_schema.model_validate( diff --git a/src/askui/models/shared/tools.py b/src/askui/models/shared/tools.py index a4bd7e1d..047e8f58 100644 --- a/src/askui/models/shared/tools.py +++ b/src/askui/models/shared/tools.py @@ -63,7 +63,7 @@ def _convert_to_content( case "image": media_type = block.mimeType # type: ignore[union-attr] if media_type not in IMAGE_MEDIA_TYPES_SUPPORTED: - logger.error(f"Unsupported image media type: {media_type}") + logger.warning(f"Unsupported image media type: {media_type}") continue _result.append( ImageBlockParam( @@ -74,7 +74,7 @@ def _convert_to_content( ) ) case _: - logger.error(f"Unsupported block type: {block.type}") + logger.warning(f"Unsupported block type: {block.type}") return _result if isinstance(result, str): @@ -336,7 +336,7 @@ def _get_mcp_tools(self) -> dict[str, McpTool]: list_mcp_tools_sync = syncify(self._list_mcp_tools, raise_sync_error=False) tools_list = list_mcp_tools_sync(self._mcp_client) except Exception as e: # noqa: BLE001 - logger.error(f"Failed to list MCP tools: {e}", exc_info=True) + logger.exception(f"Failed to list MCP tools: {e}", exc_info=True) return {} else: return {tool.name: tool for tool in tools_list} @@ -355,7 +355,9 @@ def _run_regular_tool( except AgentException: raise except Exception as e: # noqa: BLE001 - logger.error(f"Tool {tool_use_block_param.name} failed: {e}", exc_info=True) + logger.warning( + f"Tool {tool_use_block_param.name} failed: {e}", exc_info=True + ) return ToolResultBlockParam( content=f"Tool {tool_use_block_param.name} failed: {e}", is_error=True, @@ -392,7 +394,7 @@ def _run_mcp_tool( tool_use_id=tool_use_block_param.id, ) except Exception as e: # noqa: BLE001 - logger.error( + logger.warning( f"MCP tool {tool_use_block_param.name} failed: {e}", exc_info=True ) return ToolResultBlockParam( diff --git a/src/askui/utils/api_utils.py b/src/askui/utils/api_utils.py index 3f433c25..06cd0700 100644 --- a/src/askui/utils/api_utils.py +++ b/src/askui/utils/api_utils.py @@ -94,10 +94,12 @@ def _build_list_filter_fn(list_query: ListQuery) -> Callable[[Path], bool]: return lambda _: True -def list_resource_paths(base_dir: Path, list_query: ListQuery) -> list[Path]: +def list_resource_paths( + base_dir: Path, list_query: ListQuery, pattern: str = "*.json" +) -> list[Path]: paths: list[Path] = [] filter_fn = _build_list_filter_fn(list_query) - for f in base_dir.glob("*.json"): + for f in base_dir.glob(pattern): try: if filter_fn(f): paths.append(f) @@ -118,6 +120,7 @@ def list_resources( query: ListQuery, resource_type: Type[ResourceT], filter_fn: Callable[[ResourceT], bool] | None = None, + pattern: str = "*.json", ) -> ListResponse[ResourceT]: """ List resources from a directory. @@ -132,7 +135,7 @@ def list_resources( Returns: A list of resources. """ - resource_paths = list_resource_paths(base_dir, query) + resource_paths = list_resource_paths(base_dir, query, pattern) resources: list[ResourceT] = [] for resource_file in resource_paths: try: