From f531bd223b10a02b62570e5e915562acfc076fee Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 25 Apr 2025 12:22:48 -0300 Subject: [PATCH 01/40] basic tools for workspaces, projects, teams, and tasks --- toolkits/asana/.pre-commit-config.yaml | 18 +++ toolkits/asana/.ruff.toml | 46 +++++++ toolkits/asana/LICENSE | 21 ++++ toolkits/asana/Makefile | 53 ++++++++ toolkits/asana/arcade_asana/__init__.py | 0 toolkits/asana/arcade_asana/constants.py | 3 + toolkits/asana/arcade_asana/exceptions.py | 9 ++ toolkits/asana/arcade_asana/models.py | 118 ++++++++++++++++++ toolkits/asana/arcade_asana/tools/__init__.py | 5 + toolkits/asana/arcade_asana/tools/projects.py | 57 +++++++++ toolkits/asana/arcade_asana/tools/tasks.py | 67 ++++++++++ toolkits/asana/arcade_asana/tools/teams.py | 30 +++++ .../asana/arcade_asana/tools/workspaces.py | 25 ++++ toolkits/asana/arcade_asana/utils.py | 16 +++ toolkits/asana/conftest.py | 16 +++ toolkits/asana/pyproject.toml | 42 +++++++ 16 files changed, 526 insertions(+) create mode 100644 toolkits/asana/.pre-commit-config.yaml create mode 100644 toolkits/asana/.ruff.toml create mode 100644 toolkits/asana/LICENSE create mode 100644 toolkits/asana/Makefile create mode 100644 toolkits/asana/arcade_asana/__init__.py create mode 100644 toolkits/asana/arcade_asana/constants.py create mode 100644 toolkits/asana/arcade_asana/exceptions.py create mode 100644 toolkits/asana/arcade_asana/models.py create mode 100644 toolkits/asana/arcade_asana/tools/__init__.py create mode 100644 toolkits/asana/arcade_asana/tools/projects.py create mode 100644 toolkits/asana/arcade_asana/tools/tasks.py create mode 100644 toolkits/asana/arcade_asana/tools/teams.py create mode 100644 toolkits/asana/arcade_asana/tools/workspaces.py create mode 100644 toolkits/asana/arcade_asana/utils.py create mode 100644 toolkits/asana/conftest.py create mode 100644 toolkits/asana/pyproject.toml diff --git a/toolkits/asana/.pre-commit-config.yaml b/toolkits/asana/.pre-commit-config.yaml new file mode 100644 index 000000000..3953e996e --- /dev/null +++ b/toolkits/asana/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v4.4.0" + hooks: + - id: check-case-conflict + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.7 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/toolkits/asana/.ruff.toml b/toolkits/asana/.ruff.toml new file mode 100644 index 000000000..bacd9161a --- /dev/null +++ b/toolkits/asana/.ruff.toml @@ -0,0 +1,46 @@ +target-version = "py39" +line-length = 100 +fix = true + +[lint] +select = [ + # flake8-2020 + "YTT", + # flake8-bandit + "S", + # flake8-bugbear + "B", + # flake8-builtins + "A", + # flake8-comprehensions + "C4", + # flake8-debugger + "T10", + # flake8-simplify + "SIM", + # isort + "I", + # mccabe + "C90", + # pycodestyle + "E", "W", + # pyflakes + "F", + # pygrep-hooks + "PGH", + # pyupgrade + "UP", + # ruff + "RUF", + # tryceratops + "TRY", +] + +[lint.per-file-ignores] +"*" = ["TRY003", "B904"] +"**/tests/*" = ["S101", "E501"] +"**/evals/*" = ["S101", "E501"] + +[format] +preview = true +skip-magic-trailing-comma = false diff --git a/toolkits/asana/LICENSE b/toolkits/asana/LICENSE new file mode 100644 index 000000000..45f53e202 --- /dev/null +++ b/toolkits/asana/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Arcade + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/toolkits/asana/Makefile b/toolkits/asana/Makefile new file mode 100644 index 000000000..8ca4a8041 --- /dev/null +++ b/toolkits/asana/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "🛠️ dropbox Commands:\n" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.PHONY: install +install: ## Install the poetry environment and install the pre-commit hooks + @echo "📦 Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "📦 Installing Poetry with pip"; \ + pip install poetry==1.8.5; \ + else \ + echo "📦 Poetry is already installed"; \ + fi + @echo "🚀 Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "🚀 Creating wheel file" + poetry build + +.PHONY: clean-build +clean-build: ## clean build artifacts + @echo "🗑️ Cleaning dist directory" + rm -rf dist + +.PHONY: test +test: ## Test the code with pytest + @echo "🚀 Testing code: Running pytest" + @poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml + +.PHONY: coverage +coverage: ## Generate coverage report + @echo "coverage report" + coverage report + @echo "Generating coverage report" + coverage html + +.PHONY: bump-version +bump-version: ## Bump the version in the pyproject.toml file + @echo "🚀 Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "🚀 Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "🚀 Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/asana/arcade_asana/__init__.py b/toolkits/asana/arcade_asana/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/toolkits/asana/arcade_asana/constants.py b/toolkits/asana/arcade_asana/constants.py new file mode 100644 index 000000000..e57d1e088 --- /dev/null +++ b/toolkits/asana/arcade_asana/constants.py @@ -0,0 +1,3 @@ +ASANA_BASE_URL = "https://app.asana.com/api" +ASANA_API_VERSION = "1.0" +ASANA_MAX_CONCURRENT_REQUESTS = 3 diff --git a/toolkits/asana/arcade_asana/exceptions.py b/toolkits/asana/arcade_asana/exceptions.py new file mode 100644 index 000000000..b218bf87f --- /dev/null +++ b/toolkits/asana/arcade_asana/exceptions.py @@ -0,0 +1,9 @@ +from arcade.sdk.exceptions import ToolExecutionError + + +class AsanaToolExecutionError(ToolExecutionError): + pass + + +class AsanaNotFoundError(AsanaToolExecutionError): + pass diff --git a/toolkits/asana/arcade_asana/models.py b/toolkits/asana/arcade_asana/models.py new file mode 100644 index 000000000..bf56dbec9 --- /dev/null +++ b/toolkits/asana/arcade_asana/models.py @@ -0,0 +1,118 @@ +import asyncio +import json +from dataclasses import dataclass +from typing import Optional, cast + +import httpx + +from arcade_asana.constants import ASANA_API_VERSION, ASANA_BASE_URL, ASANA_MAX_CONCURRENT_REQUESTS +from arcade_asana.exceptions import AsanaToolExecutionError + + +@dataclass +class AsanaClient: + auth_token: str + base_url: str = ASANA_BASE_URL + api_version: str = ASANA_API_VERSION + max_concurrent_requests: int = ASANA_MAX_CONCURRENT_REQUESTS + _semaphore: asyncio.Semaphore | None = None + + def __post_init__(self) -> None: + self._semaphore = self._semaphore or asyncio.Semaphore(self.max_concurrent_requests) + + def _build_url(self, endpoint: str, api_version: str | None = None) -> str: + api_version = api_version or self.api_version + return f"{self.base_url.rstrip('/')}/{api_version.strip('/')}/{endpoint.lstrip('/')}" + + def _build_error_messages(self, response: httpx.Response) -> tuple[str, str]: + try: + data = response.json() + errors = data["errors"] + + if len(errors) == 1: + error_message = errors[0]["message"] + developer_message = ( + f"{errors[0]['message']} | {errors[0]['help']} " + f"(HTTP status code: {response.status_code})" + ) + else: + errors_concat = "', '".join([error["message"] for error in errors]) + error_message = f"Multiple errors occurred: '{errors_concat}'" + developer_message = ( + f"Multiple errors occurred: {json.dumps(errors)} " + f"(HTTP status code: {response.status_code})" + ) + + except Exception as e: + error_message = "Failed to parse Asana error response" + developer_message = f"Failed to parse Asana error response: {type(e).__name__}: {e!s}" + + return error_message, developer_message + + def _raise_for_status(self, response: httpx.Response) -> None: + if response.status_code < 300: + return + + error_message, developer_message = self._build_error_messages(response) + + raise AsanaToolExecutionError(error_message, developer_message) + + async def get( + self, + endpoint: str, + params: Optional[dict] = None, + headers: Optional[dict] = None, + api_version: str | None = None, + ) -> dict: + headers = headers or {} + headers["Authorization"] = f"Bearer {self.auth_token}" + headers["Accept"] = "application/json" + + kwargs = { + "url": self._build_url(endpoint, api_version), + "headers": headers, + } + + if params: + kwargs["params"] = params + + async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] + response = await client.get(**kwargs) # type: ignore[arg-type] + self._raise_for_status(response) + return cast(dict, response.json()) + + async def post( + self, + endpoint: str, + data: Optional[dict] = None, + json_data: Optional[dict] = None, + headers: Optional[dict] = None, + api_version: str | None = None, + ) -> dict: + headers = headers or {} + headers["Authorization"] = f"Bearer {self.auth_token}" + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + + kwargs = { + "url": self._build_url(endpoint, api_version), + "headers": headers, + } + + if data and json_data: + raise ValueError("Cannot provide both data and json_data") + + if data: + kwargs["data"] = data + + elif json_data: + kwargs["json"] = json_data + + async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] + response = await client.post(**kwargs) # type: ignore[arg-type] + self._raise_for_status(response) + return cast(dict, response.json()) + + async def get_current_user(self) -> dict: + response = await self.get("/users/me") + return response["data"] diff --git a/toolkits/asana/arcade_asana/tools/__init__.py b/toolkits/asana/arcade_asana/tools/__init__.py new file mode 100644 index 000000000..cc45df477 --- /dev/null +++ b/toolkits/asana/arcade_asana/tools/__init__.py @@ -0,0 +1,5 @@ +from arcade_asana.tools.tasks import create_task + +__all__ = [ + "create_task", +] diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py new file mode 100644 index 000000000..cf200039e --- /dev/null +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -0,0 +1,57 @@ +import asyncio +from typing import Annotated, Any + +from arcade.sdk import ToolContext, tool +from arcade.sdk.auth import OAuth2 + +from arcade_asana.models import AsanaClient +from arcade_asana.tools.teams import list_teams_the_current_user_is_a_member_of + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["projects:read"])) +async def list_projects( + context: ToolContext, + limit: Annotated[ + int, "The maximum number of projects to return. Min is 1, max is 100. Defaults to 100." + ] = 100, + offset: Annotated[int, "The offset of projects to return. Defaults to 0"] = 0, +) -> Annotated[ + dict[str, Any], + "List projects in Asana associated to teams the current user is a member of", +]: + """List projects in Asana associated to teams the current user is a member of""" + # Note: Asana recommends filtering by team to avoid timeout in large domains. + # Ref: https://developers.asana.com/reference/getprojects + user_teams = await list_teams_the_current_user_is_a_member_of(context) + + client = AsanaClient(context.get_auth_token_or_empty()) + + responses = await asyncio.gather(*[ + client.get( + "/projects", + params={ + "limit": limit, + "offset": offset, + "team": team["gid"], + "opt_fields": [ + "gid", + "name", + "current_status_update", + "due_date", + "start_on", + "notes", + "members", + "completed", + "completed_at", + "completed_by", + "owner", + "team", + "workspace", + "permalink_url", + ], + }, + ) + for team in user_teams["teams"] + ]) + + return {"projects": [project for response in responses for project in response["data"]]} diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py new file mode 100644 index 000000000..91cb1f7af --- /dev/null +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -0,0 +1,67 @@ +from datetime import datetime +from typing import Annotated, Any + +from arcade.sdk import ToolContext, tool +from arcade.sdk.auth import OAuth2 +from arcade.sdk.errors import ToolExecutionError + +from arcade_asana.models import AsanaClient +from arcade_asana.utils import remove_none_values + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:write"])) +async def create_task( + context: ToolContext, + name: Annotated[str, "The name of the task"], + start_date: Annotated[ + str | None, "The start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'." + ] = None, + due_date: Annotated[ + str | None, "The due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'." + ] = None, + description: Annotated[str | None, "The description of the task."] = None, + parent_task_id: Annotated[str | None, "The ID of the parent task."] = None, + workspace_id: Annotated[str | None, "The ID of the workspace to associate the task to."] = None, + project_ids: Annotated[ + list[str] | None, "The IDs of the projects to associate the task to." + ] = None, + assignee_id: Annotated[ + str | None, + "The ID of the user to assign the task to. " + "Provide 'me' to assign the task to the current user.", + ] = None, + tags: Annotated[list[str] | None, "The tags to associate with the task."] = None, +) -> Annotated[ + dict[str, Any], + "Creates a task in Asana", +]: + """Creates a task in Asana""" + client = AsanaClient(context.get_auth_token_or_empty()) + + try: + datetime.strptime(due_date, "%Y-%m-%d") + datetime.strptime(start_date, "%Y-%m-%d") + except ValueError: + raise ToolExecutionError( + "Invalid date format. Use the format YYYY-MM-DD for both start_date and due_date." + ) + + if assignee_id == "me": + assignee_id = await client.get_current_user()["gid"] + + task_data = { + "name": name, + "due_date": due_date, + "notes": description, + "parent": parent_task_id, + "projects": project_ids, + "workspace": workspace_id, + "assignee": assignee_id, + "tags": tags, + } + + response = await client.post("/tasks", json_data=remove_none_values(task_data)) + return { + "status": {"success": True, "message": "task created successfully"}, + "task": response["data"], + } diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py new file mode 100644 index 000000000..ac4835ccc --- /dev/null +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -0,0 +1,30 @@ +from typing import Annotated, Any + +from arcade.sdk import ToolContext, tool +from arcade.sdk.auth import OAuth2 + +from arcade_asana.models import AsanaClient + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["teams:read"])) +async def list_teams_the_current_user_is_a_member_of( + context: ToolContext, + limit: Annotated[ + int, "The maximum number of teams to return. Min is 1, max is 100. Defaults to 100." + ] = 100, + offset: Annotated[int, "The offset of teams to return. Defaults to 0"] = 0, +) -> Annotated[ + dict[str, Any], + "List teams in Asana that the current user is a member of", +]: + """List teams in Asana that the current user is a member of""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + "/users/me/teams", + params={ + "limit": limit, + "offset": offset, + "opt_fields": ["gid", "name", "description", "organization", "permalink_url"], + }, + ) + return {"teams": response["data"]} diff --git a/toolkits/asana/arcade_asana/tools/workspaces.py b/toolkits/asana/arcade_asana/tools/workspaces.py new file mode 100644 index 000000000..ac00021e7 --- /dev/null +++ b/toolkits/asana/arcade_asana/tools/workspaces.py @@ -0,0 +1,25 @@ +from typing import Annotated, Any + +from arcade.sdk import ToolContext, tool +from arcade.sdk.auth import OAuth2 + +from arcade_asana.models import AsanaClient + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["workspaces:read"])) +async def list_workspaces( + context: ToolContext, + limit: Annotated[ + int, "The maximum number of workspaces to return. Min is 1, max is 100. Defaults to 100." + ] = 100, + offset: Annotated[ + int, "The offset of workspaces to return. Defaults to 0 (first page of results)" + ] = 0, +) -> Annotated[ + dict[str, Any], + "List workspaces in Asana that are visible to the authenticated user", +]: + """List workspaces in Asana that are visible to the authenticated user""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get("/workspaces", params={"limit": limit, "offset": offset}) + return {"workspaces": response["data"]} diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py new file mode 100644 index 000000000..669709db0 --- /dev/null +++ b/toolkits/asana/arcade_asana/utils.py @@ -0,0 +1,16 @@ +def remove_none_values(data: dict) -> dict: + return {k: v for k, v in data.items() if v is not None} + + +def inject_pagination_in_tool_response(response: dict, page: int) -> dict: + if "next_page" not in response: + return response + + response["pagination"] = { + "current_page": page, + "next_page": page + 1 if "next_page" in response else None, + } + + del response["next_page"] + + return response diff --git a/toolkits/asana/conftest.py b/toolkits/asana/conftest.py new file mode 100644 index 000000000..6e522ac69 --- /dev/null +++ b/toolkits/asana/conftest.py @@ -0,0 +1,16 @@ +from unittest.mock import patch + +import pytest +from arcade.sdk import ToolAuthorizationContext, ToolContext + + +@pytest.fixture +def mock_context(): + mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 + return ToolContext(authorization=mock_auth) + + +@pytest.fixture +def mock_httpx_client(): + with patch("arcade_asana.models.httpx") as mock_httpx: + yield mock_httpx.AsyncClient().__aenter__.return_value diff --git a/toolkits/asana/pyproject.toml b/toolkits/asana/pyproject.toml new file mode 100644 index 000000000..6bd0d7ef3 --- /dev/null +++ b/toolkits/asana/pyproject.toml @@ -0,0 +1,42 @@ +[tool.poetry] +name = "arcade_asana" +version = "0.1.0" +description = "Arcade tools designed for LLMs to interact with Asana" +authors = ["Arcade "] + +[tool.poetry.dependencies] +python = "^3.10" +arcade-ai = ">=1.3.2,<2.0" +httpx = "^0.27.2" + +[tool.poetry.dev-dependencies] +pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.mypy] +files = ["arcade_hubspot/**/*.py"] +python_version = "3.10" +disallow_untyped_defs = "True" +disallow_any_unimported = "True" +no_implicit_optional = "True" +check_untyped_defs = "True" +warn_return_any = "True" +warn_unused_ignores = "True" +show_error_codes = "True" +ignore_missing_imports = "True" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.coverage.report] +skip_empty = true From b2c932fb5c5c70a60e6caf6c1407d51664ef7347 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 25 Apr 2025 15:59:27 -0300 Subject: [PATCH 02/40] more task tools --- toolkits/asana/arcade_asana/constants.py | 30 +++++ toolkits/asana/arcade_asana/exceptions.py | 6 +- toolkits/asana/arcade_asana/models.py | 44 +++++-- toolkits/asana/arcade_asana/tools/tasks.py | 132 ++++++++++++++++++++- toolkits/asana/arcade_asana/utils.py | 75 ++++++++++-- 5 files changed, 265 insertions(+), 22 deletions(-) diff --git a/toolkits/asana/arcade_asana/constants.py b/toolkits/asana/arcade_asana/constants.py index e57d1e088..e296484fc 100644 --- a/toolkits/asana/arcade_asana/constants.py +++ b/toolkits/asana/arcade_asana/constants.py @@ -1,3 +1,33 @@ ASANA_BASE_URL = "https://app.asana.com/api" ASANA_API_VERSION = "1.0" ASANA_MAX_CONCURRENT_REQUESTS = 3 + +TASK_OPT_FIELDS = [ + "gid", + "name", + "notes", + "completed", + "completed_at", + "completed_by", + "created_at", + "created_by", + "due_on", + "start_on", + "owner", + "team", + "workspace", + "permalink_url", + "approval_status", + "assignee", + "assignee_status", + "dependencies", + "dependents", + "memberships", + "num_subtasks", + "resource_type", + "custom_type", + "custom_type_status_option", + "parent", + "tags", + "workspace", +] diff --git a/toolkits/asana/arcade_asana/exceptions.py b/toolkits/asana/arcade_asana/exceptions.py index b218bf87f..3d1f774fb 100644 --- a/toolkits/asana/arcade_asana/exceptions.py +++ b/toolkits/asana/arcade_asana/exceptions.py @@ -1,9 +1,5 @@ -from arcade.sdk.exceptions import ToolExecutionError +from arcade.sdk.errors import ToolExecutionError class AsanaToolExecutionError(ToolExecutionError): pass - - -class AsanaNotFoundError(AsanaToolExecutionError): - pass diff --git a/toolkits/asana/arcade_asana/models.py b/toolkits/asana/arcade_asana/models.py index bf56dbec9..89881d671 100644 --- a/toolkits/asana/arcade_asana/models.py +++ b/toolkits/asana/arcade_asana/models.py @@ -57,6 +57,18 @@ def _raise_for_status(self, response: httpx.Response) -> None: raise AsanaToolExecutionError(error_message, developer_message) + def _set_request_body(self, kwargs: dict, data: dict | None, json_data: dict | None) -> dict: + if data and json_data: + raise ValueError("Cannot provide both data and json_data") + + if data: + kwargs["data"] = data + + elif json_data: + kwargs["json"] = json_data + + return kwargs + async def get( self, endpoint: str, @@ -99,17 +111,35 @@ async def post( "headers": headers, } - if data and json_data: - raise ValueError("Cannot provide both data and json_data") + kwargs = self._set_request_body(kwargs, data, json_data) - if data: - kwargs["data"] = data + async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] + response = await client.post(**kwargs) # type: ignore[arg-type] + self._raise_for_status(response) + return cast(dict, response.json()) - elif json_data: - kwargs["json"] = json_data + async def put( + self, + endpoint: str, + data: Optional[dict] = None, + json_data: Optional[dict] = None, + headers: Optional[dict] = None, + api_version: str | None = None, + ) -> dict: + headers = headers or {} + headers["Authorization"] = f"Bearer {self.auth_token}" + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + + kwargs = { + "url": self._build_url(endpoint, api_version), + "headers": headers, + } + + kwargs = self._set_request_body(kwargs, data, json_data) async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.post(**kwargs) # type: ignore[arg-type] + response = await client.put(**kwargs) # type: ignore[arg-type] self._raise_for_status(response) return cast(dict, response.json()) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 91cb1f7af..d485f1225 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -5,8 +5,138 @@ from arcade.sdk.auth import OAuth2 from arcade.sdk.errors import ToolExecutionError +from arcade_asana.constants import TASK_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.utils import remove_none_values +from arcade_asana.utils import build_task_search_query_params, remove_none_values + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:read"])) +async def get_task_by_id( + context: ToolContext, + task_id: Annotated[str, "The ID of the task to get."], +) -> Annotated[dict[str, Any], "The task with the given ID."]: + """Get a task by its ID""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + f"/tasks/{task_id}", + params={"opt_fields": TASK_OPT_FIELDS}, + ) + return {"task": response["data"]} + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:read"])) +async def search_tasks( + context: ToolContext, + keywords: Annotated[ + str, "Keywords to search for tasks. Matches against the task name and description." + ], + assignee_ids: Annotated[ + list[str] | None, "IDs of the users to search for tasks assigned to." + ] = None, + project_ids: Annotated[list[str] | None, "IDs of the projects to search for tasks in."] = None, + team_ids: Annotated[list[str] | None, "IDs of the teams to search for tasks in."] = None, + tags: Annotated[list[str] | None, "Tags to search for tasks with."] = None, + due_on: Annotated[ + str | None, + "Match tasks that are due on this date. Format: YYYY-MM-DD. Example: '2025-01-01'.", + ] = None, + due_on_or_after: Annotated[ + str | None, + "Match tasks that are due on or after this date. " + "Format: YYYY-MM-DD. Example: '2025-01-01'.", + ] = None, + due_on_or_before: Annotated[ + str | None, + "Match tasks that are due on or before this date. " + "Format: YYYY-MM-DD. Example: '2025-01-01'.", + ] = None, + start_on: Annotated[ + str | None, + "Match tasks that are started on this date. Format: YYYY-MM-DD. Example: '2025-01-01'.", + ] = None, + start_on_or_after: Annotated[ + str | None, + "Match tasks that are started on or after this date. " + "Format: YYYY-MM-DD. Example: '2025-01-01'.", + ] = None, + start_on_or_before: Annotated[ + str | None, + "Match tasks that are started on or before this date. " + "Format: YYYY-MM-DD. Example: '2025-01-01'.", + ] = None, + completed: Annotated[ + bool, + "Match tasks that are completed. Defaults to False (tasks that are NOT completed).", + ] = False, +) -> Annotated[dict[str, Any], "The tasks that match the query."]: + """Search for tasks""" + client = AsanaClient(context.get_auth_token_or_empty()) + query_params = build_task_search_query_params( + keywords, + completed, + assignee_ids, + project_ids, + team_ids, + tags, + due_on, + due_on_or_after, + due_on_or_before, + start_on, + start_on_or_after, + start_on_or_before, + ) + + response = await client.get("/tasks", params=query_params) + return {"tasks": response["data"]} + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:write"])) +async def update_task( + context: ToolContext, + task_id: Annotated[str, "The ID of the task to update."], + name: Annotated[str | None, "The new name of the task"] = None, + start_date: Annotated[ + str | None, + "The new start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'.", + ] = None, + due_date: Annotated[ + str | None, + "The new due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'.", + ] = None, + description: Annotated[str | None, "The new description of the task."] = None, + parent_task_id: Annotated[str | None, "The ID of the new parent task."] = None, + project_ids: Annotated[ + list[str] | None, "The IDs of the new projects to associate the task to." + ] = None, + assignee_id: Annotated[ + str | None, + "The ID of the new user to assign the task to. " + "Provide 'me' to assign the task to the current user.", + ] = None, + tags: Annotated[list[str] | None, "The new tags to associate with the task."] = None, +) -> Annotated[ + dict[str, Any], + "Updates a task in Asana", +]: + """Updates a task in Asana""" + client = AsanaClient(context.get_auth_token_or_empty()) + + task_data = { + "name": name, + "due_on": due_date, + "start_on": start_date, + "notes": description, + "parent": parent_task_id, + "projects": project_ids, + "assignee": assignee_id, + "tags": tags, + } + + response = await client.put(f"/tasks/{task_id}", json_data=remove_none_values(task_data)) + return { + "status": {"success": True, "message": "task updated successfully"}, + "task": response["data"], + } @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:write"])) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index 669709db0..a3686f36a 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -1,16 +1,73 @@ +from typing import Any + +from arcade_asana.constants import TASK_OPT_FIELDS + + def remove_none_values(data: dict) -> dict: return {k: v for k, v in data.items() if v is not None} -def inject_pagination_in_tool_response(response: dict, page: int) -> dict: - if "next_page" not in response: - return response - - response["pagination"] = { - "current_page": page, - "next_page": page + 1 if "next_page" in response else None, +def build_task_search_query_params( + keywords: str, + completed: bool, + assignee_ids: list[str] | None, + project_ids: list[str] | None, + team_ids: list[str] | None, + tags: list[str] | None, + due_on: str | None, + due_on_or_after: str | None, + due_on_or_before: str | None, + start_on: str | None, + start_on_or_after: str | None, + start_on_or_before: str | None, +) -> dict[str, Any]: + query_params = { + "text": keywords, + "opt_fields": TASK_OPT_FIELDS, + "completed": completed, } + if assignee_ids: + query_params["assignee.any"] = ",".join(assignee_ids) + if project_ids: + query_params["projects.any"] = ",".join(project_ids) + if team_ids: + query_params["team.any"] = ",".join(team_ids) + if tags: + query_params["tags.any"] = ",".join(tags) + + query_params = build_task_search_date_params( + query_params, + due_on, + due_on_or_after, + due_on_or_before, + start_on, + start_on_or_after, + start_on_or_before, + ) + + return query_params + - del response["next_page"] +def build_task_search_date_params( + query_params: dict, + due_on: str, + due_on_or_after: str, + due_on_or_before: str, + start_on: str, + start_on_or_after: str, + start_on_or_before: str, +) -> dict: + if due_on: + query_params["due_on"] = due_on + if due_on_or_after: + query_params["due_on.after"] = due_on_or_after + if due_on_or_before: + query_params["due_on.before"] = due_on_or_before + if start_on: + query_params["start_on"] = start_on + if start_on_or_after: + query_params["start_on.after"] = start_on_or_after + if start_on_or_before: + query_params["start_on.before"] = start_on_or_before - return response + return query_params From 40e4624d341238291b9bec81053d5587edbb678d Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 2 May 2025 12:39:32 -0300 Subject: [PATCH 03/40] add several tools; refactor others; make tools more ergonomic --- toolkits/asana/arcade_asana/constants.py | 79 ++++++- toolkits/asana/arcade_asana/exceptions.py | 9 + toolkits/asana/arcade_asana/tools/projects.py | 136 ++++++++++-- toolkits/asana/arcade_asana/tools/tags.py | 160 ++++++++++++++ toolkits/asana/arcade_asana/tools/tasks.py | 147 +++++++++---- toolkits/asana/arcade_asana/tools/teams.py | 64 +++++- .../asana/arcade_asana/tools/workspaces.py | 21 +- toolkits/asana/arcade_asana/utils.py | 199 ++++++++++++++++-- toolkits/asana/evals/eval_tasks.py | 0 9 files changed, 720 insertions(+), 95 deletions(-) create mode 100644 toolkits/asana/arcade_asana/tools/tags.py create mode 100644 toolkits/asana/evals/eval_tasks.py diff --git a/toolkits/asana/arcade_asana/constants.py b/toolkits/asana/arcade_asana/constants.py index e296484fc..0cfd46019 100644 --- a/toolkits/asana/arcade_asana/constants.py +++ b/toolkits/asana/arcade_asana/constants.py @@ -1,6 +1,33 @@ +import os +from enum import Enum + ASANA_BASE_URL = "https://app.asana.com/api" ASANA_API_VERSION = "1.0" -ASANA_MAX_CONCURRENT_REQUESTS = 3 + +try: + ASANA_MAX_CONCURRENT_REQUESTS = int(os.getenv("ASANA_MAX_CONCURRENT_REQUESTS", 3)) +except ValueError: + ASANA_MAX_CONCURRENT_REQUESTS = 3 + +PROJECT_OPT_FIELDS = [ + "gid", + "resource_type", + "name", + "workspace", + "color", + "created_at", + "current_status_update", + "due_on", + "members", + "notes", + "completed", + "completed_at", + "completed_by", + "owner", + "team", + "workspace", + "permalink_url", +] TASK_OPT_FIELDS = [ "gid", @@ -31,3 +58,53 @@ "tags", "workspace", ] + + +TAG_OPT_FIELDS = [ + "gid", + "name", + "workspace", +] + +TEAM_OPT_FIELDS = [ + "gid", + "name", + "description", + "organization", + "permalink_url", +] + + +WORKSPACE_OPT_FIELDS = [ + "gid", + "resource_type", + "name", + "email_domains", + "is_organization", +] + + +class TagColor(Enum): + DARK_GREEN = "dark-green" + DARK_RED = "dark-red" + DARK_BLUE = "dark-blue" + DARK_PURPLE = "dark-purple" + DARK_PINK = "dark-pink" + DARK_ORANGE = "dark-orange" + DARK_TEAL = "dark-teal" + DARK_BROWN = "dark-brown" + DARK_WARM_GRAY = "dark-warm-gray" + LIGHT_GREEN = "light-green" + LIGHT_RED = "light-red" + LIGHT_BLUE = "light-blue" + LIGHT_PURPLE = "light-purple" + LIGHT_PINK = "light-pink" + LIGHT_ORANGE = "light-orange" + LIGHT_TEAL = "light-teal" + LIGHT_BROWN = "light-brown" + LIGHT_WARM_GRAY = "light-warm-gray" + + +class ReturnType(Enum): + FULL_ITEMS_DATA = "full_items_data" + ITEMS_COUNT = "items_count" diff --git a/toolkits/asana/arcade_asana/exceptions.py b/toolkits/asana/arcade_asana/exceptions.py index 3d1f774fb..68d2cd0b8 100644 --- a/toolkits/asana/arcade_asana/exceptions.py +++ b/toolkits/asana/arcade_asana/exceptions.py @@ -3,3 +3,12 @@ class AsanaToolExecutionError(ToolExecutionError): pass + + +class PaginationTimeoutError(AsanaToolExecutionError): + def __init__(self, timeout_seconds: int, tool_name: str): + message = f"Pagination timed out after {timeout_seconds} seconds" + super().__init__( + message=message, + developer_message=f"{message} while calling the tool {tool_name}", + ) diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index cf200039e..90a460b03 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -4,13 +4,99 @@ from arcade.sdk import ToolContext, tool from arcade.sdk.auth import OAuth2 +from arcade_asana.constants import PROJECT_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.tools.teams import list_teams_the_current_user_is_a_member_of +from arcade_asana.utils import clean_request_params, paginate_tool_call -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["projects:read"])) +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def get_project_by_id( + context: ToolContext, + project_id: Annotated[str, "The ID of the project."], +) -> Annotated[ + dict[str, Any], + "Get a project by its ID", +]: + """Get an Asana project by its ID""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + f"/projects/{project_id}", + params={"opt_fields": PROJECT_OPT_FIELDS}, + ) + return {"project": response["data"]} + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def search_projects_by_name( + context: ToolContext, + names: Annotated[list[str], "The names of the projects to search for."], + team_ids: Annotated[ + list[str] | None, + "The IDs of the teams to get projects from. " + "Defaults to None (get projects from all teams the user is a member of).", + ] = None, + limit: Annotated[ + int, "The maximum number of projects to return. Min is 1, max is 100. Defaults to 100." + ] = 100, + return_projects_not_matched: Annotated[ + bool, "Whether to return projects that were not matched. Defaults to False." + ] = False, +) -> Annotated[dict[str, Any], "Search for projects by name"]: + """Search for projects by name""" + names_lower = {name.casefold() for name in names} + + projects = await paginate_tool_call( + tool=list_projects, + context=context, + response_key="projects", + max_items=500, + timeout_seconds=15, + team_ids=team_ids, + ) + + matches: list[dict[str, Any]] = [] + not_matched: list[str] = [] + + for project in projects: + project_name_lower = project["name"].casefold() + if len(matches) >= limit: + break + if project_name_lower in names_lower: + matches.append(project) + names_lower.remove(project_name_lower) + else: + not_matched.append(project["name"]) + + not_found = [name for name in names if name.casefold() in names_lower] + + response = { + "matches": { + "projects": matches, + "count": len(matches), + }, + "not_found": { + "names": not_found, + "count": len(not_found), + }, + } + + if return_projects_not_matched: + response["not_matched"] = { + "projects": not_matched, + "count": len(not_matched), + } + + return response + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def list_projects( context: ToolContext, + team_ids: Annotated[ + list[str] | None, + "The IDs of the teams to get projects from. " + "Defaults to None (get projects from all teams the user is a member of).", + ] = None, limit: Annotated[ int, "The maximum number of projects to return. Min is 1, max is 100. Defaults to 100." ] = 100, @@ -19,39 +105,43 @@ async def list_projects( dict[str, Any], "List projects in Asana associated to teams the current user is a member of", ]: - """List projects in Asana associated to teams the current user is a member of""" + """List projects in Asana""" # Note: Asana recommends filtering by team to avoid timeout in large domains. # Ref: https://developers.asana.com/reference/getprojects - user_teams = await list_teams_the_current_user_is_a_member_of(context) client = AsanaClient(context.get_auth_token_or_empty()) + if team_ids: + from arcade_asana.tools.teams import get_team_by_id # avoid circular imports + + responses = await asyncio.gather(*[ + get_team_by_id(context, team_id) for team_id in team_ids + ]) + user_teams = {"teams": [response["data"] for response in responses]} + + else: + # Avoid circular imports + from arcade_asana.tools.teams import list_teams_the_current_user_is_a_member_of + + user_teams = await list_teams_the_current_user_is_a_member_of(context) + responses = await asyncio.gather(*[ client.get( "/projects", - params={ + params=clean_request_params({ "limit": limit, "offset": offset, "team": team["gid"], - "opt_fields": [ - "gid", - "name", - "current_status_update", - "due_date", - "start_on", - "notes", - "members", - "completed", - "completed_at", - "completed_by", - "owner", - "team", - "workspace", - "permalink_url", - ], - }, + "workspace": team["organization"]["gid"], + "opt_fields": PROJECT_OPT_FIELDS, + }), ) for team in user_teams["teams"] ]) - return {"projects": [project for response in responses for project in response["data"]]} + projects = [project for response in responses for project in response["data"]] + + return { + "projects": projects, + "count": len(projects), + } diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py new file mode 100644 index 000000000..1793c832b --- /dev/null +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -0,0 +1,160 @@ +import asyncio +from typing import Annotated, Any + +from arcade.sdk import ToolContext, tool +from arcade.sdk.auth import OAuth2 +from arcade.sdk.errors import ToolExecutionError + +from arcade_asana.constants import TAG_OPT_FIELDS, TagColor +from arcade_asana.models import AsanaClient +from arcade_asana.utils import ( + clean_request_params, + get_unique_workspace_id_or_raise_error, + paginate_tool_call, + remove_none_values, +) + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def create_tag( + context: ToolContext, + name: Annotated[str, "The name of the tag to create. Length must be between 1 and 100."], + description: Annotated[ + str | None, "The description of the tag to create. Defaults to None (no description)." + ] = None, + color: Annotated[ + TagColor | None, "The color of the tag to create. Defaults to None (no color)." + ] = None, + workspace_id: Annotated[ + str | None, + "The ID of the workspace to create the tag in. If not provided, it will associated the tag " + "to a current workspace, if there's only one. Otherwise, it will raise an error.", + ] = None, +) -> Annotated[dict[str, Any], "The created tag."]: + """Create a tag in Asana""" + if not 1 <= len(name) <= 100: + raise ToolExecutionError("Tag name must be between 1 and 100 characters long.") + + workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) + + data = remove_none_values({ + "name": name, + "notes": description, + "color": color.value if color else None, + "workspace": workspace_id, + }) + + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.post("/tags", json_data={"data": data}) + return {"tag": response["data"]} + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=[])) +async def list_tags( + context: ToolContext, + workspace_ids: Annotated[ + list[str] | None, + "The IDs of the workspaces to search for tags in. " + "If not provided, it will search across all workspaces.", + ] = None, + limit: Annotated[ + int, "The maximum number of tags to return. Min is 1, max is 100. Defaults to 100." + ] = 100, + offset: Annotated[ + int | None, "The offset of tags to return. Defaults to 0 (first page of results)" + ] = 0, +) -> Annotated[ + dict[str, Any], + "List tags in Asana that are visible to the authenticated user", +]: + """List tags in Asana that are visible to the authenticated user""" + if not workspace_ids: + from arcade_asana.tools.workspaces import list_workspaces # avoid circular import + + workspaces = await list_workspaces(context) + workspace_ids = [workspace["gid"] for workspace in workspaces["workspaces"]] + + client = AsanaClient(context.get_auth_token_or_empty()) + responses = await asyncio.gather(*[ + client.get( + "/tags", + params=clean_request_params({ + "limit": limit, + "offset": offset, + "workspace": workspace_id, + "opt_fields": TAG_OPT_FIELDS, + }), + ) + for workspace_id in workspace_ids + ]) + return {"tags": [tag for response in responses for tag in response["data"]]} + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=[])) +async def search_tags_by_name( + context: ToolContext, + names: Annotated[ + list[str], "The names of the tags to search for (the search is case-insensitive)." + ], + workspace_ids: Annotated[ + list[str] | None, + "The IDs of the workspaces to search for tags in. " + "If not provided, it will search across all workspaces.", + ] = None, + limit: Annotated[ + int, "The maximum number of tags to return. Min is 1, max is 100. Defaults to 100." + ] = 100, + return_tags_not_matched: Annotated[ + bool, "Whether to return tags that were not matched. Defaults to False." + ] = False, +) -> Annotated[ + dict[str, Any], + "List tags in Asana with names matching the provided names", +]: + """List tags in Asana with names matching the provided names + + The search is case-insensitive. + """ + names_lower = {name.casefold() for name in names} + + tags = await paginate_tool_call( + tool=list_tags, + context=context, + response_key="tags", + max_items=500, + timeout_seconds=15, + workspace_ids=workspace_ids, + ) + + matches: list[dict[str, Any]] = [] + not_matched: list[str] = [] + for tag in tags: + tag_name_lower = tag["name"].casefold() + if len(matches) >= limit: + break + if tag_name_lower in names_lower: + matches.append(tag) + names_lower.remove(tag_name_lower) + else: + not_matched.append(tag["name"]) + + not_found = [name for name in names if name.casefold() in names_lower] + + response = { + "matches": { + "tags": matches, + "count": len(matches), + }, + "not_found": { + "names": not_found, + "count": len(not_found), + }, + } + + if return_tags_not_matched: + response["not_matched"] = { + "tags": not_matched, + "count": len(not_matched), + } + + return response diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index d485f1225..40bd4e552 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -7,10 +7,15 @@ from arcade_asana.constants import TASK_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.utils import build_task_search_query_params, remove_none_values +from arcade_asana.utils import ( + build_task_search_query_params, + handle_new_task_associations, + handle_new_task_tags, + remove_none_values, +) -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:read"])) +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def get_task_by_id( context: ToolContext, task_id: Annotated[str, "The ID of the task to get."], @@ -24,45 +29,66 @@ async def get_task_by_id( return {"task": response["data"]} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:read"])) +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def search_tasks( context: ToolContext, keywords: Annotated[ str, "Keywords to search for tasks. Matches against the task name and description." ], + workspace_id: Annotated[ + str | None, + "Restricts the search to the given workspace. " + "Defaults to None (searches across all workspaces).", + ] = None, assignee_ids: Annotated[ - list[str] | None, "IDs of the users to search for tasks assigned to." + list[str] | None, + "Restricts the search to tasks assigned to the given users. " + "Defaults to None (searches tasks assigned to anyone or no one).", + ] = None, + project_ids: Annotated[ + list[str] | None, + "Restricts the search to tasks associated to the given projects. " + "Defaults to None (searches tasks associated to any project).", + ] = None, + team_ids: Annotated[ + list[str] | None, + "Restricts the search to tasks associated to the given teams. " + "Defaults to None (searches tasks associated to any team).", + ] = None, + tags: Annotated[ + list[str] | None, + "Restricts the search to tasks associated to the given tags. " + "Defaults to None (searches tasks associated to any tag or no tag).", ] = None, - project_ids: Annotated[list[str] | None, "IDs of the projects to search for tasks in."] = None, - team_ids: Annotated[list[str] | None, "IDs of the teams to search for tasks in."] = None, - tags: Annotated[list[str] | None, "Tags to search for tasks with."] = None, due_on: Annotated[ str | None, - "Match tasks that are due on this date. Format: YYYY-MM-DD. Example: '2025-01-01'.", + "Match tasks that are due exactly on this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. " + "Defaults to None (searches tasks due on any date or without a due date).", ] = None, due_on_or_after: Annotated[ str | None, - "Match tasks that are due on or after this date. " - "Format: YYYY-MM-DD. Example: '2025-01-01'.", + "Match tasks that are due on OR AFTER this date. Format: YYYY-MM-DD. Ex: '2025-01-01' " + "Defaults to None (searches tasks due on any date or without a due date).", ] = None, due_on_or_before: Annotated[ str | None, - "Match tasks that are due on or before this date. " - "Format: YYYY-MM-DD. Example: '2025-01-01'.", + "Match tasks that are due on OR BEFORE this date. Format: YYYY-MM-DD. Ex: '2025-01-01' " + "Defaults to None (searches tasks due on any date or without a due date).", ] = None, start_on: Annotated[ str | None, - "Match tasks that are started on this date. Format: YYYY-MM-DD. Example: '2025-01-01'.", + "Match tasks that started on this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. " + "Defaults to None (searches tasks started on any date or without a start date).", ] = None, start_on_or_after: Annotated[ str | None, - "Match tasks that are started on or after this date. " - "Format: YYYY-MM-DD. Example: '2025-01-01'.", + "Match tasks that started on OR AFTER this date. Format: YYYY-MM-DD. Ex: '2025-01-01' " + "Defaults to None (searches tasks started on any date or without a start date).", ] = None, start_on_or_before: Annotated[ str | None, - "Match tasks that are started on or before this date. " - "Format: YYYY-MM-DD. Example: '2025-01-01'.", + "Match tasks that started on OR BEFORE this date. Format: YYYY-MM-DD. Ex: '2025-01-01' " + "Defaults to None (searches tasks started on any date or without a start date).", ] = None, completed: Annotated[ bool, @@ -87,10 +113,14 @@ async def search_tasks( ) response = await client.get("/tasks", params=query_params) - return {"tasks": response["data"]} + return { + "tasks": response["data"], + "count": len(response["data"]), + } -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:write"])) + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def update_task( context: ToolContext, task_id: Annotated[str, "The ID of the task to update."], @@ -139,35 +169,65 @@ async def update_task( } -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["tasks:write"])) +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def create_task( context: ToolContext, name: Annotated[str, "The name of the task"], start_date: Annotated[ - str | None, "The start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'." + str | None, + "The start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. " + "Defaults to None.", ] = None, due_date: Annotated[ - str | None, "The due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'." + str | None, + "The due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. " + "Defaults to None.", ] = None, - description: Annotated[str | None, "The description of the task."] = None, - parent_task_id: Annotated[str | None, "The ID of the parent task."] = None, - workspace_id: Annotated[str | None, "The ID of the workspace to associate the task to."] = None, - project_ids: Annotated[ - list[str] | None, "The IDs of the projects to associate the task to." + description: Annotated[str | None, "The description of the task. Defaults to None."] = None, + parent_task_id: Annotated[str | None, "The ID of the parent task. Defaults to None."] = None, + workspace_id: Annotated[ + str | None, "The ID of the workspace to associate the task to. Defaults to None." + ] = None, + project_id: Annotated[ + str | None, "The ID of the project to associate the task to. Defaults to None." + ] = None, + project_name: Annotated[ + str | None, "The name of the project to associate the task to. Defaults to None." ] = None, assignee_id: Annotated[ str | None, "The ID of the user to assign the task to. " - "Provide 'me' to assign the task to the current user.", + "Defaults to 'me', which assigns the task to the current user.", + ] = "me", + tag_names: Annotated[ + list[str] | None, "The names of the tags to associate with the task. Defaults to None." + ] = None, + tag_ids: Annotated[ + list[str] | None, "The IDs of the tags to associate with the task. Defaults to None." ] = None, - tags: Annotated[list[str] | None, "The tags to associate with the task."] = None, ) -> Annotated[ dict[str, Any], "Creates a task in Asana", ]: - """Creates a task in Asana""" + """Creates a task in Asana + + Provide none or at most one of the following argument pairs, never both: + - tag_names and tag_ids + - project_name and project_id + + The task must be associated to at least one of the following: parent_task_id, project_id, or + workspace_id. If none of these are provided and the account has only one workspace, the task + will be associated to that workspace. If the account has multiple workspaces, an error will + be raised with a list of available workspaces. + """ client = AsanaClient(context.get_auth_token_or_empty()) + parent_task_id, project_id, workspace_id = await handle_new_task_associations( + context, parent_task_id, project_id, project_name, workspace_id + ) + + tag_ids = await handle_new_task_tags(context, tag_names, tag_ids, workspace_id) + try: datetime.strptime(due_date, "%Y-%m-%d") datetime.strptime(start_date, "%Y-%m-%d") @@ -176,22 +236,23 @@ async def create_task( "Invalid date format. Use the format YYYY-MM-DD for both start_date and due_date." ) - if assignee_id == "me": - assignee_id = await client.get_current_user()["gid"] - task_data = { - "name": name, - "due_date": due_date, - "notes": description, - "parent": parent_task_id, - "projects": project_ids, - "workspace": workspace_id, - "assignee": assignee_id, - "tags": tags, + "data": remove_none_values({ + "name": name, + "due_on": due_date, + "start_on": start_date, + "notes": description, + "parent": parent_task_id, + "projects": [project_id] if project_id else None, + "workspace": workspace_id, + "assignee": assignee_id, + "tags": tag_ids, + }), } - response = await client.post("/tasks", json_data=remove_none_values(task_data)) + response = await client.post("tasks", json_data=task_data) + return { - "status": {"success": True, "message": "task created successfully"}, + "status": {"success": True, "message": "task successfully created"}, "task": response["data"], } diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index ac4835ccc..71f7aba52 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -1,30 +1,72 @@ +import asyncio from typing import Annotated, Any from arcade.sdk import ToolContext, tool from arcade.sdk.auth import OAuth2 +from arcade_asana.constants import TEAM_OPT_FIELDS from arcade_asana.models import AsanaClient +from arcade_asana.utils import clean_request_params -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["teams:read"])) +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def get_team_by_id( + context: ToolContext, + team_id: Annotated[str, "The ID of the Asana team to get"], +) -> Annotated[dict[str, Any], "Get an Asana team by its ID"]: + """Get an Asana team by its ID""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + f"/teams/{team_id}", params=clean_request_params({"opt_fields": TEAM_OPT_FIELDS}) + ) + return {"team": response["data"]} + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def list_teams_the_current_user_is_a_member_of( context: ToolContext, + workspace_ids: Annotated[ + list[str] | None, + "The IDs of the workspaces to get teams from. " + "Defaults to None (get teams from all workspaces the user is a member of).", + ] = None, limit: Annotated[ int, "The maximum number of teams to return. Min is 1, max is 100. Defaults to 100." ] = 100, - offset: Annotated[int, "The offset of teams to return. Defaults to 0"] = 0, + offset: Annotated[ + int | None, + "The pagination offset of teams to skip in the results. " + "Defaults to 0 (first page of results)", + ] = 0, ) -> Annotated[ dict[str, Any], "List teams in Asana that the current user is a member of", ]: """List teams in Asana that the current user is a member of""" + if not workspace_ids: + # Importing here to avoid circular imports + from arcade_asana.tools.workspaces import list_workspaces + + response = await list_workspaces(context) + workspace_ids = [workspace["gid"] for workspace in response["workspaces"]] + client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - "/users/me/teams", - params={ - "limit": limit, - "offset": offset, - "opt_fields": ["gid", "name", "description", "organization", "permalink_url"], - }, - ) - return {"teams": response["data"]} + responses = await asyncio.gather(*[ + client.get( + "/users/me/teams", + params=clean_request_params({ + "limit": limit, + "offset": offset, + "opt_fields": TEAM_OPT_FIELDS, + "organization": workspace_id, + }), + ) + for workspace_id in workspace_ids + ]) + + teams = [team for response in responses for team in response["data"]] + + return { + "teams": teams, + "count": len(teams), + } diff --git a/toolkits/asana/arcade_asana/tools/workspaces.py b/toolkits/asana/arcade_asana/tools/workspaces.py index ac00021e7..77a750e64 100644 --- a/toolkits/asana/arcade_asana/tools/workspaces.py +++ b/toolkits/asana/arcade_asana/tools/workspaces.py @@ -3,17 +3,19 @@ from arcade.sdk import ToolContext, tool from arcade.sdk.auth import OAuth2 +from arcade_asana.constants import WORKSPACE_OPT_FIELDS from arcade_asana.models import AsanaClient +from arcade_asana.utils import clean_request_params -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["workspaces:read"])) +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def list_workspaces( context: ToolContext, limit: Annotated[ int, "The maximum number of workspaces to return. Min is 1, max is 100. Defaults to 100." ] = 100, offset: Annotated[ - int, "The offset of workspaces to return. Defaults to 0 (first page of results)" + int | None, "The offset of workspaces to return. Defaults to 0 (first page of results)" ] = 0, ) -> Annotated[ dict[str, Any], @@ -21,5 +23,16 @@ async def list_workspaces( ]: """List workspaces in Asana that are visible to the authenticated user""" client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get("/workspaces", params={"limit": limit, "offset": offset}) - return {"workspaces": response["data"]} + response = await client.get( + "/workspaces", + params=clean_request_params({ + "limit": limit, + "offset": offset, + "opt_fields": WORKSPACE_OPT_FIELDS, + }), + ) + + return { + "workspaces": response["data"], + "count": len(response["data"]), + } diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index a3686f36a..77995e4d5 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -1,12 +1,26 @@ -from typing import Any +import asyncio +import json +from typing import Any, Callable + +from arcade.sdk import ToolContext +from arcade.sdk.errors import RetryableToolError, ToolExecutionError from arcade_asana.constants import TASK_OPT_FIELDS +from arcade_asana.exceptions import PaginationTimeoutError -def remove_none_values(data: dict) -> dict: +def remove_none_values(data: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in data.items() if v is not None} +def clean_request_params(params: dict[str, Any]) -> dict[str, Any]: + params = remove_none_values(params) + if "offset" in params and params["offset"] == 0: + del params["offset"] + + return params + + def build_task_search_query_params( keywords: str, completed: bool, @@ -21,7 +35,7 @@ def build_task_search_query_params( start_on_or_after: str | None, start_on_or_before: str | None, ) -> dict[str, Any]: - query_params = { + query_params: dict[str, Any] = { "text": keywords, "opt_fields": TASK_OPT_FIELDS, "completed": completed, @@ -35,7 +49,7 @@ def build_task_search_query_params( if tags: query_params["tags.any"] = ",".join(tags) - query_params = build_task_search_date_params( + query_params = add_task_search_date_params( query_params, due_on, due_on_or_after, @@ -48,15 +62,20 @@ def build_task_search_query_params( return query_params -def build_task_search_date_params( - query_params: dict, - due_on: str, - due_on_or_after: str, - due_on_or_before: str, - start_on: str, - start_on_or_after: str, - start_on_or_before: str, -) -> dict: +def add_task_search_date_params( + query_params: dict[str, Any], + due_on: str | None, + due_on_or_after: str | None, + due_on_or_before: str | None, + start_on: str | None, + start_on_or_after: str | None, + start_on_or_before: str | None, +) -> dict[str, Any]: + """ + Builds the date-related query parameters for task search. + + If a date is provided, it will be added to the query parameters. If not, it will be ignored. + """ if due_on: query_params["due_on"] = due_on if due_on_or_after: @@ -71,3 +90,157 @@ def build_task_search_date_params( query_params["start_on.before"] = start_on_or_before return query_params + + +async def handle_new_task_associations( + context: ToolContext, + parent_task_id: str | None, + project_id: str | None, + project_name: str | None, + workspace_id: str | None, +) -> tuple[str | None, str | None, str | None]: + """ + Handles the association of a new task to a parent task, project, or workspace. + + If no association is provided, it will try to find a workspace in the user's account. + In case the user has only one workspace, it will use that workspace. + Otherwise, it will raise an error. + + If a workspace_id is not provided, but a parent_task_id or a project_id is provided, it will try + to find the workspace associated with the parent task or project. + + In each of the two cases explained above, if a workspace is found, the function will return this + value, even if the workspace_id argument was None. + + Returns a tuple of (parent_task_id, project_id, workspace_id). + """ + if project_id and project_name: + raise RetryableToolError( + "Provide none or at most one of project_id and project_name, never both." + ) + + # Importing here to avoid potential circular imports + from arcade_asana.tools.projects import ( + get_project_by_id, + ) + from arcade_asana.tools.tasks import get_task_by_id + + if not any([parent_task_id, project_id, project_name, workspace_id]): + workspace_id = await get_unique_workspace_id_or_raise_error(context) + + if not workspace_id: + if parent_task_id: + response = await get_task_by_id(context, parent_task_id) + workspace_id = response["task"]["workspace"]["gid"] + elif project_id: + response = await get_project_by_id(context, project_id) + workspace_id = response["project"]["workspace"]["gid"] + elif project_name: + project = await get_project_by_name_or_raise_error(context, project_name) + project_id = project["gid"] + workspace_id = project["workspace"]["gid"] + + return parent_task_id, project_id, workspace_id + + +async def get_project_by_name_or_raise_error( + context: ToolContext, project_name: str +) -> dict[str, Any]: + # Avoid circular imports + from arcade_asana.tools.projects import search_projects_by_name + + response = await search_projects_by_name( + context, names=[project_name], limit=1, return_projects_not_matched=True + ) + + if not response["matches"]["projects"]: + projects = response["not_matched"]["projects"] + projects = [{"name": project["name"], "gid": project["gid"]} for project in projects] + message = f"Project with name '{project_name}' not found." + additional_prompt = f"Projects available: {json.dumps(projects)}" + raise RetryableToolError( + message=message, + developer_message=f"{message} {additional_prompt}", + additional_prompt_content=additional_prompt, + ) + + return response["matches"]["projects"][0] + + +async def handle_new_task_tags( + context: ToolContext, + tag_names: list[str] | None, + tag_ids: list[str] | None, + workspace_id: str | None, +) -> list[str]: + if tag_ids and tag_names: + raise ToolExecutionError( + "Provide none or at most one of tag_names and tag_ids, never both." + ) + + if tag_names: + from arcade_asana.tools.tags import create_tag, search_tags_by_name + + response = await search_tags_by_name(context, tag_names) + tag_ids = [tag["gid"] for tag in response["matches"]["tags"]] + + if response["not_found"]["names"]: + responses = await asyncio.gather(*[ + create_tag(context, name=name, workspace_id=workspace_id) + for name in response["not_found"]["names"] + ]) + tag_ids = [response["tag"]["gid"] for response in responses] + + return tag_ids + + +async def paginate_tool_call( + tool: Callable[[ToolContext, Any], dict], + context: ToolContext, + response_key: str, + max_items: int = 300, + timeout_seconds: int = 10, + **tool_kwargs: Any, +) -> dict: + results: list[dict[str, Any]] = [] + + async def paginate_loop() -> None: + nonlocal results + keep_paginating = True + + if "limit" not in tool_kwargs: + tool_kwargs["limit"] = 100 + + while keep_paginating: + response = await tool(context, **tool_kwargs) + results.extend(response[response_key]) + if "offset" not in tool_kwargs: + tool_kwargs["offset"] = 0 + if "next_page" not in response or len(results) >= max_items: + keep_paginating = False + else: + tool_kwargs["offset"] += tool_kwargs["limit"] + + try: + await asyncio.wait_for(paginate_loop(), timeout=timeout_seconds) + except TimeoutError: + raise PaginationTimeoutError(timeout_seconds, tool.__tool_name__) + else: + return results + + +async def get_unique_workspace_id_or_raise_error(context: ToolContext) -> str: + # Importing here to avoid circular imports + from arcade_asana.tools.workspaces import list_workspaces + + workspaces = await list_workspaces(context) + if len(workspaces["workspaces"]) == 1: + return workspaces["workspaces"][0]["gid"] + else: + message = "User has multiple workspaces. Please provide a workspace ID." + additional_prompt = f"Workspaces available: {json.dumps(workspaces['workspaces'])}" + raise RetryableToolError( + message=message, + developer_message=message, + additional_prompt_content=additional_prompt, + ) diff --git a/toolkits/asana/evals/eval_tasks.py b/toolkits/asana/evals/eval_tasks.py new file mode 100644 index 000000000..e69de29bb From 5b4fddcbce1ce5bb4e6bbcb23c5f2da4b03bfe78 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 2 May 2025 15:18:05 -0300 Subject: [PATCH 04/40] subtasks & attachment tools; include subtasks when retrieving tasks --- toolkits/asana/arcade_asana/constants.py | 21 ++ toolkits/asana/arcade_asana/models.py | 29 ++- toolkits/asana/arcade_asana/tools/tasks.py | 251 +++++++++++++++++---- toolkits/asana/arcade_asana/tools/users.py | 54 +++++ toolkits/asana/arcade_asana/utils.py | 54 +++-- 5 files changed, 346 insertions(+), 63 deletions(-) create mode 100644 toolkits/asana/arcade_asana/tools/users.py diff --git a/toolkits/asana/arcade_asana/constants.py b/toolkits/asana/arcade_asana/constants.py index 0cfd46019..8d8cfc7a2 100644 --- a/toolkits/asana/arcade_asana/constants.py +++ b/toolkits/asana/arcade_asana/constants.py @@ -74,6 +74,14 @@ "permalink_url", ] +USER_OPT_FIELDS = [ + "gid", + "resource_type", + "name", + "email", + "photo", + "workspaces", +] WORKSPACE_OPT_FIELDS = [ "gid", @@ -84,6 +92,19 @@ ] +class TaskSortBy(Enum): + DUE_DATE = "due_date" + CREATED_AT = "created_at" + COMPLETED_AT = "completed_at" + MODIFIED_AT = "modified_at" + LIKES = "likes" + + +class SortOrder(Enum): + ASCENDING = "ascending" + DESCENDING = "descending" + + class TagColor(Enum): DARK_GREEN = "dark-green" DARK_RED = "dark-red" diff --git a/toolkits/asana/arcade_asana/models.py b/toolkits/asana/arcade_asana/models.py index 89881d671..8fbab32cf 100644 --- a/toolkits/asana/arcade_asana/models.py +++ b/toolkits/asana/arcade_asana/models.py @@ -76,9 +76,11 @@ async def get( headers: Optional[dict] = None, api_version: str | None = None, ) -> dict: - headers = headers or {} - headers["Authorization"] = f"Bearer {self.auth_token}" - headers["Accept"] = "application/json" + default_headers = { + "Authorization": f"Bearer {self.auth_token}", + "Accept": "application/json", + } + headers = {**default_headers, **(headers or {})} kwargs = { "url": self._build_url(endpoint, api_version), @@ -98,20 +100,31 @@ async def post( endpoint: str, data: Optional[dict] = None, json_data: Optional[dict] = None, + files: Optional[dict] = None, headers: Optional[dict] = None, api_version: str | None = None, ) -> dict: - headers = headers or {} - headers["Authorization"] = f"Bearer {self.auth_token}" - headers["Content-Type"] = "application/json" - headers["Accept"] = "application/json" + default_headers = { + "Authorization": f"Bearer {self.auth_token}", + "Accept": "application/json", + } + + if files is None and json_data is not None: + default_headers["Content-Type"] = "application/json" + + headers = {**default_headers, **(headers or {})} kwargs = { "url": self._build_url(endpoint, api_version), "headers": headers, } - kwargs = self._set_request_body(kwargs, data, json_data) + if files is not None: + kwargs["files"] = files + if data is not None: + kwargs["data"] = data + else: + kwargs = self._set_request_body(kwargs, data, json_data) async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] response = await client.post(**kwargs) # type: ignore[arg-type] diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 40bd4e552..1040dc1b3 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -1,3 +1,5 @@ +import asyncio +import base64 from datetime import datetime from typing import Annotated, Any @@ -5,10 +7,11 @@ from arcade.sdk.auth import OAuth2 from arcade.sdk.errors import ToolExecutionError -from arcade_asana.constants import TASK_OPT_FIELDS +from arcade_asana.constants import TASK_OPT_FIELDS, SortOrder, TaskSortBy from arcade_asana.models import AsanaClient from arcade_asana.utils import ( build_task_search_query_params, + clean_request_params, handle_new_task_associations, handle_new_task_tags, remove_none_values, @@ -19,6 +22,10 @@ async def get_task_by_id( context: ToolContext, task_id: Annotated[str, "The ID of the task to get."], + max_subtasks: Annotated[ + int, + "The maximum number of subtasks to return. Min of 1, max of 100. Defaults to 100.", + ] = 100, ) -> Annotated[dict[str, Any], "The task with the given ID."]: """Get a task by its ID""" client = AsanaClient(context.get_auth_token_or_empty()) @@ -26,18 +33,46 @@ async def get_task_by_id( f"/tasks/{task_id}", params={"opt_fields": TASK_OPT_FIELDS}, ) + subtasks = await get_subtasks_from_a_task(context, task_id=task_id, limit=max_subtasks) + response["data"]["subtasks"] = subtasks["subtasks"] return {"task": response["data"]} +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def get_subtasks_from_a_task( + context: ToolContext, + task_id: Annotated[str, "The ID of the task to get the subtasks of."], + limit: Annotated[ + int, + "The maximum number of subtasks to return. Min of 1, max of 100. Defaults to 100.", + ] = 100, + offset: Annotated[ + int, + "The offset of the subtasks to return. Defaults to 0.", + ] = 0, +) -> Annotated[dict[str, Any], "The subtasks of the task."]: + """Get the subtasks of a task""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + f"/tasks/{task_id}/subtasks", + params=clean_request_params({ + "opt_fields": TASK_OPT_FIELDS, + "limit": limit, + "offset": offset, + }), + ) + return {"subtasks": response["data"], "count": len(response["data"])} + + @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def search_tasks( context: ToolContext, keywords: Annotated[ str, "Keywords to search for tasks. Matches against the task name and description." ], - workspace_id: Annotated[ - str | None, - "Restricts the search to the given workspace. " + workspace_ids: Annotated[ + list[str] | None, + "The IDs of the workspaces to search for tasks. " "Defaults to None (searches across all workspaces).", ] = None, assignee_ids: Annotated[ @@ -94,56 +129,107 @@ async def search_tasks( bool, "Match tasks that are completed. Defaults to False (tasks that are NOT completed).", ] = False, + limit: Annotated[ + int, + "The maximum number of tasks to return. Min of 1, max of 20. Defaults to 20.", + ] = 20, + sort_by: Annotated[ + TaskSortBy, + "The field to sort the tasks by. Defaults to TaskSortBy.MODIFIED_AT.", + ] = TaskSortBy.MODIFIED_AT, + sort_order: Annotated[ + SortOrder, + "The order to sort the tasks by. Defaults to SortOrder.DESCENDING.", + ] = SortOrder.DESCENCING, ) -> Annotated[dict[str, Any], "The tasks that match the query."]: """Search for tasks""" + from arcade_asana.tools.workspaces import list_workspaces # Avoid circular import + + workspace_ids = workspace_ids or await list_workspaces(context) + client = AsanaClient(context.get_auth_token_or_empty()) - query_params = build_task_search_query_params( - keywords, - completed, - assignee_ids, - project_ids, - team_ids, - tags, - due_on, - due_on_or_after, - due_on_or_before, - start_on, - start_on_or_after, - start_on_or_before, - ) - response = await client.get("/tasks", params=query_params) + responses = await asyncio.gather(*[ + client.get( + f"/workspaces/{workspace_id}/tasks/search", + params=build_task_search_query_params( + workspace_id=workspace_id, + keywords=keywords, + completed=completed, + assignee_ids=assignee_ids, + project_ids=project_ids, + team_ids=team_ids, + tags=tags, + due_on=due_on, + due_on_or_after=due_on_or_after, + due_on_or_before=due_on_or_before, + start_on=start_on, + start_on_or_after=start_on_or_after, + start_on_or_before=start_on_or_before, + limit=limit, + sort_by=sort_by, + sort_order=sort_order, + ), + ) + for workspace_id in workspace_ids + ]) - return { - "tasks": response["data"], - "count": len(response["data"]), - } + tasks_by_id = {task["gid"]: task for response in responses for task in response["data"]} + + subtasks = await asyncio.gather(*[ + get_subtasks_from_a_task(context, task_id=task["gid"]) for task in tasks_by_id.values() + ]) + + for response in subtasks: + for subtask in response["subtasks"]: + parent_task = tasks_by_id[subtask["parent"]["gid"]] + parent_task["subtasks"].append(subtask) + + tasks = list(tasks_by_id.values()) + + return {"tasks": tasks, "count": len(tasks)} @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def update_task( context: ToolContext, task_id: Annotated[str, "The ID of the task to update."], - name: Annotated[str | None, "The new name of the task"] = None, + name: Annotated[ + str | None, + "The new name of the task. Defaults to None (does not change the current name).", + ] = None, + completed: Annotated[ + bool | None, + "The new completion status of the task. " + "Provide True to mark the task as completed, False to mark it as not completed. " + "Defaults to None (does not change the current completion status).", + ] = None, start_date: Annotated[ str | None, - "The new start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'.", + "The new start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. " + "Defaults to None (does not change the current start date).", ] = None, due_date: Annotated[ str | None, - "The new due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'.", + "The new due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. " + "Defaults to None (does not change the current due date).", ] = None, - description: Annotated[str | None, "The new description of the task."] = None, - parent_task_id: Annotated[str | None, "The ID of the new parent task."] = None, - project_ids: Annotated[ - list[str] | None, "The IDs of the new projects to associate the task to." + description: Annotated[ + str | None, + "The new description of the task. " + "Defaults to None (does not change the current description).", + ] = None, + parent_task_id: Annotated[ + str | None, + "The ID of the new parent task. " + "Defaults to None (does not change the current parent task).", ] = None, assignee_id: Annotated[ str | None, "The ID of the new user to assign the task to. " - "Provide 'me' to assign the task to the current user.", + "Provide 'me' to assign the task to the current user. " + "Defaults to None (does not change the current assignee).", ] = None, - tags: Annotated[list[str] | None, "The new tags to associate with the task."] = None, ) -> Annotated[ dict[str, Any], "Updates a task in Asana", @@ -152,17 +238,19 @@ async def update_task( client = AsanaClient(context.get_auth_token_or_empty()) task_data = { - "name": name, - "due_on": due_date, - "start_on": start_date, - "notes": description, - "parent": parent_task_id, - "projects": project_ids, - "assignee": assignee_id, - "tags": tags, + "data": remove_none_values({ + "name": name, + "completed": completed, + "due_on": due_date, + "start_on": start_date, + "notes": description, + "parent": parent_task_id, + "assignee": assignee_id, + }), } - response = await client.put(f"/tasks/{task_id}", json_data=remove_none_values(task_data)) + response = await client.put(f"/tasks/{task_id}", json_data=task_data) + return { "status": {"success": True, "message": "task updated successfully"}, "task": response["data"], @@ -256,3 +344,84 @@ async def create_task( "status": {"success": True, "message": "task successfully created"}, "task": response["data"], } + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def attach_file_to_task( + context: ToolContext, + task_id: Annotated[str, "The ID of the task to attach the file to."], + file_name: Annotated[ + str, + "The name of the file to attach with format extension. E.g. 'Image.png' or 'Report.pdf'.", + ], + file_content_str: Annotated[ + str | None, + "The string contents of the file to attach. Use this if the file IS a text file. " + "Defaults to None.", + ] = None, + file_content_base64: Annotated[ + str | None, + "The base64-encoded binary contents of the file. " + "Use this for binary files like images or PDFs. Defaults to None.", + ] = None, + file_content_url: Annotated[ + str | None, + "The URL of the file to attach. Use this if the file is hosted on an external URL. " + "Defaults to None.", + ] = None, + file_encoding: Annotated[ + str, + "The encoding of the file to attach. Only used with file_content_str. Defaults to 'utf-8'.", + ] = "utf-8", +) -> Annotated[dict[str, Any], "The task with the file attached."]: + """Attaches a file to an Asana task + + Provide exactly one of file_content_str, file_content_base64, or file_content_url, never more + than one. + + - Use file_content_str for text files (will be encoded using file_encoding) + - Use file_content_base64 for binary files like images, PDFs, etc. + - Use file_content_url if the file is hosted on an external URL + """ + client = AsanaClient(context.get_auth_token_or_empty()) + + if sum([bool(file_content_str), bool(file_content_base64), bool(file_content_url)]) != 1: + raise ToolExecutionError( + "Provide exactly one of file_content_str, file_content_base64, or file_content_url" + ) + + data = { + "parent": task_id, + "name": file_name, + "resource_subtype": "asana", + } + + if file_content_url is not None: + data["url"] = file_content_url + data["resource_subtype"] = "external" + file_content = None + elif file_content_str is not None: + try: + file_content = file_content_str.encode(file_encoding) + except LookupError as exc: + raise ToolExecutionError(f"Unknown encoding: {file_encoding}") from exc + else: + try: + file_content = base64.b64decode(file_content_base64) + except Exception as exc: + raise ToolExecutionError(f"Invalid base64 encoding: {exc!s}") from exc + + if file_content: + if file_name.lower().endswith(".pdf"): + files = {"file": (file_name, file_content, "application/pdf")} + else: + files = {"file": (file_name, file_content)} + else: + files = None + + response = await client.post("/attachments", data=data, files=files) + + return { + "status": {"success": True, "message": f"file successfully attached to task {task_id}"}, + "response": response["data"], + } diff --git a/toolkits/asana/arcade_asana/tools/users.py b/toolkits/asana/arcade_asana/tools/users.py new file mode 100644 index 000000000..5a57ed488 --- /dev/null +++ b/toolkits/asana/arcade_asana/tools/users.py @@ -0,0 +1,54 @@ +from typing import Annotated, Any + +from arcade.sdk import ToolContext, tool +from arcade.sdk.auth import OAuth2 + +from arcade_asana.constants import USER_OPT_FIELDS +from arcade_asana.models import AsanaClient +from arcade_asana.utils import clean_request_params + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def list_users( + context: ToolContext, + workspace_id: Annotated[ + str | None, + "The workspace ID to list users from. Defaults to None (list users from all workspaces).", + ] = None, + limit: Annotated[ + int, "The maximum number of users to return. Min is 1, max is 100. Defaults to 100." + ] = 100, + offset: Annotated[ + int | None, "The offset of users to return. Defaults to 0 (first page of results)" + ] = 0, +) -> Annotated[ + dict[str, Any], + "List users in Asana", +]: + """List users in Asana""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + "/users", + params=clean_request_params({ + "workspace": workspace_id, + "limit": limit, + "offset": offset, + "opt_fields": USER_OPT_FIELDS, + }), + ) + + return { + "users": response["data"], + "count": len(response["data"]), + } + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def get_user_by_id( + context: ToolContext, + user_id: Annotated[str, "The user ID to get."], +) -> Annotated[dict[str, Any], "The user information."]: + """Get a user by ID""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get(f"/users/{user_id}", params={"opt_fields": USER_OPT_FIELDS}) + return {"user": response} diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index 77995e4d5..12db8bf25 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -5,7 +5,7 @@ from arcade.sdk import ToolContext from arcade.sdk.errors import RetryableToolError, ToolExecutionError -from arcade_asana.constants import TASK_OPT_FIELDS +from arcade_asana.constants import TASK_OPT_FIELDS, SortOrder, TaskSortBy from arcade_asana.exceptions import PaginationTimeoutError @@ -22,6 +22,7 @@ def clean_request_params(params: dict[str, Any]) -> dict[str, Any]: def build_task_search_query_params( + workspace_id: str, keywords: str, completed: bool, assignee_ids: list[str] | None, @@ -34,11 +35,18 @@ def build_task_search_query_params( start_on: str | None, start_on_or_after: str | None, start_on_or_before: str | None, + limit: int, + sort_by: TaskSortBy, + sort_order: SortOrder, ) -> dict[str, Any]: query_params: dict[str, Any] = { + "workspace": workspace_id, "text": keywords, "opt_fields": TASK_OPT_FIELDS, "completed": completed, + "sort_by": sort_by.value, + "sort_ascending": sort_order == SortOrder.ASCENDING, + "limit": limit, } if assignee_ids: query_params["assignee.any"] = ",".join(assignee_ids) @@ -119,30 +127,48 @@ async def handle_new_task_associations( "Provide none or at most one of project_id and project_name, never both." ) - # Importing here to avoid potential circular imports - from arcade_asana.tools.projects import ( - get_project_by_id, - ) - from arcade_asana.tools.tasks import get_task_by_id - if not any([parent_task_id, project_id, project_name, workspace_id]): workspace_id = await get_unique_workspace_id_or_raise_error(context) if not workspace_id: if parent_task_id: + from arcade_asana.tools.tasks import get_task_by_id # avoid circular imports + response = await get_task_by_id(context, parent_task_id) workspace_id = response["task"]["workspace"]["gid"] - elif project_id: - response = await get_project_by_id(context, project_id) - workspace_id = response["project"]["workspace"]["gid"] - elif project_name: - project = await get_project_by_name_or_raise_error(context, project_name) - project_id = project["gid"] - workspace_id = project["workspace"]["gid"] + else: + project_id, workspace_id = await handle_task_project_association( + context, project_id, project_name, workspace_id + ) return parent_task_id, project_id, workspace_id +async def handle_task_project_association( + context: ToolContext, + project_id: str | None, + project_name: str | None, + workspace_id: str | None, +) -> tuple[str | None, str | None]: + if all([project_id, project_name]): + raise ToolExecutionError( + "Provide none or at most one of project_id and project_name, never both." + ) + + if project_id: + from arcade_asana.tools.projects import get_project_by_id # avoid circular imports + + response = await get_project_by_id(context, project_id) + workspace_id = response["project"]["workspace"]["gid"] + + elif project_name: + project = await get_project_by_name_or_raise_error(context, project_name) + project_id = project["gid"] + workspace_id = project["workspace"]["gid"] + + return project_id, workspace_id + + async def get_project_by_name_or_raise_error( context: ToolContext, project_name: str ) -> dict[str, Any]: From 088170f4ce40d990217660927d1f08140b2dceb1 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 2 May 2025 15:20:47 -0300 Subject: [PATCH 05/40] fix reference to toolkit pkg name --- toolkits/asana/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolkits/asana/pyproject.toml b/toolkits/asana/pyproject.toml index 6bd0d7ef3..b54c33584 100644 --- a/toolkits/asana/pyproject.toml +++ b/toolkits/asana/pyproject.toml @@ -24,7 +24,7 @@ requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.mypy] -files = ["arcade_hubspot/**/*.py"] +files = ["arcade_asana/**/*.py"] python_version = "3.10" disallow_untyped_defs = "True" disallow_any_unimported = "True" From 3847a1566aa9b154093740ec26ea72c44f2a2752 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 2 May 2025 15:38:23 -0300 Subject: [PATCH 06/40] make mypy happy --- toolkits/asana/arcade_asana/models.py | 2 +- toolkits/asana/arcade_asana/tools/tasks.py | 27 ++++++++++-------- toolkits/asana/arcade_asana/utils.py | 32 ++++++++++++++++------ 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/toolkits/asana/arcade_asana/models.py b/toolkits/asana/arcade_asana/models.py index 8fbab32cf..4132be4d6 100644 --- a/toolkits/asana/arcade_asana/models.py +++ b/toolkits/asana/arcade_asana/models.py @@ -158,4 +158,4 @@ async def put( async def get_current_user(self) -> dict: response = await self.get("/users/me") - return response["data"] + return cast(dict, response["data"]) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 1040dc1b3..ee7389a9f 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -1,6 +1,5 @@ import asyncio import base64 -from datetime import datetime from typing import Annotated, Any from arcade.sdk import ToolContext, tool @@ -15,6 +14,7 @@ handle_new_task_associations, handle_new_task_tags, remove_none_values, + validate_date_format, ) @@ -140,7 +140,7 @@ async def search_tasks( sort_order: Annotated[ SortOrder, "The order to sort the tasks by. Defaults to SortOrder.DESCENDING.", - ] = SortOrder.DESCENCING, + ] = SortOrder.DESCENDING, ) -> Annotated[dict[str, Any], "The tasks that match the query."]: """Search for tasks""" from arcade_asana.tools.workspaces import list_workspaces # Avoid circular import @@ -149,6 +149,13 @@ async def search_tasks( client = AsanaClient(context.get_auth_token_or_empty()) + validate_date_format("due_on", due_on) + validate_date_format("due_on_or_after", due_on_or_after) + validate_date_format("due_on_or_before", due_on_or_before) + validate_date_format("start_on", start_on) + validate_date_format("start_on_or_after", start_on_or_after) + validate_date_format("start_on_or_before", start_on_or_before) + responses = await asyncio.gather(*[ client.get( f"/workspaces/{workspace_id}/tasks/search", @@ -237,6 +244,9 @@ async def update_task( """Updates a task in Asana""" client = AsanaClient(context.get_auth_token_or_empty()) + validate_date_format("start_date", start_date) + validate_date_format("due_date", due_date) + task_data = { "data": remove_none_values({ "name": name, @@ -316,13 +326,8 @@ async def create_task( tag_ids = await handle_new_task_tags(context, tag_names, tag_ids, workspace_id) - try: - datetime.strptime(due_date, "%Y-%m-%d") - datetime.strptime(start_date, "%Y-%m-%d") - except ValueError: - raise ToolExecutionError( - "Invalid date format. Use the format YYYY-MM-DD for both start_date and due_date." - ) + validate_date_format("start_date", start_date) + validate_date_format("due_date", due_date) task_data = { "data": remove_none_values({ @@ -405,7 +410,7 @@ async def attach_file_to_task( file_content = file_content_str.encode(file_encoding) except LookupError as exc: raise ToolExecutionError(f"Unknown encoding: {file_encoding}") from exc - else: + elif file_content_base64 is not None: try: file_content = base64.b64decode(file_content_base64) except Exception as exc: @@ -415,7 +420,7 @@ async def attach_file_to_task( if file_name.lower().endswith(".pdf"): files = {"file": (file_name, file_content, "application/pdf")} else: - files = {"file": (file_name, file_content)} + files = {"file": (file_name, file_content)} # type: ignore[dict-item] else: files = None diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index 12db8bf25..ca327bf0e 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -1,6 +1,8 @@ import asyncio import json -from typing import Any, Callable +from collections.abc import Awaitable +from datetime import datetime +from typing import Any, Callable, TypeVar, cast from arcade.sdk import ToolContext from arcade.sdk.errors import RetryableToolError, ToolExecutionError @@ -8,6 +10,8 @@ from arcade_asana.constants import TASK_OPT_FIELDS, SortOrder, TaskSortBy from arcade_asana.exceptions import PaginationTimeoutError +ToolResponse = TypeVar("ToolResponse", bound=dict[str, Any]) + def remove_none_values(data: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in data.items() if v is not None} @@ -21,6 +25,16 @@ def clean_request_params(params: dict[str, Any]) -> dict[str, Any]: return params +def validate_date_format(name: str, date_str: str | None) -> None: + if not date_str: + return + + try: + datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + raise ToolExecutionError(f"Invalid {name} date format. Use the format YYYY-MM-DD.") + + def build_task_search_query_params( workspace_id: str, keywords: str, @@ -190,7 +204,7 @@ async def get_project_by_name_or_raise_error( additional_prompt_content=additional_prompt, ) - return response["matches"]["projects"][0] + return cast(dict, response["matches"]["projects"][0]) async def handle_new_task_tags( @@ -198,7 +212,7 @@ async def handle_new_task_tags( tag_names: list[str] | None, tag_ids: list[str] | None, workspace_id: str | None, -) -> list[str]: +) -> list[str] | None: if tag_ids and tag_names: raise ToolExecutionError( "Provide none or at most one of tag_names and tag_ids, never both." @@ -221,14 +235,14 @@ async def handle_new_task_tags( async def paginate_tool_call( - tool: Callable[[ToolContext, Any], dict], + tool: Callable[[ToolContext, Any], Awaitable[ToolResponse]], context: ToolContext, response_key: str, max_items: int = 300, timeout_seconds: int = 10, **tool_kwargs: Any, -) -> dict: - results: list[dict[str, Any]] = [] +) -> list[ToolResponse]: + results: list[ToolResponse] = [] async def paginate_loop() -> None: nonlocal results @@ -238,7 +252,7 @@ async def paginate_loop() -> None: tool_kwargs["limit"] = 100 while keep_paginating: - response = await tool(context, **tool_kwargs) + response = await tool(context, **tool_kwargs) # type: ignore[call-arg] results.extend(response[response_key]) if "offset" not in tool_kwargs: tool_kwargs["offset"] = 0 @@ -250,7 +264,7 @@ async def paginate_loop() -> None: try: await asyncio.wait_for(paginate_loop(), timeout=timeout_seconds) except TimeoutError: - raise PaginationTimeoutError(timeout_seconds, tool.__tool_name__) + raise PaginationTimeoutError(timeout_seconds, tool.__tool_name__) # type: ignore[attr-defined] else: return results @@ -261,7 +275,7 @@ async def get_unique_workspace_id_or_raise_error(context: ToolContext) -> str: workspaces = await list_workspaces(context) if len(workspaces["workspaces"]) == 1: - return workspaces["workspaces"][0]["gid"] + return cast(str, workspaces["workspaces"][0]["gid"]) else: message = "User has multiple workspaces. Please provide a workspace ID." additional_prompt = f"Workspaces available: {json.dumps(workspaces['workspaces'])}" From b2140015966a4090a2ad527319130d596a60f9b0 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 2 May 2025 15:40:13 -0300 Subject: [PATCH 07/40] fix case --- toolkits/asana/arcade_asana/tools/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index ee7389a9f..24c8dd9e9 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -361,7 +361,7 @@ async def attach_file_to_task( ], file_content_str: Annotated[ str | None, - "The string contents of the file to attach. Use this if the file IS a text file. " + "The string contents of the file to attach. Use this if the file is a text file. " "Defaults to None.", ] = None, file_content_base64: Annotated[ From 61d8e0f5c413fbd024641273961f7eb802504fc7 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 09:28:06 -0300 Subject: [PATCH 08/40] fix issues in search tasks --- toolkits/asana/arcade_asana/tools/tasks.py | 7 +++++-- toolkits/asana/arcade_asana/utils.py | 2 -- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 24c8dd9e9..dec112690 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -145,7 +145,9 @@ async def search_tasks( """Search for tasks""" from arcade_asana.tools.workspaces import list_workspaces # Avoid circular import - workspace_ids = workspace_ids or await list_workspaces(context) + if not workspace_ids: + response = await list_workspaces(context) + workspace_ids = [workspace["gid"] for workspace in response["workspaces"]] client = AsanaClient(context.get_auth_token_or_empty()) @@ -160,7 +162,6 @@ async def search_tasks( client.get( f"/workspaces/{workspace_id}/tasks/search", params=build_task_search_query_params( - workspace_id=workspace_id, keywords=keywords, completed=completed, assignee_ids=assignee_ids, @@ -190,6 +191,8 @@ async def search_tasks( for response in subtasks: for subtask in response["subtasks"]: parent_task = tasks_by_id[subtask["parent"]["gid"]] + if "subtasks" not in parent_task: + parent_task["subtasks"] = [] parent_task["subtasks"].append(subtask) tasks = list(tasks_by_id.values()) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index ca327bf0e..de80db7c0 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -36,7 +36,6 @@ def validate_date_format(name: str, date_str: str | None) -> None: def build_task_search_query_params( - workspace_id: str, keywords: str, completed: bool, assignee_ids: list[str] | None, @@ -54,7 +53,6 @@ def build_task_search_query_params( sort_order: SortOrder, ) -> dict[str, Any]: query_params: dict[str, Any] = { - "workspace": workspace_id, "text": keywords, "opt_fields": TASK_OPT_FIELDS, "completed": completed, From 3196d6449b2bc11e4d626f5202c77988ff5e9989 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 09:31:22 -0300 Subject: [PATCH 09/40] bump required arcade-ai version --- toolkits/asana/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolkits/asana/pyproject.toml b/toolkits/asana/pyproject.toml index b54c33584..dff3258e5 100644 --- a/toolkits/asana/pyproject.toml +++ b/toolkits/asana/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Arcade "] [tool.poetry.dependencies] python = "^3.10" -arcade-ai = ">=1.3.2,<2.0" +arcade-ai = ">=1.4.0,<2.0" httpx = "^0.27.2" [tool.poetry.dev-dependencies] From b457ba66247f6b7c3432a232b30b624dc14f8c84 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 09:32:15 -0300 Subject: [PATCH 10/40] return results count --- toolkits/asana/arcade_asana/tools/tags.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index 1793c832b..a0f134d91 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -87,7 +87,10 @@ async def list_tags( ) for workspace_id in workspace_ids ]) - return {"tags": [tag for response in responses for tag in response["data"]]} + + tags = [tag for response in responses for tag in response["data"]] + + return {"tags": tags, "count": len(tags)} @tool(requires_auth=OAuth2(id="arcade-asana", scopes=[])) From f1437fc3d2b0e34d395190ea71c9c3ccaac47fc9 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 12:10:51 -0300 Subject: [PATCH 11/40] evals --- toolkits/asana/arcade_asana/tools/__init__.py | 30 +- toolkits/asana/arcade_asana/tools/tasks.py | 68 ++- toolkits/asana/arcade_asana/utils.py | 36 +- toolkits/asana/evals/eval_projects.py | 297 ++++++++++ toolkits/asana/evals/eval_tags.py | 377 +++++++++++++ toolkits/asana/evals/eval_tasks.py | 512 ++++++++++++++++++ toolkits/asana/evals/eval_teams.py | 261 +++++++++ toolkits/asana/evals/eval_users.py | 253 +++++++++ toolkits/asana/evals/eval_workspaces.py | 173 ++++++ 9 files changed, 1967 insertions(+), 40 deletions(-) create mode 100644 toolkits/asana/evals/eval_projects.py create mode 100644 toolkits/asana/evals/eval_tags.py create mode 100644 toolkits/asana/evals/eval_teams.py create mode 100644 toolkits/asana/evals/eval_users.py create mode 100644 toolkits/asana/evals/eval_workspaces.py diff --git a/toolkits/asana/arcade_asana/tools/__init__.py b/toolkits/asana/arcade_asana/tools/__init__.py index cc45df477..94e7911de 100644 --- a/toolkits/asana/arcade_asana/tools/__init__.py +++ b/toolkits/asana/arcade_asana/tools/__init__.py @@ -1,5 +1,33 @@ -from arcade_asana.tools.tasks import create_task +from arcade_asana.tools.projects import get_project_by_id, list_projects, search_projects_by_name +from arcade_asana.tools.tags import create_tag, list_tags, search_tags_by_name +from arcade_asana.tools.tasks import ( + attach_file_to_task, + create_task, + get_subtasks_from_a_task, + get_task_by_id, + search_tasks, + update_task, +) +from arcade_asana.tools.teams import get_team_by_id, list_teams_the_current_user_is_a_member_of +from arcade_asana.tools.users import get_user_by_id, list_users +from arcade_asana.tools.workspaces import list_workspaces __all__ = [ + "attach_file_to_task", + "create_tag", "create_task", + "get_project_by_id", + "get_subtasks_from_a_task", + "get_task_by_id", + "get_team_by_id", + "get_user_by_id", + "list_projects", + "list_tags", + "list_teams_the_current_user_is_a_member_of", + "list_users", + "list_workspaces", + "search_projects_by_name", + "search_tags_by_name", + "search_tasks", + "update_task", ] diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index dec112690..ab619e451 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -11,6 +11,7 @@ from arcade_asana.utils import ( build_task_search_query_params, clean_request_params, + get_project_by_name_or_raise_error, handle_new_task_associations, handle_new_task_tags, remove_none_values, @@ -80,19 +81,25 @@ async def search_tasks( "Restricts the search to tasks assigned to the given users. " "Defaults to None (searches tasks assigned to anyone or no one).", ] = None, - project_ids: Annotated[ - list[str] | None, - "Restricts the search to tasks associated to the given projects. " + project_name: Annotated[ + str | None, + "Restricts the search to tasks associated to the given project name. " "Defaults to None (searches tasks associated to any project).", ] = None, - team_ids: Annotated[ - list[str] | None, - "Restricts the search to tasks associated to the given teams. " + project_id: Annotated[ + str | None, + "Restricts the search to tasks associated to the given project ID. " + "Defaults to None (searches tasks associated to any project).", + ] = None, + team_id: Annotated[ + str | None, + "Restricts the search to tasks associated to the given team ID. " "Defaults to None (searches tasks associated to any team).", ] = None, tags: Annotated[ list[str] | None, "Restricts the search to tasks associated to the given tags. " + "Each item in the list can be a tag name (e.g. 'My Tag') or a tag ID (e.g. '1234567890'). " "Defaults to None (searches tasks associated to any tag or no tag).", ] = None, due_on: Annotated[ @@ -143,11 +150,30 @@ async def search_tasks( ] = SortOrder.DESCENDING, ) -> Annotated[dict[str, Any], "The tasks that match the query."]: """Search for tasks""" - from arcade_asana.tools.workspaces import list_workspaces # Avoid circular import - if not workspace_ids: - response = await list_workspaces(context) - workspace_ids = [workspace["gid"] for workspace in response["workspaces"]] + from arcade_asana.tools.workspaces import list_workspaces # Avoid circular import + + workspaces = await list_workspaces(context) + workspace_ids = [workspace["gid"] for workspace in workspaces["workspaces"]] + + if not project_id and project_name: + project = await get_project_by_name_or_raise_error(context, project_name) + project_id = project["gid"] + + tag_ids = [] + tag_names = [] + + for tag in tags: + if tag.isnumeric(): + tag_ids.append(tag) + else: + tag_names.append(tag) + + if tag_names: + from arcade_asana.tools.tags import search_tags_by_name # Avoid circular import + + tags = await search_tags_by_name(context, tag_names) + tag_ids.extend([tag["gid"] for tag in tags["matches"]["tags"]]) client = AsanaClient(context.get_auth_token_or_empty()) @@ -165,9 +191,9 @@ async def search_tasks( keywords=keywords, completed=completed, assignee_ids=assignee_ids, - project_ids=project_ids, - team_ids=team_ids, - tags=tags, + project_id=project_id, + team_id=team_id, + tag_ids=tag_ids, due_on=due_on, due_on_or_after=due_on_or_after, due_on_or_before=due_on_or_before, @@ -300,11 +326,10 @@ async def create_task( "The ID of the user to assign the task to. " "Defaults to 'me', which assigns the task to the current user.", ] = "me", - tag_names: Annotated[ - list[str] | None, "The names of the tags to associate with the task. Defaults to None." - ] = None, - tag_ids: Annotated[ - list[str] | None, "The IDs of the tags to associate with the task. Defaults to None." + tags: Annotated[ + list[str] | None, + "The tags to associate with the task. Each item in the list can be a tag name " + "(e.g. 'My Tag') or a tag ID (e.g. '1234567890'). Defaults to None.", ] = None, ) -> Annotated[ dict[str, Any], @@ -312,9 +337,8 @@ async def create_task( ]: """Creates a task in Asana - Provide none or at most one of the following argument pairs, never both: - - tag_names and tag_ids - - project_name and project_id + If the user provides tag name(s), it's not necessary to search for it first. Provide the tag + name(s) directly in the tags list argument. The task must be associated to at least one of the following: parent_task_id, project_id, or workspace_id. If none of these are provided and the account has only one workspace, the task @@ -327,7 +351,7 @@ async def create_task( context, parent_task_id, project_id, project_name, workspace_id ) - tag_ids = await handle_new_task_tags(context, tag_names, tag_ids, workspace_id) + tag_ids = await handle_new_task_tags(context, tags, workspace_id) validate_date_format("start_date", start_date) validate_date_format("due_date", due_date) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index de80db7c0..a6c36359c 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -39,9 +39,9 @@ def build_task_search_query_params( keywords: str, completed: bool, assignee_ids: list[str] | None, - project_ids: list[str] | None, - team_ids: list[str] | None, - tags: list[str] | None, + project_id: str | None, + team_id: str | None, + tag_ids: list[str] | None, due_on: str | None, due_on_or_after: str | None, due_on_or_before: str | None, @@ -62,12 +62,12 @@ def build_task_search_query_params( } if assignee_ids: query_params["assignee.any"] = ",".join(assignee_ids) - if project_ids: - query_params["projects.any"] = ",".join(project_ids) - if team_ids: - query_params["team.any"] = ",".join(team_ids) - if tags: - query_params["tags.any"] = ",".join(tags) + if project_id: + query_params["projects.any"] = ",".join(project_id) + if team_id: + query_params["team.any"] = ",".join(team_id) + if tag_ids: + query_params["tags.any"] = ",".join(tag_ids) query_params = add_task_search_date_params( query_params, @@ -207,27 +207,29 @@ async def get_project_by_name_or_raise_error( async def handle_new_task_tags( context: ToolContext, - tag_names: list[str] | None, - tag_ids: list[str] | None, + tags: list[str] | None, workspace_id: str | None, ) -> list[str] | None: - if tag_ids and tag_names: - raise ToolExecutionError( - "Provide none or at most one of tag_names and tag_ids, never both." - ) + tag_ids = [] + tag_names = [] + for tag in tags: + if tag.isnumeric(): + tag_ids.append(tag) + else: + tag_names.append(tag) if tag_names: from arcade_asana.tools.tags import create_tag, search_tags_by_name response = await search_tags_by_name(context, tag_names) - tag_ids = [tag["gid"] for tag in response["matches"]["tags"]] + tag_ids.extend([tag["gid"] for tag in response["matches"]["tags"]]) if response["not_found"]["names"]: responses = await asyncio.gather(*[ create_tag(context, name=name, workspace_id=workspace_id) for name in response["not_found"]["names"] ]) - tag_ids = [response["tag"]["gid"] for response in responses] + tag_ids.extend([response["tag"]["gid"] for response in responses]) return tag_ids diff --git a/toolkits/asana/evals/eval_projects.py b/toolkits/asana/evals/eval_projects.py new file mode 100644 index 000000000..7db031438 --- /dev/null +++ b/toolkits/asana/evals/eval_projects.py @@ -0,0 +1,297 @@ +import json + +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_asana +from arcade_asana.tools import get_project_by_id, list_projects, search_projects_by_name + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_asana) + + +@tool_eval() +def list_projects_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="list projects eval suite", + system_message=( + "You are an AI assistant with access to Asana tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="List projects", + user_message="List the projects in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_projects, + args={ + "team_ids": None, + "limit": 100, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="team_ids", weight=0.4), + BinaryCritic(critic_field="limit", weight=0.3), + BinaryCritic(critic_field="offset", weight=0.3), + ], + ) + + suite.add_case( + name="List projects filtering by teams", + user_message="List the projects in Asana for the team '1234567890'.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_projects, + args={ + "team_ids": ["1234567890"], + "limit": 100, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="team_ids", weight=0.6), + BinaryCritic(critic_field="limit", weight=0.2), + BinaryCritic(critic_field="offset", weight=0.2), + ], + ) + + suite.add_case( + name="List projects with limit", + user_message="List 10 projects in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_projects, + args={ + "team_ids": None, + "limit": 10, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="team_ids", weight=0.2), + BinaryCritic(critic_field="limit", weight=0.6), + BinaryCritic(critic_field="offset", weight=0.2), + ], + ) + + suite.add_case( + name="List projects with pagination", + user_message="Show me the next 2 projects.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_projects, + args={ + "limit": 2, + "offset": 2, + "team_ids": None, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.45), + BinaryCritic(critic_field="offset", weight=0.45), + BinaryCritic(critic_field="team_ids", weight=0.1), + ], + additional_messages=[ + {"role": "user", "content": "Show me 2 projects in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListProjects", + "arguments": '{"limit":2}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 2, + "workspaces": [ + { + "gid": "1234567890", + "name": "Project Hello", + }, + { + "gid": "1234567891", + "name": "Project World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListProjects", + }, + { + "role": "assistant", + "content": "Here are two projects in Asana:\n\n1. Project Hello\n2. Project World", + }, + ], + ) + + return suite + + +@tool_eval() +def get_project_by_id_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="get project by id eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Get project by id", + user_message="Get the project with id '1234567890' in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_project_by_id, + args={ + "project_id": "1234567890", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=0.7), + BinaryCritic(critic_field="description", weight=0.1), + BinaryCritic(critic_field="color", weight=0.1), + BinaryCritic(critic_field="workspace_id", weight=0.1), + ], + ) + + return suite + + +@tool_eval() +def search_projects_by_name_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="search projects by name eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Search projects by name", + user_message="Search for the project 'Hello' in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_projects_by_name, + args={ + "names": ["Hello"], + "team_ids": None, + "limit": 100, + "return_projects_not_matched": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="names", weight=0.7), + BinaryCritic(critic_field="team_ids", weight=0.1), + BinaryCritic(critic_field="limit", weight=0.1), + BinaryCritic(critic_field="return_projects_not_matched", weight=0.1), + ], + ) + + suite.add_case( + name="Search projects by multiple names with limit", + user_message="Search for up to 10 projects with the names 'Hello' or 'World' in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_projects_by_name, + args={ + "names": ["Hello", "World"], + "team_ids": None, + "limit": 10, + "return_projects_not_matched": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="names", weight=0.4), + BinaryCritic(critic_field="team_ids", weight=0.1), + BinaryCritic(critic_field="limit", weight=0.4), + BinaryCritic(critic_field="return_projects_not_matched", weight=0.1), + ], + ) + + suite.add_case( + name="Search projects by name and team", + user_message="Search for the project 'Hello' in Asana in the team '1234567890'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_projects_by_name, + args={ + "names": ["Hello"], + "team_ids": ["1234567890"], + "limit": 100, + "return_projects_not_matched": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="names", weight=0.4), + BinaryCritic(critic_field="team_ids", weight=0.4), + BinaryCritic(critic_field="limit", weight=0.1), + BinaryCritic(critic_field="return_projects_not_matched", weight=0.1), + ], + ) + + suite.add_case( + name="Search projects by name in multiple teams", + user_message="Search for the project 'Hello' in Asana in the teams '1234567890' and '1234567891'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_projects_by_name, + args={ + "names": ["Hello"], + "team_ids": ["1234567890", "1234567891"], + "limit": 100, + "return_projects_not_matched": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="names", weight=0.4), + BinaryCritic(critic_field="team_ids", weight=0.4), + BinaryCritic(critic_field="limit", weight=0.1), + BinaryCritic(critic_field="return_projects_not_matched", weight=0.1), + ], + ) + + return suite diff --git a/toolkits/asana/evals/eval_tags.py b/toolkits/asana/evals/eval_tags.py new file mode 100644 index 000000000..b4a31ce5d --- /dev/null +++ b/toolkits/asana/evals/eval_tags.py @@ -0,0 +1,377 @@ +import json + +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_asana +from arcade_asana.tools import create_tag, list_tags, search_tags_by_name + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_asana) + + +@tool_eval() +def list_tags_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="list tags eval suite", + system_message=( + "You are an AI assistant with access to Asana tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="List tags", + user_message="List the tags in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_tags, + args={ + "limit": 100, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.5), + BinaryCritic(critic_field="offset", weight=0.5), + ], + ) + + suite.add_case( + name="List tags with limit", + user_message="List 10 tags in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_tags, + args={ + "limit": 10, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.75), + BinaryCritic(critic_field="offset", weight=0.25), + ], + ) + + suite.add_case( + name="List tags with pagination", + user_message="Show me the next 2 tags.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_tags, + args={ + "limit": 2, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.5), + BinaryCritic(critic_field="offset", weight=0.5), + ], + additional_messages=[ + {"role": "user", "content": "Show me 2 tags in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListTags", + "arguments": '{"limit":2}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 2, + "workspaces": [ + { + "gid": "1234567890", + "name": "Tag Hello", + }, + { + "gid": "1234567891", + "name": "Tag World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListTags", + }, + { + "role": "assistant", + "content": "Here are two tags in Asana:\n\n1. Tag Hello\n2. Tag World", + }, + ], + ) + + suite.add_case( + name="List tags with pagination changing the limit", + user_message="Show me the next 5 tags.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_tags, + args={ + "limit": 5, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.5), + BinaryCritic(critic_field="offset", weight=0.5), + ], + additional_messages=[ + {"role": "user", "content": "Show me 5 tags in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListTags", + "arguments": '{"limit":5}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 2, + "workspaces": [ + { + "gid": "1234567890", + "name": "Tag Hello", + }, + { + "gid": "1234567891", + "name": "Tag World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListTags", + }, + { + "role": "assistant", + "content": "Here are five tags in Asana:\n\n1. Tag Hello\n2. Tag World\n3. Tag Hello\n4. Tag World\n5. Tag Hello", + }, + ], + ) + + return suite + + +@tool_eval() +def create_tag_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="create tag eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Create tag", + user_message="Create a 'Hello World' tag in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_tag, + args={ + "name": "Hello World", + "description": None, + "color": None, + "workspace_id": None, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=0.7), + BinaryCritic(critic_field="description", weight=0.1), + BinaryCritic(critic_field="color", weight=0.1), + BinaryCritic(critic_field="workspace_id", weight=0.1), + ], + ) + + suite.add_case( + name="Create tag with description and color", + user_message="Create a dark orange tag 'Attention' in Asana with the description 'This is a tag for important tasks'.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_tag, + args={ + "name": "Attention", + "description": "This is a tag for important tasks", + "color": "dark-orange", + "workspace_id": None, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=0.3), + BinaryCritic(critic_field="description", weight=0.3), + BinaryCritic(critic_field="color", weight=0.3), + BinaryCritic(critic_field="workspace_id", weight=0.1), + ], + ) + + suite.add_case( + name="Create tag in a specific workspace", + user_message="Create a dark orange tag 'Attention' in Asana with the description 'This is a tag for important tasks' in the workspace '1234567890'.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_tag, + args={ + "name": "Attention", + "description": "This is a tag for important tasks", + "color": "dark-orange", + "workspace_id": "1234567890", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=0.25), + BinaryCritic(critic_field="description", weight=0.25), + BinaryCritic(critic_field="color", weight=0.25), + BinaryCritic(critic_field="workspace_id", weight=0.25), + ], + ) + + return suite + + +@tool_eval() +def search_tags_by_name_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="search tags by name eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Search tags by name", + user_message="Search for the tag 'Hello' in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tags_by_name, + args={ + "names": ["Hello"], + "workspace_ids": None, + "limit": 100, + "return_tags_not_matched": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="names", weight=0.7), + BinaryCritic(critic_field="workspace_ids", weight=0.1), + BinaryCritic(critic_field="limit", weight=0.1), + BinaryCritic(critic_field="return_tags_not_matched", weight=0.1), + ], + ) + + suite.add_case( + name="Search tags by multiple names with limit", + user_message="Search for up to 10 tags with the names 'Hello' or 'World' in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tags_by_name, + args={ + "names": ["Hello", "World"], + "workspace_ids": None, + "limit": 10, + "return_tags_not_matched": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="names", weight=0.4), + BinaryCritic(critic_field="workspace_ids", weight=0.1), + BinaryCritic(critic_field="limit", weight=0.4), + BinaryCritic(critic_field="return_tags_not_matched", weight=0.1), + ], + ) + + suite.add_case( + name="Search tags by name and workspace", + user_message="Search for the tag 'Hello' in Asana in the workspace '1234567890'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tags_by_name, + args={ + "names": ["Hello"], + "workspace_ids": ["1234567890"], + "limit": 100, + "return_tags_not_matched": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="names", weight=0.4), + BinaryCritic(critic_field="workspace_ids", weight=0.4), + BinaryCritic(critic_field="limit", weight=0.1), + BinaryCritic(critic_field="return_tags_not_matched", weight=0.1), + ], + ) + + suite.add_case( + name="Search tags by name in multiple workspaces", + user_message="Search for the tag 'Hello' in Asana in the workspaces '1234567890' and '1234567891'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tags_by_name, + args={ + "names": ["Hello"], + "workspace_ids": ["1234567890", "1234567891"], + "limit": 100, + "return_tags_not_matched": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="names", weight=0.4), + BinaryCritic(critic_field="workspace_ids", weight=0.4), + BinaryCritic(critic_field="limit", weight=0.1), + BinaryCritic(critic_field="return_tags_not_matched", weight=0.1), + ], + ) + + return suite diff --git a/toolkits/asana/evals/eval_tasks.py b/toolkits/asana/evals/eval_tasks.py index e69de29bb..9135905f1 100644 --- a/toolkits/asana/evals/eval_tasks.py +++ b/toolkits/asana/evals/eval_tasks.py @@ -0,0 +1,512 @@ +import json + +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_asana +from arcade_asana.constants import SortOrder, TaskSortBy +from arcade_asana.tools import ( + create_task, + get_subtasks_from_a_task, + get_task_by_id, + search_tasks, + update_task, +) + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_asana) + + +@tool_eval() +def get_task_by_id_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="get task by id eval suite", + system_message=( + "You are an AI assistant with access to Asana tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Get task by id", + user_message="Get the task with id '1234567890' in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_task_by_id, + args={ + "task_id": "1234567890", + "max_subtasks": 100, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="task_id", weight=0.8), + BinaryCritic(critic_field="max_subtasks", weight=0.2), + ], + ) + + suite.add_case( + name="Get task by id with subtasks limit", + user_message="Get the task with id '1234567890' in Asana with up to 10 subtasks.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_task_by_id, + args={ + "task_id": "1234567890", + "max_subtasks": 10, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="task_id", weight=0.5), + BinaryCritic(critic_field="max_subtasks", weight=0.5), + ], + ) + + return suite + + +@tool_eval() +def get_subtasks_from_a_task_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="get subtasks from a task eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Get subtasks from a task", + user_message="Get the next 2 subtasks.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_subtasks_from_a_task, + args={ + "task_id": "1234567890", + "limit": 2, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="task_id", weight=1 / 3), + BinaryCritic(critic_field="limit", weight=1 / 3), + BinaryCritic(critic_field="offset", weight=1 / 3), + ], + additional_messages=[ + {"role": "user", "content": "Get 2 subtasks from the task '1234567890'."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_GetSubtasksFromATask", + "arguments": '{"task_id":"1234567890","limit":2}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 2, + "subtasks": [ + { + "gid": "1234567890", + "name": "Subtask Hello", + }, + { + "gid": "1234567891", + "name": "Subtask World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_GetSubtasksFromATask", + }, + { + "role": "assistant", + "content": "Here are two subtasks in Asana:\n\n1. Subtask Hello\n2. Subtask World", + }, + ], + ) + + return suite + + +@tool_eval() +def search_tasks_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="search tasks eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Search tasks by name", + user_message="Search for the task 'Hello' in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=1), + ], + ) + + suite.add_case( + name="Search tasks by name with custom sorting", + user_message="Search for the task 'Hello' in Asana sorting by likes in descending order.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "sort_by": TaskSortBy.LIKES, + "sort_order": SortOrder.DESCENDING, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=1 / 3), + BinaryCritic(critic_field="sort_by", weight=1 / 3), + BinaryCritic(critic_field="sort_order", weight=1 / 3), + ], + ) + + suite.add_case( + name="Search tasks by name filtering by project ID", + user_message="Search for the task 'Hello' associated to the project with ID '1234567890'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "project_id": "1234567890", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=0.5), + BinaryCritic(critic_field="project_id", weight=0.5), + ], + ) + + suite.add_case( + name="Search tasks by name filtering by project name", + user_message="Search for the task 'Hello' associated to the project named 'My Project'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "project_name": "My Project", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=0.5), + BinaryCritic(critic_field="project_name", weight=0.5), + ], + ) + + suite.add_case( + name="Search tasks by name filtering by team ID", + user_message="Search for the task 'Hello' associated to the team with ID '1234567890'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "team_id": "1234567890", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=0.5), + BinaryCritic(critic_field="team_id", weight=0.5), + ], + ) + + suite.add_case( + name="Search tasks by name filtering by tag IDs", + user_message="Search for the task 'Hello' associated to the tags with IDs '1234567890' and '1234567891'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "tags": ["1234567890", "1234567891"], + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=0.5), + BinaryCritic(critic_field="tag_ids", weight=0.5), + ], + ) + + suite.add_case( + name="Search tasks by name filtering by tags names", + user_message="Search for the task 'Hello' associated to the tags 'My Tag' and 'My Other Tag'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "tags": ["My Tag", "My Other Tag"], + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=0.5), + BinaryCritic(critic_field="tag_names", weight=0.5), + ], + ) + + suite.add_case( + name="Search tasks by name filtering by start and due dates", + user_message="Search for tasks 'Hello' that started on '2025-01-01' and are due on '2025-01-02'.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "start_on": "2025-01-01", + "due_on": "2025-01-02", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=1 / 3), + BinaryCritic(critic_field="start_on", weight=1 / 3), + BinaryCritic(critic_field="due_on", weight=1 / 3), + ], + ) + + suite.add_case( + name="Search tasks by name filtering by start and due dates", + user_message="Search for tasks 'Hello' that start on 2025-05-05 and are due on or before 2025-05-11.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "start_on": "2025-05-05", + "due_on_or_before": "2025-05-11", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=1 / 3), + BinaryCritic(critic_field="start_on", weight=1 / 3), + BinaryCritic(critic_field="due_on_or_before", weight=1 / 3), + ], + ) + + suite.add_case( + name="Search not-completed tasks by name filtering by due date", + user_message="Search for tasks 'Hello' that are not completed and are due on or before 2025-05-11.", + expected_tool_calls=[ + ExpectedToolCall( + func=search_tasks, + args={ + "keywords": "Hello", + "due_on_or_before": "2025-05-11", + "completed": False, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=1 / 3), + BinaryCritic(critic_field="due_on_or_before", weight=1 / 3), + BinaryCritic(critic_field="completed", weight=1 / 3), + ], + ) + + return suite + + +@tool_eval() +def update_task_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="update task eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Update task name", + user_message="Update the task '1234567890' with the name 'Hello World'.", + expected_tool_calls=[ + ExpectedToolCall( + func=update_task, + args={"task_id": "1234567890", "name": "Hello World"}, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="task_id", weight=0.5), + BinaryCritic(critic_field="name", weight=0.5), + ], + ) + + suite.add_case( + name="Update task as completed", + user_message="Mark the task '1234567890' as completed.", + expected_tool_calls=[ + ExpectedToolCall( + func=update_task, + args={"task_id": "1234567890", "completed": True}, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="task_id", weight=0.5), + BinaryCritic(critic_field="completed", weight=0.5), + ], + ) + + suite.add_case( + name="Update task with new parent task", + user_message="Update the task '1234567890' with the parent task '1234567891'.", + expected_tool_calls=[ + ExpectedToolCall( + func=update_task, + args={"task_id": "1234567890", "parent_task_id": "1234567891"}, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="task_id", weight=0.5), + BinaryCritic(critic_field="parent_task_id", weight=0.5), + ], + ) + + suite.add_case( + name="Update task with new assignee", + user_message="Update the task '1234567890' with the assignee '1234567891'.", + expected_tool_calls=[ + ExpectedToolCall( + func=update_task, + args={"task_id": "1234567890", "assignee_id": "1234567891"}, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="task_id", weight=0.5), + BinaryCritic(critic_field="assignee_id", weight=0.5), + ], + ) + + return suite + + +@tool_eval() +def create_task_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="create task eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Create task with name, description, start and due dates", + user_message="Create a task with the name 'Hello World' and the description 'This is a task description' starting on 2025-05-05 and due on 2025-05-11.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_task, + args={ + "name": "Hello World", + "description": "This is a task description", + "start_date": "2025-05-05", + "due_date": "2025-05-11", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=1 / 4), + BinaryCritic(critic_field="description", weight=1 / 4), + BinaryCritic(critic_field="start_date", weight=1 / 4), + BinaryCritic(critic_field="due_date", weight=1 / 4), + ], + ) + + suite.add_case( + name="Create task with name and tag names", + user_message="Create a task with the name 'Hello World' and the tags 'My Tag' and 'My Other Tag'.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_task, + args={ + "name": "Hello World", + "tags": ["My Tag", "My Other Tag"], + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=0.5), + BinaryCritic(critic_field="tags", weight=0.5), + ], + ) + + suite.add_case( + name="Create task with name and tag IDs", + user_message="Create a task with the name 'Hello World' and the tags '1234567890' and '1234567891'.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_task, + args={ + "name": "Hello World", + "tags": ["1234567890", "1234567891"], + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=0.5), + BinaryCritic(critic_field="tags", weight=0.5), + ], + ) + + return suite diff --git a/toolkits/asana/evals/eval_teams.py b/toolkits/asana/evals/eval_teams.py new file mode 100644 index 000000000..726645482 --- /dev/null +++ b/toolkits/asana/evals/eval_teams.py @@ -0,0 +1,261 @@ +import json + +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_asana +from arcade_asana.tools import get_team_by_id, list_teams_the_current_user_is_a_member_of + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_asana) + + +@tool_eval() +def get_team_by_id_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="get team by id eval suite", + system_message=( + "You are an AI assistant with access to Asana tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Get team by id", + user_message="Get the team with ID 1234567890.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_team_by_id, + args={ + "team_id": "1234567890", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="team_id", weight=1), + ], + ) + + return suite + + +@tool_eval() +def list_teams_the_current_user_is_a_member_of_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="list teams the current user is a member of eval suite", + system_message=( + "You are an AI assistant with access to Asana tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="List teams the current user is a member of", + user_message="List the teams the current user is a member of.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_teams_the_current_user_is_a_member_of, + args={}, + ), + ], + rubric=rubric, + critics=[], + ) + + suite.add_case( + name="List teams I am a member of", + user_message="List the teams I'm a member of.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_teams_the_current_user_is_a_member_of, + args={}, + ), + ], + rubric=rubric, + critics=[], + ) + + suite.add_case( + name="List teams I am a member of", + user_message="What teams am I a member of in asana?", + expected_tool_calls=[ + ExpectedToolCall( + func=list_teams_the_current_user_is_a_member_of, + args={}, + ), + ], + rubric=rubric, + critics=[], + ) + + suite.add_case( + name="List teams the current user is a member of filtering by workspace", + user_message="List the teams the current user is a member of in the workspace 1234567890.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_teams_the_current_user_is_a_member_of, + args={ + "workspace_ids": ["1234567890"], + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="workspace_ids", weight=1), + ], + ) + + suite.add_case( + name="List up to 5 teams the current user is a member of filtering by workspace", + user_message="List up to 5 teams the current user is a member of in the workspace 1234567890.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_teams_the_current_user_is_a_member_of, + args={ + "workspace_ids": ["1234567890"], + "limit": 5, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="workspace_ids", weight=0.5), + BinaryCritic(critic_field="limit", weight=0.5), + ], + ) + + suite.add_case( + name="List teams with pagination", + user_message="Show me the next 2 teams.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_teams_the_current_user_is_a_member_of, + args={ + "limit": 2, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="offset", weight=0.5), + BinaryCritic(critic_field="limit", weight=0.5), + ], + additional_messages=[ + {"role": "user", "content": "Show me 2 teams I'm a member of in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListTeamsTheCurrentUserIsAMemberOf", + "arguments": '{"limit":2}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 1, + "teams": [ + { + "gid": "1234567890", + "name": "Team Hello", + }, + { + "gid": "1234567891", + "name": "Team World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListTeamsTheCurrentUserIsAMemberOf", + }, + { + "role": "assistant", + "content": "Here are two teams you're a member of in Asana:\n\n1. Team Hello\n2. Team World", + }, + ], + ) + + suite.add_case( + name="List teams with pagination changing the limit", + user_message="Show me the next 5 teams.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_teams_the_current_user_is_a_member_of, + args={ + "limit": 5, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.5), + BinaryCritic(critic_field="offset", weight=0.5), + ], + additional_messages=[ + {"role": "user", "content": "Show me 2 teams I'm a member of in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListTeamsTheCurrentUserIsAMemberOf", + "arguments": '{"limit":2}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 1, + "teams": [ + { + "gid": "1234567890", + "name": "Team Hello", + }, + { + "gid": "1234567891", + "name": "Team World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListTeamsTheCurrentUserIsAMemberOf", + }, + { + "role": "assistant", + "content": "Here are two teams you're a member of in Asana:\n\n1. Team Hello\n2. Team World", + }, + ], + ) + + return suite diff --git a/toolkits/asana/evals/eval_users.py b/toolkits/asana/evals/eval_users.py new file mode 100644 index 000000000..fce8fdf94 --- /dev/null +++ b/toolkits/asana/evals/eval_users.py @@ -0,0 +1,253 @@ +import json + +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_asana +from arcade_asana.tools import get_user_by_id, list_users + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_asana) + + +@tool_eval() +def get_user_by_id_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="get user by id eval suite", + system_message=( + "You are an AI assistant with access to Asana tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Get user by id", + user_message="Get the user with ID 1234567890.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_user_by_id, + args={ + "user_id": "1234567890", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="user_id", weight=0.1), + ], + ) + + return suite + + +@tool_eval() +def list_users_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="list users eval suite", + system_message=( + "You are an AI assistant with access to Asana tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="List users", + user_message="List the users in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_users, + args={ + "workspace_id": None, + "limit": 100, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="workspace_id", weight=0.3), + BinaryCritic(critic_field="limit", weight=0.3), + BinaryCritic(critic_field="offset", weight=0.4), + ], + ) + + suite.add_case( + name="List users filtering by workspace", + user_message="List the users in the workspace 1234567890.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_users, + args={ + "workspace_id": "1234567890", + "limit": 100, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="workspace_id", weight=0.8), + BinaryCritic(critic_field="limit", weight=0.1), + BinaryCritic(critic_field="offset", weight=0.1), + ], + ) + + suite.add_case( + name="List users with limit", + user_message="List up to 5 users.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_users, + args={ + "limit": 5, + "workspace_id": None, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.8), + BinaryCritic(critic_field="workspace_id", weight=0.1), + BinaryCritic(critic_field="offset", weight=0.1), + ], + ) + + suite.add_case( + name="List users with pagination", + user_message="Show me the next 2 users.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_users, + args={ + "workspace_id": None, + "limit": 2, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.45), + BinaryCritic(critic_field="offset", weight=0.45), + BinaryCritic(critic_field="workspace_id", weight=0.1), + ], + additional_messages=[ + {"role": "user", "content": "Show me 2 users in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListUsers", + "arguments": '{"limit":2}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 2, + "users": [ + { + "gid": "1234567890", + "name": "User Hello", + }, + { + "gid": "1234567891", + "name": "User World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListUsers", + }, + { + "role": "assistant", + "content": "Here are two users in Asana:\n\n1. User Hello\n2. User World", + }, + ], + ) + + suite.add_case( + name="List users with pagination changing the limit", + user_message="Show me the next 5 users.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_users, + args={ + "workspace_id": None, + "limit": 5, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.45), + BinaryCritic(critic_field="offset", weight=0.45), + BinaryCritic(critic_field="workspace_id", weight=0.1), + ], + additional_messages=[ + {"role": "user", "content": "Show me 2 users in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListUsers", + "arguments": '{"limit":2}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 2, + "users": [ + { + "gid": "1234567890", + "name": "User Hello", + }, + { + "gid": "1234567891", + "name": "User World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListUsers", + }, + { + "role": "assistant", + "content": "Here are two users in Asana:\n\n1. User Hello\n2. User World", + }, + ], + ) + + return suite diff --git a/toolkits/asana/evals/eval_workspaces.py b/toolkits/asana/evals/eval_workspaces.py new file mode 100644 index 000000000..9dacccadf --- /dev/null +++ b/toolkits/asana/evals/eval_workspaces.py @@ -0,0 +1,173 @@ +import json + +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_asana +from arcade_asana.tools import list_workspaces + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_asana) + + +@tool_eval() +def list_workspaces_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="list workspaces eval suite", + system_message=( + "You are an AI assistant with access to Asana tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="List workspaces", + user_message="List the workspaces in Asana.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_workspaces, + args={ + "limit": 100, + "offset": 0, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.5), + BinaryCritic(critic_field="offset", weight=0.5), + ], + ) + + suite.add_case( + name="List workspaces with pagination", + user_message="Show me the next 2 workspaces.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_workspaces, + args={ + "limit": 2, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.5), + BinaryCritic(critic_field="offset", weight=0.5), + ], + additional_messages=[ + {"role": "user", "content": "Show me 2 workspaces in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListWorkspaces", + "arguments": '{"limit":2}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 2, + "workspaces": [ + { + "gid": "1234567890", + "name": "Workspace Hello", + }, + { + "gid": "1234567891", + "name": "Workspace World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListWorkspaces", + }, + { + "role": "assistant", + "content": "Here are two workspaces in Asana:\n\n1. Workspace Hello\n2. Workspace World", + }, + ], + ) + + suite.add_case( + name="List workspaces with pagination changing the limit", + user_message="Show me the next 5 workspaces.", + expected_tool_calls=[ + ExpectedToolCall( + func=list_workspaces, + args={ + "limit": 5, + "offset": 2, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="limit", weight=0.5), + BinaryCritic(critic_field="offset", weight=0.5), + ], + additional_messages=[ + {"role": "user", "content": "Show me 5 workspaces in Asana."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "Asana_ListWorkspaces", + "arguments": '{"limit":5}', + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({ + "count": 2, + "workspaces": [ + { + "gid": "1234567890", + "name": "Workspace Hello", + }, + { + "gid": "1234567891", + "name": "Workspace World", + }, + ], + }), + "tool_call_id": "call_1", + "name": "Asana_ListWorkspaces", + }, + { + "role": "assistant", + "content": "Here are five workspaces in Asana:\n\n1. Workspace Hello\n2. Workspace World\n3. Workspace Hello\n4. Workspace World\n5. Workspace Hello", + }, + ], + ) + + return suite From dfd7cd9c0909ddc904145e23fc432f1df3b58825 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 14:28:19 -0300 Subject: [PATCH 12/40] various adjustments; unit tests --- toolkits/asana/arcade_asana/tools/tasks.py | 30 ++---- toolkits/asana/arcade_asana/utils.py | 44 ++++++-- toolkits/asana/tests/test_utils.py | 111 +++++++++++++++++++++ 3 files changed, 154 insertions(+), 31 deletions(-) create mode 100644 toolkits/asana/tests/test_utils.py diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index ab619e451..93e580382 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -12,6 +12,7 @@ build_task_search_query_params, clean_request_params, get_project_by_name_or_raise_error, + get_tag_ids, handle_new_task_associations, handle_new_task_tags, remove_none_values, @@ -160,20 +161,7 @@ async def search_tasks( project = await get_project_by_name_or_raise_error(context, project_name) project_id = project["gid"] - tag_ids = [] - tag_names = [] - - for tag in tags: - if tag.isnumeric(): - tag_ids.append(tag) - else: - tag_names.append(tag) - - if tag_names: - from arcade_asana.tools.tags import search_tags_by_name # Avoid circular import - - tags = await search_tags_by_name(context, tag_names) - tag_ids.extend([tag["gid"] for tag in tags["matches"]["tags"]]) + tag_ids = await get_tag_ids(context, tags) client = AsanaClient(context.get_auth_token_or_empty()) @@ -315,11 +303,8 @@ async def create_task( workspace_id: Annotated[ str | None, "The ID of the workspace to associate the task to. Defaults to None." ] = None, - project_id: Annotated[ - str | None, "The ID of the project to associate the task to. Defaults to None." - ] = None, - project_name: Annotated[ - str | None, "The name of the project to associate the task to. Defaults to None." + project: Annotated[ + str | None, "The ID or name of the project to associate the task to. Defaults to None." ] = None, assignee_id: Annotated[ str | None, @@ -337,10 +322,7 @@ async def create_task( ]: """Creates a task in Asana - If the user provides tag name(s), it's not necessary to search for it first. Provide the tag - name(s) directly in the tags list argument. - - The task must be associated to at least one of the following: parent_task_id, project_id, or + The task must be associated to at least one of the following: parent_task_id, project, or workspace_id. If none of these are provided and the account has only one workspace, the task will be associated to that workspace. If the account has multiple workspaces, an error will be raised with a list of available workspaces. @@ -348,7 +330,7 @@ async def create_task( client = AsanaClient(context.get_auth_token_or_empty()) parent_task_id, project_id, workspace_id = await handle_new_task_associations( - context, parent_task_id, project_id, project_name, workspace_id + context, parent_task_id, project, workspace_id ) tag_ids = await handle_new_task_tags(context, tags, workspace_id) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index a6c36359c..167864944 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -115,8 +115,7 @@ def add_task_search_date_params( async def handle_new_task_associations( context: ToolContext, parent_task_id: str | None, - project_id: str | None, - project_name: str | None, + project: str | None, workspace_id: str | None, ) -> tuple[str | None, str | None, str | None]: """ @@ -134,12 +133,15 @@ async def handle_new_task_associations( Returns a tuple of (parent_task_id, project_id, workspace_id). """ - if project_id and project_name: - raise RetryableToolError( - "Provide none or at most one of project_id and project_name, never both." - ) + project_id, project_name = (None, None) - if not any([parent_task_id, project_id, project_name, workspace_id]): + if project: + if project.isnumeric(): + project_id = project + else: + project_name = project + + if not any([parent_task_id, project_id, workspace_id]): workspace_id = await get_unique_workspace_id_or_raise_error(context) if not workspace_id: @@ -210,6 +212,9 @@ async def handle_new_task_tags( tags: list[str] | None, workspace_id: str | None, ) -> list[str] | None: + if not tags: + return None + tag_ids = [] tag_names = [] for tag in tags: @@ -234,6 +239,31 @@ async def handle_new_task_tags( return tag_ids +async def get_tag_ids(context: ToolContext, tags: list[str] | None) -> list[str] | None: + """ + Returns the IDs of the tags provided in the tags list, which can be either tag IDs or tag names. + + If the tags list is empty, it returns None. + """ + tag_ids = [] + tag_names = [] + + if tags: + for tag in tags: + if tag.isnumeric(): + tag_ids.append(tag) + else: + tag_names.append(tag) + + if tag_names: + from arcade_asana.tools.tags import search_tags_by_name # Avoid circular import + + searched_tags = await search_tags_by_name(context, tag_names) + tag_ids.extend([tag["gid"] for tag in searched_tags["matches"]["tags"]]) + + return tag_ids if tag_ids else None + + async def paginate_tool_call( tool: Callable[[ToolContext, Any], Awaitable[ToolResponse]], context: ToolContext, diff --git a/toolkits/asana/tests/test_utils.py b/toolkits/asana/tests/test_utils.py new file mode 100644 index 000000000..73998e7f6 --- /dev/null +++ b/toolkits/asana/tests/test_utils.py @@ -0,0 +1,111 @@ +from unittest.mock import patch + +import pytest +from arcade.sdk.errors import RetryableToolError + +from arcade_asana.utils import ( + get_project_by_name_or_raise_error, + get_tag_ids, + get_unique_workspace_id_or_raise_error, + handle_task_project_association, +) + + +@pytest.mark.asyncio +@patch("arcade_asana.tools.tags.search_tags_by_name") +async def test_get_tag_ids(mock_search_tags_by_name, mock_context): + assert await get_tag_ids(mock_context, None) is None + assert await get_tag_ids(mock_context, ["1234567890", "1234567891"]) == [ + "1234567890", + "1234567891", + ] + + mock_search_tags_by_name.return_value = { + "matches": { + "tags": [ + {"gid": "1234567890", "name": "My Tag"}, + {"gid": "1234567891", "name": "My Other Tag"}, + ] + } + } + + assert await get_tag_ids(mock_context, ["My Tag", "My Other Tag"]) == [ + "1234567890", + "1234567891", + ] + + +@pytest.mark.asyncio +@patch("arcade_asana.tools.workspaces.list_workspaces") +async def test_get_unique_workspace_id_or_raise_error(mock_list_workspaces, mock_context): + mock_list_workspaces.return_value = { + "workspaces": [ + {"gid": "1234567890", "name": "My Workspace"}, + ] + } + assert await get_unique_workspace_id_or_raise_error(mock_context) == "1234567890" + + mock_list_workspaces.return_value = { + "workspaces": [ + {"gid": "1234567890", "name": "My Workspace"}, + {"gid": "1234567891", "name": "My Other Workspace"}, + ] + } + with pytest.raises(RetryableToolError) as exc_info: + await get_unique_workspace_id_or_raise_error(mock_context) + + assert "My Other Workspace" in exc_info.value.additional_prompt_content + + +@pytest.mark.asyncio +@patch("arcade_asana.tools.projects.search_projects_by_name") +async def test_get_project_by_name_or_raise_error(mock_search_projects_by_name, mock_context): + project1 = {"gid": "1234567890", "name": "My Project"} + + mock_search_projects_by_name.return_value = { + "matches": {"projects": [project1]}, + "not_matched": {"projects": []}, + } + assert await get_project_by_name_or_raise_error(mock_context, project1["name"]) == project1 + + mock_search_projects_by_name.return_value = { + "matches": {"projects": []}, + "not_matched": {"projects": [project1]}, + } + with pytest.raises(RetryableToolError) as exc_info: + await get_project_by_name_or_raise_error(mock_context, "Inexistent Project") + + assert project1["name"] in exc_info.value.additional_prompt_content + + +@pytest.mark.asyncio +@patch("arcade_asana.tools.projects.get_project_by_id") +@patch("arcade_asana.utils.get_project_by_name_or_raise_error") +async def test_handle_task_project_association_by_project_id( + mock_get_project_by_name_or_raise_error, mock_get_project_by_id, mock_context +): + mock_get_project_by_id.return_value = {"project": {"workspace": {"gid": "9999999999"}}} + assert await handle_task_project_association(mock_context, "1234567890", None, None) == ( + "1234567890", + "9999999999", + ) + mock_get_project_by_id.assert_called_once_with(mock_context, "1234567890") + mock_get_project_by_name_or_raise_error.assert_not_called() + + +@pytest.mark.asyncio +@patch("arcade_asana.tools.projects.get_project_by_id") +@patch("arcade_asana.utils.get_project_by_name_or_raise_error") +async def test_handle_task_project_association_by_project_name( + mock_get_project_by_name_or_raise_error, mock_get_project_by_id, mock_context +): + mock_get_project_by_name_or_raise_error.return_value = { + "gid": "1234567890", + "workspace": {"gid": "9999999999"}, + } + assert await handle_task_project_association(mock_context, None, "hello project", None) == ( + "1234567890", + "9999999999", + ) + mock_get_project_by_name_or_raise_error.assert_called_once_with(mock_context, "hello project") + mock_get_project_by_id.assert_not_called() From ee6852e18bc8b6b85b1cf314b272ac3b9a6e1b88 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 16:46:41 -0300 Subject: [PATCH 13/40] fix bug in filter by project name --- toolkits/asana/arcade_asana/tools/projects.py | 2 +- toolkits/asana/arcade_asana/tools/tasks.py | 1 + toolkits/asana/arcade_asana/utils.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index 90a460b03..79ddb51f5 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -65,7 +65,7 @@ async def search_projects_by_name( matches.append(project) names_lower.remove(project_name_lower) else: - not_matched.append(project["name"]) + not_matched.append(project) not_found = [name for name in names if name.casefold() in names_lower] diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 93e580382..50bde0ee3 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -159,6 +159,7 @@ async def search_tasks( if not project_id and project_name: project = await get_project_by_name_or_raise_error(context, project_name) + print("\n\nproject:", project, "\n\n") project_id = project["gid"] tag_ids = await get_tag_ids(context, tags) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index 167864944..d746f6963 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -63,9 +63,9 @@ def build_task_search_query_params( if assignee_ids: query_params["assignee.any"] = ",".join(assignee_ids) if project_id: - query_params["projects.any"] = ",".join(project_id) + query_params["projects.any"] = project_id if team_id: - query_params["team.any"] = ",".join(team_id) + query_params["team.any"] = team_id if tag_ids: query_params["tags.any"] = ",".join(tag_ids) From b371a647ae5aea84c1355fe4b85b904a864748d6 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 16:57:29 -0300 Subject: [PATCH 14/40] improve tag name filtering --- toolkits/asana/arcade_asana/tools/tags.py | 2 +- toolkits/asana/arcade_asana/tools/tasks.py | 5 ++--- toolkits/asana/arcade_asana/utils.py | 7 +++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index a0f134d91..9c939b1d8 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -149,7 +149,7 @@ async def search_tags_by_name( "count": len(matches), }, "not_found": { - "names": not_found, + "tags": not_found, "count": len(not_found), }, } diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 50bde0ee3..c55da6705 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -70,8 +70,8 @@ async def get_subtasks_from_a_task( async def search_tasks( context: ToolContext, keywords: Annotated[ - str, "Keywords to search for tasks. Matches against the task name and description." - ], + str | None, "Keywords to search for tasks. Matches against the task name and description." + ] = None, workspace_ids: Annotated[ list[str] | None, "The IDs of the workspaces to search for tasks. " @@ -159,7 +159,6 @@ async def search_tasks( if not project_id and project_name: project = await get_project_by_name_or_raise_error(context, project_name) - print("\n\nproject:", project, "\n\n") project_id = project["gid"] tag_ids = await get_tag_ids(context, tags) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index d746f6963..fa18c61bd 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -259,6 +259,13 @@ async def get_tag_ids(context: ToolContext, tags: list[str] | None) -> list[str] from arcade_asana.tools.tags import search_tags_by_name # Avoid circular import searched_tags = await search_tags_by_name(context, tag_names) + + if searched_tags["not_found"]["tags"]: + tag_names_not_found = ", ".join(searched_tags["not_found"]["tags"]) + raise ToolExecutionError( + f"Tags not found: {tag_names_not_found}. Please provide valid tag names or IDs." + ) + tag_ids.extend([tag["gid"] for tag in searched_tags["matches"]["tags"]]) return tag_ids if tag_ids else None From 2e4e48a368c7a6b0a052be5faf6822bbedcdfc28 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 16:58:49 -0300 Subject: [PATCH 15/40] improve annotation about auto-creating tags --- toolkits/asana/arcade_asana/tools/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index c55da6705..c7af6c0ca 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -314,7 +314,8 @@ async def create_task( tags: Annotated[ list[str] | None, "The tags to associate with the task. Each item in the list can be a tag name " - "(e.g. 'My Tag') or a tag ID (e.g. '1234567890'). Defaults to None.", + "(e.g. 'My Tag') or a tag ID (e.g. '1234567890'). If a tag name does not exist, " + "it will be created. Defaults to None (no tags are associated).", ] = None, ) -> Annotated[ dict[str, Any], From 0d509e6db664af5f2d5823367ded760b775c605a Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 17:09:24 -0300 Subject: [PATCH 16/40] fix task association to project name --- toolkits/asana/arcade_asana/tools/tasks.py | 2 + toolkits/asana/arcade_asana/utils.py | 47 +++++----------------- toolkits/asana/tests/test_utils.py | 37 +---------------- 3 files changed, 15 insertions(+), 71 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index c7af6c0ca..150e2db93 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -334,6 +334,8 @@ async def create_task( context, parent_task_id, project, workspace_id ) + print("\n\nproject_id:", project_id, "\n\n") + tag_ids = await handle_new_task_tags(context, tags, workspace_id) validate_date_format("start_date", start_date) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index fa18c61bd..4d1089a0f 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -141,48 +141,23 @@ async def handle_new_task_associations( else: project_name = project + if project_name: + project = await get_project_by_name_or_raise_error(context, project_name) + project_id = project["gid"] + workspace_id = project["workspace"]["gid"] + if not any([parent_task_id, project_id, workspace_id]): workspace_id = await get_unique_workspace_id_or_raise_error(context) - if not workspace_id: - if parent_task_id: - from arcade_asana.tools.tasks import get_task_by_id # avoid circular imports + if not workspace_id and parent_task_id: + from arcade_asana.tools.tasks import get_task_by_id # avoid circular imports - response = await get_task_by_id(context, parent_task_id) - workspace_id = response["task"]["workspace"]["gid"] - else: - project_id, workspace_id = await handle_task_project_association( - context, project_id, project_name, workspace_id - ) + response = await get_task_by_id(context, parent_task_id) + workspace_id = response["task"]["workspace"]["gid"] return parent_task_id, project_id, workspace_id -async def handle_task_project_association( - context: ToolContext, - project_id: str | None, - project_name: str | None, - workspace_id: str | None, -) -> tuple[str | None, str | None]: - if all([project_id, project_name]): - raise ToolExecutionError( - "Provide none or at most one of project_id and project_name, never both." - ) - - if project_id: - from arcade_asana.tools.projects import get_project_by_id # avoid circular imports - - response = await get_project_by_id(context, project_id) - workspace_id = response["project"]["workspace"]["gid"] - - elif project_name: - project = await get_project_by_name_or_raise_error(context, project_name) - project_id = project["gid"] - workspace_id = project["workspace"]["gid"] - - return project_id, workspace_id - - async def get_project_by_name_or_raise_error( context: ToolContext, project_name: str ) -> dict[str, Any]: @@ -229,10 +204,10 @@ async def handle_new_task_tags( response = await search_tags_by_name(context, tag_names) tag_ids.extend([tag["gid"] for tag in response["matches"]["tags"]]) - if response["not_found"]["names"]: + if response["not_found"]["tags"]: responses = await asyncio.gather(*[ create_tag(context, name=name, workspace_id=workspace_id) - for name in response["not_found"]["names"] + for name in response["not_found"]["tags"] ]) tag_ids.extend([response["tag"]["gid"] for response in responses]) diff --git a/toolkits/asana/tests/test_utils.py b/toolkits/asana/tests/test_utils.py index 73998e7f6..0348be2e6 100644 --- a/toolkits/asana/tests/test_utils.py +++ b/toolkits/asana/tests/test_utils.py @@ -7,7 +7,6 @@ get_project_by_name_or_raise_error, get_tag_ids, get_unique_workspace_id_or_raise_error, - handle_task_project_association, ) @@ -26,7 +25,8 @@ async def test_get_tag_ids(mock_search_tags_by_name, mock_context): {"gid": "1234567890", "name": "My Tag"}, {"gid": "1234567891", "name": "My Other Tag"}, ] - } + }, + "not_found": {"tags": []}, } assert await get_tag_ids(mock_context, ["My Tag", "My Other Tag"]) == [ @@ -76,36 +76,3 @@ async def test_get_project_by_name_or_raise_error(mock_search_projects_by_name, await get_project_by_name_or_raise_error(mock_context, "Inexistent Project") assert project1["name"] in exc_info.value.additional_prompt_content - - -@pytest.mark.asyncio -@patch("arcade_asana.tools.projects.get_project_by_id") -@patch("arcade_asana.utils.get_project_by_name_or_raise_error") -async def test_handle_task_project_association_by_project_id( - mock_get_project_by_name_or_raise_error, mock_get_project_by_id, mock_context -): - mock_get_project_by_id.return_value = {"project": {"workspace": {"gid": "9999999999"}}} - assert await handle_task_project_association(mock_context, "1234567890", None, None) == ( - "1234567890", - "9999999999", - ) - mock_get_project_by_id.assert_called_once_with(mock_context, "1234567890") - mock_get_project_by_name_or_raise_error.assert_not_called() - - -@pytest.mark.asyncio -@patch("arcade_asana.tools.projects.get_project_by_id") -@patch("arcade_asana.utils.get_project_by_name_or_raise_error") -async def test_handle_task_project_association_by_project_name( - mock_get_project_by_name_or_raise_error, mock_get_project_by_id, mock_context -): - mock_get_project_by_name_or_raise_error.return_value = { - "gid": "1234567890", - "workspace": {"gid": "9999999999"}, - } - assert await handle_task_project_association(mock_context, None, "hello project", None) == ( - "1234567890", - "9999999999", - ) - mock_get_project_by_name_or_raise_error.assert_called_once_with(mock_context, "hello project") - mock_get_project_by_id.assert_not_called() From 00812242eff9562bfc7f1604ce5cb301786c4b52 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 5 May 2025 17:11:00 -0300 Subject: [PATCH 17/40] remove debug msg --- toolkits/asana/arcade_asana/tools/tasks.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 150e2db93..c7af6c0ca 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -334,8 +334,6 @@ async def create_task( context, parent_task_id, project, workspace_id ) - print("\n\nproject_id:", project_id, "\n\n") - tag_ids = await handle_new_task_tags(context, tags, workspace_id) validate_date_format("start_date", start_date) From 2c74bb87ac11988dcc8cf2eee7462756cd5a1db5 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 21:34:46 -0300 Subject: [PATCH 18/40] Make search tags/projects by name utility functions, instead of tools Having them as tools was confusign the LLMs when calling create_task, update_task, or search_tasks. Instead of passing a tag name as arg to create_task, for instance, the LLM was thinking it was supposed to call search_tags_by_name first, which is unnecessary. --- toolkits/asana/arcade_asana/tools/__init__.py | 6 +- toolkits/asana/arcade_asana/tools/projects.py | 65 +-------- toolkits/asana/arcade_asana/tools/tags.py | 76 +---------- toolkits/asana/arcade_asana/utils.py | 126 ++++++++++++++++-- toolkits/asana/evals/eval_projects.py | 106 +-------------- toolkits/asana/evals/eval_tags.py | 106 +-------------- toolkits/asana/tests/test_utils.py | 14 +- 7 files changed, 130 insertions(+), 369 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/__init__.py b/toolkits/asana/arcade_asana/tools/__init__.py index 94e7911de..5298b4e67 100644 --- a/toolkits/asana/arcade_asana/tools/__init__.py +++ b/toolkits/asana/arcade_asana/tools/__init__.py @@ -1,5 +1,5 @@ -from arcade_asana.tools.projects import get_project_by_id, list_projects, search_projects_by_name -from arcade_asana.tools.tags import create_tag, list_tags, search_tags_by_name +from arcade_asana.tools.projects import get_project_by_id, list_projects +from arcade_asana.tools.tags import create_tag, list_tags from arcade_asana.tools.tasks import ( attach_file_to_task, create_task, @@ -26,8 +26,6 @@ "list_teams_the_current_user_is_a_member_of", "list_users", "list_workspaces", - "search_projects_by_name", - "search_tags_by_name", "search_tasks", "update_task", ] diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index 79ddb51f5..867be8cd2 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -6,7 +6,7 @@ from arcade_asana.constants import PROJECT_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.utils import clean_request_params, paginate_tool_call +from arcade_asana.utils import clean_request_params @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) @@ -26,69 +26,6 @@ async def get_project_by_id( return {"project": response["data"]} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) -async def search_projects_by_name( - context: ToolContext, - names: Annotated[list[str], "The names of the projects to search for."], - team_ids: Annotated[ - list[str] | None, - "The IDs of the teams to get projects from. " - "Defaults to None (get projects from all teams the user is a member of).", - ] = None, - limit: Annotated[ - int, "The maximum number of projects to return. Min is 1, max is 100. Defaults to 100." - ] = 100, - return_projects_not_matched: Annotated[ - bool, "Whether to return projects that were not matched. Defaults to False." - ] = False, -) -> Annotated[dict[str, Any], "Search for projects by name"]: - """Search for projects by name""" - names_lower = {name.casefold() for name in names} - - projects = await paginate_tool_call( - tool=list_projects, - context=context, - response_key="projects", - max_items=500, - timeout_seconds=15, - team_ids=team_ids, - ) - - matches: list[dict[str, Any]] = [] - not_matched: list[str] = [] - - for project in projects: - project_name_lower = project["name"].casefold() - if len(matches) >= limit: - break - if project_name_lower in names_lower: - matches.append(project) - names_lower.remove(project_name_lower) - else: - not_matched.append(project) - - not_found = [name for name in names if name.casefold() in names_lower] - - response = { - "matches": { - "projects": matches, - "count": len(matches), - }, - "not_found": { - "names": not_found, - "count": len(not_found), - }, - } - - if return_projects_not_matched: - response["not_matched"] = { - "projects": not_matched, - "count": len(not_matched), - } - - return response - - @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def list_projects( context: ToolContext, diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index 9c939b1d8..3c66112bd 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -3,14 +3,12 @@ from arcade.sdk import ToolContext, tool from arcade.sdk.auth import OAuth2 -from arcade.sdk.errors import ToolExecutionError from arcade_asana.constants import TAG_OPT_FIELDS, TagColor from arcade_asana.models import AsanaClient from arcade_asana.utils import ( clean_request_params, get_unique_workspace_id_or_raise_error, - paginate_tool_call, remove_none_values, ) @@ -32,8 +30,8 @@ async def create_tag( ] = None, ) -> Annotated[dict[str, Any], "The created tag."]: """Create a tag in Asana""" - if not 1 <= len(name) <= 100: - raise ToolExecutionError("Tag name must be between 1 and 100 characters long.") + # if not 1 <= len(name) <= 100: + # raise ToolExecutionError("Tag name must be between 1 and 100 characters long.") workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) @@ -91,73 +89,3 @@ async def list_tags( tags = [tag for response in responses for tag in response["data"]] return {"tags": tags, "count": len(tags)} - - -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=[])) -async def search_tags_by_name( - context: ToolContext, - names: Annotated[ - list[str], "The names of the tags to search for (the search is case-insensitive)." - ], - workspace_ids: Annotated[ - list[str] | None, - "The IDs of the workspaces to search for tags in. " - "If not provided, it will search across all workspaces.", - ] = None, - limit: Annotated[ - int, "The maximum number of tags to return. Min is 1, max is 100. Defaults to 100." - ] = 100, - return_tags_not_matched: Annotated[ - bool, "Whether to return tags that were not matched. Defaults to False." - ] = False, -) -> Annotated[ - dict[str, Any], - "List tags in Asana with names matching the provided names", -]: - """List tags in Asana with names matching the provided names - - The search is case-insensitive. - """ - names_lower = {name.casefold() for name in names} - - tags = await paginate_tool_call( - tool=list_tags, - context=context, - response_key="tags", - max_items=500, - timeout_seconds=15, - workspace_ids=workspace_ids, - ) - - matches: list[dict[str, Any]] = [] - not_matched: list[str] = [] - for tag in tags: - tag_name_lower = tag["name"].casefold() - if len(matches) >= limit: - break - if tag_name_lower in names_lower: - matches.append(tag) - names_lower.remove(tag_name_lower) - else: - not_matched.append(tag["name"]) - - not_found = [name for name in names if name.casefold() in names_lower] - - response = { - "matches": { - "tags": matches, - "count": len(matches), - }, - "not_found": { - "tags": not_found, - "count": len(not_found), - }, - } - - if return_tags_not_matched: - response["not_matched"] = { - "tags": not_matched, - "count": len(not_matched), - } - - return response diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index 4d1089a0f..d79c2a2a7 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -161,10 +161,7 @@ async def handle_new_task_associations( async def get_project_by_name_or_raise_error( context: ToolContext, project_name: str ) -> dict[str, Any]: - # Avoid circular imports - from arcade_asana.tools.projects import search_projects_by_name - - response = await search_projects_by_name( + response = await find_projects_by_name( context, names=[project_name], limit=1, return_projects_not_matched=True ) @@ -199,12 +196,12 @@ async def handle_new_task_tags( tag_names.append(tag) if tag_names: - from arcade_asana.tools.tags import create_tag, search_tags_by_name - - response = await search_tags_by_name(context, tag_names) + response = await find_tags_by_name(context, tag_names) tag_ids.extend([tag["gid"] for tag in response["matches"]["tags"]]) if response["not_found"]["tags"]: + from arcade_asana.tools.tags import create_tag # avoid circular imports + responses = await asyncio.gather(*[ create_tag(context, name=name, workspace_id=workspace_id) for name in response["not_found"]["tags"] @@ -231,9 +228,7 @@ async def get_tag_ids(context: ToolContext, tags: list[str] | None) -> list[str] tag_names.append(tag) if tag_names: - from arcade_asana.tools.tags import search_tags_by_name # Avoid circular import - - searched_tags = await search_tags_by_name(context, tag_names) + searched_tags = await find_tags_by_name(context, tag_names) if searched_tags["not_found"]["tags"]: tag_names_not_found = ", ".join(searched_tags["not_found"]["tags"]) @@ -296,3 +291,114 @@ async def get_unique_workspace_id_or_raise_error(context: ToolContext) -> str: developer_message=message, additional_prompt_content=additional_prompt, ) + + +async def find_projects_by_name( + context: ToolContext, + names: list[str], + team_ids: list[str] | None = None, + limit: int = 100, + return_projects_not_matched: bool = False, +) -> dict[str, Any]: + """Find projects by name using exact match""" + from arcade_asana.tools.projects import list_projects # avoid circular imports + + names_lower = {name.casefold() for name in names} + + projects = await paginate_tool_call( + tool=list_projects, + context=context, + response_key="projects", + max_items=500, + timeout_seconds=15, + team_ids=team_ids, + ) + + matches: list[dict[str, Any]] = [] + not_matched: list[str] = [] + + for project in projects: + project_name_lower = project["name"].casefold() + if len(matches) >= limit: + break + if project_name_lower in names_lower: + matches.append(project) + names_lower.remove(project_name_lower) + else: + not_matched.append(project) + + not_found = [name for name in names if name.casefold() in names_lower] + + response = { + "matches": { + "projects": matches, + "count": len(matches), + }, + "not_found": { + "names": not_found, + "count": len(not_found), + }, + } + + if return_projects_not_matched: + response["not_matched"] = { + "projects": not_matched, + "count": len(not_matched), + } + + return response + + +async def find_tags_by_name( + context: ToolContext, + names: list[str], + workspace_ids: list[str] | None = None, + limit: int = 100, + return_tags_not_matched: bool = False, +) -> dict[str, Any]: + """Find tags by name using exact match""" + from arcade_asana.tools.tags import list_tags # avoid circular imports + + names_lower = {name.casefold() for name in names} + + tags = await paginate_tool_call( + tool=list_tags, + context=context, + response_key="tags", + max_items=500, + timeout_seconds=15, + workspace_ids=workspace_ids, + ) + + matches: list[dict[str, Any]] = [] + not_matched: list[str] = [] + for tag in tags: + tag_name_lower = tag["name"].casefold() + if len(matches) >= limit: + break + if tag_name_lower in names_lower: + matches.append(tag) + names_lower.remove(tag_name_lower) + else: + not_matched.append(tag["name"]) + + not_found = [name for name in names if name.casefold() in names_lower] + + response = { + "matches": { + "tags": matches, + "count": len(matches), + }, + "not_found": { + "tags": not_found, + "count": len(not_found), + }, + } + + if return_tags_not_matched: + response["not_matched"] = { + "tags": not_matched, + "count": len(not_matched), + } + + return response diff --git a/toolkits/asana/evals/eval_projects.py b/toolkits/asana/evals/eval_projects.py index 7db031438..bb5d85bf4 100644 --- a/toolkits/asana/evals/eval_projects.py +++ b/toolkits/asana/evals/eval_projects.py @@ -10,7 +10,7 @@ from arcade.sdk.eval.critic import BinaryCritic import arcade_asana -from arcade_asana.tools import get_project_by_id, list_projects, search_projects_by_name +from arcade_asana.tools import get_project_by_id, list_projects # Evaluation rubric rubric = EvalRubric( @@ -191,107 +191,3 @@ def get_project_by_id_eval_suite() -> EvalSuite: ) return suite - - -@tool_eval() -def search_projects_by_name_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="search projects by name eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search projects by name", - user_message="Search for the project 'Hello' in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_projects_by_name, - args={ - "names": ["Hello"], - "team_ids": None, - "limit": 100, - "return_projects_not_matched": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="names", weight=0.7), - BinaryCritic(critic_field="team_ids", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="return_projects_not_matched", weight=0.1), - ], - ) - - suite.add_case( - name="Search projects by multiple names with limit", - user_message="Search for up to 10 projects with the names 'Hello' or 'World' in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_projects_by_name, - args={ - "names": ["Hello", "World"], - "team_ids": None, - "limit": 10, - "return_projects_not_matched": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="names", weight=0.4), - BinaryCritic(critic_field="team_ids", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.4), - BinaryCritic(critic_field="return_projects_not_matched", weight=0.1), - ], - ) - - suite.add_case( - name="Search projects by name and team", - user_message="Search for the project 'Hello' in Asana in the team '1234567890'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_projects_by_name, - args={ - "names": ["Hello"], - "team_ids": ["1234567890"], - "limit": 100, - "return_projects_not_matched": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="names", weight=0.4), - BinaryCritic(critic_field="team_ids", weight=0.4), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="return_projects_not_matched", weight=0.1), - ], - ) - - suite.add_case( - name="Search projects by name in multiple teams", - user_message="Search for the project 'Hello' in Asana in the teams '1234567890' and '1234567891'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_projects_by_name, - args={ - "names": ["Hello"], - "team_ids": ["1234567890", "1234567891"], - "limit": 100, - "return_projects_not_matched": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="names", weight=0.4), - BinaryCritic(critic_field="team_ids", weight=0.4), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="return_projects_not_matched", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/asana/evals/eval_tags.py b/toolkits/asana/evals/eval_tags.py index b4a31ce5d..8f35588d8 100644 --- a/toolkits/asana/evals/eval_tags.py +++ b/toolkits/asana/evals/eval_tags.py @@ -10,7 +10,7 @@ from arcade.sdk.eval.critic import BinaryCritic import arcade_asana -from arcade_asana.tools import create_tag, list_tags, search_tags_by_name +from arcade_asana.tools import create_tag, list_tags # Evaluation rubric rubric = EvalRubric( @@ -271,107 +271,3 @@ def create_tag_eval_suite() -> EvalSuite: ) return suite - - -@tool_eval() -def search_tags_by_name_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="search tags by name eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search tags by name", - user_message="Search for the tag 'Hello' in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_tags_by_name, - args={ - "names": ["Hello"], - "workspace_ids": None, - "limit": 100, - "return_tags_not_matched": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="names", weight=0.7), - BinaryCritic(critic_field="workspace_ids", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="return_tags_not_matched", weight=0.1), - ], - ) - - suite.add_case( - name="Search tags by multiple names with limit", - user_message="Search for up to 10 tags with the names 'Hello' or 'World' in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_tags_by_name, - args={ - "names": ["Hello", "World"], - "workspace_ids": None, - "limit": 10, - "return_tags_not_matched": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="names", weight=0.4), - BinaryCritic(critic_field="workspace_ids", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.4), - BinaryCritic(critic_field="return_tags_not_matched", weight=0.1), - ], - ) - - suite.add_case( - name="Search tags by name and workspace", - user_message="Search for the tag 'Hello' in Asana in the workspace '1234567890'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_tags_by_name, - args={ - "names": ["Hello"], - "workspace_ids": ["1234567890"], - "limit": 100, - "return_tags_not_matched": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="names", weight=0.4), - BinaryCritic(critic_field="workspace_ids", weight=0.4), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="return_tags_not_matched", weight=0.1), - ], - ) - - suite.add_case( - name="Search tags by name in multiple workspaces", - user_message="Search for the tag 'Hello' in Asana in the workspaces '1234567890' and '1234567891'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_tags_by_name, - args={ - "names": ["Hello"], - "workspace_ids": ["1234567890", "1234567891"], - "limit": 100, - "return_tags_not_matched": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="names", weight=0.4), - BinaryCritic(critic_field="workspace_ids", weight=0.4), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="return_tags_not_matched", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/asana/tests/test_utils.py b/toolkits/asana/tests/test_utils.py index 0348be2e6..b5a82903b 100644 --- a/toolkits/asana/tests/test_utils.py +++ b/toolkits/asana/tests/test_utils.py @@ -11,15 +11,15 @@ @pytest.mark.asyncio -@patch("arcade_asana.tools.tags.search_tags_by_name") -async def test_get_tag_ids(mock_search_tags_by_name, mock_context): +@patch("arcade_asana.utils.find_tags_by_name") +async def test_get_tag_ids(mock_find_tags_by_name, mock_context): assert await get_tag_ids(mock_context, None) is None assert await get_tag_ids(mock_context, ["1234567890", "1234567891"]) == [ "1234567890", "1234567891", ] - mock_search_tags_by_name.return_value = { + mock_find_tags_by_name.return_value = { "matches": { "tags": [ {"gid": "1234567890", "name": "My Tag"}, @@ -58,17 +58,17 @@ async def test_get_unique_workspace_id_or_raise_error(mock_list_workspaces, mock @pytest.mark.asyncio -@patch("arcade_asana.tools.projects.search_projects_by_name") -async def test_get_project_by_name_or_raise_error(mock_search_projects_by_name, mock_context): +@patch("arcade_asana.utils.find_projects_by_name") +async def test_get_project_by_name_or_raise_error(mock_find_projects_by_name, mock_context): project1 = {"gid": "1234567890", "name": "My Project"} - mock_search_projects_by_name.return_value = { + mock_find_projects_by_name.return_value = { "matches": {"projects": [project1]}, "not_matched": {"projects": []}, } assert await get_project_by_name_or_raise_error(mock_context, project1["name"]) == project1 - mock_search_projects_by_name.return_value = { + mock_find_projects_by_name.return_value = { "matches": {"projects": []}, "not_matched": {"projects": [project1]}, } From 3a5773f42ca0b4cbb8f78e98b3033842a00f4aee Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 21:52:37 -0300 Subject: [PATCH 19/40] rename gid to id --- toolkits/asana/arcade_asana/decorators.py | 26 +++++++++++++++++++++++ toolkits/asana/arcade_asana/models.py | 4 ++++ toolkits/asana/arcade_asana/tools/tags.py | 7 +++--- toolkits/asana/arcade_asana/utils.py | 16 +++++++------- toolkits/asana/evals/eval_tasks.py | 4 ++-- toolkits/asana/evals/eval_workspaces.py | 8 +++---- toolkits/asana/tests/test_utils.py | 12 +++++------ 7 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 toolkits/asana/arcade_asana/decorators.py diff --git a/toolkits/asana/arcade_asana/decorators.py b/toolkits/asana/arcade_asana/decorators.py new file mode 100644 index 000000000..9dbd1d6dd --- /dev/null +++ b/toolkits/asana/arcade_asana/decorators.py @@ -0,0 +1,26 @@ +from functools import wraps +from typing import Any + + +def clean_asana_response(func): + def response_cleaner(data: dict[str, Any]) -> dict[str, Any]: + if "gid" in data: + data["id"] = data["gid"] + del data["gid"] + + for k, v in data.items(): + if isinstance(v, dict): + data[k] = response_cleaner(v) + elif isinstance(v, list): + data[k] = [ + item if not isinstance(item, dict) else response_cleaner(item) for item in v + ] + + return data + + @wraps(func) + async def wrapper(*args, **kwargs): + response = await func(*args, **kwargs) + return response_cleaner(response) + + return wrapper diff --git a/toolkits/asana/arcade_asana/models.py b/toolkits/asana/arcade_asana/models.py index 4132be4d6..9cbfa8e07 100644 --- a/toolkits/asana/arcade_asana/models.py +++ b/toolkits/asana/arcade_asana/models.py @@ -6,6 +6,7 @@ import httpx from arcade_asana.constants import ASANA_API_VERSION, ASANA_BASE_URL, ASANA_MAX_CONCURRENT_REQUESTS +from arcade_asana.decorators import clean_asana_response from arcade_asana.exceptions import AsanaToolExecutionError @@ -69,6 +70,7 @@ def _set_request_body(self, kwargs: dict, data: dict | None, json_data: dict | N return kwargs + @clean_asana_response async def get( self, endpoint: str, @@ -95,6 +97,7 @@ async def get( self._raise_for_status(response) return cast(dict, response.json()) + @clean_asana_response async def post( self, endpoint: str, @@ -131,6 +134,7 @@ async def post( self._raise_for_status(response) return cast(dict, response.json()) + @clean_asana_response async def put( self, endpoint: str, diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index 3c66112bd..1587410ea 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -3,6 +3,7 @@ from arcade.sdk import ToolContext, tool from arcade.sdk.auth import OAuth2 +from arcade.sdk.errors import ToolExecutionError from arcade_asana.constants import TAG_OPT_FIELDS, TagColor from arcade_asana.models import AsanaClient @@ -30,8 +31,8 @@ async def create_tag( ] = None, ) -> Annotated[dict[str, Any], "The created tag."]: """Create a tag in Asana""" - # if not 1 <= len(name) <= 100: - # raise ToolExecutionError("Tag name must be between 1 and 100 characters long.") + if not 1 <= len(name) <= 100: + raise ToolExecutionError("Tag name must be between 1 and 100 characters long.") workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) @@ -70,7 +71,7 @@ async def list_tags( from arcade_asana.tools.workspaces import list_workspaces # avoid circular import workspaces = await list_workspaces(context) - workspace_ids = [workspace["gid"] for workspace in workspaces["workspaces"]] + workspace_ids = [workspace["id"] for workspace in workspaces["workspaces"]] client = AsanaClient(context.get_auth_token_or_empty()) responses = await asyncio.gather(*[ diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index d79c2a2a7..290182fc1 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -143,8 +143,8 @@ async def handle_new_task_associations( if project_name: project = await get_project_by_name_or_raise_error(context, project_name) - project_id = project["gid"] - workspace_id = project["workspace"]["gid"] + project_id = project["id"] + workspace_id = project["workspace"]["id"] if not any([parent_task_id, project_id, workspace_id]): workspace_id = await get_unique_workspace_id_or_raise_error(context) @@ -153,7 +153,7 @@ async def handle_new_task_associations( from arcade_asana.tools.tasks import get_task_by_id # avoid circular imports response = await get_task_by_id(context, parent_task_id) - workspace_id = response["task"]["workspace"]["gid"] + workspace_id = response["task"]["workspace"]["id"] return parent_task_id, project_id, workspace_id @@ -167,7 +167,7 @@ async def get_project_by_name_or_raise_error( if not response["matches"]["projects"]: projects = response["not_matched"]["projects"] - projects = [{"name": project["name"], "gid": project["gid"]} for project in projects] + projects = [{"name": project["name"], "id": project["id"]} for project in projects] message = f"Project with name '{project_name}' not found." additional_prompt = f"Projects available: {json.dumps(projects)}" raise RetryableToolError( @@ -197,7 +197,7 @@ async def handle_new_task_tags( if tag_names: response = await find_tags_by_name(context, tag_names) - tag_ids.extend([tag["gid"] for tag in response["matches"]["tags"]]) + tag_ids.extend([tag["id"] for tag in response["matches"]["tags"]]) if response["not_found"]["tags"]: from arcade_asana.tools.tags import create_tag # avoid circular imports @@ -206,7 +206,7 @@ async def handle_new_task_tags( create_tag(context, name=name, workspace_id=workspace_id) for name in response["not_found"]["tags"] ]) - tag_ids.extend([response["tag"]["gid"] for response in responses]) + tag_ids.extend([response["tag"]["id"] for response in responses]) return tag_ids @@ -236,7 +236,7 @@ async def get_tag_ids(context: ToolContext, tags: list[str] | None) -> list[str] f"Tags not found: {tag_names_not_found}. Please provide valid tag names or IDs." ) - tag_ids.extend([tag["gid"] for tag in searched_tags["matches"]["tags"]]) + tag_ids.extend([tag["id"] for tag in searched_tags["matches"]["tags"]]) return tag_ids if tag_ids else None @@ -282,7 +282,7 @@ async def get_unique_workspace_id_or_raise_error(context: ToolContext) -> str: workspaces = await list_workspaces(context) if len(workspaces["workspaces"]) == 1: - return cast(str, workspaces["workspaces"][0]["gid"]) + return cast(str, workspaces["workspaces"][0]["id"]) else: message = "User has multiple workspaces. Please provide a workspace ID." additional_prompt = f"Workspaces available: {json.dumps(workspaces['workspaces'])}" diff --git a/toolkits/asana/evals/eval_tasks.py b/toolkits/asana/evals/eval_tasks.py index 9135905f1..a2e659aaa 100644 --- a/toolkits/asana/evals/eval_tasks.py +++ b/toolkits/asana/evals/eval_tasks.py @@ -133,11 +133,11 @@ def get_subtasks_from_a_task_eval_suite() -> EvalSuite: "count": 2, "subtasks": [ { - "gid": "1234567890", + "id": "1234567890", "name": "Subtask Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "Subtask World", }, ], diff --git a/toolkits/asana/evals/eval_workspaces.py b/toolkits/asana/evals/eval_workspaces.py index 9dacccadf..77bc428a1 100644 --- a/toolkits/asana/evals/eval_workspaces.py +++ b/toolkits/asana/evals/eval_workspaces.py @@ -93,11 +93,11 @@ def list_workspaces_eval_suite() -> EvalSuite: "count": 2, "workspaces": [ { - "gid": "1234567890", + "id": "1234567890", "name": "Workspace Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "Workspace World", }, ], @@ -151,11 +151,11 @@ def list_workspaces_eval_suite() -> EvalSuite: "count": 2, "workspaces": [ { - "gid": "1234567890", + "id": "1234567890", "name": "Workspace Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "Workspace World", }, ], diff --git a/toolkits/asana/tests/test_utils.py b/toolkits/asana/tests/test_utils.py index b5a82903b..52e8e3e9f 100644 --- a/toolkits/asana/tests/test_utils.py +++ b/toolkits/asana/tests/test_utils.py @@ -22,8 +22,8 @@ async def test_get_tag_ids(mock_find_tags_by_name, mock_context): mock_find_tags_by_name.return_value = { "matches": { "tags": [ - {"gid": "1234567890", "name": "My Tag"}, - {"gid": "1234567891", "name": "My Other Tag"}, + {"id": "1234567890", "name": "My Tag"}, + {"id": "1234567891", "name": "My Other Tag"}, ] }, "not_found": {"tags": []}, @@ -40,15 +40,15 @@ async def test_get_tag_ids(mock_find_tags_by_name, mock_context): async def test_get_unique_workspace_id_or_raise_error(mock_list_workspaces, mock_context): mock_list_workspaces.return_value = { "workspaces": [ - {"gid": "1234567890", "name": "My Workspace"}, + {"id": "1234567890", "name": "My Workspace"}, ] } assert await get_unique_workspace_id_or_raise_error(mock_context) == "1234567890" mock_list_workspaces.return_value = { "workspaces": [ - {"gid": "1234567890", "name": "My Workspace"}, - {"gid": "1234567891", "name": "My Other Workspace"}, + {"id": "1234567890", "name": "My Workspace"}, + {"id": "1234567891", "name": "My Other Workspace"}, ] } with pytest.raises(RetryableToolError) as exc_info: @@ -60,7 +60,7 @@ async def test_get_unique_workspace_id_or_raise_error(mock_list_workspaces, mock @pytest.mark.asyncio @patch("arcade_asana.utils.find_projects_by_name") async def test_get_project_by_name_or_raise_error(mock_find_projects_by_name, mock_context): - project1 = {"gid": "1234567890", "name": "My Project"} + project1 = {"id": "1234567890", "name": "My Project"} mock_find_projects_by_name.return_value = { "matches": {"projects": [project1]}, From 6975778e20202393966504a9d1423db8f4ce7035 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 21:53:34 -0300 Subject: [PATCH 20/40] rename gid to id --- toolkits/asana/arcade_asana/tools/projects.py | 4 ++-- toolkits/asana/arcade_asana/tools/tasks.py | 10 +++++----- toolkits/asana/arcade_asana/tools/teams.py | 2 +- toolkits/asana/evals/eval_projects.py | 4 ++-- toolkits/asana/evals/eval_tags.py | 8 ++++---- toolkits/asana/evals/eval_teams.py | 8 ++++---- toolkits/asana/evals/eval_users.py | 8 ++++---- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index 867be8cd2..1bc3a9754 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -68,8 +68,8 @@ async def list_projects( params=clean_request_params({ "limit": limit, "offset": offset, - "team": team["gid"], - "workspace": team["organization"]["gid"], + "team": team["id"], + "workspace": team["organization"]["id"], "opt_fields": PROJECT_OPT_FIELDS, }), ) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index c7af6c0ca..548dd76bd 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -155,11 +155,11 @@ async def search_tasks( from arcade_asana.tools.workspaces import list_workspaces # Avoid circular import workspaces = await list_workspaces(context) - workspace_ids = [workspace["gid"] for workspace in workspaces["workspaces"]] + workspace_ids = [workspace["id"] for workspace in workspaces["workspaces"]] if not project_id and project_name: project = await get_project_by_name_or_raise_error(context, project_name) - project_id = project["gid"] + project_id = project["id"] tag_ids = await get_tag_ids(context, tags) @@ -196,15 +196,15 @@ async def search_tasks( for workspace_id in workspace_ids ]) - tasks_by_id = {task["gid"]: task for response in responses for task in response["data"]} + tasks_by_id = {task["id"]: task for response in responses for task in response["data"]} subtasks = await asyncio.gather(*[ - get_subtasks_from_a_task(context, task_id=task["gid"]) for task in tasks_by_id.values() + get_subtasks_from_a_task(context, task_id=task["id"]) for task in tasks_by_id.values() ]) for response in subtasks: for subtask in response["subtasks"]: - parent_task = tasks_by_id[subtask["parent"]["gid"]] + parent_task = tasks_by_id[subtask["parent"]["id"]] if "subtasks" not in parent_task: parent_task["subtasks"] = [] parent_task["subtasks"].append(subtask) diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index 71f7aba52..fd75b0493 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -48,7 +48,7 @@ async def list_teams_the_current_user_is_a_member_of( from arcade_asana.tools.workspaces import list_workspaces response = await list_workspaces(context) - workspace_ids = [workspace["gid"] for workspace in response["workspaces"]] + workspace_ids = [workspace["id"] for workspace in response["workspaces"]] client = AsanaClient(context.get_auth_token_or_empty()) responses = await asyncio.gather(*[ diff --git a/toolkits/asana/evals/eval_projects.py b/toolkits/asana/evals/eval_projects.py index bb5d85bf4..6fc4e87e2 100644 --- a/toolkits/asana/evals/eval_projects.py +++ b/toolkits/asana/evals/eval_projects.py @@ -139,11 +139,11 @@ def list_projects_eval_suite() -> EvalSuite: "count": 2, "workspaces": [ { - "gid": "1234567890", + "id": "1234567890", "name": "Project Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "Project World", }, ], diff --git a/toolkits/asana/evals/eval_tags.py b/toolkits/asana/evals/eval_tags.py index 8f35588d8..9d0c61a02 100644 --- a/toolkits/asana/evals/eval_tags.py +++ b/toolkits/asana/evals/eval_tags.py @@ -112,11 +112,11 @@ def list_tags_eval_suite() -> EvalSuite: "count": 2, "workspaces": [ { - "gid": "1234567890", + "id": "1234567890", "name": "Tag Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "Tag World", }, ], @@ -170,11 +170,11 @@ def list_tags_eval_suite() -> EvalSuite: "count": 2, "workspaces": [ { - "gid": "1234567890", + "id": "1234567890", "name": "Tag Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "Tag World", }, ], diff --git a/toolkits/asana/evals/eval_teams.py b/toolkits/asana/evals/eval_teams.py index 726645482..f10e44744 100644 --- a/toolkits/asana/evals/eval_teams.py +++ b/toolkits/asana/evals/eval_teams.py @@ -181,11 +181,11 @@ def list_teams_the_current_user_is_a_member_of_eval_suite() -> EvalSuite: "count": 1, "teams": [ { - "gid": "1234567890", + "id": "1234567890", "name": "Team Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "Team World", }, ], @@ -239,11 +239,11 @@ def list_teams_the_current_user_is_a_member_of_eval_suite() -> EvalSuite: "count": 1, "teams": [ { - "gid": "1234567890", + "id": "1234567890", "name": "Team Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "Team World", }, ], diff --git a/toolkits/asana/evals/eval_users.py b/toolkits/asana/evals/eval_users.py index fce8fdf94..daee15ff0 100644 --- a/toolkits/asana/evals/eval_users.py +++ b/toolkits/asana/evals/eval_users.py @@ -171,11 +171,11 @@ def list_users_eval_suite() -> EvalSuite: "count": 2, "users": [ { - "gid": "1234567890", + "id": "1234567890", "name": "User Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "User World", }, ], @@ -231,11 +231,11 @@ def list_users_eval_suite() -> EvalSuite: "count": 2, "users": [ { - "gid": "1234567890", + "id": "1234567890", "name": "User Hello", }, { - "gid": "1234567891", + "id": "1234567891", "name": "User World", }, ], From e57f263ab29e4921a934c2e820073d8d5880efad Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 22:01:17 -0300 Subject: [PATCH 21/40] sanitize offset to always be a str --- toolkits/asana/arcade_asana/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/toolkits/asana/arcade_asana/models.py b/toolkits/asana/arcade_asana/models.py index 9cbfa8e07..cab849d51 100644 --- a/toolkits/asana/arcade_asana/models.py +++ b/toolkits/asana/arcade_asana/models.py @@ -90,6 +90,11 @@ async def get( } if params: + # Weirdly, Asana expects offset to be a string. Decided to sanitize it here + # to avoid having to remember every time we call a get endpoint. + if "offset" in params: + params["offset"] = str(params["offset"]) + kwargs["params"] = params async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] From 7c32444c2a82b55799b0c7e9eba142fa1267437a Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 22:04:26 -0300 Subject: [PATCH 22/40] make sure limit is always between 1 and 100 --- toolkits/asana/arcade_asana/tools/projects.py | 1 + toolkits/asana/arcade_asana/tools/tags.py | 2 ++ toolkits/asana/arcade_asana/tools/tasks.py | 4 ++++ toolkits/asana/arcade_asana/tools/teams.py | 2 ++ toolkits/asana/arcade_asana/tools/users.py | 2 ++ toolkits/asana/arcade_asana/tools/workspaces.py | 2 ++ 6 files changed, 13 insertions(+) diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index 1bc3a9754..97407682c 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -45,6 +45,7 @@ async def list_projects( """List projects in Asana""" # Note: Asana recommends filtering by team to avoid timeout in large domains. # Ref: https://developers.asana.com/reference/getprojects + limit = max(1, min(100, limit)) client = AsanaClient(context.get_auth_token_or_empty()) diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index 1587410ea..6bca6a2bc 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -67,6 +67,8 @@ async def list_tags( "List tags in Asana that are visible to the authenticated user", ]: """List tags in Asana that are visible to the authenticated user""" + limit = max(1, min(100, limit)) + if not workspace_ids: from arcade_asana.tools.workspaces import list_workspaces # avoid circular import diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 548dd76bd..f0b5c2f2d 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -54,6 +54,8 @@ async def get_subtasks_from_a_task( ] = 0, ) -> Annotated[dict[str, Any], "The subtasks of the task."]: """Get the subtasks of a task""" + limit = max(1, min(100, limit)) + client = AsanaClient(context.get_auth_token_or_empty()) response = await client.get( f"/tasks/{task_id}/subtasks", @@ -151,6 +153,8 @@ async def search_tasks( ] = SortOrder.DESCENDING, ) -> Annotated[dict[str, Any], "The tasks that match the query."]: """Search for tasks""" + limit = max(1, min(100, limit)) + if not workspace_ids: from arcade_asana.tools.workspaces import list_workspaces # Avoid circular import diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index fd75b0493..9d53530a3 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -43,6 +43,8 @@ async def list_teams_the_current_user_is_a_member_of( "List teams in Asana that the current user is a member of", ]: """List teams in Asana that the current user is a member of""" + limit = max(1, min(100, limit)) + if not workspace_ids: # Importing here to avoid circular imports from arcade_asana.tools.workspaces import list_workspaces diff --git a/toolkits/asana/arcade_asana/tools/users.py b/toolkits/asana/arcade_asana/tools/users.py index 5a57ed488..1a6d9fc25 100644 --- a/toolkits/asana/arcade_asana/tools/users.py +++ b/toolkits/asana/arcade_asana/tools/users.py @@ -26,6 +26,8 @@ async def list_users( "List users in Asana", ]: """List users in Asana""" + limit = max(1, min(100, limit)) + client = AsanaClient(context.get_auth_token_or_empty()) response = await client.get( "/users", diff --git a/toolkits/asana/arcade_asana/tools/workspaces.py b/toolkits/asana/arcade_asana/tools/workspaces.py index 77a750e64..09d915784 100644 --- a/toolkits/asana/arcade_asana/tools/workspaces.py +++ b/toolkits/asana/arcade_asana/tools/workspaces.py @@ -22,6 +22,8 @@ async def list_workspaces( "List workspaces in Asana that are visible to the authenticated user", ]: """List workspaces in Asana that are visible to the authenticated user""" + limit = max(1, min(100, limit)) + client = AsanaClient(context.get_auth_token_or_empty()) response = await client.get( "/workspaces", From 0865befb81de98762d99c959622a463c8be4fdee Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 22:09:55 -0300 Subject: [PATCH 23/40] make max items scanned an arg in find tags/projects funcs --- toolkits/asana/arcade_asana/utils.py | 54 +++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index 290182fc1..da1b318c2 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -162,7 +162,7 @@ async def get_project_by_name_or_raise_error( context: ToolContext, project_name: str ) -> dict[str, Any]: response = await find_projects_by_name( - context, names=[project_name], limit=1, return_projects_not_matched=True + context, names=[project_name], response_limit=1, return_projects_not_matched=True ) if not response["matches"]["projects"]: @@ -297,10 +297,28 @@ async def find_projects_by_name( context: ToolContext, names: list[str], team_ids: list[str] | None = None, - limit: int = 100, + response_limit: int = 100, + max_items_to_scan: int = 500, return_projects_not_matched: bool = False, ) -> dict[str, Any]: - """Find projects by name using exact match""" + """Find projects by name using exact match + + This function will paginate the list_projects tool call and return the projects that match + the names provided. If the names provided are not found, it will return the names searched for + that did not match any projects. + + If return_projects_not_matched is True, it will also return the projects that were scanned, + but did not match any of the names searched for. + + Args: + context: The tool context to use in the list_projects tool call. + names: The names of the projects to search for. + team_ids: The IDs of the teams to search for projects in. + response_limit: The maximum number of matched projects to return. + max_items_to_scan: The maximum number of projects to scan while looking for matches. + return_projects_not_matched: Whether to return the projects that were scanned, but did not + match any of the names searched for. + """ from arcade_asana.tools.projects import list_projects # avoid circular imports names_lower = {name.casefold() for name in names} @@ -309,7 +327,7 @@ async def find_projects_by_name( tool=list_projects, context=context, response_key="projects", - max_items=500, + max_items=max_items_to_scan, timeout_seconds=15, team_ids=team_ids, ) @@ -319,7 +337,7 @@ async def find_projects_by_name( for project in projects: project_name_lower = project["name"].casefold() - if len(matches) >= limit: + if len(matches) >= response_limit: break if project_name_lower in names_lower: matches.append(project) @@ -353,10 +371,28 @@ async def find_tags_by_name( context: ToolContext, names: list[str], workspace_ids: list[str] | None = None, - limit: int = 100, + response_limit: int = 100, + max_items_to_scan: int = 500, return_tags_not_matched: bool = False, ) -> dict[str, Any]: - """Find tags by name using exact match""" + """Find tags by name using exact match + + This function will paginate the list_tags tool call and return the tags that match the names + provided. If the names provided are not found, it will return the names searched for that did + not match any tags. + + If return_tags_not_matched is True, it will also return the tags that were scanned, but did not + match any of the names searched for. + + Args: + context: The tool context to use in the list_tags tool call. + names: The names of the tags to search for. + workspace_ids: The IDs of the workspaces to search for tags in. + response_limit: The maximum number of matched tags to return. + max_items_to_scan: The maximum number of tags to scan while looking for matches. + return_tags_not_matched: Whether to return the tags that were scanned, but did not match + any of the names searched for. + """ from arcade_asana.tools.tags import list_tags # avoid circular imports names_lower = {name.casefold() for name in names} @@ -365,7 +401,7 @@ async def find_tags_by_name( tool=list_tags, context=context, response_key="tags", - max_items=500, + max_items=max_items_to_scan, timeout_seconds=15, workspace_ids=workspace_ids, ) @@ -374,7 +410,7 @@ async def find_tags_by_name( not_matched: list[str] = [] for tag in tags: tag_name_lower = tag["name"].casefold() - if len(matches) >= limit: + if len(matches) >= response_limit: break if tag_name_lower in names_lower: matches.append(tag) From ee42f058f905ebe4bd02948412a8d3769cc957aa Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 22:21:12 -0300 Subject: [PATCH 24/40] include msg about the limitation in find by name --- toolkits/asana/arcade_asana/constants.py | 8 ++++ toolkits/asana/arcade_asana/utils.py | 47 +++++++++++++++++++----- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/toolkits/asana/arcade_asana/constants.py b/toolkits/asana/arcade_asana/constants.py index 8d8cfc7a2..16b4e1dda 100644 --- a/toolkits/asana/arcade_asana/constants.py +++ b/toolkits/asana/arcade_asana/constants.py @@ -9,6 +9,14 @@ except ValueError: ASANA_MAX_CONCURRENT_REQUESTS = 3 +try: + ASANA_MAX_TIMEOUT_SECONDS = int(os.getenv("ASANA_MAX_TIMEOUT_SECONDS", 20)) +except ValueError: + ASANA_MAX_TIMEOUT_SECONDS = 20 + +MAX_PROJECTS_TO_SCAN_BY_NAME = 1000 +MAX_TAGS_TO_SCAN_BY_NAME = 1000 + PROJECT_OPT_FIELDS = [ "gid", "resource_type", diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index da1b318c2..0ba7a7267 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -7,7 +7,14 @@ from arcade.sdk import ToolContext from arcade.sdk.errors import RetryableToolError, ToolExecutionError -from arcade_asana.constants import TASK_OPT_FIELDS, SortOrder, TaskSortBy +from arcade_asana.constants import ( + ASANA_MAX_TIMEOUT_SECONDS, + MAX_PROJECTS_TO_SCAN_BY_NAME, + MAX_TAGS_TO_SCAN_BY_NAME, + TASK_OPT_FIELDS, + SortOrder, + TaskSortBy, +) from arcade_asana.exceptions import PaginationTimeoutError ToolResponse = TypeVar("ToolResponse", bound=dict[str, Any]) @@ -159,16 +166,26 @@ async def handle_new_task_associations( async def get_project_by_name_or_raise_error( - context: ToolContext, project_name: str + context: ToolContext, + project_name: str, + max_items_to_scan: int = MAX_PROJECTS_TO_SCAN_BY_NAME, ) -> dict[str, Any]: response = await find_projects_by_name( - context, names=[project_name], response_limit=1, return_projects_not_matched=True + context=context, + names=[project_name], + response_limit=1, + max_items_to_scan=max_items_to_scan, + return_projects_not_matched=True, ) if not response["matches"]["projects"]: projects = response["not_matched"]["projects"] projects = [{"name": project["name"], "id": project["id"]} for project in projects] - message = f"Project with name '{project_name}' not found." + message = ( + f"Project with name '{project_name}' was not found. The search scans up to " + f"{max_items_to_scan} projects. If the user account has a larger number of projects, " + "it's possible that it exists, but the search didn't find it." + ) additional_prompt = f"Projects available: {json.dumps(projects)}" raise RetryableToolError( message=message, @@ -211,7 +228,11 @@ async def handle_new_task_tags( return tag_ids -async def get_tag_ids(context: ToolContext, tags: list[str] | None) -> list[str] | None: +async def get_tag_ids( + context: ToolContext, + tags: list[str] | None, + max_items_to_scan: int = MAX_TAGS_TO_SCAN_BY_NAME, +) -> list[str] | None: """ Returns the IDs of the tags provided in the tags list, which can be either tag IDs or tag names. @@ -228,12 +249,18 @@ async def get_tag_ids(context: ToolContext, tags: list[str] | None) -> list[str] tag_names.append(tag) if tag_names: - searched_tags = await find_tags_by_name(context, tag_names) + searched_tags = await find_tags_by_name( + context=context, + names=tag_names, + max_items_to_scan=max_items_to_scan, + ) if searched_tags["not_found"]["tags"]: tag_names_not_found = ", ".join(searched_tags["not_found"]["tags"]) raise ToolExecutionError( - f"Tags not found: {tag_names_not_found}. Please provide valid tag names or IDs." + f"Tags not found: {tag_names_not_found}. The search scans up to " + f"{max_items_to_scan} tags. If the user account has a larger number of tags, " + "it's possible that the tags exist, but the search didn't find them." ) tag_ids.extend([tag["id"] for tag in searched_tags["matches"]["tags"]]) @@ -246,7 +273,7 @@ async def paginate_tool_call( context: ToolContext, response_key: str, max_items: int = 300, - timeout_seconds: int = 10, + timeout_seconds: int = ASANA_MAX_TIMEOUT_SECONDS, **tool_kwargs: Any, ) -> list[ToolResponse]: results: list[ToolResponse] = [] @@ -298,7 +325,7 @@ async def find_projects_by_name( names: list[str], team_ids: list[str] | None = None, response_limit: int = 100, - max_items_to_scan: int = 500, + max_items_to_scan: int = MAX_PROJECTS_TO_SCAN_BY_NAME, return_projects_not_matched: bool = False, ) -> dict[str, Any]: """Find projects by name using exact match @@ -372,7 +399,7 @@ async def find_tags_by_name( names: list[str], workspace_ids: list[str] | None = None, response_limit: int = 100, - max_items_to_scan: int = 500, + max_items_to_scan: int = MAX_TAGS_TO_SCAN_BY_NAME, return_tags_not_matched: bool = False, ) -> dict[str, Any]: """Find tags by name using exact match From ac6cc9adf7a2a93030f2fe3b0d90dec0264c9226 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 22:22:54 -0300 Subject: [PATCH 25/40] cannot update a task parent --- toolkits/asana/arcade_asana/tools/tasks.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index f0b5c2f2d..d8a126f66 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -247,11 +247,6 @@ async def update_task( "The new description of the task. " "Defaults to None (does not change the current description).", ] = None, - parent_task_id: Annotated[ - str | None, - "The ID of the new parent task. " - "Defaults to None (does not change the current parent task).", - ] = None, assignee_id: Annotated[ str | None, "The ID of the new user to assign the task to. " @@ -275,7 +270,6 @@ async def update_task( "due_on": due_date, "start_on": start_date, "notes": description, - "parent": parent_task_id, "assignee": assignee_id, }), } From 3c016f5425ed7f560c2867acc037be84621f7579 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 22:26:05 -0300 Subject: [PATCH 26/40] update evals; default workspace_id in list_users --- toolkits/asana/arcade_asana/tools/users.py | 5 ++++- toolkits/asana/evals/eval_tags.py | 2 +- toolkits/asana/evals/eval_workspaces.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/users.py b/toolkits/asana/arcade_asana/tools/users.py index 1a6d9fc25..124f3f757 100644 --- a/toolkits/asana/arcade_asana/tools/users.py +++ b/toolkits/asana/arcade_asana/tools/users.py @@ -5,7 +5,7 @@ from arcade_asana.constants import USER_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.utils import clean_request_params +from arcade_asana.utils import clean_request_params, get_unique_workspace_id_or_raise_error @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) @@ -28,6 +28,9 @@ async def list_users( """List users in Asana""" limit = max(1, min(100, limit)) + if not workspace_id: + workspace_id = await get_unique_workspace_id_or_raise_error(context) + client = AsanaClient(context.get_auth_token_or_empty()) response = await client.get( "/users", diff --git a/toolkits/asana/evals/eval_tags.py b/toolkits/asana/evals/eval_tags.py index 9d0c61a02..566f5bc08 100644 --- a/toolkits/asana/evals/eval_tags.py +++ b/toolkits/asana/evals/eval_tags.py @@ -184,7 +184,7 @@ def list_tags_eval_suite() -> EvalSuite: }, { "role": "assistant", - "content": "Here are five tags in Asana:\n\n1. Tag Hello\n2. Tag World\n3. Tag Hello\n4. Tag World\n5. Tag Hello", + "content": "Here are two tags in Asana:\n\n1. Tag Hello\n2. Tag World", }, ], ) diff --git a/toolkits/asana/evals/eval_workspaces.py b/toolkits/asana/evals/eval_workspaces.py index 77bc428a1..39bb43d8b 100644 --- a/toolkits/asana/evals/eval_workspaces.py +++ b/toolkits/asana/evals/eval_workspaces.py @@ -165,7 +165,7 @@ def list_workspaces_eval_suite() -> EvalSuite: }, { "role": "assistant", - "content": "Here are five workspaces in Asana:\n\n1. Workspace Hello\n2. Workspace World\n3. Workspace Hello\n4. Workspace World\n5. Workspace Hello", + "content": "Here are two workspaces in Asana:\n\n1. Workspace Hello\n2. Workspace World", }, ], ) From 56fd00f8e8ad8a90e8f4339587dc9a8418d9ca50 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 22:54:58 -0300 Subject: [PATCH 27/40] fix mypy errors --- toolkits/asana/arcade_asana/decorators.py | 6 +++--- toolkits/asana/arcade_asana/utils.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/toolkits/asana/arcade_asana/decorators.py b/toolkits/asana/arcade_asana/decorators.py index 9dbd1d6dd..64f7fa7da 100644 --- a/toolkits/asana/arcade_asana/decorators.py +++ b/toolkits/asana/arcade_asana/decorators.py @@ -1,8 +1,8 @@ from functools import wraps -from typing import Any +from typing import Any, Callable -def clean_asana_response(func): +def clean_asana_response(func: Callable[..., Any]) -> Callable[..., Any]: def response_cleaner(data: dict[str, Any]) -> dict[str, Any]: if "gid" in data: data["id"] = data["gid"] @@ -19,7 +19,7 @@ def response_cleaner(data: dict[str, Any]) -> dict[str, Any]: return data @wraps(func) - async def wrapper(*args, **kwargs): + async def wrapper(*args: Any, **kwargs: Any) -> Any: response = await func(*args, **kwargs) return response_cleaner(response) diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index 0ba7a7267..1915ff512 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -43,7 +43,7 @@ def validate_date_format(name: str, date_str: str | None) -> None: def build_task_search_query_params( - keywords: str, + keywords: str | None, completed: bool, assignee_ids: list[str] | None, project_id: str | None, @@ -149,9 +149,9 @@ async def handle_new_task_associations( project_name = project if project_name: - project = await get_project_by_name_or_raise_error(context, project_name) - project_id = project["id"] - workspace_id = project["workspace"]["id"] + project_data = await get_project_by_name_or_raise_error(context, project_name) + project_id = project_data["id"] + workspace_id = project_data["workspace"]["id"] if not any([parent_task_id, project_id, workspace_id]): workspace_id = await get_unique_workspace_id_or_raise_error(context) From 8a763fa5df72162d7698e52f3e50ee19dc7599e2 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Tue, 6 May 2025 23:06:54 -0300 Subject: [PATCH 28/40] make annotations more clear in args with list of values --- toolkits/asana/arcade_asana/tools/projects.py | 2 +- toolkits/asana/arcade_asana/tools/tasks.py | 15 +-- toolkits/asana/arcade_asana/tools/teams.py | 2 +- toolkits/asana/evals/eval_create_task.py | 96 +++++++++++++++++++ toolkits/asana/evals/eval_tasks.py | 74 -------------- 5 files changed, 106 insertions(+), 83 deletions(-) create mode 100644 toolkits/asana/evals/eval_create_task.py diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index 97407682c..0970be33d 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -31,7 +31,7 @@ async def list_projects( context: ToolContext, team_ids: Annotated[ list[str] | None, - "The IDs of the teams to get projects from. " + "The team IDs to get projects from. Multiple team IDs can be provided in the list. " "Defaults to None (get projects from all teams the user is a member of).", ] = None, limit: Annotated[ diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index d8a126f66..281fe21e8 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -76,13 +76,13 @@ async def search_tasks( ] = None, workspace_ids: Annotated[ list[str] | None, - "The IDs of the workspaces to search for tasks. " - "Defaults to None (searches across all workspaces).", + "The workspace IDs to search for tasks. Multiple workspace IDs can be provided in " + "the list. Defaults to None (searches across all workspaces).", ] = None, assignee_ids: Annotated[ list[str] | None, - "Restricts the search to tasks assigned to the given users. " - "Defaults to None (searches tasks assigned to anyone or no one).", + "Restricts the search to tasks assigned to the given user IDs. Multiple user IDs can be " + "provided in the list. Defaults to None (searches tasks assigned to anyone or no one).", ] = None, project_name: Annotated[ str | None, @@ -311,9 +311,10 @@ async def create_task( ] = "me", tags: Annotated[ list[str] | None, - "The tags to associate with the task. Each item in the list can be a tag name " - "(e.g. 'My Tag') or a tag ID (e.g. '1234567890'). If a tag name does not exist, " - "it will be created. Defaults to None (no tags are associated).", + "The tags to associate with the task. Multiple tags can be provided in the list. " + "Each item in the list can be a tag name (e.g. 'My Tag') or a tag ID (e.g. '1234567890'). " + "If a tag name does not exist, it will be automatically created with the new task. " + "Defaults to None (no tags associated).", ] = None, ) -> Annotated[ dict[str, Any], diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index 9d53530a3..ee05ff275 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -27,7 +27,7 @@ async def list_teams_the_current_user_is_a_member_of( context: ToolContext, workspace_ids: Annotated[ list[str] | None, - "The IDs of the workspaces to get teams from. " + "The workspace IDs to get teams from. Multiple workspace IDs can be provided in the list. " "Defaults to None (get teams from all workspaces the user is a member of).", ] = None, limit: Annotated[ diff --git a/toolkits/asana/evals/eval_create_task.py b/toolkits/asana/evals/eval_create_task.py new file mode 100644 index 000000000..418317f7d --- /dev/null +++ b/toolkits/asana/evals/eval_create_task.py @@ -0,0 +1,96 @@ +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_asana +from arcade_asana.tools import ( + create_task, +) + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_asana) + + +@tool_eval() +def create_task_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="create task eval suite", + system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Create task with name, description, start and due dates", + user_message="Create a task with the name 'Hello World' and the description 'This is a task description' starting on 2025-05-05 and due on 2025-05-11.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_task, + args={ + "name": "Hello World", + "description": "This is a task description", + "start_date": "2025-05-05", + "due_date": "2025-05-11", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=1 / 4), + BinaryCritic(critic_field="description", weight=1 / 4), + BinaryCritic(critic_field="start_date", weight=1 / 4), + BinaryCritic(critic_field="due_date", weight=1 / 4), + ], + ) + + suite.add_case( + name="Create task with name and tag names", + user_message="Create a task with the name 'Hello World' and the tags 'My Tag' and 'My Other Tag'.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_task, + args={ + "name": "Hello World", + "tags": ["My Tag", "My Other Tag"], + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=0.5), + BinaryCritic(critic_field="tags", weight=0.5), + ], + ) + + suite.add_case( + name="Create task with name and tag IDs", + user_message="Create a task with the name 'Hello World' and the tags '1234567890' and '1234567891'.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_task, + args={ + "name": "Hello World", + "tags": ["1234567890", "1234567891"], + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="name", weight=0.5), + BinaryCritic(critic_field="tags", weight=0.5), + ], + ) + + return suite diff --git a/toolkits/asana/evals/eval_tasks.py b/toolkits/asana/evals/eval_tasks.py index a2e659aaa..7fad1db40 100644 --- a/toolkits/asana/evals/eval_tasks.py +++ b/toolkits/asana/evals/eval_tasks.py @@ -12,7 +12,6 @@ import arcade_asana from arcade_asana.constants import SortOrder, TaskSortBy from arcade_asana.tools import ( - create_task, get_subtasks_from_a_task, get_task_by_id, search_tasks, @@ -437,76 +436,3 @@ def update_task_eval_suite() -> EvalSuite: ) return suite - - -@tool_eval() -def create_task_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="create task eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create task with name, description, start and due dates", - user_message="Create a task with the name 'Hello World' and the description 'This is a task description' starting on 2025-05-05 and due on 2025-05-11.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_task, - args={ - "name": "Hello World", - "description": "This is a task description", - "start_date": "2025-05-05", - "due_date": "2025-05-11", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=1 / 4), - BinaryCritic(critic_field="description", weight=1 / 4), - BinaryCritic(critic_field="start_date", weight=1 / 4), - BinaryCritic(critic_field="due_date", weight=1 / 4), - ], - ) - - suite.add_case( - name="Create task with name and tag names", - user_message="Create a task with the name 'Hello World' and the tags 'My Tag' and 'My Other Tag'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_task, - args={ - "name": "Hello World", - "tags": ["My Tag", "My Other Tag"], - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=0.5), - BinaryCritic(critic_field="tags", weight=0.5), - ], - ) - - suite.add_case( - name="Create task with name and tag IDs", - user_message="Create a task with the name 'Hello World' and the tags '1234567890' and '1234567891'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_task, - args={ - "name": "Hello World", - "tags": ["1234567890", "1234567891"], - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=0.5), - BinaryCritic(critic_field="tags", weight=0.5), - ], - ) - - return suite From 31053d8e7491ce63fdfb02eacaf013167b24e026 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Wed, 7 May 2025 16:01:30 -0300 Subject: [PATCH 29/40] various fixes and improvements --- toolkits/asana/arcade_asana/models.py | 5 - toolkits/asana/arcade_asana/tools/projects.py | 72 +++++---- toolkits/asana/arcade_asana/tools/tags.py | 59 ++++---- toolkits/asana/arcade_asana/tools/tasks.py | 139 +++++++++--------- toolkits/asana/arcade_asana/tools/teams.py | 64 ++++---- toolkits/asana/arcade_asana/tools/users.py | 26 +++- .../asana/arcade_asana/tools/workspaces.py | 15 +- toolkits/asana/arcade_asana/utils.py | 59 +++++--- 8 files changed, 223 insertions(+), 216 deletions(-) diff --git a/toolkits/asana/arcade_asana/models.py b/toolkits/asana/arcade_asana/models.py index cab849d51..9cbfa8e07 100644 --- a/toolkits/asana/arcade_asana/models.py +++ b/toolkits/asana/arcade_asana/models.py @@ -90,11 +90,6 @@ async def get( } if params: - # Weirdly, Asana expects offset to be a string. Decided to sanitize it here - # to avoid having to remember every time we call a get endpoint. - if "offset" in params: - params["offset"] = str(params["offset"]) - kwargs["params"] = params async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index 0970be33d..35bcfc029 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -1,4 +1,3 @@ -import asyncio from typing import Annotated, Any from arcade.sdk import ToolContext, tool @@ -6,7 +5,11 @@ from arcade_asana.constants import PROJECT_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.utils import clean_request_params +from arcade_asana.utils import ( + get_next_page, + get_unique_workspace_id_or_raise_error, + remove_none_values, +) @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) @@ -29,15 +32,24 @@ async def get_project_by_id( @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def list_projects( context: ToolContext, - team_ids: Annotated[ - list[str] | None, - "The team IDs to get projects from. Multiple team IDs can be provided in the list. " - "Defaults to None (get projects from all teams the user is a member of).", + team_id: Annotated[ + str | None, + "The team ID to get projects from. Defaults to None (does not filter by team).", + ] = None, + workspace_id: Annotated[ + str | None, + "The workspace ID to get projects from. Defaults to None. If not provided and the user " + "has only one workspace, it will use that workspace. If not provided and the user has " + "multiple workspaces, it will raise an error listing the available workspaces.", ] = None, limit: Annotated[ int, "The maximum number of projects to return. Min is 1, max is 100. Defaults to 100." ] = 100, - offset: Annotated[int, "The offset of projects to return. Defaults to 0"] = 0, + next_page_token: Annotated[ + str | None, + "The token to retrieve the next page of projects. Defaults to None (start from the first " + "page of projects).", + ] = None, ) -> Annotated[ dict[str, Any], "List projects in Asana associated to teams the current user is a member of", @@ -47,39 +59,23 @@ async def list_projects( # Ref: https://developers.asana.com/reference/getprojects limit = max(1, min(100, limit)) - client = AsanaClient(context.get_auth_token_or_empty()) - - if team_ids: - from arcade_asana.tools.teams import get_team_by_id # avoid circular imports - - responses = await asyncio.gather(*[ - get_team_by_id(context, team_id) for team_id in team_ids - ]) - user_teams = {"teams": [response["data"] for response in responses]} + workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) - else: - # Avoid circular imports - from arcade_asana.tools.teams import list_teams_the_current_user_is_a_member_of - - user_teams = await list_teams_the_current_user_is_a_member_of(context) - - responses = await asyncio.gather(*[ - client.get( - "/projects", - params=clean_request_params({ - "limit": limit, - "offset": offset, - "team": team["id"], - "workspace": team["organization"]["id"], - "opt_fields": PROJECT_OPT_FIELDS, - }), - ) - for team in user_teams["teams"] - ]) + client = AsanaClient(context.get_auth_token_or_empty()) - projects = [project for response in responses for project in response["data"]] + response = await client.get( + "/projects", + params=remove_none_values({ + "limit": limit, + "offset": next_page_token, + "team": team_id, + "workspace": workspace_id, + "opt_fields": PROJECT_OPT_FIELDS, + }), + ) return { - "projects": projects, - "count": len(projects), + "projects": response["data"], + "count": len(response["data"]), + "next_page": get_next_page(response), } diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index 6bca6a2bc..6d98dc701 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -1,4 +1,3 @@ -import asyncio from typing import Annotated, Any from arcade.sdk import ToolContext, tool @@ -8,7 +7,7 @@ from arcade_asana.constants import TAG_OPT_FIELDS, TagColor from arcade_asana.models import AsanaClient from arcade_asana.utils import ( - clean_request_params, + get_next_page, get_unique_workspace_id_or_raise_error, remove_none_values, ) @@ -51,44 +50,42 @@ async def create_tag( @tool(requires_auth=OAuth2(id="arcade-asana", scopes=[])) async def list_tags( context: ToolContext, - workspace_ids: Annotated[ - list[str] | None, - "The IDs of the workspaces to search for tags in. " - "If not provided, it will search across all workspaces.", + workspace_id: Annotated[ + str | None, + "The workspace ID to retrieve tags from. Defaults to None. If not provided and the user " + "has only one workspace, it will use that workspace. If not provided and the user has " + "multiple workspaces, it will raise an error listing the available workspaces.", ] = None, limit: Annotated[ int, "The maximum number of tags to return. Min is 1, max is 100. Defaults to 100." ] = 100, - offset: Annotated[ - int | None, "The offset of tags to return. Defaults to 0 (first page of results)" - ] = 0, + next_page_token: Annotated[ + str | None, + "The token to retrieve the next page of tags. Defaults to None (start from the first page " + "of tags)", + ] = None, ) -> Annotated[ dict[str, Any], - "List tags in Asana that are visible to the authenticated user", + "List tags in an Asana workspace", ]: - """List tags in Asana that are visible to the authenticated user""" + """List tags in an Asana workspace""" limit = max(1, min(100, limit)) - if not workspace_ids: - from arcade_asana.tools.workspaces import list_workspaces # avoid circular import - - workspaces = await list_workspaces(context) - workspace_ids = [workspace["id"] for workspace in workspaces["workspaces"]] + workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) client = AsanaClient(context.get_auth_token_or_empty()) - responses = await asyncio.gather(*[ - client.get( - "/tags", - params=clean_request_params({ - "limit": limit, - "offset": offset, - "workspace": workspace_id, - "opt_fields": TAG_OPT_FIELDS, - }), - ) - for workspace_id in workspace_ids - ]) - - tags = [tag for response in responses for tag in response["data"]] + response = await client.get( + "/tags", + params=remove_none_values({ + "limit": limit, + "offset": next_page_token, + "workspace": workspace_id, + "opt_fields": TAG_OPT_FIELDS, + }), + ) - return {"tags": tags, "count": len(tags)} + return { + "tags": response["data"], + "count": len(response["data"]), + "next_page": get_next_page(response), + } diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 281fe21e8..9df8f02d8 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -1,4 +1,3 @@ -import asyncio import base64 from typing import Annotated, Any @@ -10,9 +9,10 @@ from arcade_asana.models import AsanaClient from arcade_asana.utils import ( build_task_search_query_params, - clean_request_params, + get_next_page, get_project_by_name_or_raise_error, get_tag_ids, + get_unique_workspace_id_or_raise_error, handle_new_task_associations, handle_new_task_tags, remove_none_values, @@ -26,7 +26,8 @@ async def get_task_by_id( task_id: Annotated[str, "The ID of the task to get."], max_subtasks: Annotated[ int, - "The maximum number of subtasks to return. Min of 1, max of 100. Defaults to 100.", + "The maximum number of subtasks to return. " + "Min of 0 (no subtasks), max of 100. Defaults to 100.", ] = 100, ) -> Annotated[dict[str, Any], "The task with the given ID."]: """Get a task by its ID""" @@ -35,8 +36,10 @@ async def get_task_by_id( f"/tasks/{task_id}", params={"opt_fields": TASK_OPT_FIELDS}, ) - subtasks = await get_subtasks_from_a_task(context, task_id=task_id, limit=max_subtasks) - response["data"]["subtasks"] = subtasks["subtasks"] + if max_subtasks > 0: + max_subtasks = min(max_subtasks, 100) + subtasks = await get_subtasks_from_a_task(context, task_id=task_id, limit=max_subtasks) + response["data"]["subtasks"] = subtasks["subtasks"] return {"task": response["data"]} @@ -48,10 +51,11 @@ async def get_subtasks_from_a_task( int, "The maximum number of subtasks to return. Min of 1, max of 100. Defaults to 100.", ] = 100, - offset: Annotated[ - int, - "The offset of the subtasks to return. Defaults to 0.", - ] = 0, + next_page_token: Annotated[ + str | None, + "The token to retrieve the next page of subtasks. Defaults to None (start from the first " + "page of subtasks)", + ] = None, ) -> Annotated[dict[str, Any], "The subtasks of the task."]: """Get the subtasks of a task""" limit = max(1, min(100, limit)) @@ -59,13 +63,17 @@ async def get_subtasks_from_a_task( client = AsanaClient(context.get_auth_token_or_empty()) response = await client.get( f"/tasks/{task_id}/subtasks", - params=clean_request_params({ + params=remove_none_values({ "opt_fields": TASK_OPT_FIELDS, "limit": limit, - "offset": offset, + "offset": next_page_token, }), ) - return {"subtasks": response["data"], "count": len(response["data"])} + return { + "subtasks": response["data"], + "count": len(response["data"]), + "next_page": get_next_page(response), + } @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) @@ -74,25 +82,21 @@ async def search_tasks( keywords: Annotated[ str | None, "Keywords to search for tasks. Matches against the task name and description." ] = None, - workspace_ids: Annotated[ - list[str] | None, - "The workspace IDs to search for tasks. Multiple workspace IDs can be provided in " - "the list. Defaults to None (searches across all workspaces).", - ] = None, - assignee_ids: Annotated[ - list[str] | None, - "Restricts the search to tasks assigned to the given user IDs. Multiple user IDs can be " - "provided in the list. Defaults to None (searches tasks assigned to anyone or no one).", + workspace_id: Annotated[ + str | None, + "The workspace ID to search for tasks. Defaults to None. If not provided and the user " + "has only one workspace, it will use that workspace. If not provided and the user has " + "multiple workspaces, it will raise an error listing the available workspaces.", ] = None, - project_name: Annotated[ + assignee_id: Annotated[ str | None, - "Restricts the search to tasks associated to the given project name. " - "Defaults to None (searches tasks associated to any project).", + "The ID of the user to filter tasks assigned to. " + "Defaults to None (does not filter by assignee).", ] = None, - project_id: Annotated[ + project: Annotated[ str | None, - "Restricts the search to tasks associated to the given project ID. " - "Defaults to None (searches tasks associated to any project).", + "The ID or name of the project to filter tasks. " + "Defaults to None (searches tasks associated to any project or no project).", ] = None, team_id: Annotated[ str | None, @@ -141,8 +145,8 @@ async def search_tasks( ] = False, limit: Annotated[ int, - "The maximum number of tasks to return. Min of 1, max of 20. Defaults to 20.", - ] = 20, + "The maximum number of tasks to return. Min of 1, max of 100. Defaults to 100.", + ] = 100, sort_by: Annotated[ TaskSortBy, "The field to sort the tasks by. Defaults to TaskSortBy.MODIFIED_AT.", @@ -155,15 +159,16 @@ async def search_tasks( """Search for tasks""" limit = max(1, min(100, limit)) - if not workspace_ids: - from arcade_asana.tools.workspaces import list_workspaces # Avoid circular import + workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) - workspaces = await list_workspaces(context) - workspace_ids = [workspace["id"] for workspace in workspaces["workspaces"]] + project_id = None - if not project_id and project_name: - project = await get_project_by_name_or_raise_error(context, project_name) - project_id = project["id"] + if project: + if project.isnumeric(): + project_id = project + else: + project_data = await get_project_by_name_or_raise_error(context, project) + project_id = project_data["id"] tag_ids = await get_tag_ids(context, tags) @@ -176,42 +181,28 @@ async def search_tasks( validate_date_format("start_on_or_after", start_on_or_after) validate_date_format("start_on_or_before", start_on_or_before) - responses = await asyncio.gather(*[ - client.get( - f"/workspaces/{workspace_id}/tasks/search", - params=build_task_search_query_params( - keywords=keywords, - completed=completed, - assignee_ids=assignee_ids, - project_id=project_id, - team_id=team_id, - tag_ids=tag_ids, - due_on=due_on, - due_on_or_after=due_on_or_after, - due_on_or_before=due_on_or_before, - start_on=start_on, - start_on_or_after=start_on_or_after, - start_on_or_before=start_on_or_before, - limit=limit, - sort_by=sort_by, - sort_order=sort_order, - ), - ) - for workspace_id in workspace_ids - ]) - - tasks_by_id = {task["id"]: task for response in responses for task in response["data"]} - - subtasks = await asyncio.gather(*[ - get_subtasks_from_a_task(context, task_id=task["id"]) for task in tasks_by_id.values() - ]) + response = await client.get( + f"/workspaces/{workspace_id}/tasks/search", + params=build_task_search_query_params( + keywords=keywords, + completed=completed, + assignee_id=assignee_id, + project_id=project_id, + team_id=team_id, + tag_ids=tag_ids, + due_on=due_on, + due_on_or_after=due_on_or_after, + due_on_or_before=due_on_or_before, + start_on=start_on, + start_on_or_after=start_on_or_after, + start_on_or_before=start_on_or_before, + limit=limit, + sort_by=sort_by, + sort_order=sort_order, + ), + ) - for response in subtasks: - for subtask in response["subtasks"]: - parent_task = tasks_by_id[subtask["parent"]["id"]] - if "subtasks" not in parent_task: - parent_task["subtasks"] = [] - parent_task["subtasks"].append(subtask) + tasks_by_id = {task["id"]: task for task in response["data"]} tasks = list(tasks_by_id.values()) @@ -419,11 +410,15 @@ async def attach_file_to_task( file_content = file_content_str.encode(file_encoding) except LookupError as exc: raise ToolExecutionError(f"Unknown encoding: {file_encoding}") from exc + except Exception as exc: + raise ToolExecutionError( + f"Failed to encode file content string with {file_encoding} encoding: {exc!s}" + ) from exc elif file_content_base64 is not None: try: file_content = base64.b64decode(file_content_base64) except Exception as exc: - raise ToolExecutionError(f"Invalid base64 encoding: {exc!s}") from exc + raise ToolExecutionError(f"Failed to decode base64 file content: {exc!s}") from exc if file_content: if file_name.lower().endswith(".pdf"): diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index ee05ff275..7fb39af32 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -1,4 +1,3 @@ -import asyncio from typing import Annotated, Any from arcade.sdk import ToolContext, tool @@ -6,7 +5,11 @@ from arcade_asana.constants import TEAM_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.utils import clean_request_params +from arcade_asana.utils import ( + get_next_page, + get_unique_workspace_id_or_raise_error, + remove_none_values, +) @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) @@ -17,7 +20,8 @@ async def get_team_by_id( """Get an Asana team by its ID""" client = AsanaClient(context.get_auth_token_or_empty()) response = await client.get( - f"/teams/{team_id}", params=clean_request_params({"opt_fields": TEAM_OPT_FIELDS}) + f"/teams/{team_id}", + params=remove_none_values({"opt_fields": TEAM_OPT_FIELDS}), ) return {"team": response["data"]} @@ -25,19 +29,20 @@ async def get_team_by_id( @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def list_teams_the_current_user_is_a_member_of( context: ToolContext, - workspace_ids: Annotated[ - list[str] | None, - "The workspace IDs to get teams from. Multiple workspace IDs can be provided in the list. " - "Defaults to None (get teams from all workspaces the user is a member of).", + workspace_id: Annotated[ + str | None, + "The workspace ID to list teams from. Defaults to None. If no workspace ID is provided, " + "it will use the current user's workspace , if there's only one. If the user has multiple " + "workspaces, it will raise an error.", ] = None, limit: Annotated[ int, "The maximum number of teams to return. Min is 1, max is 100. Defaults to 100." ] = 100, - offset: Annotated[ - int | None, - "The pagination offset of teams to skip in the results. " - "Defaults to 0 (first page of results)", - ] = 0, + next_page_token: Annotated[ + str | None, + "The token to retrieve the next page of teams. Defaults to None (start from the first page " + "of teams)", + ] = None, ) -> Annotated[ dict[str, Any], "List teams in Asana that the current user is a member of", @@ -45,30 +50,21 @@ async def list_teams_the_current_user_is_a_member_of( """List teams in Asana that the current user is a member of""" limit = max(1, min(100, limit)) - if not workspace_ids: - # Importing here to avoid circular imports - from arcade_asana.tools.workspaces import list_workspaces - - response = await list_workspaces(context) - workspace_ids = [workspace["id"] for workspace in response["workspaces"]] + workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) client = AsanaClient(context.get_auth_token_or_empty()) - responses = await asyncio.gather(*[ - client.get( - "/users/me/teams", - params=clean_request_params({ - "limit": limit, - "offset": offset, - "opt_fields": TEAM_OPT_FIELDS, - "organization": workspace_id, - }), - ) - for workspace_id in workspace_ids - ]) - - teams = [team for response in responses for team in response["data"]] + response = await client.get( + "/users/me/teams", + params=remove_none_values({ + "limit": limit, + "offset": next_page_token, + "opt_fields": TEAM_OPT_FIELDS, + "organization": workspace_id, + }), + ) return { - "teams": teams, - "count": len(teams), + "teams": response["data"], + "count": len(response["data"]), + "next_page": get_next_page(response), } diff --git a/toolkits/asana/arcade_asana/tools/users.py b/toolkits/asana/arcade_asana/tools/users.py index 124f3f757..25ce89dbc 100644 --- a/toolkits/asana/arcade_asana/tools/users.py +++ b/toolkits/asana/arcade_asana/tools/users.py @@ -5,7 +5,11 @@ from arcade_asana.constants import USER_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.utils import clean_request_params, get_unique_workspace_id_or_raise_error +from arcade_asana.utils import ( + get_next_page, + get_unique_workspace_id_or_raise_error, + remove_none_values, +) @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) @@ -13,14 +17,19 @@ async def list_users( context: ToolContext, workspace_id: Annotated[ str | None, - "The workspace ID to list users from. Defaults to None (list users from all workspaces).", + "The workspace ID to list users from. Defaults to None. If no workspace ID is provided, " + "it will use the current user's workspace , if there's only one. If the user has multiple " + "workspaces, it will raise an error.", ] = None, limit: Annotated[ - int, "The maximum number of users to return. Min is 1, max is 100. Defaults to 100." + int, + "The maximum number of users to retrieve. Min is 1, max is 100. Defaults to 100.", ] = 100, - offset: Annotated[ - int | None, "The offset of users to return. Defaults to 0 (first page of results)" - ] = 0, + next_page_token: Annotated[ + str | None, + "The token to retrieve the next page of users. Defaults to None (start from the first page " + "of users)", + ] = None, ) -> Annotated[ dict[str, Any], "List users in Asana", @@ -34,10 +43,10 @@ async def list_users( client = AsanaClient(context.get_auth_token_or_empty()) response = await client.get( "/users", - params=clean_request_params({ + params=remove_none_values({ "workspace": workspace_id, "limit": limit, - "offset": offset, + "offset": next_page_token, "opt_fields": USER_OPT_FIELDS, }), ) @@ -45,6 +54,7 @@ async def list_users( return { "users": response["data"], "count": len(response["data"]), + "next_page": get_next_page(response), } diff --git a/toolkits/asana/arcade_asana/tools/workspaces.py b/toolkits/asana/arcade_asana/tools/workspaces.py index 09d915784..09632ec1b 100644 --- a/toolkits/asana/arcade_asana/tools/workspaces.py +++ b/toolkits/asana/arcade_asana/tools/workspaces.py @@ -5,7 +5,7 @@ from arcade_asana.constants import WORKSPACE_OPT_FIELDS from arcade_asana.models import AsanaClient -from arcade_asana.utils import clean_request_params +from arcade_asana.utils import get_next_page, remove_none_values @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) @@ -14,9 +14,11 @@ async def list_workspaces( limit: Annotated[ int, "The maximum number of workspaces to return. Min is 1, max is 100. Defaults to 100." ] = 100, - offset: Annotated[ - int | None, "The offset of workspaces to return. Defaults to 0 (first page of results)" - ] = 0, + next_page_token: Annotated[ + str | None, + "The token to retrieve the next page of workspaces. Defaults to None (start from the first " + "page of workspaces)", + ] = None, ) -> Annotated[ dict[str, Any], "List workspaces in Asana that are visible to the authenticated user", @@ -27,9 +29,9 @@ async def list_workspaces( client = AsanaClient(context.get_auth_token_or_empty()) response = await client.get( "/workspaces", - params=clean_request_params({ + params=remove_none_values({ "limit": limit, - "offset": offset, + "offset": next_page_token, "opt_fields": WORKSPACE_OPT_FIELDS, }), ) @@ -37,4 +39,5 @@ async def list_workspaces( return { "workspaces": response["data"], "count": len(response["data"]), + "next_page": get_next_page(response), } diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index 1915ff512..dbee8c8db 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -24,14 +24,6 @@ def remove_none_values(data: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in data.items() if v is not None} -def clean_request_params(params: dict[str, Any]) -> dict[str, Any]: - params = remove_none_values(params) - if "offset" in params and params["offset"] == 0: - del params["offset"] - - return params - - def validate_date_format(name: str, date_str: str | None) -> None: if not date_str: return @@ -45,7 +37,7 @@ def validate_date_format(name: str, date_str: str | None) -> None: def build_task_search_query_params( keywords: str | None, completed: bool, - assignee_ids: list[str] | None, + assignee_id: str | None, project_id: str | None, team_id: str | None, tag_ids: list[str] | None, @@ -67,8 +59,8 @@ def build_task_search_query_params( "sort_ascending": sort_order == SortOrder.ASCENDING, "limit": limit, } - if assignee_ids: - query_params["assignee.any"] = ",".join(assignee_ids) + if assignee_id: + query_params["assignee.any"] = assignee_id if project_id: query_params["projects.any"] = project_id if team_id: @@ -173,7 +165,7 @@ async def get_project_by_name_or_raise_error( response = await find_projects_by_name( context=context, names=[project_name], - response_limit=1, + response_limit=100, max_items_to_scan=max_items_to_scan, return_projects_not_matched=True, ) @@ -193,6 +185,19 @@ async def get_project_by_name_or_raise_error( additional_prompt_content=additional_prompt, ) + elif response["matches"]["count"] > 1: + projects = [ + {"name": project["name"], "id": project["id"]} + for project in response["matches"]["projects"] + ] + message = "Multiple projects found with the same name. Please provide a project ID instead." + additional_prompt = f"Projects matching the name '{project_name}': {json.dumps(projects)}" + raise ToolExecutionError( + message=message, + developer_message=message, + additional_prompt_content=additional_prompt, + ) + return cast(dict, response["matches"]["projects"][0]) @@ -274,6 +279,7 @@ async def paginate_tool_call( response_key: str, max_items: int = 300, timeout_seconds: int = ASANA_MAX_TIMEOUT_SECONDS, + next_page_token: str | None = None, **tool_kwargs: Any, ) -> list[ToolResponse]: results: list[ToolResponse] = [] @@ -288,12 +294,12 @@ async def paginate_loop() -> None: while keep_paginating: response = await tool(context, **tool_kwargs) # type: ignore[call-arg] results.extend(response[response_key]) - if "offset" not in tool_kwargs: - tool_kwargs["offset"] = 0 - if "next_page" not in response or len(results) >= max_items: + next_page = get_next_page(response) + next_page_token = next_page["next_page_token"] + if not next_page_token or len(results) >= max_items: keep_paginating = False else: - tool_kwargs["offset"] += tool_kwargs["limit"] + tool_kwargs["next_page_token"] = next_page_token try: await asyncio.wait_for(paginate_loop(), timeout=timeout_seconds) @@ -323,7 +329,7 @@ async def get_unique_workspace_id_or_raise_error(context: ToolContext) -> str: async def find_projects_by_name( context: ToolContext, names: list[str], - team_ids: list[str] | None = None, + team_id: list[str] | None = None, response_limit: int = 100, max_items_to_scan: int = MAX_PROJECTS_TO_SCAN_BY_NAME, return_projects_not_matched: bool = False, @@ -340,7 +346,7 @@ async def find_projects_by_name( Args: context: The tool context to use in the list_projects tool call. names: The names of the projects to search for. - team_ids: The IDs of the teams to search for projects in. + team_id: The ID of the team to search for projects in. response_limit: The maximum number of matched projects to return. max_items_to_scan: The maximum number of projects to scan while looking for matches. return_projects_not_matched: Whether to return the projects that were scanned, but did not @@ -356,7 +362,7 @@ async def find_projects_by_name( response_key="projects", max_items=max_items_to_scan, timeout_seconds=15, - team_ids=team_ids, + team_id=team_id, ) matches: list[dict[str, Any]] = [] @@ -397,7 +403,7 @@ async def find_projects_by_name( async def find_tags_by_name( context: ToolContext, names: list[str], - workspace_ids: list[str] | None = None, + workspace_id: list[str] | None = None, response_limit: int = 100, max_items_to_scan: int = MAX_TAGS_TO_SCAN_BY_NAME, return_tags_not_matched: bool = False, @@ -414,7 +420,7 @@ async def find_tags_by_name( Args: context: The tool context to use in the list_tags tool call. names: The names of the tags to search for. - workspace_ids: The IDs of the workspaces to search for tags in. + workspace_id: The ID of the workspace to search for tags in. response_limit: The maximum number of matched tags to return. max_items_to_scan: The maximum number of tags to scan while looking for matches. return_tags_not_matched: Whether to return the tags that were scanned, but did not match @@ -430,7 +436,7 @@ async def find_tags_by_name( response_key="tags", max_items=max_items_to_scan, timeout_seconds=15, - workspace_ids=workspace_ids, + workspace_id=workspace_id, ) matches: list[dict[str, Any]] = [] @@ -465,3 +471,12 @@ async def find_tags_by_name( } return response + + +def get_next_page(response: dict[str, Any]) -> str | None: + try: + token = response["next_page"]["offset"] + except (KeyError, TypeError): + token = None + + return {"has_more_pages": token is not None, "next_page_token": token} From 792626b1ad7a9f80bcde3bc62852f9ae14807217 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Thu, 8 May 2025 08:58:52 -0300 Subject: [PATCH 30/40] make completed optional when searching for tasks --- toolkits/asana/arcade_asana/tools/tasks.py | 6 +++--- toolkits/asana/arcade_asana/utils.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 9df8f02d8..97d9c3781 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -140,9 +140,9 @@ async def search_tasks( "Defaults to None (searches tasks started on any date or without a start date).", ] = None, completed: Annotated[ - bool, - "Match tasks that are completed. Defaults to False (tasks that are NOT completed).", - ] = False, + bool | None, + "Match tasks that are completed. Defaults to None (does not filter by completion status).", + ] = None, limit: Annotated[ int, "The maximum number of tasks to return. Min of 1, max of 100. Defaults to 100.", diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index dbee8c8db..c262d50eb 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -36,7 +36,7 @@ def validate_date_format(name: str, date_str: str | None) -> None: def build_task_search_query_params( keywords: str | None, - completed: bool, + completed: bool | None, assignee_id: str | None, project_id: str | None, team_id: str | None, @@ -54,11 +54,12 @@ def build_task_search_query_params( query_params: dict[str, Any] = { "text": keywords, "opt_fields": TASK_OPT_FIELDS, - "completed": completed, "sort_by": sort_by.value, "sort_ascending": sort_order == SortOrder.ASCENDING, "limit": limit, } + if completed is not None: + query_params["completed"] = completed if assignee_id: query_params["assignee.any"] = assignee_id if project_id: From 66396e0bf387fc2028757a985e6074cdd61989e4 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Thu, 8 May 2025 09:06:43 -0300 Subject: [PATCH 31/40] fix type errors; handle missing workspace_id in search_tasks --- toolkits/asana/arcade_asana/tools/tasks.py | 13 +++++++++++-- toolkits/asana/arcade_asana/utils.py | 4 ++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 97d9c3781..719836656 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -159,8 +159,6 @@ async def search_tasks( """Search for tasks""" limit = max(1, min(100, limit)) - workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) - project_id = None if project: @@ -169,6 +167,8 @@ async def search_tasks( else: project_data = await get_project_by_name_or_raise_error(context, project) project_id = project_data["id"] + if not workspace_id: + workspace_id = project_data["workspace"]["id"] tag_ids = await get_tag_ids(context, tags) @@ -181,6 +181,15 @@ async def search_tasks( validate_date_format("start_on_or_after", start_on_or_after) validate_date_format("start_on_or_before", start_on_or_before) + if not any([workspace_id, project_id, team_id]): + workspace_id = await get_unique_workspace_id_or_raise_error(context) + + if not workspace_id and team_id: + from arcade_asana.tools.teams import get_team_by_id + + team = await get_team_by_id(context, team_id) + workspace_id = team["organization"]["id"] + response = await client.get( f"/workspaces/{workspace_id}/tasks/search", params=build_task_search_query_params( diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py index c262d50eb..f804f8dd0 100644 --- a/toolkits/asana/arcade_asana/utils.py +++ b/toolkits/asana/arcade_asana/utils.py @@ -193,7 +193,7 @@ async def get_project_by_name_or_raise_error( ] message = "Multiple projects found with the same name. Please provide a project ID instead." additional_prompt = f"Projects matching the name '{project_name}': {json.dumps(projects)}" - raise ToolExecutionError( + raise RetryableToolError( message=message, developer_message=message, additional_prompt_content=additional_prompt, @@ -474,7 +474,7 @@ async def find_tags_by_name( return response -def get_next_page(response: dict[str, Any]) -> str | None: +def get_next_page(response: dict[str, Any]) -> dict[str, Any]: try: token = response["next_page"]["offset"] except (KeyError, TypeError): From 92b97f047ab4ab76df55e13c56e917825d17217f Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Thu, 8 May 2025 09:13:08 -0300 Subject: [PATCH 32/40] add some missing tools --- toolkits/asana/arcade_asana/tools/tags.py | 11 +++++ toolkits/asana/arcade_asana/tools/teams.py | 40 +++++++++++++++++++ .../asana/arcade_asana/tools/workspaces.py | 11 +++++ 3 files changed, 62 insertions(+) diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index 6d98dc701..52f721af5 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -13,6 +13,17 @@ ) +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def get_tag_by_id( + context: ToolContext, + tag_id: Annotated[str, "The ID of the Asana tag to get"], +) -> Annotated[dict[str, Any], "Get an Asana tag by its ID"]: + """Get an Asana tag by its ID""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get(f"/tags/{tag_id}") + return {"tag": response["data"]} + + @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def create_tag( context: ToolContext, diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index 7fb39af32..60bc471f8 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -68,3 +68,43 @@ async def list_teams_the_current_user_is_a_member_of( "count": len(response["data"]), "next_page": get_next_page(response), } + + +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def list_teams( + context: ToolContext, + workspace_id: Annotated[ + str | None, + "The workspace ID to list teams from. Defaults to None. If no workspace ID is provided, " + "it will use the current user's workspace, if there's only one. If the user has multiple " + "workspaces, it will raise an error.", + ] = None, + limit: Annotated[ + int, "The maximum number of teams to return. Min is 1, max is 100. Defaults to 100." + ] = 100, + next_page_token: Annotated[ + str | None, + "The token to retrieve the next page of teams. Defaults to None (start from the first page " + "of teams)", + ] = None, +) -> Annotated[dict[str, Any], "List teams in an Asana workspace"]: + """List teams in an Asana workspace""" + limit = max(1, min(100, limit)) + + workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) + + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + f"/workspaces/{workspace_id}/teams", + params=remove_none_values({ + "limit": limit, + "offset": next_page_token, + "opt_fields": TEAM_OPT_FIELDS, + }), + ) + + return { + "teams": response["data"], + "count": len(response["data"]), + "next_page": get_next_page(response), + } diff --git a/toolkits/asana/arcade_asana/tools/workspaces.py b/toolkits/asana/arcade_asana/tools/workspaces.py index 09632ec1b..f21ce4216 100644 --- a/toolkits/asana/arcade_asana/tools/workspaces.py +++ b/toolkits/asana/arcade_asana/tools/workspaces.py @@ -8,6 +8,17 @@ from arcade_asana.utils import get_next_page, remove_none_values +@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +async def get_workspace_by_id( + context: ToolContext, + workspace_id: Annotated[str, "The ID of the Asana workspace to get"], +) -> Annotated[dict[str, Any], "Get an Asana workspace by its ID"]: + """Get an Asana workspace by its ID""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get(f"/workspaces/{workspace_id}") + return {"workspace": response["data"]} + + @tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) async def list_workspaces( context: ToolContext, From cf4d3d2b929dcfa248bd12ccd354c42c32d6d856 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Thu, 8 May 2025 09:44:11 -0300 Subject: [PATCH 33/40] update annotation --- toolkits/asana/arcade_asana/tools/teams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index 60bc471f8..da96c31d4 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -77,7 +77,7 @@ async def list_teams( str | None, "The workspace ID to list teams from. Defaults to None. If no workspace ID is provided, " "it will use the current user's workspace, if there's only one. If the user has multiple " - "workspaces, it will raise an error.", + "workspaces, it will raise an error listing the available workspaces.", ] = None, limit: Annotated[ int, "The maximum number of teams to return. Min is 1, max is 100. Defaults to 100." From 7fe92f1e65c7ac308c9e93075aad008ea28bc8e7 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Thu, 8 May 2025 16:00:34 -0300 Subject: [PATCH 34/40] fix test --- toolkits/asana/tests/test_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/toolkits/asana/tests/test_utils.py b/toolkits/asana/tests/test_utils.py index 52e8e3e9f..d73ba0ab3 100644 --- a/toolkits/asana/tests/test_utils.py +++ b/toolkits/asana/tests/test_utils.py @@ -63,14 +63,14 @@ async def test_get_project_by_name_or_raise_error(mock_find_projects_by_name, mo project1 = {"id": "1234567890", "name": "My Project"} mock_find_projects_by_name.return_value = { - "matches": {"projects": [project1]}, - "not_matched": {"projects": []}, + "matches": {"projects": [project1], "count": 1}, + "not_matched": {"projects": [], "count": 0}, } assert await get_project_by_name_or_raise_error(mock_context, project1["name"]) == project1 mock_find_projects_by_name.return_value = { - "matches": {"projects": []}, - "not_matched": {"projects": [project1]}, + "matches": {"projects": [], "count": 0}, + "not_matched": {"projects": [project1], "count": 1}, } with pytest.raises(RetryableToolError) as exc_info: await get_project_by_name_or_raise_error(mock_context, "Inexistent Project") From cec77a38c0ba4f4dc45b6abd8669bfe362db7f42 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Thu, 8 May 2025 16:52:27 -0300 Subject: [PATCH 35/40] use well-known provider name --- toolkits/asana/arcade_asana/tools/projects.py | 4 ++-- toolkits/asana/arcade_asana/tools/tags.py | 6 +++--- toolkits/asana/arcade_asana/tools/tasks.py | 12 ++++++------ toolkits/asana/arcade_asana/tools/teams.py | 6 +++--- toolkits/asana/arcade_asana/tools/users.py | 4 ++-- toolkits/asana/arcade_asana/tools/workspaces.py | 4 ++-- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index 35bcfc029..37ceab273 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -12,7 +12,7 @@ ) -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def get_project_by_id( context: ToolContext, project_id: Annotated[str, "The ID of the project."], @@ -29,7 +29,7 @@ async def get_project_by_id( return {"project": response["data"]} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def list_projects( context: ToolContext, team_id: Annotated[ diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index 52f721af5..bb9084350 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -13,7 +13,7 @@ ) -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def get_tag_by_id( context: ToolContext, tag_id: Annotated[str, "The ID of the Asana tag to get"], @@ -24,7 +24,7 @@ async def get_tag_by_id( return {"tag": response["data"]} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def create_tag( context: ToolContext, name: Annotated[str, "The name of the tag to create. Length must be between 1 and 100."], @@ -58,7 +58,7 @@ async def create_tag( return {"tag": response["data"]} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=[])) +@tool(requires_auth=OAuth2(id="asana", scopes=[])) async def list_tags( context: ToolContext, workspace_id: Annotated[ diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 719836656..1bcc087a4 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -20,7 +20,7 @@ ) -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def get_task_by_id( context: ToolContext, task_id: Annotated[str, "The ID of the task to get."], @@ -43,7 +43,7 @@ async def get_task_by_id( return {"task": response["data"]} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def get_subtasks_from_a_task( context: ToolContext, task_id: Annotated[str, "The ID of the task to get the subtasks of."], @@ -76,7 +76,7 @@ async def get_subtasks_from_a_task( } -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def search_tasks( context: ToolContext, keywords: Annotated[ @@ -218,7 +218,7 @@ async def search_tasks( return {"tasks": tasks, "count": len(tasks)} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def update_task( context: ToolContext, task_id: Annotated[str, "The ID of the task to update."], @@ -282,7 +282,7 @@ async def update_task( } -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def create_task( context: ToolContext, name: Annotated[str, "The name of the task"], @@ -360,7 +360,7 @@ async def create_task( } -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def attach_file_to_task( context: ToolContext, task_id: Annotated[str, "The ID of the task to attach the file to."], diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index da96c31d4..3b43dd5e2 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -12,7 +12,7 @@ ) -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def get_team_by_id( context: ToolContext, team_id: Annotated[str, "The ID of the Asana team to get"], @@ -26,7 +26,7 @@ async def get_team_by_id( return {"team": response["data"]} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def list_teams_the_current_user_is_a_member_of( context: ToolContext, workspace_id: Annotated[ @@ -70,7 +70,7 @@ async def list_teams_the_current_user_is_a_member_of( } -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def list_teams( context: ToolContext, workspace_id: Annotated[ diff --git a/toolkits/asana/arcade_asana/tools/users.py b/toolkits/asana/arcade_asana/tools/users.py index 25ce89dbc..3881d5750 100644 --- a/toolkits/asana/arcade_asana/tools/users.py +++ b/toolkits/asana/arcade_asana/tools/users.py @@ -12,7 +12,7 @@ ) -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def list_users( context: ToolContext, workspace_id: Annotated[ @@ -58,7 +58,7 @@ async def list_users( } -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def get_user_by_id( context: ToolContext, user_id: Annotated[str, "The user ID to get."], diff --git a/toolkits/asana/arcade_asana/tools/workspaces.py b/toolkits/asana/arcade_asana/tools/workspaces.py index f21ce4216..9a5886c77 100644 --- a/toolkits/asana/arcade_asana/tools/workspaces.py +++ b/toolkits/asana/arcade_asana/tools/workspaces.py @@ -8,7 +8,7 @@ from arcade_asana.utils import get_next_page, remove_none_values -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def get_workspace_by_id( context: ToolContext, workspace_id: Annotated[str, "The ID of the Asana workspace to get"], @@ -19,7 +19,7 @@ async def get_workspace_by_id( return {"workspace": response["data"]} -@tool(requires_auth=OAuth2(id="arcade-asana", scopes=["default"])) +@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) async def list_workspaces( context: ToolContext, limit: Annotated[ From 6b1c2ecdabdb572ea9bbc9c426794d8789ad6db6 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Thu, 8 May 2025 16:58:22 -0300 Subject: [PATCH 36/40] add asana to docker txt --- docker/toolkits.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/toolkits.txt b/docker/toolkits.txt index a682d4e9a..058e9dcff 100644 --- a/docker/toolkits.txt +++ b/docker/toolkits.txt @@ -1,3 +1,4 @@ +arcade-asana arcade-code-sandbox arcade-dropbox arcade-github From 29f83c619c59329d2fedbd388627449e5a7d06d4 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Thu, 8 May 2025 17:03:51 -0300 Subject: [PATCH 37/40] update auth class --- toolkits/asana/arcade_asana/tools/projects.py | 6 +++--- toolkits/asana/arcade_asana/tools/tags.py | 8 ++++---- toolkits/asana/arcade_asana/tools/tasks.py | 14 +++++++------- toolkits/asana/arcade_asana/tools/teams.py | 8 ++++---- toolkits/asana/arcade_asana/tools/users.py | 6 +++--- toolkits/asana/arcade_asana/tools/workspaces.py | 6 +++--- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py index 37ceab273..3e36d7579 100644 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ b/toolkits/asana/arcade_asana/tools/projects.py @@ -1,7 +1,7 @@ from typing import Annotated, Any from arcade.sdk import ToolContext, tool -from arcade.sdk.auth import OAuth2 +from arcade.sdk.auth import Asana from arcade_asana.constants import PROJECT_OPT_FIELDS from arcade_asana.models import AsanaClient @@ -12,7 +12,7 @@ ) -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def get_project_by_id( context: ToolContext, project_id: Annotated[str, "The ID of the project."], @@ -29,7 +29,7 @@ async def get_project_by_id( return {"project": response["data"]} -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def list_projects( context: ToolContext, team_id: Annotated[ diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py index bb9084350..b0aea1dc3 100644 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ b/toolkits/asana/arcade_asana/tools/tags.py @@ -1,7 +1,7 @@ from typing import Annotated, Any from arcade.sdk import ToolContext, tool -from arcade.sdk.auth import OAuth2 +from arcade.sdk.auth import Asana from arcade.sdk.errors import ToolExecutionError from arcade_asana.constants import TAG_OPT_FIELDS, TagColor @@ -13,7 +13,7 @@ ) -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def get_tag_by_id( context: ToolContext, tag_id: Annotated[str, "The ID of the Asana tag to get"], @@ -24,7 +24,7 @@ async def get_tag_by_id( return {"tag": response["data"]} -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def create_tag( context: ToolContext, name: Annotated[str, "The name of the tag to create. Length must be between 1 and 100."], @@ -58,7 +58,7 @@ async def create_tag( return {"tag": response["data"]} -@tool(requires_auth=OAuth2(id="asana", scopes=[])) +@tool(requires_auth=Asana(scopes=["default"])) async def list_tags( context: ToolContext, workspace_id: Annotated[ diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 1bcc087a4..b0852f605 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -2,7 +2,7 @@ from typing import Annotated, Any from arcade.sdk import ToolContext, tool -from arcade.sdk.auth import OAuth2 +from arcade.sdk.auth import Asana from arcade.sdk.errors import ToolExecutionError from arcade_asana.constants import TASK_OPT_FIELDS, SortOrder, TaskSortBy @@ -20,7 +20,7 @@ ) -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def get_task_by_id( context: ToolContext, task_id: Annotated[str, "The ID of the task to get."], @@ -43,7 +43,7 @@ async def get_task_by_id( return {"task": response["data"]} -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def get_subtasks_from_a_task( context: ToolContext, task_id: Annotated[str, "The ID of the task to get the subtasks of."], @@ -76,7 +76,7 @@ async def get_subtasks_from_a_task( } -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def search_tasks( context: ToolContext, keywords: Annotated[ @@ -218,7 +218,7 @@ async def search_tasks( return {"tasks": tasks, "count": len(tasks)} -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def update_task( context: ToolContext, task_id: Annotated[str, "The ID of the task to update."], @@ -282,7 +282,7 @@ async def update_task( } -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def create_task( context: ToolContext, name: Annotated[str, "The name of the task"], @@ -360,7 +360,7 @@ async def create_task( } -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def attach_file_to_task( context: ToolContext, task_id: Annotated[str, "The ID of the task to attach the file to."], diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py index 3b43dd5e2..ff7f0860c 100644 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ b/toolkits/asana/arcade_asana/tools/teams.py @@ -1,7 +1,7 @@ from typing import Annotated, Any from arcade.sdk import ToolContext, tool -from arcade.sdk.auth import OAuth2 +from arcade.sdk.auth import Asana from arcade_asana.constants import TEAM_OPT_FIELDS from arcade_asana.models import AsanaClient @@ -12,7 +12,7 @@ ) -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def get_team_by_id( context: ToolContext, team_id: Annotated[str, "The ID of the Asana team to get"], @@ -26,7 +26,7 @@ async def get_team_by_id( return {"team": response["data"]} -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def list_teams_the_current_user_is_a_member_of( context: ToolContext, workspace_id: Annotated[ @@ -70,7 +70,7 @@ async def list_teams_the_current_user_is_a_member_of( } -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def list_teams( context: ToolContext, workspace_id: Annotated[ diff --git a/toolkits/asana/arcade_asana/tools/users.py b/toolkits/asana/arcade_asana/tools/users.py index 3881d5750..86368fc99 100644 --- a/toolkits/asana/arcade_asana/tools/users.py +++ b/toolkits/asana/arcade_asana/tools/users.py @@ -1,7 +1,7 @@ from typing import Annotated, Any from arcade.sdk import ToolContext, tool -from arcade.sdk.auth import OAuth2 +from arcade.sdk.auth import Asana from arcade_asana.constants import USER_OPT_FIELDS from arcade_asana.models import AsanaClient @@ -12,7 +12,7 @@ ) -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def list_users( context: ToolContext, workspace_id: Annotated[ @@ -58,7 +58,7 @@ async def list_users( } -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def get_user_by_id( context: ToolContext, user_id: Annotated[str, "The user ID to get."], diff --git a/toolkits/asana/arcade_asana/tools/workspaces.py b/toolkits/asana/arcade_asana/tools/workspaces.py index 9a5886c77..2709135af 100644 --- a/toolkits/asana/arcade_asana/tools/workspaces.py +++ b/toolkits/asana/arcade_asana/tools/workspaces.py @@ -1,14 +1,14 @@ from typing import Annotated, Any from arcade.sdk import ToolContext, tool -from arcade.sdk.auth import OAuth2 +from arcade.sdk.auth import Asana from arcade_asana.constants import WORKSPACE_OPT_FIELDS from arcade_asana.models import AsanaClient from arcade_asana.utils import get_next_page, remove_none_values -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def get_workspace_by_id( context: ToolContext, workspace_id: Annotated[str, "The ID of the Asana workspace to get"], @@ -19,7 +19,7 @@ async def get_workspace_by_id( return {"workspace": response["data"]} -@tool(requires_auth=OAuth2(id="asana", scopes=["default"])) +@tool(requires_auth=Asana(scopes=["default"])) async def list_workspaces( context: ToolContext, limit: Annotated[ From c9de17d56654fba10425e433bb7c41f242b41b44 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 9 May 2025 11:16:10 -0300 Subject: [PATCH 38/40] rename search_tasks tool; update evals --- toolkits/asana/arcade_asana/tools/__init__.py | 4 +- toolkits/asana/arcade_asana/tools/tasks.py | 115 +++++++++--------- toolkits/asana/evals/eval_projects.py | 12 +- toolkits/asana/evals/eval_tags.py | 16 ++- toolkits/asana/evals/eval_tasks.py | 28 +++-- toolkits/asana/evals/eval_teams.py | 12 +- toolkits/asana/evals/eval_users.py | 6 +- toolkits/asana/evals/eval_workspaces.py | 6 +- 8 files changed, 114 insertions(+), 85 deletions(-) diff --git a/toolkits/asana/arcade_asana/tools/__init__.py b/toolkits/asana/arcade_asana/tools/__init__.py index 5298b4e67..3380b470b 100644 --- a/toolkits/asana/arcade_asana/tools/__init__.py +++ b/toolkits/asana/arcade_asana/tools/__init__.py @@ -5,7 +5,7 @@ create_task, get_subtasks_from_a_task, get_task_by_id, - search_tasks, + get_tasks_without_id, update_task, ) from arcade_asana.tools.teams import get_team_by_id, list_teams_the_current_user_is_a_member_of @@ -26,6 +26,6 @@ "list_teams_the_current_user_is_a_member_of", "list_users", "list_workspaces", - "search_tasks", + "get_tasks_without_id", "update_task", ] diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index b0852f605..907cbfb2a 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -21,63 +21,7 @@ @tool(requires_auth=Asana(scopes=["default"])) -async def get_task_by_id( - context: ToolContext, - task_id: Annotated[str, "The ID of the task to get."], - max_subtasks: Annotated[ - int, - "The maximum number of subtasks to return. " - "Min of 0 (no subtasks), max of 100. Defaults to 100.", - ] = 100, -) -> Annotated[dict[str, Any], "The task with the given ID."]: - """Get a task by its ID""" - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - f"/tasks/{task_id}", - params={"opt_fields": TASK_OPT_FIELDS}, - ) - if max_subtasks > 0: - max_subtasks = min(max_subtasks, 100) - subtasks = await get_subtasks_from_a_task(context, task_id=task_id, limit=max_subtasks) - response["data"]["subtasks"] = subtasks["subtasks"] - return {"task": response["data"]} - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_subtasks_from_a_task( - context: ToolContext, - task_id: Annotated[str, "The ID of the task to get the subtasks of."], - limit: Annotated[ - int, - "The maximum number of subtasks to return. Min of 1, max of 100. Defaults to 100.", - ] = 100, - next_page_token: Annotated[ - str | None, - "The token to retrieve the next page of subtasks. Defaults to None (start from the first " - "page of subtasks)", - ] = None, -) -> Annotated[dict[str, Any], "The subtasks of the task."]: - """Get the subtasks of a task""" - limit = max(1, min(100, limit)) - - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - f"/tasks/{task_id}/subtasks", - params=remove_none_values({ - "opt_fields": TASK_OPT_FIELDS, - "limit": limit, - "offset": next_page_token, - }), - ) - return { - "subtasks": response["data"], - "count": len(response["data"]), - "next_page": get_next_page(response), - } - - -@tool(requires_auth=Asana(scopes=["default"])) -async def search_tasks( +async def get_tasks_without_id( context: ToolContext, keywords: Annotated[ str | None, "Keywords to search for tasks. Matches against the task name and description." @@ -218,6 +162,63 @@ async def search_tasks( return {"tasks": tasks, "count": len(tasks)} +@tool(requires_auth=Asana(scopes=["default"])) +async def get_task_by_id( + context: ToolContext, + task_id: Annotated[str, "The ID of the task to get."], + max_subtasks: Annotated[ + int, + "The maximum number of subtasks to return. " + "Min of 0 (no subtasks), max of 100. Defaults to 100.", + ] = 100, +) -> Annotated[dict[str, Any], "The task with the given ID."]: + """Get a task by its ID""" + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + f"/tasks/{task_id}", + params={"opt_fields": TASK_OPT_FIELDS}, + ) + if max_subtasks > 0: + max_subtasks = min(max_subtasks, 100) + subtasks = await get_subtasks_from_a_task(context, task_id=task_id, limit=max_subtasks) + response["data"]["subtasks"] = subtasks["subtasks"] + return {"task": response["data"]} + + +@tool(requires_auth=Asana(scopes=["default"])) +async def get_subtasks_from_a_task( + context: ToolContext, + task_id: Annotated[str, "The ID of the task to get the subtasks of."], + limit: Annotated[ + int, + "The maximum number of subtasks to return. Min of 1, max of 100. Defaults to 100.", + ] = 100, + next_page_token: Annotated[ + str | None, + "The token to retrieve the next page of subtasks. Defaults to None (start from the first " + "page of subtasks)", + ] = None, +) -> Annotated[dict[str, Any], "The subtasks of the task."]: + """Get the subtasks of a task""" + limit = max(1, min(100, limit)) + + client = AsanaClient(context.get_auth_token_or_empty()) + response = await client.get( + f"/tasks/{task_id}/subtasks", + params=remove_none_values({ + "opt_fields": TASK_OPT_FIELDS, + "limit": limit, + "offset": next_page_token, + }), + ) + + return { + "subtasks": response["data"], + "count": len(response["data"]), + "next_page": get_next_page(response), + } + + @tool(requires_auth=Asana(scopes=["default"])) async def update_task( context: ToolContext, diff --git a/toolkits/asana/evals/eval_projects.py b/toolkits/asana/evals/eval_projects.py index 6fc4e87e2..980ab3139 100644 --- a/toolkits/asana/evals/eval_projects.py +++ b/toolkits/asana/evals/eval_projects.py @@ -44,7 +44,7 @@ def list_projects_eval_suite() -> EvalSuite: args={ "team_ids": None, "limit": 100, - "offset": 0, + "offset": None, }, ), ], @@ -65,7 +65,7 @@ def list_projects_eval_suite() -> EvalSuite: args={ "team_ids": ["1234567890"], "limit": 100, - "offset": 0, + "offset": None, }, ), ], @@ -86,7 +86,7 @@ def list_projects_eval_suite() -> EvalSuite: args={ "team_ids": None, "limit": 10, - "offset": 0, + "offset": None, }, ), ], @@ -106,7 +106,7 @@ def list_projects_eval_suite() -> EvalSuite: func=list_projects, args={ "limit": 2, - "offset": 2, + "offset": "abc123", "team_ids": None, }, ), @@ -137,6 +137,10 @@ def list_projects_eval_suite() -> EvalSuite: "role": "tool", "content": json.dumps({ "count": 2, + "next_page": { + "has_more_results": True, + "next_page_token": "abc123", + }, "workspaces": [ { "id": "1234567890", diff --git a/toolkits/asana/evals/eval_tags.py b/toolkits/asana/evals/eval_tags.py index 566f5bc08..d706658b2 100644 --- a/toolkits/asana/evals/eval_tags.py +++ b/toolkits/asana/evals/eval_tags.py @@ -43,7 +43,7 @@ def list_tags_eval_suite() -> EvalSuite: func=list_tags, args={ "limit": 100, - "offset": 0, + "offset": None, }, ), ], @@ -62,7 +62,7 @@ def list_tags_eval_suite() -> EvalSuite: func=list_tags, args={ "limit": 10, - "offset": 0, + "offset": None, }, ), ], @@ -81,7 +81,7 @@ def list_tags_eval_suite() -> EvalSuite: func=list_tags, args={ "limit": 2, - "offset": 2, + "offset": "abc123", }, ), ], @@ -110,6 +110,10 @@ def list_tags_eval_suite() -> EvalSuite: "role": "tool", "content": json.dumps({ "count": 2, + "next_page": { + "has_more_results": True, + "next_page_token": "abc123", + }, "workspaces": [ { "id": "1234567890", @@ -139,7 +143,7 @@ def list_tags_eval_suite() -> EvalSuite: func=list_tags, args={ "limit": 5, - "offset": 2, + "offset": "abc123", }, ), ], @@ -168,6 +172,10 @@ def list_tags_eval_suite() -> EvalSuite: "role": "tool", "content": json.dumps({ "count": 2, + "next_page": { + "has_more_results": True, + "next_page_token": "abc123", + }, "workspaces": [ { "id": "1234567890", diff --git a/toolkits/asana/evals/eval_tasks.py b/toolkits/asana/evals/eval_tasks.py index 7fad1db40..2b9d0b8f9 100644 --- a/toolkits/asana/evals/eval_tasks.py +++ b/toolkits/asana/evals/eval_tasks.py @@ -14,7 +14,7 @@ from arcade_asana.tools import ( get_subtasks_from_a_task, get_task_by_id, - search_tasks, + get_tasks_without_id, update_task, ) @@ -100,7 +100,7 @@ def get_subtasks_from_a_task_eval_suite() -> EvalSuite: args={ "task_id": "1234567890", "limit": 2, - "offset": 2, + "offset": "abc123", }, ), ], @@ -130,6 +130,10 @@ def get_subtasks_from_a_task_eval_suite() -> EvalSuite: "role": "tool", "content": json.dumps({ "count": 2, + "next_page": { + "has_more_results": True, + "next_page_token": "abc123", + }, "subtasks": [ { "id": "1234567890", @@ -168,7 +172,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for the task 'Hello' in Asana.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", }, @@ -185,7 +189,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for the task 'Hello' in Asana sorting by likes in descending order.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "sort_by": TaskSortBy.LIKES, @@ -206,7 +210,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for the task 'Hello' associated to the project with ID '1234567890'.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "project_id": "1234567890", @@ -225,7 +229,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for the task 'Hello' associated to the project named 'My Project'.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "project_name": "My Project", @@ -244,7 +248,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for the task 'Hello' associated to the team with ID '1234567890'.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "team_id": "1234567890", @@ -263,7 +267,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for the task 'Hello' associated to the tags with IDs '1234567890' and '1234567891'.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "tags": ["1234567890", "1234567891"], @@ -282,7 +286,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for the task 'Hello' associated to the tags 'My Tag' and 'My Other Tag'.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "tags": ["My Tag", "My Other Tag"], @@ -301,7 +305,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for tasks 'Hello' that started on '2025-01-01' and are due on '2025-01-02'.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "start_on": "2025-01-01", @@ -322,7 +326,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for tasks 'Hello' that start on 2025-05-05 and are due on or before 2025-05-11.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "start_on": "2025-05-05", @@ -343,7 +347,7 @@ def search_tasks_eval_suite() -> EvalSuite: user_message="Search for tasks 'Hello' that are not completed and are due on or before 2025-05-11.", expected_tool_calls=[ ExpectedToolCall( - func=search_tasks, + func=get_tasks_without_id, args={ "keywords": "Hello", "due_on_or_before": "2025-05-11", diff --git a/toolkits/asana/evals/eval_teams.py b/toolkits/asana/evals/eval_teams.py index f10e44744..0fabd02ea 100644 --- a/toolkits/asana/evals/eval_teams.py +++ b/toolkits/asana/evals/eval_teams.py @@ -150,7 +150,7 @@ def list_teams_the_current_user_is_a_member_of_eval_suite() -> EvalSuite: func=list_teams_the_current_user_is_a_member_of, args={ "limit": 2, - "offset": 2, + "offset": "abc123", }, ), ], @@ -179,6 +179,10 @@ def list_teams_the_current_user_is_a_member_of_eval_suite() -> EvalSuite: "role": "tool", "content": json.dumps({ "count": 1, + "next_page": { + "has_more_results": True, + "next_page_token": "abc123", + }, "teams": [ { "id": "1234567890", @@ -208,7 +212,7 @@ def list_teams_the_current_user_is_a_member_of_eval_suite() -> EvalSuite: func=list_teams_the_current_user_is_a_member_of, args={ "limit": 5, - "offset": 2, + "offset": "abc123", }, ), ], @@ -237,6 +241,10 @@ def list_teams_the_current_user_is_a_member_of_eval_suite() -> EvalSuite: "role": "tool", "content": json.dumps({ "count": 1, + "next_page": { + "has_more_results": True, + "next_page_token": "abc123", + }, "teams": [ { "id": "1234567890", diff --git a/toolkits/asana/evals/eval_users.py b/toolkits/asana/evals/eval_users.py index daee15ff0..694172bc0 100644 --- a/toolkits/asana/evals/eval_users.py +++ b/toolkits/asana/evals/eval_users.py @@ -76,7 +76,7 @@ def list_users_eval_suite() -> EvalSuite: args={ "workspace_id": None, "limit": 100, - "offset": 0, + "offset": None, }, ), ], @@ -97,7 +97,7 @@ def list_users_eval_suite() -> EvalSuite: args={ "workspace_id": "1234567890", "limit": 100, - "offset": 0, + "offset": None, }, ), ], @@ -118,7 +118,7 @@ def list_users_eval_suite() -> EvalSuite: args={ "limit": 5, "workspace_id": None, - "offset": 0, + "offset": None, }, ), ], diff --git a/toolkits/asana/evals/eval_workspaces.py b/toolkits/asana/evals/eval_workspaces.py index 39bb43d8b..4811fd3b0 100644 --- a/toolkits/asana/evals/eval_workspaces.py +++ b/toolkits/asana/evals/eval_workspaces.py @@ -120,7 +120,7 @@ def list_workspaces_eval_suite() -> EvalSuite: func=list_workspaces, args={ "limit": 5, - "offset": 2, + "offset": "abc123", }, ), ], @@ -149,6 +149,10 @@ def list_workspaces_eval_suite() -> EvalSuite: "role": "tool", "content": json.dumps({ "count": 2, + "next_page": { + "has_more_results": True, + "next_page_token": "abc123", + }, "workspaces": [ { "id": "1234567890", From e0880c53e9e3568a5feb69fed01d232ca66e2f76 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 9 May 2025 15:35:29 -0300 Subject: [PATCH 39/40] create tool to mark task as completed --- toolkits/asana/arcade_asana/tools/tasks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 907cbfb2a..346529981 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -283,6 +283,15 @@ async def update_task( } +@tool(requires_auth=Asana(scopes=["default"])) +async def mark_task_as_completed( + context: ToolContext, + task_id: Annotated[str, "The ID of the task to mark as completed."], +) -> Annotated[dict[str, Any], "The task marked as completed."]: + """Mark a task in Asana as completed""" + return await update_task(context, task_id, completed=True) + + @tool(requires_auth=Asana(scopes=["default"])) async def create_task( context: ToolContext, From cd9e7b60981ec873dc3d7a902d0cfea86b2d24b0 Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Mon, 12 May 2025 14:56:33 -0300 Subject: [PATCH 40/40] make mypy happy --- toolkits/asana/arcade_asana/tools/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py index 346529981..4cf37f10c 100644 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ b/toolkits/asana/arcade_asana/tools/tasks.py @@ -289,7 +289,7 @@ async def mark_task_as_completed( task_id: Annotated[str, "The ID of the task to mark as completed."], ) -> Annotated[dict[str, Any], "The task marked as completed."]: """Mark a task in Asana as completed""" - return await update_task(context, task_id, completed=True) + return await update_task(context, task_id, completed=True) # type: ignore[no-any-return] @tool(requires_auth=Asana(scopes=["default"]))