Remove Toolkits (#512)

As of today, Arcade's toolkits are moving to closed-source.  

Toolkit source code remains available upon request for our paying
customers. The history of this repository contains the toolkits as of
their most recent publication under their previous license terms.
This commit is contained in:
Eric Gustin 2025-07-24 16:45:24 -07:00 committed by GitHub
parent 27a6cd31a3
commit b50726d044
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
817 changed files with 0 additions and 88352 deletions

View file

@ -1,90 +0,0 @@
# This workflow prevents unauthorized updates to existing toolkit versions,
# as well as unauthorized renames or removals of toolkits.
# Toolkits are versioned via the `toolkits/*/pyproject.toml` file.
# It ensures that only toolkit release managers can modify existing toolkit versions, rename, or remove toolkits.
# If a pull request is made by someone not in the toolkit release managers list, then the workflow
# will fail if any existing toolkit version is changed, or if a toolkit is renamed or removed.
name: Prevent Unauthorized Version Updates
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
version-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Load toolkit release managers
id: load_trm
run: |
echo "Loading authorized toolkit release managers from toolkits/TOOLKIT_RELEASE_MANAGERS.txt"
if [[ -f toolkits/TOOLKIT_RELEASE_MANAGERS.txt ]]; then
TOOLKIT_RELEASE_MANAGERS=$(cat toolkits/TOOLKIT_RELEASE_MANAGERS.txt | tr '\n' ' ')
echo "toolkit_release_managers=${TOOLKIT_RELEASE_MANAGERS}" >> $GITHUB_OUTPUT
else
echo "ERROR: TOOLKIT_RELEASE_MANAGERS.txt not found."
exit 1
fi
- name: Check if PR author is a toolkit release manager
id: check_author
run: |
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
echo "PR Author: $PR_AUTHOR"
if echo "${{ steps.load_trm.outputs.toolkit_release_managers }}" | grep -wq "$PR_AUTHOR"; then
echo "Author is a toolkit release manager. Exiting workflow successfully."
echo "authorized=true" >> $GITHUB_OUTPUT
else
echo "Author is not authorized to perform toolkit release. Need to perform toolkit version checks."
echo "authorized=false" >> $GITHUB_OUTPUT
fi
- name: Get versions from current commit
if: steps.check_author.outputs.authorized == 'false'
id: current_versions
# Get all toolkits in the format of "package_name=version" for the PR's current commit and save to current_versions.txt
run: |
paste <(cat toolkits/*/pyproject.toml | grep "^name = " | grep "arcade_" | cut -d'"' -f2) <(cat toolkits/*/pyproject.toml | grep "^version = " | cut -d'"' -f2) | awk '{print $1"="$2}' > current_versions.txt
echo "Package versions in current commit:"
cat current_versions.txt
- name: Get versions from target branch
if: steps.check_author.outputs.authorized == 'false'
id: target_versions
# Get all toolkits in the format of "package_name=version" for the target branch and save to target_versions.txt
run: |
git fetch origin main
git checkout origin/main
paste <(cat toolkits/*/pyproject.toml | grep "^name = " | grep "arcade_" | cut -d'"' -f2) <(cat toolkits/*/pyproject.toml | grep "^version = " | cut -d'"' -f2) | awk '{print $1"="$2}' > target_versions.txt
echo "Package versions in target branch:"
cat target_versions.txt
- name: Compare versions
if: steps.check_author.outputs.authorized == 'false'
id: compare_versions
# Iterate over each toolkit in the target branch and compare its version with the current commit
# Only fails if an existing toolkit version is changed, or if a toolkit is renamed or removed.
run: |
while read -r target_line; do
package_name=$(echo "$target_line" | cut -d'=' -f1)
target_version=$(echo "$target_line" | cut -d'=' -f2)
current_version=$(grep "^$package_name=" current_versions.txt | cut -d'=' -f2)
echo "Comparing $package_name: $target_version (target) vs $current_version (current)"
if [ -z "$current_version" ]; then
echo "Package $package_name has been removed or renamed."
echo "ERROR: Only toolkit release managers can remove or rename toolkits."
exit 1
elif [ "$target_version" != "$current_version" ]; then
echo "Version mismatch for $package_name: $target_version (target) vs $current_version (current)"
echo "ERROR: Only toolkit release managers can alter an existing toolkit version."
exit 1
else
echo "Versions match for $package_name: $target_version (target) vs $current_version (current)"
fi
done < target_versions.txt

View file

@ -1,109 +0,0 @@
name: Test Toolkits
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
setup:
runs-on: ubuntu-latest
outputs:
toolkits_with_gha_secrets: ${{ steps.load_toolkits.outputs.toolkits_with_gha_secrets }}
toolkits_without_gha_secrets: ${{ steps.load_toolkits.outputs.toolkits_without_gha_secrets }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: determine toolkits with and without GHA secrets
id: load_toolkits
run: |
# Find all directories in toolkits/ that have a pyproject.toml
TOOLKITS=$(find toolkits -maxdepth 1 -type d -not -name "toolkits" -exec test -f {}/pyproject.toml \; -exec basename {} \; | jq -R -s -c 'split("\n")[:-1]')
TOOLKITS_WITH_GHA_SECRETS='["postgres"]'
TOOLKITS_WITHOUT_GHA_SECRETS=$(echo "$TOOLKITS" | jq -c --argjson with "$TOOLKITS_WITH_GHA_SECRETS" '[.[] | select(. as $t | $with | index($t) | not)]')
echo "Found toolkits: $TOOLKITS"
echo "Found toolkits without GHA secrets: $TOOLKITS_WITHOUT_GHA_SECRETS"
echo "Found toolkits with GHA secrets: $TOOLKITS_WITH_GHA_SECRETS"
echo "toolkits_without_gha_secrets=$TOOLKITS_WITHOUT_GHA_SECRETS" >> $GITHUB_OUTPUT
echo "toolkits_with_gha_secrets=$TOOLKITS_WITH_GHA_SECRETS" >> $GITHUB_OUTPUT
test-toolkits:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix:
toolkit: ${{ fromJson(needs.setup.outputs.toolkits_without_gha_secrets) }}
fail-fast: true
steps:
- name: Check out
uses: actions/checkout@v4
- name: Set up the environment
uses: ./.github/actions/setup-uv-env
- name: Install toolkit dependencies
working-directory: toolkits/${{ matrix.toolkit }}
run: uv pip install -e ".[dev]"
- name: Check toolkit
working-directory: toolkits/${{ matrix.toolkit }}
run: |
uv run --active pre-commit run -a
uv run --active mypy --config-file=pyproject.toml
- name: Test stand-alone toolkits (no secrets)
working-directory: toolkits/${{ matrix.toolkit }}
run: |
# Run pytest and capture exit code
uv run --active pytest -W ignore -v --cov=arcade_${{ matrix.toolkit }} --cov-report=xml || EXIT_CODE=$?
if [ "${EXIT_CODE:-0}" -eq 5 ]; then
echo "No tests found for toolkit ${{ matrix.toolkit }}, skipping..."
exit 0
elif [ "${EXIT_CODE:-0}" -ne 0 ]; then
exit ${EXIT_CODE}
fi
test-toolkits-with-gha-secrets:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix:
toolkit: ${{ fromJson(needs.setup.outputs.toolkits_with_gha_secrets) }}
fail-fast: true
steps:
- name: Check out
uses: actions/checkout@v4
- name: Set up the environment
uses: ./.github/actions/setup-uv-env
- name: Install toolkit dependencies
working-directory: toolkits/${{ matrix.toolkit }}
run: uv pip install -e ".[dev]"
- name: Check toolkit
working-directory: toolkits/${{ matrix.toolkit }}
run: |
uv run --active pre-commit run -a
uv run --active mypy --config-file=pyproject.toml
- name: Test stand-alone toolkits (with secrets)
if: |
!github.event.pull_request.head.repo.fork
working-directory: toolkits/${{ matrix.toolkit }}
env:
TEST_POSTGRES_DATABASE_CONNECTION_STRING: ${{ secrets.TEST_POSTGRES_DATABASE_CONNECTION_STRING }} # TODO: dynamically only load the `TEST_${{ matrix.toolkit }}_DATABASE_CONNECTION_STRING secret`
run: |
# Run pytest and capture exit code
uv run --active pytest -W ignore -v --cov=arcade_${{ matrix.toolkit }} --cov-report=xml || EXIT_CODE=$?
if [ "${EXIT_CODE:-0}" -eq 5 ]; then
echo "No tests found for toolkit ${{ matrix.toolkit }}, skipping..."
exit 0
elif [ "${EXIT_CODE:-0}" -ne 0 ]; then
exit ${EXIT_CODE}
fi

View file

@ -1,8 +0,0 @@
Spartee
nbarbettini
EricGustin
sdreyer
wdawson
byrro
torresmateo
evantahler

View file

@ -1,18 +0,0 @@
files: ^.*/asana/.*
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

View file

@ -1,46 +0,0 @@
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

View file

@ -1,21 +0,0 @@
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.

View file

@ -1,55 +0,0 @@
.PHONY: help
help:
@echo "🛠️ github 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 uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv 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"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -1,139 +0,0 @@
import os
from enum import Enum
ASANA_BASE_URL = "https://app.asana.com/api"
ASANA_API_VERSION = "1.0"
try:
ASANA_MAX_CONCURRENT_REQUESTS = int(os.getenv("ASANA_MAX_CONCURRENT_REQUESTS", 3))
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",
"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",
"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",
]
TAG_OPT_FIELDS = [
"gid",
"name",
"workspace",
]
TEAM_OPT_FIELDS = [
"gid",
"name",
"description",
"organization",
"permalink_url",
]
USER_OPT_FIELDS = [
"gid",
"resource_type",
"name",
"email",
"photo",
"workspaces",
]
WORKSPACE_OPT_FIELDS = [
"gid",
"resource_type",
"name",
"email_domains",
"is_organization",
]
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"
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"

View file

@ -1,26 +0,0 @@
from functools import wraps
from typing import Any, Callable
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"]
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: Any, **kwargs: Any) -> Any:
response = await func(*args, **kwargs)
return response_cleaner(response)
return wrapper

View file

@ -1,14 +0,0 @@
from arcade_tdk.errors import ToolExecutionError
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}",
)

View file

@ -1,165 +0,0 @@
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.decorators import clean_asana_response
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)
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
@clean_asana_response
async def get(
self,
endpoint: str,
params: Optional[dict] = None,
headers: Optional[dict] = None,
api_version: str | None = None,
) -> dict:
default_headers = {
"Authorization": f"Bearer {self.auth_token}",
"Accept": "application/json",
}
headers = {**default_headers, **(headers or {})}
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())
@clean_asana_response
async def post(
self,
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:
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,
}
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]
self._raise_for_status(response)
return cast(dict, response.json())
@clean_asana_response
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.put(**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 cast(dict, response["data"])

View file

@ -1,31 +0,0 @@
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,
get_subtasks_from_a_task,
get_task_by_id,
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
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",
"get_tasks_without_id",
"update_task",
]

View file

@ -1,81 +0,0 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Asana
from arcade_asana.constants import PROJECT_OPT_FIELDS
from arcade_asana.models import AsanaClient
from arcade_asana.utils import (
get_next_page,
get_unique_workspace_id_or_raise_error,
remove_none_values,
)
@tool(requires_auth=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=Asana(scopes=["default"]))
async def list_projects(
context: ToolContext,
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,
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",
]:
"""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))
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(
"/projects",
params=remove_none_values({
"limit": limit,
"offset": next_page_token,
"team": team_id,
"workspace": workspace_id,
"opt_fields": PROJECT_OPT_FIELDS,
}),
)
return {
"projects": response["data"],
"count": len(response["data"]),
"next_page": get_next_page(response),
}

View file

@ -1,102 +0,0 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Asana
from arcade_tdk.errors import ToolExecutionError
from arcade_asana.constants import TAG_OPT_FIELDS, TagColor
from arcade_asana.models import AsanaClient
from arcade_asana.utils import (
get_next_page,
get_unique_workspace_id_or_raise_error,
remove_none_values,
)
@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"],
) -> 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=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=Asana(scopes=["default"]))
async def list_tags(
context: ToolContext,
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,
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 an Asana workspace",
]:
"""List tags 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(
"/tags",
params=remove_none_values({
"limit": limit,
"offset": next_page_token,
"workspace": workspace_id,
"opt_fields": TAG_OPT_FIELDS,
}),
)
return {
"tags": response["data"],
"count": len(response["data"]),
"next_page": get_next_page(response),
}

View file

@ -1,455 +0,0 @@
import base64
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Asana
from arcade_tdk.errors import ToolExecutionError
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,
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,
validate_date_format,
)
@tool(requires_auth=Asana(scopes=["default"]))
async def get_tasks_without_id(
context: ToolContext,
keywords: Annotated[
str | None, "Keywords to search for tasks. Matches against the task name and description."
] = None,
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,
assignee_id: Annotated[
str | None,
"The ID of the user to filter tasks assigned to. "
"Defaults to None (does not filter by assignee).",
] = None,
project: Annotated[
str | None,
"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,
"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[
str | None,
"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. 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. 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 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 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 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 | 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.",
] = 100,
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.DESCENDING,
) -> Annotated[dict[str, Any], "The tasks that match the query."]:
"""Search for tasks"""
limit = max(1, min(100, limit))
project_id = None
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"]
if not workspace_id:
workspace_id = project_data["workspace"]["id"]
tag_ids = await get_tag_ids(context, tags)
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)
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(
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,
),
)
tasks_by_id = {task["id"]: task for task in response["data"]}
tasks = list(tasks_by_id.values())
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,
task_id: Annotated[str, "The ID of the task to update."],
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'. "
"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'. "
"Defaults to None (does not change the current due date).",
] = None,
description: Annotated[
str | None,
"The new description of the task. "
"Defaults to None (does not change the current description).",
] = 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. "
"Defaults to None (does not change the current assignee).",
] = None,
) -> Annotated[
dict[str, Any],
"Updates a task in Asana",
]:
"""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,
"completed": completed,
"due_on": due_date,
"start_on": start_date,
"notes": description,
"assignee": assignee_id,
}),
}
response = await client.put(f"/tasks/{task_id}", json_data=task_data)
return {
"status": {"success": True, "message": "task updated successfully"},
"task": response["data"],
}
@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) # type: ignore[no-any-return]
@tool(requires_auth=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'. "
"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'. "
"Defaults to None.",
] = None,
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: Annotated[
str | None, "The ID or 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. "
"Defaults to 'me', which assigns the task to the current user.",
] = "me",
tags: Annotated[
list[str] | None,
"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],
"Creates a task in Asana",
]:
"""Creates a task in Asana
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.
"""
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, 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)
task_data = {
"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=task_data)
return {
"status": {"success": True, "message": "task successfully created"},
"task": response["data"],
}
@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."],
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
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"Failed to decode base64 file content: {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)} # type: ignore[dict-item]
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"],
}

View file

@ -1,110 +0,0 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Asana
from arcade_asana.constants import TEAM_OPT_FIELDS
from arcade_asana.models import AsanaClient
from arcade_asana.utils import (
get_next_page,
get_unique_workspace_id_or_raise_error,
remove_none_values,
)
@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"],
) -> 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=remove_none_values({"opt_fields": TEAM_OPT_FIELDS}),
)
return {"team": response["data"]}
@tool(requires_auth=Asana(scopes=["default"]))
async def list_teams_the_current_user_is_a_member_of(
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 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))
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(
"/users/me/teams",
params=remove_none_values({
"limit": limit,
"offset": next_page_token,
"opt_fields": TEAM_OPT_FIELDS,
"organization": workspace_id,
}),
)
return {
"teams": response["data"],
"count": len(response["data"]),
"next_page": get_next_page(response),
}
@tool(requires_auth=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 listing the available workspaces.",
] = 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),
}

View file

@ -1,69 +0,0 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Asana
from arcade_asana.constants import USER_OPT_FIELDS
from arcade_asana.models import AsanaClient
from arcade_asana.utils import (
get_next_page,
get_unique_workspace_id_or_raise_error,
remove_none_values,
)
@tool(requires_auth=Asana(scopes=["default"]))
async def list_users(
context: ToolContext,
workspace_id: Annotated[
str | None,
"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 retrieve. Min is 1, max is 100. Defaults to 100.",
] = 100,
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",
]:
"""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",
params=remove_none_values({
"workspace": workspace_id,
"limit": limit,
"offset": next_page_token,
"opt_fields": USER_OPT_FIELDS,
}),
)
return {
"users": response["data"],
"count": len(response["data"]),
"next_page": get_next_page(response),
}
@tool(requires_auth=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}

View file

@ -1,54 +0,0 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from arcade_tdk.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=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=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,
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",
]:
"""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",
params=remove_none_values({
"limit": limit,
"offset": next_page_token,
"opt_fields": WORKSPACE_OPT_FIELDS,
}),
)
return {
"workspaces": response["data"],
"count": len(response["data"]),
"next_page": get_next_page(response),
}

View file

@ -1,483 +0,0 @@
import asyncio
import json
from collections.abc import Awaitable
from datetime import datetime
from typing import Any, Callable, TypeVar, cast
from arcade_tdk import ToolContext
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
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])
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 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(
keywords: str | None,
completed: bool | None,
assignee_id: 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,
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] = {
"text": keywords,
"opt_fields": TASK_OPT_FIELDS,
"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:
query_params["projects.any"] = project_id
if team_id:
query_params["team.any"] = team_id
if tag_ids:
query_params["tags.any"] = ",".join(tag_ids)
query_params = add_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
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:
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 query_params
async def handle_new_task_associations(
context: ToolContext,
parent_task_id: str | None,
project: 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).
"""
project_id, project_name = (None, None)
if project:
if project.isnumeric():
project_id = project
else:
project_name = project
if project_name:
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)
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"]["id"]
return parent_task_id, project_id, workspace_id
async def get_project_by_name_or_raise_error(
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=context,
names=[project_name],
response_limit=100,
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}' 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,
developer_message=f"{message} {additional_prompt}",
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 RetryableToolError(
message=message,
developer_message=message,
additional_prompt_content=additional_prompt,
)
return cast(dict, response["matches"]["projects"][0])
async def handle_new_task_tags(
context: ToolContext,
tags: list[str] | None,
workspace_id: str | None,
) -> list[str] | None:
if not tags:
return None
tag_ids = []
tag_names = []
for tag in tags:
if tag.isnumeric():
tag_ids.append(tag)
else:
tag_names.append(tag)
if tag_names:
response = await find_tags_by_name(context, tag_names)
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
responses = await asyncio.gather(*[
create_tag(context, name=name, workspace_id=workspace_id)
for name in response["not_found"]["tags"]
])
tag_ids.extend([response["tag"]["id"] for response in responses])
return tag_ids
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.
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:
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}. 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"]])
return tag_ids if tag_ids else None
async def paginate_tool_call(
tool: Callable[[ToolContext, Any], Awaitable[ToolResponse]],
context: ToolContext,
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] = []
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) # type: ignore[call-arg]
results.extend(response[response_key])
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["next_page_token"] = next_page_token
try:
await asyncio.wait_for(paginate_loop(), timeout=timeout_seconds)
except TimeoutError:
raise PaginationTimeoutError(timeout_seconds, tool.__tool_name__) # type: ignore[attr-defined]
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 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'])}"
raise RetryableToolError(
message=message,
developer_message=message,
additional_prompt_content=additional_prompt,
)
async def find_projects_by_name(
context: ToolContext,
names: list[str],
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,
) -> dict[str, Any]:
"""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_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
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}
projects = await paginate_tool_call(
tool=list_projects,
context=context,
response_key="projects",
max_items=max_items_to_scan,
timeout_seconds=15,
team_id=team_id,
)
matches: list[dict[str, Any]] = []
not_matched: list[str] = []
for project in projects:
project_name_lower = project["name"].casefold()
if len(matches) >= response_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_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,
) -> dict[str, Any]:
"""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_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
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}
tags = await paginate_tool_call(
tool=list_tags,
context=context,
response_key="tags",
max_items=max_items_to_scan,
timeout_seconds=15,
workspace_id=workspace_id,
)
matches: list[dict[str, Any]] = []
not_matched: list[str] = []
for tag in tags:
tag_name_lower = tag["name"].casefold()
if len(matches) >= response_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
def get_next_page(response: dict[str, Any]) -> dict[str, Any]:
try:
token = response["next_page"]["offset"]
except (KeyError, TypeError):
token = None
return {"has_more_pages": token is not None, "next_page_token": token}

View file

@ -1,16 +0,0 @@
from unittest.mock import patch
import pytest
from arcade_tdk 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

View file

@ -1,96 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
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

View file

@ -1,197 +0,0 @@
import json
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_asana
from arcade_asana.tools import get_project_by_id, list_projects
# 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": None,
},
),
],
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": None,
},
),
],
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": None,
},
),
],
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": "abc123",
"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,
"next_page": {
"has_more_results": True,
"next_page_token": "abc123",
},
"workspaces": [
{
"id": "1234567890",
"name": "Project Hello",
},
{
"id": "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

View file

@ -1,281 +0,0 @@
import json
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_asana
from arcade_asana.tools import create_tag, list_tags
# 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": None,
},
),
],
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": None,
},
),
],
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": "abc123",
},
),
],
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,
"next_page": {
"has_more_results": True,
"next_page_token": "abc123",
},
"workspaces": [
{
"id": "1234567890",
"name": "Tag Hello",
},
{
"id": "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": "abc123",
},
),
],
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,
"next_page": {
"has_more_results": True,
"next_page_token": "abc123",
},
"workspaces": [
{
"id": "1234567890",
"name": "Tag Hello",
},
{
"id": "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",
},
],
)
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

View file

@ -1,442 +0,0 @@
import json
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_asana
from arcade_asana.constants import SortOrder, TaskSortBy
from arcade_asana.tools import (
get_subtasks_from_a_task,
get_task_by_id,
get_tasks_without_id,
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": "abc123",
},
),
],
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,
"next_page": {
"has_more_results": True,
"next_page_token": "abc123",
},
"subtasks": [
{
"id": "1234567890",
"name": "Subtask Hello",
},
{
"id": "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=get_tasks_without_id,
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=get_tasks_without_id,
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=get_tasks_without_id,
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=get_tasks_without_id,
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=get_tasks_without_id,
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=get_tasks_without_id,
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=get_tasks_without_id,
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=get_tasks_without_id,
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=get_tasks_without_id,
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=get_tasks_without_id,
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

View file

@ -1,269 +0,0 @@
import json
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
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": "abc123",
},
),
],
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,
"next_page": {
"has_more_results": True,
"next_page_token": "abc123",
},
"teams": [
{
"id": "1234567890",
"name": "Team Hello",
},
{
"id": "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": "abc123",
},
),
],
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,
"next_page": {
"has_more_results": True,
"next_page_token": "abc123",
},
"teams": [
{
"id": "1234567890",
"name": "Team Hello",
},
{
"id": "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

View file

@ -1,253 +0,0 @@
import json
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
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": None,
},
),
],
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": None,
},
),
],
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": None,
},
),
],
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": [
{
"id": "1234567890",
"name": "User Hello",
},
{
"id": "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": [
{
"id": "1234567890",
"name": "User Hello",
},
{
"id": "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

View file

@ -1,177 +0,0 @@
import json
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
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": [
{
"id": "1234567890",
"name": "Workspace Hello",
},
{
"id": "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": "abc123",
},
),
],
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,
"next_page": {
"has_more_results": True,
"next_page_token": "abc123",
},
"workspaces": [
{
"id": "1234567890",
"name": "Workspace Hello",
},
{
"id": "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",
},
],
)
return suite

View file

@ -1,57 +0,0 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_asana"
version = "0.1.3"
description = "Arcade tools designed for LLMs to interact with Asana"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"httpx>=0.27.2,<1.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"pytest-mock>=3.11.1,<3.12.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = {path = "../../", editable = true}
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
[tool.mypy]
files = [ "arcade_asana/**/*.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
[tool.hatch.build.targets.wheel]
packages = [ "arcade_asana",]

View file

@ -1,78 +0,0 @@
from unittest.mock import patch
import pytest
from arcade_tdk.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,
)
@pytest.mark.asyncio
@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_find_tags_by_name.return_value = {
"matches": {
"tags": [
{"id": "1234567890", "name": "My Tag"},
{"id": "1234567891", "name": "My Other Tag"},
]
},
"not_found": {"tags": []},
}
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": [
{"id": "1234567890", "name": "My Workspace"},
]
}
assert await get_unique_workspace_id_or_raise_error(mock_context) == "1234567890"
mock_list_workspaces.return_value = {
"workspaces": [
{"id": "1234567890", "name": "My Workspace"},
{"id": "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.utils.find_projects_by_name")
async def test_get_project_by_name_or_raise_error(mock_find_projects_by_name, mock_context):
project1 = {"id": "1234567890", "name": "My Project"}
mock_find_projects_by_name.return_value = {
"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": [], "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")
assert project1["name"] in exc_info.value.additional_prompt_content

View file

@ -1,18 +0,0 @@
files: ^.*/code_sandbox/.*
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

View file

@ -1,47 +0,0 @@
target-version = "py310"
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

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025, Arcade AI
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.

View file

@ -1,55 +0,0 @@
.PHONY: help
help:
@echo "🛠️ github 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 uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv 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"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -1,52 +0,0 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from e2b_code_interpreter import Sandbox
from arcade_code_sandbox.tools.models import E2BSupportedLanguage
# See https://e2b.dev/docs to learn more about E2B
@tool(requires_secrets=["E2B_API_KEY"])
def run_code(
context: ToolContext,
code: Annotated[str, "The code to run"],
language: Annotated[
E2BSupportedLanguage, "The language of the code"
] = E2BSupportedLanguage.PYTHON,
) -> Annotated[str, "The sandbox execution as a JSON string"]:
"""
Run code in a sandbox and return the output.
"""
api_key = context.get_secret("E2B_API_KEY")
with Sandbox(api_key=api_key) as sbx:
execution = sbx.run_code(code=code, language=language)
return str(execution.to_json())
# Note: Not recommended to use tool_choice='generate' with this tool
# since it contains base64 encoded image.
@tool(requires_secrets=["E2B_API_KEY"])
def create_static_matplotlib_chart(
context: ToolContext,
code: Annotated[str, "The Python code to run"],
) -> Annotated[dict, "A dictionary with the following keys: base64_image, logs, error"]:
"""
Run the provided Python code to generate a static matplotlib chart.
The resulting chart is returned as a base64 encoded image.
"""
api_key = context.get_secret("E2B_API_KEY")
with Sandbox(api_key=api_key) as sbx:
execution = sbx.run_code(code=code)
result = {
"base64_image": execution.results[0].png if execution.results else None,
"logs": execution.logs.to_json(),
"error": execution.error.to_json() if execution.error else None,
}
return result

View file

@ -1,10 +0,0 @@
from enum import Enum
# Models and enums for the e2b code interpreter
class E2BSupportedLanguage(str, Enum):
PYTHON = "python"
JAVASCRIPT = "js"
R = "r"
JAVA = "java"
BASH = "bash"

View file

@ -1,119 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
SimilarityCritic,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_code_sandbox
from arcade_code_sandbox.tools.e2b import create_static_matplotlib_chart, run_code
from arcade_code_sandbox.tools.models import E2BSupportedLanguage
merge_sort_code = """
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i, j = 0, 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
sample_list = ["banana", "apple", "cherry", "date", "elderberry"]
sorted_list = merge_sort(sample_list)
print("Sorted list:", sorted_list)
"""
matplotlib_chart_code = """
import matplotlib.pyplot as plt
labels = ['Apples', 'Bananas', 'Cherries', 'Dates']
sizes = [30, 25, 20, 25]
colors = ['red', 'yellow', 'purple', 'brown']
plt.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90)
plt.axis('equal')
plt.title('Fruit Distribution')
plt.savefig('fruit_pie_chart.png')
"""
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_code_sandbox)
@tool_eval()
def code_sandbox_eval_suite():
suite = EvalSuite(
name="code_sandbox Tools Evaluation",
system_message="You are an AI assistant with access to code_sandbox tools. Use them to help the user with their tasks.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Run code",
user_message=f"Can you please run my merge sort algo?\n\n{merge_sort_code}",
expected_tool_calls=[
ExpectedToolCall(
func=run_code,
args={
"code": merge_sort_code,
"language": E2BSupportedLanguage.PYTHON,
},
)
],
critics=[
SimilarityCritic(critic_field="code", weight=0.8),
BinaryCritic(critic_field="language", weight=0.2),
],
)
suite.add_case(
name="Create static matplotlib chart",
user_message=f"Run this code:\n\n{matplotlib_chart_code}",
expected_tool_calls=[
ExpectedToolCall(
func=create_static_matplotlib_chart,
args={
"code": matplotlib_chart_code,
},
)
],
critics=[
SimilarityCritic(critic_field="code", weight=1.0),
],
)
return suite

View file

@ -1,57 +0,0 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_code_sandbox"
version = "1.0.2"
description = "Arcade.dev LLM tools for running code in a sandbox"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"e2b-code-interpreter>=1.0.1,<2.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"pytest-mock>=3.11.1,<3.12.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = {path = "../../", editable = true}
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
[tool.mypy]
files = [ "arcade_code_sandbox/**/*.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
[tool.hatch.build.targets.wheel]
packages = [ "arcade_code_sandbox",]

View file

@ -1,67 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from arcade_tdk import ToolContext, ToolSecretItem
from arcade_tdk.errors import ToolExecutionError
from arcade_code_sandbox.tools.e2b import create_static_matplotlib_chart, run_code
from arcade_code_sandbox.tools.models import E2BSupportedLanguage
@pytest.fixture
def mock_sandbox():
with patch("arcade_code_sandbox.tools.e2b.Sandbox") as mock:
yield mock.return_value.__enter__.return_value
@pytest.fixture
def mock_context():
return ToolContext(secrets=[ToolSecretItem(key="e2b_api_key", value="fake_api_key")])
def test_run_code_success(mock_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.to_json.return_value = '{"result": "success"}'
mock_sandbox.run_code.return_value = mock_execution
result = run_code(mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON)
assert result == '{"result": "success"}'
def test_run_code_error(mock_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.to_json.side_effect = ToolExecutionError("Execution failed")
mock_sandbox.run_code.return_value = mock_execution
with pytest.raises(ToolExecutionError, match="Execution failed"):
run_code(mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON)
def test_create_static_matplotlib_chart_success(mock_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.results = [MagicMock(png="base64encodedimage")]
mock_execution.logs.to_json.return_value = '{"logs": "log data"}'
mock_execution.error = None
mock_sandbox.run_code.return_value = mock_execution
result = create_static_matplotlib_chart(mock_context, "import matplotlib.pyplot as plt")
assert result == {
"base64_image": "base64encodedimage",
"logs": '{"logs": "log data"}',
"error": None,
}
def test_create_static_matplotlib_chart_error(mock_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.results = []
mock_execution.logs.to_json.return_value = '{"logs": "log data"}'
mock_execution.error.to_json.return_value = '{"error": "some error"}'
mock_sandbox.run_code.return_value = mock_execution
result = create_static_matplotlib_chart(mock_context, "import matplotlib.pyplot as plt")
assert result == {
"base64_image": None,
"logs": '{"logs": "log data"}',
"error": '{"error": "some error"}',
}

View file

@ -1,18 +0,0 @@
files: ^.*/confluence/.*
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

View file

@ -1,44 +0,0 @@
target-version = "py310"
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]
"**/tests/*" = ["S101"]
[format]
preview = true
skip-magic-trailing-comma = false

View file

@ -1,21 +0,0 @@
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.

View file

@ -1,55 +0,0 @@
.PHONY: help
help:
@echo "🛠️ github 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 uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv 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"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -1,617 +0,0 @@
from enum import Enum
from typing import Any
from urllib.parse import parse_qs, urlparse
import httpx
from arcade_tdk.errors import ToolExecutionError
from arcade_confluence.enums import BodyFormat, PageUpdateMode
from arcade_confluence.utils import (
build_child_url,
build_hierarchy,
remove_none_values,
)
class ConfluenceAPIVersion(str, Enum):
V1 = "wiki/rest/api"
V2 = "wiki/api/v2"
class ConfluenceClient:
ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
BASE_URL = "https://api.atlassian.com/ex/confluence"
def __init__(self, token: str, api_version: ConfluenceAPIVersion):
self.token = token
self.cloud_id = self._get_cloud_id()
self.api_version = api_version.value
def _get_cloud_id(self) -> str:
"""
Fetch the cloudId for <workspace_name>.atlassian.net
using the OAuth2 3LO accessible-resources endpoint.
For details on why this is necessary, see: https://developer.atlassian.com/cloud/oauth/getting-started/making-calls-to-api
"""
headers = {"Authorization": f"Bearer {self.token}"}
resp = httpx.get(self.ACCESSIBLE_RESOURCES_URL, headers=headers)
resp.raise_for_status()
resp_json = resp.json()
if len(resp_json) == 0:
raise ToolExecutionError(message="No workspaces found for the authenticated user.")
return resp_json[0].get("id") # type: ignore[no-any-return]
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {self.token}",
}
async with httpx.AsyncClient() as client:
response = await client.request(
method,
f"{self.BASE_URL}/{self.cloud_id}/{self.api_version}/{path.lstrip('/')}",
headers=headers,
**kwargs,
)
response.raise_for_status()
return response.json()
async def get(self, path: str, **kwargs: Any) -> Any:
return await self.request("GET", path, **kwargs)
async def post(self, path: str, **kwargs: Any) -> Any:
return await self.request("POST", path, **kwargs)
async def put(self, path: str, **kwargs: Any) -> Any:
return await self.request("PUT", path, **kwargs)
class ConfluenceClientV1(ConfluenceClient):
def __init__(self, token: str):
super().__init__(token, api_version=ConfluenceAPIVersion.V1)
def _build_query_cql(self, query: str, enable_fuzzy: bool) -> str:
"""Build CQL for a single query (term or phrase).
Args:
query: The search query (single word term or multi-word phrase)
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string for the query
"""
query = query.strip()
if not query:
return ""
# For phrases (multiple words), don't use fuzzy matching
if " " in query:
return f'(text ~ "{query}" OR title ~ "{query}" OR space.title ~ "{query}")'
else:
# For single terms, optionally use fuzzy matching
term_suffix = "~" if enable_fuzzy else ""
return f'(text ~ "{query}{term_suffix}" OR title ~ "{query}{term_suffix}" OR space.title ~ "{query}{term_suffix}")' # noqa: E501
def _build_and_cql(self, queries: list[str], enable_fuzzy: bool) -> str:
"""Build CQL for queries that must ALL be present (AND logic).
Args:
queries: List of queries that must all be present
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string with AND logic
"""
and_parts = []
for query in queries:
query_cql = self._build_query_cql(query, enable_fuzzy)
if query_cql:
and_parts.append(query_cql)
if not and_parts:
return ""
return f"({' AND '.join(and_parts)})"
def _build_or_cql(self, queries: list[str], enable_fuzzy: bool) -> str:
"""Build CQL for queries where ANY can be present (OR logic).
Args:
queries: List of queries where any can be present
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string with OR logic
"""
or_parts = []
for query in queries:
query_cql = self._build_query_cql(query, enable_fuzzy)
if query_cql:
or_parts.append(query_cql)
if not or_parts:
return ""
return f"({' OR '.join(or_parts)})"
def construct_cql(
self,
must_contain_all: list[str] | None,
can_contain_any: list[str] | None,
enable_fuzzy: bool = False,
) -> str:
"""Construct CQL query with AND/OR logic.
Learn about advanced searching using CQL here: https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/
Args:
must_contain_all: Queries that must ALL be present (AND logic)
can_contain_any: Queries where ANY can be present (OR logic)
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL query string
Raises:
ToolExecutionError: If no search parameters are provided
"""
cql_parts = []
# Handle must_contain_all (AND logic)
if must_contain_all:
and_cql = self._build_and_cql(must_contain_all, enable_fuzzy)
if and_cql:
cql_parts.append(and_cql)
# Handle can_contain_any (OR logic)
if can_contain_any:
or_cql = self._build_or_cql(can_contain_any, enable_fuzzy)
if or_cql:
cql_parts.append(or_cql)
# If there's only one part, return it
if len(cql_parts) == 1:
return cql_parts[0]
# AND the must_contain_all with the can_contain_any
if len(cql_parts) > 1:
return f"({' AND '.join(cql_parts)})"
raise ToolExecutionError(message="At least one search parameter must be provided")
def transform_search_content_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""
Transform the response from the GET /search endpoint by converting relative webui paths
to absolute URLs using the base URL from the response.
"""
base_url = response.get("_links", {}).get("base", "")
transformed_results = []
for result in response.get("results", []):
content = result.get("content", {})
transformed_result = {
"id": content.get("id"),
"title": content.get("title"),
"type": content.get("type"),
"status": content.get("status"),
"excerpt": result.get("excerpt"),
"url": f"{base_url}{result.get('url')}",
}
transformed_results.append(transformed_result)
return {"results": transformed_results}
class ConfluenceClientV2(ConfluenceClient):
def __init__(self, token: str):
super().__init__(token, api_version=ConfluenceAPIVersion.V2)
def _transform_links(
self, response: dict[str, Any], base_url: str | None = None
) -> dict[str, Any]:
"""
Transform the links in a page response by converting relative URLs to absolute URLs.
Args:
response: A page object from the API
base_url: The base URL to use for the transformation
Returns:
The transformed response
"""
result = response.copy()
if "_links" in result:
base_url = base_url or result["_links"].get("base", "")
webui_path = result["_links"].get("webui", "")
result["url"] = f"{base_url}{webui_path}"
del result["_links"]
return result
def transform_get_spaces_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""
Transform the response from the GET /spaces endpoint by converting relative webui paths
to absolute URLs using the base URL from the response.
"""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor",
[None], # type: ignore[list-item]
)[0]
base_url = response.get("_links", {}).get("base", "")
results = response.get("results", [])
transformed_results = []
for space in results:
space_copy = space.copy()
if "_links" in space_copy and "webui" in space_copy["_links"]:
webui_path = space_copy["_links"]["webui"]
space_copy["url"] = base_url + webui_path
del space_copy["_links"]
transformed_results.append(space_copy)
results = {"spaces": transformed_results, "pagination_token": pagination_token}
return remove_none_values(results)
def transform_list_pages_response(self, response: dict[str, Any]) -> dict[str, Any]:
"""Transform the response from the GET /pages endpoint."""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor",
[None], # type: ignore[list-item]
)[0]
base_url = response.get("_links", {}).get("base", "")
pages = [self._transform_links(page, base_url) for page in response["results"]]
results = {"pages": pages, "pagination_token": pagination_token}
return remove_none_values(results)
def transform_get_multiple_pages_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""Transform the response from the GET /pages endpoint."""
base_url = response.get("_links", {}).get("base", "")
pages = [self._transform_links(page, base_url) for page in response["results"]]
return {"pages": pages}
def transform_space_response(
self, response: dict[str, Any], base_url: str | None = None
) -> dict[str, dict[str, Any]]:
"""Transform API responses that return a space object."""
return {"space": self._transform_links(response, base_url)}
def transform_page_response(self, response: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Transform API responses that return a page object."""
return {"page": self._transform_links(response)}
def transform_get_attachments_response(self, response: dict[str, Any]) -> dict[str, Any]:
"""Transform the response from the GET /pages/{id}/attachments endpoint."""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor",
[None], # type: ignore[list-item]
)[0]
base_url = response.get("_links", {}).get("base", "")
attachments = []
for attachment in response["results"]:
result = attachment.copy()
if "_links" in result:
webui_path = result["_links"].get("webui", "")
download_path = result["_links"].get("download", "")
result["url"] = f"{base_url}{webui_path}"
result["download_link"] = f"{base_url}{download_path}"
del result["_links"]
del result["webuiLink"]
del result["downloadLink"]
del result["version"]
attachments.append(result)
return {"attachments": attachments, "pagination_token": pagination_token}
def prepare_update_page_payload(
self,
page_id: str,
status: str,
title: str,
body_representation: str,
body_value: str,
version_number: int,
version_message: str,
) -> dict[str, Any]:
"""Prepare a payload for the PUT /pages/{id} endpoint."""
return {
"id": page_id,
"status": status,
"title": title,
"body": {
"representation": body_representation,
"value": body_value,
},
"version": {
"number": version_number,
"message": version_message,
},
}
def prepare_update_page_content_payload(
self,
content: str,
update_mode: PageUpdateMode,
old_content: str,
page_id: str,
status: str,
title: str,
body_representation: BodyFormat,
old_version_number: int,
) -> dict[str, Any]:
"""Prepare a payload for when updating the content of a page
Args:
content: The content to update the page with
update_mode: The mode of update to use
old_content: The content of the page before the update
page_id: The ID of the page to update
status: The status of the page
title: The title of the page
body_representation: The format that the body (content) is in
old_version_number: The version number of the page before the update
Returns:
A payload for the PUT /pages/{id} endpoint's json body
"""
updated_content = ""
updated_message = ""
if update_mode == PageUpdateMode.APPEND:
updated_content = f"{old_content}<br/>{content}"
updated_message = "Append content to the page"
elif update_mode == PageUpdateMode.PREPEND:
updated_content = f"{content}<br/>{old_content}"
updated_message = "Prepend content to the page"
elif update_mode == PageUpdateMode.REPLACE:
updated_content = content
updated_message = "Replace the page content"
payload = self.prepare_update_page_payload(
page_id=page_id,
status=status,
title=title,
body_representation=body_representation.to_api_value(),
body_value=updated_content,
version_number=old_version_number + 1,
version_message=updated_message,
)
return payload
async def get_root_pages_in_space(self, space_id: str) -> dict[str, Any]:
"""
Get the root pages in a space.
Requires Confluence scope 'read:page:confluence'
"""
params = {
"depth": "root",
"limit": 250,
}
pages = await self.get(f"spaces/{space_id}/pages", params=params)
base_url = pages.get("_links", {}).get("base", "")
return {"pages": [self._transform_links(page, base_url) for page in pages["results"]]}
async def get_space_homepage(self, space_id: str) -> dict[str, Any]:
"""
Get the homepage of a space.
Requires Confluence scope 'read:page:confluence'
"""
root_pages = await self.get_root_pages_in_space(space_id)
for page in root_pages["pages"]:
if page.get("url", "").endswith("overview"):
return self._transform_links(page)
raise ToolExecutionError(message="No homepage found for space.")
async def get_page_by_id(
self, page_id: str, content_format: BodyFormat = BodyFormat.STORAGE
) -> dict[str, Any]:
"""Get a page by its ID.
Requires Confluence scope 'read:page:confluence'
Args:
page_id: The ID of the page to get
content_format: The format of the page content
Returns:
The page object
"""
params = remove_none_values({
"body-format": content_format.to_api_value(),
})
try:
page = await self.get(f"pages/{page_id}", params=params)
except httpx.HTTPStatusError as e:
# If the page is not found, return an empty page object
if e.response.status_code in [400, 404]:
return self.transform_page_response({})
raise
return self.transform_page_response(page)
async def get_page_by_title(
self, page_title: str, content_format: BodyFormat = BodyFormat.STORAGE
) -> dict[str, Any]:
"""Get a page by its title.
Requires Confluence scope 'read:page:confluence'
Args:
page_title: The title of the page to get
content_format: The format of the page content
Returns:
The page object
"""
params = {
"title": page_title,
"body-format": content_format.to_api_value(),
}
response = await self.get("pages", params=params)
pages = response.get("results", [])
if not pages:
# If the page is not found, return an empty page object
return self.transform_page_response({})
return self.transform_page_response(pages[0])
async def get_space_by_id(self, space_id: str) -> dict[str, Any]:
"""Get a space by its ID.
Requires Confluence scope 'read:space:confluence'
Args:
space_id: The ID of the space to get
Returns:
The space object
"""
space = await self.get(f"spaces/{space_id}")
return self.transform_space_response(space)
async def get_space_by_key(self, space_key: str) -> dict[str, Any]:
"""Get a space by its key.
Requires Confluence scope 'read:space:confluence'
Args:
space_key: The key of the space to get
Returns:
The space object
"""
response = await self.get("spaces", params={"keys": [space_key]})
base_url = response.get("_links", {}).get("base", "")
spaces = response.get("results", [])
if not spaces:
raise ToolExecutionError(message=f"No space found with key: '{space_key}'")
return self.transform_space_response(spaces[0], base_url=base_url)
async def get_space(self, space_identifier: str) -> dict[str, Any]:
"""Get a space from its identifier.
Requires Confluence scope 'read:space:confluence'
Args:
space_identifier: The identifier of the space to get. Can be a space's ID or key.
"""
return (
await self.get_space_by_id(space_identifier)
if space_identifier.isdigit()
else await self.get_space_by_key(space_identifier)
)
async def get_page_id(self, page_identifier: str) -> str:
"""Get the ID of a page from its identifier.
Args:
page_identifier: The identifier of the page to get. Can be a page's ID or title.
Returns:
The ID of the page
"""
if page_identifier.isdigit():
page_id = page_identifier
else:
page = await self.get_page_by_title(page_identifier)
page_id = page.get("page", {}).get("id")
if not page_id:
raise ToolExecutionError(message=f"No page found with identifier: '{page_identifier}'")
return page_id
async def get_space_id(self, space_identifier: str) -> str:
"""Get the ID of a space from its identifier.
Args:
space_identifier: The identifier of the space to get. Can be a space's ID or title.
"""
if space_identifier.isdigit():
space_id = space_identifier
else:
space = await self.get_space_by_key(space_identifier)
space_id = space.get("space", {}).get("id")
return space_id
def create_space_tree(self, space: dict) -> dict:
"""Create the initial tree structure for a space.
Args:
space: The transformed space object
Returns:
A dictionary representing the root of the space hierarchy tree without any children
"""
space_internal = space.get("space", {})
return {
"key": space_internal.get("key"),
"id": space_internal.get("id"),
"type": "space",
"url": space_internal.get("url"),
"children": [],
}
def convert_root_pages_to_tree_nodes(self, pages: list) -> list:
"""Convert root pages of a space to tree nodes.
Args:
pages: List of page objects from the API
Returns:
A list of tree nodes representing the root pages
"""
return [
{
"title": page.get("title"),
"id": page.get("id"),
"type": "page",
"url": page.get("url"),
"children": [],
}
for page in pages
]
async def process_page_descendants(self, root_children: list, base_url: str) -> None:
"""Process descendants for each page and build the hierarchy.
Args:
root_children: The root children of the space
base_url: The base URL for the Confluence space
Returns:
None (modifies root_children in place)
"""
descendent_params = {"limit": 250, "depth": 5}
for i, child in enumerate(root_children):
page_id = child["id"]
descendants = await self.get(f"pages/{page_id}/descendants", params=descendent_params)
# Transform descendants into our desired format
transformed_children = []
for descendant in descendants.get("results", []):
transformed_child = {
"title": descendant.get("title"),
"id": descendant.get("id"),
"type": descendant.get("type"),
"parent_id": page_id
if descendant.get("parentId") is None
else descendant.get("parentId"),
"parent_type": descendant.get("parentType", "TODO"),
"url": build_child_url(base_url, descendant),
"children": [],
"status": descendant.get("status"),
}
transformed_children.append(transformed_child)
# Build the hierarchy for the current root page
build_hierarchy(transformed_children, page_id, root_children[i])

View file

@ -1,63 +0,0 @@
from enum import Enum
class BodyFormat(str, Enum):
STORAGE = "storage" # Storage representation for editing, with relative urls in the markup
def to_api_value(self) -> str:
mapping = {
BodyFormat.STORAGE: "storage",
}
return mapping.get(self, BodyFormat.STORAGE.value)
class PageUpdateMode(str, Enum):
"""The mode of update for a page"""
PREPEND = "prepend" # Add content to the beginning of the page.
APPEND = "append" # Add content to the end of the page.
REPLACE = "replace" # Replace the entire page with the new content.
class PageSortOrder(str, Enum):
"""The order of the pages to sort by"""
ID_ASCENDING = "id-ascending"
ID_DESCENDING = "id-descending"
TITLE_ASCENDING = "title-ascending"
TITLE_DESCENDING = "title-descending"
CREATED_DATE_ASCENDING = "created-date-oldest-to-newest"
CREATED_DATE_DESCENDING = "created-date-newest-to-oldest"
MODIFIED_DATE_ASCENDING = "modified-date-oldest-to-newest"
MODIFIED_DATE_DESCENDING = "modified-date-newest-to-oldest"
def to_api_value(self) -> str:
mapping = {
PageSortOrder.ID_ASCENDING: "id",
PageSortOrder.ID_DESCENDING: "-id",
PageSortOrder.TITLE_ASCENDING: "title",
PageSortOrder.TITLE_DESCENDING: "-title",
PageSortOrder.CREATED_DATE_ASCENDING: "created-date",
PageSortOrder.CREATED_DATE_DESCENDING: "-created-date",
PageSortOrder.MODIFIED_DATE_ASCENDING: "modified-date",
PageSortOrder.MODIFIED_DATE_DESCENDING: "-modified-date",
}
return mapping.get(self, PageSortOrder.CREATED_DATE_DESCENDING.value)
class AttachmentSortOrder(str, Enum):
"""The order of the attachments to sort by"""
CREATED_DATE_ASCENDING = "created-date-oldest-to-newest"
CREATED_DATE_DESCENDING = "created-date-newest-to-oldest"
MODIFIED_DATE_ASCENDING = "modified-date-oldest-to-newest"
MODIFIED_DATE_DESCENDING = "modified-date-newest-to-oldest"
def to_api_value(self) -> str:
mapping = {
AttachmentSortOrder.CREATED_DATE_ASCENDING: "created-date",
AttachmentSortOrder.CREATED_DATE_DESCENDING: "-created-date",
AttachmentSortOrder.MODIFIED_DATE_ASCENDING: "modified-date",
AttachmentSortOrder.MODIFIED_DATE_DESCENDING: "-modified-date",
}
return mapping.get(self, AttachmentSortOrder.CREATED_DATE_DESCENDING.value)

View file

@ -1,30 +0,0 @@
from arcade_confluence.tools.attachment import get_attachments_for_page, list_attachments
from arcade_confluence.tools.page import (
create_page,
get_page,
get_pages_by_id,
list_pages,
rename_page,
update_page_content,
)
from arcade_confluence.tools.search import search_content
from arcade_confluence.tools.space import get_space, get_space_hierarchy, list_spaces
__all__ = [
# Attachment
"get_attachments_for_page",
"list_attachments",
# Page
"create_page",
"get_pages_by_id",
"get_page",
"list_pages",
"rename_page",
"update_page_content",
# Search
"search_content",
# Space
"get_space",
"get_space_hierarchy",
"list_spaces",
]

View file

@ -1,69 +0,0 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Atlassian
from arcade_confluence.client import ConfluenceClientV2
from arcade_confluence.enums import AttachmentSortOrder
from arcade_confluence.utils import remove_none_values
@tool(
requires_auth=Atlassian(
scopes=["read:attachment:confluence"],
)
)
async def list_attachments(
context: ToolContext,
sort_order: Annotated[
AttachmentSortOrder,
"The order of the attachments to sort by. Defaults to created-date-newest-to-oldest",
] = AttachmentSortOrder.CREATED_DATE_DESCENDING,
limit: Annotated[
int, "The maximum number of attachments to return. Defaults to 25. Max is 250"
] = 25,
pagination_token: Annotated[
str | None,
"The pagination token to use for the next page of results",
] = None,
) -> Annotated[dict, "The attachments"]:
"""List attachments in a workspace"""
client = ConfluenceClientV2(context.get_auth_token_or_empty())
params = remove_none_values({
"sort": sort_order.to_api_value(),
"limit": max(1, min(limit, 250)),
"cursor": pagination_token,
})
attachments = await client.get("attachments", params=params)
return client.transform_get_attachments_response(attachments)
@tool(
requires_auth=Atlassian(
scopes=["read:attachment:confluence"],
)
)
async def get_attachments_for_page(
context: ToolContext,
page_identifier: Annotated[str, "The ID or title of the page to get attachments for"],
limit: Annotated[
int, "The maximum number of attachments to return. Defaults to 25. Max is 250"
] = 25,
pagination_token: Annotated[
str | None,
"The pagination token to use for the next page of results",
] = None,
) -> Annotated[dict, "The attachments"]:
"""Get attachments for a page by its ID or title.
If a page title is provided, then the first page with an exact matching title will be returned.
"""
client = ConfluenceClientV2(context.get_auth_token_or_empty())
page_id = await client.get_page_id(page_identifier)
params = remove_none_values({
"limit": max(1, min(limit, 250)),
"cursor": pagination_token,
})
attachments = await client.get(f"pages/{page_id}/attachments", params=params)
return client.transform_get_attachments_response(attachments)

View file

@ -1,236 +0,0 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Atlassian
from arcade_tdk.errors import ToolExecutionError
from arcade_confluence.client import ConfluenceClientV2
from arcade_confluence.enums import BodyFormat, PageSortOrder, PageUpdateMode
from arcade_confluence.utils import remove_none_values, validate_ids
@tool(
requires_auth=Atlassian(
scopes=["read:page:confluence", "write:page:confluence"],
)
)
async def create_page(
context: ToolContext,
space_identifier: Annotated[str, "The ID or title of the space to create the page in"],
title: Annotated[str, "The title of the page"],
content: Annotated[str, "The content of the page. Only plain text is supported"],
parent_id: Annotated[
str | None,
"The ID of the parent. If not provided, the page will be created at the root of the space.",
] = None,
is_private: Annotated[
bool,
"If true, then only the user who creates this page will be able to see it. "
"Defaults to False",
] = False,
is_draft: Annotated[
bool,
"If true, then the page will be created as a draft. Defaults to False",
] = False,
) -> Annotated[dict, "The page"]:
"""Create a new page at the root of the given space."""
client = ConfluenceClientV2(context.get_auth_token_or_empty())
space_id = await client.get_space_id(space_identifier)
parent_id = parent_id or (await client.get_space_homepage(space_id)).get("id")
params = remove_none_values({
"root-level": False,
"private": is_private,
})
body = remove_none_values({
"spaceId": space_id,
"status": "draft" if is_draft else None,
"parentId": parent_id,
"title": title,
"body": {
"storage": {
"value": content,
"representation": BodyFormat.STORAGE.to_api_value(),
}
},
})
page = await client.post("pages", params=params, json=body)
return client.transform_page_response(page)
@tool(
requires_auth=Atlassian(
scopes=["read:page:confluence", "write:page:confluence"],
)
)
async def update_page_content(
context: ToolContext,
page_identifier: Annotated[
str, "The ID or title of the page to update. Numerical titles are NOT supported."
],
content: Annotated[str, "The content of the page. Only plain text is supported"],
update_mode: Annotated[
PageUpdateMode,
"The mode of update. Defaults to 'append'.",
] = PageUpdateMode.APPEND,
) -> Annotated[dict, "The page"]:
"""Update a page's content."""
# Get the page to update
client = ConfluenceClientV2(context.get_auth_token_or_empty())
page_id = await client.get_page_id(page_identifier)
page = await get_page(context, page_id)
if not page.get("page"):
raise ToolExecutionError(message=f"No page found with identifier: '{page_identifier}'")
status = page.get("page", {}).get("status", "current")
title = page.get("page", {}).get("title", "Untitled page")
body = page.get("page", {}).get("body", {})
old_content = body.get(BodyFormat.STORAGE, {}).get("value", "")
old_version_number = page.get("page", {}).get("version", {}).get("number", 0)
# Update the page content
payload = client.prepare_update_page_content_payload(
content=content,
update_mode=update_mode,
old_content=old_content,
page_id=page_id,
status=status,
title=title,
body_representation=BodyFormat.STORAGE,
old_version_number=old_version_number,
)
updated_page = await client.put(f"pages/{page_id}", json=payload)
return client.transform_page_response(updated_page)
@tool(
requires_auth=Atlassian(
scopes=["read:page:confluence", "write:page:confluence"],
)
)
async def rename_page(
context: ToolContext,
page_identifier: Annotated[
str, "The ID or title of the page to rename. Numerical titles are NOT supported."
],
title: Annotated[str, "The title of the page"],
) -> Annotated[dict, "The page"]:
"""Rename a page by changing its title."""
# Get the page to rename
client = ConfluenceClientV2(context.get_auth_token_or_empty())
page_id = await client.get_page_id(page_identifier)
page = await get_page(context, page_id)
if not page.get("page"):
raise ToolExecutionError(message=f"No page found with identifier: '{page_identifier}'")
status = page.get("page", {}).get("status", "current")
content = page.get("page", {}).get("body", {}).get(BodyFormat.STORAGE, {}).get("value", "")
old_version_number = page.get("page", {}).get("version", {}).get("number", 0)
# Rename the page
payload = client.prepare_update_page_payload(
page_id=page_id,
status=status,
title=title,
body_representation=BodyFormat.STORAGE,
body_value=content,
version_number=old_version_number + 1,
version_message="Rename the page",
)
updated_page = await client.put(f"pages/{page_id}", json=payload)
return client.transform_page_response(updated_page)
@tool(
requires_auth=Atlassian(
scopes=["read:page:confluence"],
)
)
async def get_page(
context: ToolContext,
page_identifier: Annotated[
str, "Can be a page's ID or title. Numerical titles are NOT supported."
],
) -> Annotated[dict, "The page"]:
"""Retrieve a SINGLE page's content by its ID or title.
If a title is provided, then the first page with an exact matching title will be returned.
IMPORTANT: For retrieving MULTIPLE pages, use `get_pages_by_id` instead
for a massive performance and efficiency boost. If you call this function multiple times
instead of using `get_pages_by_id`, then the universe will explode.
"""
client = ConfluenceClientV2(context.get_auth_token_or_empty())
if page_identifier.isdigit():
return await client.get_page_by_id(page_identifier, BodyFormat.STORAGE)
else:
return await client.get_page_by_title(page_identifier, BodyFormat.STORAGE)
@tool(
requires_auth=Atlassian(
scopes=["read:page:confluence"],
)
)
async def get_pages_by_id(
context: ToolContext,
page_ids: Annotated[
list[str],
"The IDs of the pages to get. IDs are numeric. Titles of pages are NOT supported. "
"Maximum of 250 page ids supported.",
],
) -> Annotated[dict, "The pages"]:
"""Get the content of MULTIPLE pages by their ID in a single efficient request.
IMPORTANT: Always use this function when you need to retrieve content from more than one page,
rather than making multiple separate calls to get_page, because this function is significantly
more efficient than calling get_page multiple times.
"""
validate_ids(page_ids, max_length=250)
client = ConfluenceClientV2(context.get_auth_token_or_empty())
pages = await client.get(
"pages", params={"id": page_ids, "body-format": BodyFormat.STORAGE.to_api_value()}
)
return client.transform_get_multiple_pages_response(pages)
@tool(
requires_auth=Atlassian(
scopes=["read:page:confluence"],
)
)
async def list_pages(
context: ToolContext,
space_ids: Annotated[
list[str] | None,
"Restrict the response to only include pages in these spaces. "
"Only space IDs are supported. Titles of spaces are NOT supported. "
"If not provided, then no restriction is applied. "
"Maximum of 100 space ids supported.",
] = None,
sort_by: Annotated[
PageSortOrder,
"The order of the pages to sort by. Defaults to created-date-newest-to-oldest",
] = PageSortOrder.CREATED_DATE_DESCENDING,
limit: Annotated[int, "The maximum number of pages to return. Defaults to 25. Max is 250"] = 25,
pagination_token: Annotated[
str | None,
"The pagination token to use for the next page of results",
] = None,
) -> Annotated[dict, "The pages"]:
"""Get the content of multiple pages by their ID"""
validate_ids(space_ids, max_length=100)
limit = max(1, min(limit, 250))
client = ConfluenceClientV2(context.get_auth_token_or_empty())
params = remove_none_values({
"space-id": space_ids,
"sort": sort_by.to_api_value(),
"body-format": BodyFormat.STORAGE.to_api_value(),
"limit": limit,
"cursor": pagination_token,
})
pages = await client.get("pages", params=params)
return client.transform_list_pages_response(pages)

View file

@ -1,53 +0,0 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Atlassian
from arcade_confluence.client import ConfluenceClientV1
@tool(
requires_auth=Atlassian(
scopes=["search:confluence"],
)
)
async def search_content(
context: ToolContext,
must_contain_all: Annotated[
list[str] | None,
"Words/phrases that content MUST contain (AND logic). Each item can be:\n"
"- Single word: 'banana' - content must contain this word\n"
"- Multi-word phrase: 'How to' - content must contain all these words (in any order)\n"
"- All items in this list must be present for content to match\n"
"- Example: ['banana', 'apple'] finds content containing BOTH 'banana' AND 'apple'",
] = None,
can_contain_any: Annotated[
list[str] | None,
"Words/phrases where content can contain ANY of these (OR logic). Each item can be:\n"
"- Single word: 'project' - content containing this word will match\n"
"- Multi-word phrase: 'pen & paper' - content containing all these words will match\n"
"- Content matching ANY item in this list will be included\n"
"- Example: ['project', 'documentation'] finds content with 'project' OR 'documentation'",
] = None,
enable_fuzzy: Annotated[
bool,
"Enable fuzzy matching to find similar terms (e.g. 'roam' will find 'foam'). "
"Defaults to True",
] = True,
limit: Annotated[int, "Maximum number of results to return (1-100). Defaults to 25"] = 25,
) -> Annotated[dict, "Search results containing content items matching the criteria"]:
"""Search for content in Confluence.
The search is performed across all content in the authenticated user's Confluence workspace.
All search terms in Confluence are case insensitive.
You can use the parameters in different ways:
- must_contain_all: For AND logic - content must contain ALL of these
- can_contain_any: For OR logic - content can contain ANY of these
- Combine them: must_contain_all=['banana'] AND can_contain_any=['database', 'guide']
"""
client = ConfluenceClientV1(context.get_auth_token_or_empty())
cql = client.construct_cql(must_contain_all, can_contain_any, enable_fuzzy)
response = await client.get("search", params={"cql": cql, "limit": max(1, min(limit, 100))})
return client.transform_search_content_response(response)

View file

@ -1,95 +0,0 @@
import re
from typing import Annotated
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Atlassian
from arcade_confluence.client import ConfluenceClientV2
from arcade_confluence.utils import remove_none_values
@tool(
requires_auth=Atlassian(
scopes=["read:space:confluence"],
)
)
async def get_space(
context: ToolContext,
space_identifier: Annotated[
str, "Can be a space's ID or key. Numerical keys are NOT supported"
],
) -> Annotated[dict, "The space"]:
"""Get the details of a space by its ID or key."""
client = ConfluenceClientV2(context.get_auth_token_or_empty())
if space_identifier.isdigit():
return await client.get_space_by_id(space_identifier)
else:
return await client.get_space_by_key(space_identifier)
@tool(
requires_auth=Atlassian(
scopes=["read:space:confluence"],
)
)
async def list_spaces(
context: ToolContext,
limit: Annotated[
int, "The maximum number of spaces to return. Defaults to 25. Max is 250"
] = 25,
pagination_token: Annotated[
str | None, "The pagination token to use for the next page of results"
] = None,
) -> Annotated[dict, "The spaces"]:
"""List all spaces sorted by name in ascending order."""
client = ConfluenceClientV2(context.get_auth_token_or_empty())
params = {"limit": max(1, min(limit, 250)), "sort": "name", "cursor": pagination_token}
params = remove_none_values(params)
spaces = await client.get("spaces", params=params)
return client.transform_get_spaces_response(spaces)
@tool(
requires_auth=Atlassian(
scopes=[
"read:page:confluence", # needed for getting the space's root pages
"read:space:confluence", # needed for when a space key is provided
"read:hierarchical-content:confluence", # needed for getting the descendents of a page
],
)
)
async def get_space_hierarchy(
context: ToolContext,
space_identifier: Annotated[
str, "Can be a space's ID or key. Numerical keys are NOT supported"
],
) -> Annotated[dict, "The space hierarchy"]:
"""Retrieve the full hierarchical structure of a Confluence space as a tree structure
Only structural metadata is returned (not content).
The response is akin to the sidebar in the Confluence UI.
Includes all pages, folders, whiteboards, databases,
smart links, etc. organized by parent-child relationships.
"""
client = ConfluenceClientV2(context.get_auth_token_or_empty())
space = await client.get_space(space_identifier)
tree = client.create_space_tree(space)
# Get root pages
root_pages = await client.get_root_pages_in_space(space["space"]["id"])
tree["children"] = client.convert_root_pages_to_tree_nodes(root_pages["pages"])
if not tree["children"]:
return {}
# Extract base URL for children URLs. The base URL is the space's URL.
root_page_url = tree["url"]
match = re.match(r"(.*?/spaces/[^/]+)", root_page_url)
children_base_url = match.group(1) if match else ""
# Get and descendants for each root page
await client.process_page_descendants(tree["children"], children_base_url)
return tree

View file

@ -1,89 +0,0 @@
import re
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
def remove_none_values(data: dict) -> dict:
"""Remove all keys with None values from the dictionary."""
return {k: v for k, v in data.items() if v is not None}
def validate_ids(ids: list[str] | None, max_length: int) -> None:
"""Validate a list of IDs. The ids can be page ids, space ids, etc.
A valid id is a string that is a number.
Args:
ids: A list of IDs to validate.
Returns:
None
Raises:
ToolExecutionError: If any of the IDs are not valid.
RetryableToolError: If the number of IDs is greater than the max length.
"""
if not ids:
return
if len(ids) > max_length:
raise RetryableToolError(
message=f"The 'ids' parameter must have less than {max_length} items. Got {len(ids)}"
)
if any(not id_.isdigit() for id_ in ids):
raise ToolExecutionError(message="Invalid ID provided. IDs are numeric")
def build_child_url(base_url: str, child: dict) -> str | None:
"""Build URL for a child node based on its type and status.
Args:
base_url: The base URL for the Confluence space
child: A dictionary representing a Confluence content item
Returns:
The URL for the child, or None if it can't be determined
"""
if child["type"] in ("whiteboard", "database", "embed"):
return f"{base_url}/{child['type']}/{child['id']}"
elif child["type"] == "folder":
return None
elif child["type"] == "page":
parsed_title = re.sub(r"[ '\s]+", "+", child["title"].strip())
if child.get("status") == "draft":
return f"{base_url}/{child['type']}s/edit-v2/{child['id']}"
else:
return f"{base_url}/{child['type']}s/{child['id']}/{parsed_title}"
return None
def build_hierarchy(transformed_children: list, parent_id: str, parent_node: dict) -> None:
"""Build parent-child hierarchy from a flat list of descendants.
This function takes a flat list of items that have parent_id references and
builds a hierarchical tree structure. It modifies the parent_node in place.
Args:
transformed_children: List of child nodes with parent_id fields
parent_id: The ID of the parent node
parent_node: The parent node to attach direct children to
Returns:
None (modifies parent_node in place)
"""
# Create a map of children by their ID for efficient lookups
child_map = {child["id"]: child for child in transformed_children}
# Find all direct children of the given parent_id
direct_children = []
for child in transformed_children:
if child.get("parent_id") == parent_id:
direct_children.append(child)
elif child.get("parent_id") in child_map:
# Add child to its parent's children list
parent = child_map[child.get("parent_id")]
if "children" not in parent:
parent["children"] = []
parent["children"].append(child)
# Set the direct children on the parent node
parent_node["children"] = direct_children

View file

@ -1,89 +0,0 @@
# A conversation where a user asks for the structure of a space,
# and an LLM calls a tool to get the structure.
get_space_hierarchy_1 = [
{"role": "system", "content": "Today is 2025-05-16, Friday."},
{"role": "user", "content": "get structure of 98308"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_exyK4LmJEHSDn1Xw5oVfS9Xx",
"type": "function",
"function": {
"name": "Confluence_GetSpaceHierarchy",
"arguments": '{"space_identifier":"98308"}',
},
}
],
},
{
"role": "tool",
"content": '{"98418":{"children":[{"children":[],"id":"4653060","parent_id":"98418","parent_type":"TODO","title":"Mydb","type":"database","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/database/4653060"},{"children":[{"children":[],"id":"4685837","parent_id":"4653064","parent_type":"TODO","title":"10-03-98","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/4685837/10-03-98"}],"id":"4653064","parent_id":"98418","parent_type":"TODO","title":"DailyNotes","type":"folder","url":null},{"children":[{"children":[],"id":"5242883","parent_id":"5275650","parent_type":"TODO","title":"To be or not tobe","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242883/To+be+or+not+to+be"},{"children":[],"id":"5275653","parent_id":"5275650","parent_type":"TODO","title":"Two roadsdiverged","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/edit-v2/5275653"},{"children":[],"id":"5242903","parent_id":"5275650","parent_type":"TODO","title":"Eternity","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242903/Eternity"},{"children":[],"id":"5242913","parent_id":"5275650","parent_type":"TODO","title":"BlackHole","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242913/Black+Hole"}],"id":"5275650","parent_id":"98418","parent_type":"TODO","title":"Poetry","type":"folder","url":null},{"children":[],"id":"5275676","parent_id":"98418","parent_type":"TODO","title":"Drawings","type":"whiteboard","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/whiteboard/5275676"}],"id":"98418","parent_id":null,"parent_type":null,"title":"testspace Home","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/overview"}}', # noqa: E501
"tool_call_id": "call_exyK4LmJEHSDn1Xw5oVfS9Xx",
"name": "Confluence_GetSpaceHierarchy",
},
{
"role": "assistant",
"content": 'Here is the structure for the space with the identifier 98308:\n\n- **testspace Home** [Page](https://ericconfluence.atlassian.net/wiki/spaces/testspace/overview)\n - **My db** [Database](https://ericconfluence.atlassian.net/wiki/spaces/testspace/database/4653060)\n - **Daily Notes** (Folder)\n - [10-03-98](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/4685837/10-03-98) (Page)\n - **Poetry** (Folder)\n - [To be or not to be](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242883/To+be+or+not+to+be) (Page)\n - [Two roads diverged](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/edit-v2/5275653) (Page)\n - [Eternity](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242903/Eternity) (Page)\n - [Black Hole](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242913/Black+Hole) (Page)\n - **Drawings** [Whiteboard](https://ericconfluence.atlassian.net/wiki/spaces/testspace/whiteboard/5275676)\n\nThis structure includes various elements such as pages, a database, folders, and a whiteboard within the "testspace" space.', # noqa: E501
},
]
# A conversation where a user asks the assistant to create a page with content
create_page_1 = [
{
"role": "user",
"content": "make a new page within the testspace space under the 4653064 folder. Within it, make a list of all the TODOs for the day which are 1. Write a blog 2. Walk the dog", # noqa: E501
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_xuvOuXpLAmX57RO1zj5hqQ1r",
"type": "function",
"function": {
"name": "Confluence_CreatePage",
"arguments": '{"space_identifier":"testspace","title":"TODOs forToday","content":"<h1>TODOs for Today</h1><ol><li>Write a blog</li><li>Walk thedog</li></ol>","parent_id":"4653064"}', # noqa: E501
},
}
],
},
{
"role": "tool",
"content": '{"page":{"authorId":"712020:93b309a8-e9af-4615-b289-07bf23e6f13f","body":{"storage":{"representation":"storage","value":"\\u003ch1\\u003eTODOs for Today\\u003c/h1\\u003e\\u003col\\u003e\\u003cli\\u003eWrite a blog\\u003c/li\\u003e\\u003cli\\u003eWalk the dog\\u003c/li\\u003e\\u003c/ol\\u003e"}},"createdAt":"2025-05-16T22:29:59.969Z","id":"5439489","lastOwnerId":null,"ownerId":"712020:93b309a8-e9af-4615-b289-07bf23e6f13f","parentId":"4653064","parentType":"folder","position":1675,"spaceId":"98308","status":"current","title":"TODOs for Today","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5439489/TODOs+for+Today","version":{"authorId":"712020:93b309a8-e9af-4615-b289-07bf23e6f13f","createdAt":"2025-05-16T22:29:59.969Z","message":"","minorEdit":false,"ncsStepVersion":"1","number":1}}}', # noqa: E501
"tool_call_id": "call_xuvOuXpLAmX57RO1zj5hqQ1r",
"name": "Confluence_CreatePage",
},
{
"role": "assistant",
"content": 'I have created a new page titled "TODOs for Today" in the "testspace" space, under the folder with ID 4653064. The list of your tasks for the day is included:\n\n1. Write a blog\n2. Walk the dog\n\nYou can view the page [here](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5439489/TODOs+for+Today).', # noqa: E501
},
]
# A conversation where a user asks the assistant to list one space.
list_spaces_1 = [
{"role": "system", "content": "Today is 2025-05-19, Monday."},
{"role": "user", "content": "get 1 space"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_ur97pVeXzdB7ECNrr0zQBJmE",
"type": "function",
"function": {"name": "Confluence_ListSpaces", "arguments": '{"limit":1}'},
}
],
},
{
"role": "tool",
"content": '{"pagination_token":"eyJpZCI6MjMyNjc5NCwic3BhY2VTb3J0T3JkZXIiOnsiZmllbGQiOiJOQU1FIiwiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIn0sInNwYWNlU29ydE9yZGVyVmFsdWUiOiJlcmljYXJjYWRlIn0=","spaces":[{"authorId":"712020:f889c42e-f53e-4500-a7c6-706c3ef10951","createdAt":"2025-05-12T21:52:24.230Z","currentActiveAlias":"~712020f889c42ef53e4500a7c6706c3ef10951","description":null,"homepageId":"2327032","icon":null,"id":"2326794","key":"~712020f889c42ef53e4500a7c6706c3ef10951","name":"ericarcade","spaceOwnerId":"712020:f889c42e-f53e-4500-a7c6-706c3ef10951","status":"current","type":"personal","url":"https://ericconfluence.atlassian.net/wiki/spaces/~712020f889c42ef53e4500a7c6706c3ef10951"}]}',
"tool_call_id": "call_ur97pVeXzdB7ECNrr0zQBJmE",
"name": "Confluence_ListSpaces",
},
{
"role": "assistant",
"content": "Here is one space from Confluence:\n\n- **Name**: ericarcade\n- **Key**: ~712020f889c42ef53e4500a7c6706c3ef10951\n- **Type**:Personal\n- **Status**: Current\n- **Author ID**: 712020:f889c42e-f53e-4500-a7c6-706c3ef10951\n- **Created At**: 2025-05-12\n- **Homepage ID**:2327032\n- **URL**: [ericarcade Space](https://ericconfluence.atlassian.net/wiki/spaces/~712020f889c42ef53e4500a7c6706c3ef10951)", # noqa: E501
},
]

View file

@ -1,36 +0,0 @@
from dataclasses import dataclass
from typing import Any
from arcade_evals.critic import Critic
@dataclass
class ListCritic(Critic):
"""
A critic for comparing two lists.
"""
def __init__(
self,
critic_field: str,
weight: float = 1.0,
order_matters: bool = True,
duplicates_matter: bool = True,
case_sensitive: bool = False,
):
self.critic_field = critic_field
self.weight = weight
self.order_matters = order_matters
self.duplicates_matter = duplicates_matter
self.case_sensitive = case_sensitive
def evaluate(self, expected: list[Any], actual: list[Any]) -> dict[str, float | bool]:
if not self.case_sensitive:
actual = [item.lower() for item in actual]
expected = [item.lower() for item in expected]
match = actual == expected if self.order_matters else set(actual) == set(expected)
if self.duplicates_matter:
match = match and len(actual) == len(expected)
return {"match": match, "score": self.weight if match else 0.0}

View file

@ -1,208 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
SimilarityCritic,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_confluence
from arcade_confluence.enums import PageUpdateMode
from arcade_confluence.tools import (
create_page,
get_page,
get_pages_by_id,
rename_page,
update_page_content,
)
from evals.conversations import create_page_1, get_space_hierarchy_1
from evals.critics import ListCritic
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_confluence)
@tool_eval()
def confluence_get_page_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="Confluence get_page tool evaluation",
system_message="You are an AI assistant with access to Confluence tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Get page by ID",
user_message="Get page 65816",
expected_tool_calls=[ExpectedToolCall(func=get_page, args={"page_identifier": 65816})],
rubric=rubric,
critics=[BinaryCritic(critic_field="page_identifier", weight=1)],
)
suite.add_case(
name="Get page by title",
user_message="Get my 'Poem - May 24th' page",
expected_tool_calls=[
ExpectedToolCall(func=get_page, args={"page_identifier": "Poem - May 24th"})
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="page_identifier", weight=1),
],
)
suite.add_case(
name="Get page based on previous conversation",
user_message=(
"Get the content of my daily note. You MUST use the page's title when getting the page."
),
expected_tool_calls=[ExpectedToolCall(func=get_page, args={"page_identifier": "10-03-98"})],
rubric=rubric,
critics=[
BinaryCritic(critic_field="page_identifier", weight=1),
],
additional_messages=get_space_hierarchy_1,
)
return suite
@tool_eval()
def confluence_get_multiple_pages_by_id_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="Confluence get_multiple_pages_by_id tool evaluation",
system_message="You are an AI assistant with access to Confluence tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Get multiple pages by ID",
user_message="Get 98418, 4685837, 5242883, 5275653, 5242903, 5242913 pages",
expected_tool_calls=[
ExpectedToolCall(
func=get_pages_by_id,
args={"page_ids": ["98418", "4685837", "5242883", "5275653", "5242903", "5242913"]},
)
],
rubric=rubric,
critics=[
ListCritic(critic_field="page_ids", order_matters=False, weight=1),
],
)
suite.add_case(
name="Get multiple pages by ID with existing conversation",
user_message=("Get the content of all pages in the space except 4685837"),
expected_tool_calls=[
ExpectedToolCall(
func=get_pages_by_id,
args={"page_ids": ["98418", "5242883", "5275653", "5242903", "5242913"]},
)
],
rubric=rubric,
critics=[
ListCritic(critic_field="page_ids", order_matters=False, weight=1),
],
additional_messages=get_space_hierarchy_1,
)
return suite
@tool_eval()
def confluence_create_page_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="Confluence create_page tool evaluation",
system_message="You are an AI assistant with access to Confluence tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Create a page",
user_message=(
"Make a page within the softwareeng space called 'TODOs' under the 4830960 folder. "
"Within it, make a list of all the TODOs for the day which are "
"1. Write a blog post about the future of AI"
"2. Write an agent that calls my mom at 5:30 every Friday evening"
),
expected_tool_calls=[
ExpectedToolCall(
func=create_page,
args={
"space_identifier": "softwareeng",
"title": "TODOs",
"content": "1. Write a blog post about the future of AI\n2. Write an agent that calls my mom at 5:30 every Friday evening", # noqa: E501
"parent_id": "4830960",
},
)
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="space_identifier", weight=1 / 4),
BinaryCritic(critic_field="title", weight=1 / 4),
SimilarityCritic(critic_field="content", weight=1 / 4),
BinaryCritic(critic_field="parent_id", weight=1 / 4),
],
)
return suite
@tool_eval()
def confluence_update_page_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="Confluence update page tools evaluation",
system_message="You are an AI assistant with access to Confluence tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Update page content",
user_message="Thanks, now append '3. Walk the dog' to the end of the list",
expected_tool_calls=[
ExpectedToolCall(
func=update_page_content,
args={
"page_identifier": "5439489",
"content": "3. Walk the dog",
"update_mode": PageUpdateMode.APPEND,
},
)
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="page_identifier", weight=1 / 3),
SimilarityCritic(critic_field="content", weight=1 / 3),
BinaryCritic(critic_field="update_mode", weight=1 / 3),
],
additional_messages=create_page_1,
)
suite.extend_case(
name="Rename page",
user_message="Actually, rename it to 'My TODOs'",
expected_tool_calls=[
ExpectedToolCall(
func=rename_page,
args={"page_identifier": "5439489", "title": "My TODOs"},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="page_identifier", weight=1 / 2),
BinaryCritic(critic_field="title", weight=1 / 2),
],
)
return suite

View file

@ -1,111 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_confluence
from arcade_confluence.tools import (
search_content,
)
from evals.critics import ListCritic
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_confluence)
@tool_eval()
def confluence_search_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="Confluence search content tool evaluation",
system_message="You are an AI assistant with access to Confluence tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Search for content - easy case",
user_message="Find all pages that contain 'Arcade.dev'",
expected_tool_calls=[
ExpectedToolCall(func=search_content, args={"can_contain_any": ["Arcade.dev"]})
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="can_contain_any", weight=1),
],
)
suite.add_case(
name="Search for content - medium case",
user_message=("Find 20 pages that contain 'Arcade' or 'AI', or 'tool calls'"),
expected_tool_calls=[
ExpectedToolCall(
func=search_content,
args={
"can_contain_any": ["Arcade", "AI", "tool calls"],
"limit": 20,
},
)
],
rubric=rubric,
critics=[
ListCritic(
critic_field="can_contain_any",
weight=0.9,
case_sensitive=False,
order_matters=False,
),
BinaryCritic(critic_field="limit", weight=0.1),
],
)
suite.add_case(
name="Search for content - hard case",
user_message=(
"Look for 25 databases that have 'How to', "
"and also have 'carborator' in the content and "
"also has one of the following: 'money', 'redbull gives you wings', "
"'honey hole', 'don't snap the pasta!'."
),
expected_tool_calls=[
ExpectedToolCall(
func=search_content,
args={
"must_contain_all": ["carborator", "How to"],
"can_contain_any": [
"money",
"redbull gives you wings",
"honey hole",
"don't snap the pasta!",
],
},
),
],
rubric=rubric,
critics=[
ListCritic(
critic_field="must_contain_all",
weight=0.5,
case_sensitive=False,
order_matters=False,
),
ListCritic(
critic_field="can_contain_any",
weight=0.5,
case_sensitive=False,
order_matters=False,
),
],
)
return suite

View file

@ -1,113 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_confluence
from arcade_confluence.tools import (
get_space,
get_space_hierarchy,
list_spaces,
)
from evals.conversations import list_spaces_1
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_confluence)
@tool_eval()
def confluence_get_space_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="Confluence get_space tool evaluation",
system_message="You are an AI assistant with access to Confluence tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Get two spaces - one by ID, and one by key",
user_message="Get spaces 3498573 and 'Poetry'",
expected_tool_calls=[
ExpectedToolCall(func=get_space, args={"space_identifier": 3498573}),
ExpectedToolCall(func=get_space, args={"space_identifier": "Poetry"}),
],
rubric=rubric,
critics=[BinaryCritic(critic_field="space_identifier", weight=1)],
)
return suite
@tool_eval()
def confluence_list_spaces_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="Confluence list_spaces tool evaluation",
system_message="You are an AI assistant with access to Confluence tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="List the next space using a pagination token",
user_message="get the next one",
expected_tool_calls=[
ExpectedToolCall(
func=list_spaces,
args={
"limit": 1,
"pagination_token": "eyJpZCI6MjMyNjc5NCwic3BhY2VTb3J0T3JkZXIiOnsiZmllbGQiOiJOQU1FIiwiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIn0sInNwYWNlU29ydE9yZGVyVmFsdWUiOiJlcmljYXJjYWRlIn0=", # noqa: E501
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="limit", weight=0.3),
BinaryCritic(critic_field="pagination_token", weight=0.7),
],
additional_messages=list_spaces_1,
)
return suite
@tool_eval()
def confluence_get_space_hierarchy_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="Confluence get_space_hierarchy tool evaluation",
system_message="You are an AI assistant with access to Confluence tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Get the hierarchy of a space",
user_message=(
"What is the best file location within my 'Poetry' space to create a new page "
"named 'Rough Draft - Poem for King Henry VIII'?"
),
expected_tool_calls=[
ExpectedToolCall(
func=get_space_hierarchy,
args={
"space_identifier": "Poetry",
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="space_identifier", weight=1),
],
)
return suite

View file

@ -1,56 +0,0 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_confluence"
version = "0.0.4"
description = "Arcade.dev LLM tools for Confluence"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"httpx>=0.27.2,<1.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = {path = "../../", editable = true}
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
[tool.mypy]
files = [ "arcade_confluence/**/*.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
[tool.hatch.build.targets.wheel]
packages = [ "arcade_confluence",]

View file

@ -1,120 +0,0 @@
from unittest.mock import patch
import pytest
from arcade_tdk.errors import ToolExecutionError
from arcade_confluence.client import ConfluenceClientV1
@pytest.fixture
def client_v1() -> ConfluenceClientV1:
"""Fixture that provides a ConfluenceClientV1 instance with mocked cloud_id."""
with patch("arcade_confluence.client.ConfluenceClient._get_cloud_id", return_value=None):
return ConfluenceClientV1("fake-token")
@pytest.mark.parametrize(
"query, enable_fuzzy, expected_cql",
[
("foo", False, '(text ~ "foo" OR title ~ "foo" OR space.title ~ "foo")'),
("foo bar", False, '(text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")'),
("foo", True, '(text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~")'),
("foo bar", True, '(text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")'),
],
)
def test_build_query_cql(client_v1: ConfluenceClientV1, query, enable_fuzzy, expected_cql) -> None:
cql = client_v1._build_query_cql(query, enable_fuzzy)
assert cql == expected_cql
@pytest.mark.parametrize(
"queries, enable_fuzzy, expected_cql",
[
(
["foo", "foo bar"],
False,
'((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501
),
(
["foo", "foo bar"],
True,
'((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501
),
],
)
def test_build_and_cql(client_v1: ConfluenceClientV1, queries, enable_fuzzy, expected_cql) -> None:
cql = client_v1._build_and_cql(queries, enable_fuzzy)
assert cql == expected_cql
@pytest.mark.parametrize(
"queries, enable_fuzzy, expected_cql",
[
(
["foo", "foo bar"],
False,
'((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501
),
(
["foo", "foo bar"],
True,
'((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501
),
],
)
def test_build_or_cql(client_v1: ConfluenceClientV1, queries, enable_fuzzy, expected_cql) -> None:
cql = client_v1._build_or_cql(queries, enable_fuzzy)
assert cql == expected_cql
@pytest.mark.parametrize(
"must_contain_all, can_contain_any, enable_fuzzy, expected_cql",
[
(None, None, False, ""),
(
["foo", "foo bar"],
None,
False,
'((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501
),
(
["foo", "foo bar"],
None,
True,
'((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501
),
(
None,
["foo", "foo bar"],
False,
'((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501
),
(
None,
["foo", "foo bar"],
True,
'((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501
),
(
["foo", "foo bar"],
["foo", "foo bar"],
False,
'(((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")) AND ((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")))', # noqa: E501
),
(
["foo", "foo bar"],
["foo", "foo bar"],
True,
'(((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")) AND ((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")))', # noqa: E501
),
],
)
def test_construct_cql(
client_v1: ConfluenceClientV1, must_contain_all, can_contain_any, enable_fuzzy, expected_cql
) -> None:
if not expected_cql:
with pytest.raises(ToolExecutionError):
client_v1.construct_cql(must_contain_all, can_contain_any, enable_fuzzy)
else:
cql = client_v1.construct_cql(must_contain_all, can_contain_any, enable_fuzzy)
assert cql == expected_cql

View file

@ -1,103 +0,0 @@
from unittest.mock import patch
import pytest
from arcade_confluence.client import ConfluenceClientV2
@pytest.mark.asyncio
@pytest.mark.parametrize(
"space_identifier, is_id",
[
(
"12345",
True,
),
(
"test-space",
False,
),
],
)
async def test_get_space_id(space_identifier, is_id) -> None:
with patch("arcade_confluence.client.ConfluenceClient._get_cloud_id", return_value=None):
client_v2 = ConfluenceClientV2("fake-token")
with patch(
"arcade_confluence.client.ConfluenceClientV2.get_space_by_key",
return_value={"space": {"id": "12345"}},
):
space_id = await client_v2.get_space_id(space_identifier)
if is_id:
assert space_id == space_identifier
else:
assert space_id == "12345"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"page_identifier, is_id",
[
(
"67890",
True,
),
(
"test-page",
False,
),
],
)
async def test_get_page_id(page_identifier, is_id) -> None:
with patch("arcade_confluence.client.ConfluenceClient._get_cloud_id", return_value=None):
client_v2 = ConfluenceClientV2("fake-token")
with patch(
"arcade_confluence.client.ConfluenceClientV2.get_page_by_title",
return_value={"page": {"id": "67890"}},
):
page_id = await client_v2.get_page_id(page_identifier)
if is_id:
assert page_id == page_identifier
else:
assert page_id == "67890"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"space_identifier, is_id",
[
(
"12345",
True,
),
(
"test-space",
False,
),
],
)
async def test_get_space(space_identifier, is_id) -> None:
with patch("arcade_confluence.client.ConfluenceClient._get_cloud_id", return_value=None):
client_v2 = ConfluenceClientV2("fake-token")
mock_space = {"space": {"id": "12345", "key": "TEST"}}
with (
patch(
"arcade_confluence.client.ConfluenceClientV2.get_space_by_id",
return_value=mock_space,
) as mock_by_id,
patch(
"arcade_confluence.client.ConfluenceClientV2.get_space_by_key",
return_value=mock_space,
) as mock_by_key,
):
result = await client_v2.get_space(space_identifier)
if is_id:
mock_by_id.assert_called_once_with(space_identifier)
mock_by_key.assert_not_called()
else:
mock_by_id.assert_not_called()
mock_by_key.assert_called_once_with(space_identifier)
assert result == mock_space

View file

@ -1,195 +0,0 @@
import pytest
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
from arcade_confluence.utils import build_child_url, build_hierarchy, validate_ids
@pytest.mark.parametrize(
"ids, max_length, expected_error",
[
(None, 250, None),
(["123", "456"], 250, None),
(["123", "foo"], 250, ToolExecutionError),
(["123", "456"], 1, RetryableToolError),
],
)
def test_validate_ids(ids: list[str], max_length: int, expected_error: Exception | None) -> None:
if expected_error:
with pytest.raises(expected_error):
validate_ids(ids, max_length)
else:
validate_ids(ids, max_length)
@pytest.mark.parametrize(
"base_url, child, expected",
[
( # Published page
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT",
{"type": "page", "title": "Test Title-1", "id": "123", "status": "current"},
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/pages/123/Test+Title-1",
),
( # Draft page
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT",
{"type": "page", "title": "Test Title-1", "id": "123", "status": "draft"},
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/pages/edit-v2/123",
),
( # Whiteboard
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT",
{"type": "whiteboard", "title": "Test Title-1", "id": "123", "status": "current"},
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/whiteboard/123",
),
( # Database
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT",
{"type": "database", "title": "Test Title-1", "id": "123", "status": "current"},
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/database/123",
),
( # Embed
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT",
{"type": "embed", "title": "Test Title-1", "id": "123", "status": "current"},
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/embed/123",
),
( # Folder
"https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT",
{"type": "folder", "title": "Test Title-1", "id": "123", "status": "current"},
None, # Folders don't have URLs
),
],
)
def test_build_child_url(base_url: str, child: dict, expected: str) -> None:
assert build_child_url(base_url, child) == expected
@pytest.mark.parametrize(
"input_transformed_children, input_parent_id, input_parent_node, expected_parent_node",
[
( # Parent node is a leaf
[],
"2195457",
{
"title": "A One Sentence Story About Trees",
"id": "2195457",
"type": "page",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2195457/A+One+Sentence+Story+About+Trees",
"children": [],
},
{
"title": "A One Sentence Story About Trees",
"id": "2195457",
"type": "page",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2195457/A+One+Sentence+Story+About+Trees",
"children": [],
},
),
(
[
{
"title": "The Importance of Trees",
"id": "2555906",
"type": "page",
"parent_id": "2162740",
"parent_type": "TODO",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2555906/The+Importance+of+Trees",
"children": [],
"status": "current",
},
{
"title": "Erics page",
"id": "2686977",
"type": "page",
"parent_id": "2555906",
"parent_type": "TODO",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2686977/Erics+page",
"children": [],
"status": "current",
},
{
"title": "Erics page",
"id": "2719745",
"type": "page",
"parent_id": "2555906",
"parent_type": "TODO",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/edit-v2/2719745",
"children": [],
"status": "draft",
},
{
"title": "Execute tools",
"id": "2621441",
"type": "page",
"parent_id": "2162740",
"parent_type": "TODO",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2621441/Execute+tools",
"children": [],
"status": "current",
},
],
"2162740",
{
"title": "Trees",
"id": "2162740",
"type": "page",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2162740/Trees",
"children": [],
},
{
"title": "Trees",
"id": "2162740",
"type": "page",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2162740/Trees",
"children": [
{
"title": "The Importance of Trees",
"id": "2555906",
"type": "page",
"parent_id": "2162740",
"parent_type": "TODO",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2555906/The+Importance+of+Trees",
"children": [
{
"title": "Erics page",
"id": "2686977",
"type": "page",
"parent_id": "2555906",
"parent_type": "TODO",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2686977/Erics+page",
"children": [],
"status": "current",
},
{
"title": "Erics page",
"id": "2719745",
"type": "page",
"parent_id": "2555906",
"parent_type": "TODO",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/edit-v2/2719745",
"children": [],
"status": "draft",
},
],
"status": "current",
},
{
"title": "Execute tools",
"id": "2621441",
"type": "page",
"parent_id": "2162740",
"parent_type": "TODO",
"url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2621441/Execute+tools",
"children": [],
"status": "current",
},
],
},
),
],
)
def test_build_hierarchy(
input_transformed_children: list[dict],
input_parent_id: int,
input_parent_node: dict,
expected_parent_node: dict,
) -> None:
# build_hierarchy modifies the input_parent_node in-place
build_hierarchy(input_transformed_children, input_parent_id, input_parent_node)
assert input_parent_node == expected_parent_node

View file

@ -1,18 +0,0 @@
files: ^.*/dropbox/.*
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

View file

@ -1,46 +0,0 @@
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

View file

@ -1,21 +0,0 @@
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.

View file

@ -1,55 +0,0 @@
.PHONY: help
help:
@echo "🛠️ github 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 uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv 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"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -1,34 +0,0 @@
from enum import Enum
class EndpointType(Enum):
API = "api"
CONTENT = "content"
class Endpoint(Enum):
LIST_FOLDER = "/files/list_folder"
SEARCH_FILES = "/files/search"
DOWNLOAD_FILE = "/files/download"
class ItemCategory(Enum):
IMAGE = "image"
DOCUMENT = "document"
PDF = "pdf"
SPREADSHEET = "spreadsheet"
PRESENTATION = "presentation"
AUDIO = "audio"
VIDEO = "video"
FOLDER = "folder"
PAPER = "paper"
API_BASE_URL = "https://{endpoint_type}.dropboxapi.com"
API_VERSION = "2"
ENDPOINT_URL_MAP = {
Endpoint.LIST_FOLDER: (EndpointType.API, "files/list_folder"),
Endpoint.SEARCH_FILES: (EndpointType.API, "files/search_v2"),
Endpoint.DOWNLOAD_FILE: (EndpointType.CONTENT, "files/download"),
}
MAX_RESPONSE_BODY_SIZE = 10 * 1024 * 1024 # 10 MiB

View file

@ -1,34 +0,0 @@
from typing import Any
from arcade_evals import BinaryCritic
class DropboxPathCritic(BinaryCritic):
def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]:
"""
Ignores leading slash in the actual value when comparing to the expected value.
Note: sometimes the LLM won't start the path with a slash, so this critic ignores it when
comparing. Dropbox tools will add the slash, when needed, so no worries about API errors.
Args:
expected: The expected value.
actual: The actual value to compare, cast to the type of expected.
Returns:
dict: A dictionary containing the match status and score.
"""
try:
actual_casted = self.cast_actual(expected, actual)
# TODO log or something better here
except TypeError:
actual_casted = actual
if isinstance(expected, str):
expected = expected.lstrip("/")
if isinstance(actual_casted, str):
actual_casted = actual_casted.lstrip("/")
match = expected == actual_casted
return {"match": match, "score": self.weight if match else 0.0}

View file

@ -1,18 +0,0 @@
from typing import Optional
class DropboxApiError(Exception):
def __init__(
self,
status_code: int,
error_summary: str,
user_message: Optional[str],
):
if "path/not_found" in error_summary:
self.message = "The specified path was not found by Dropbox"
elif "unsupported_file" in error_summary:
self.message = "The specified file is not supported for the requested operation"
else:
self.message = user_message or error_summary
self.status_code = status_code

View file

@ -1,138 +0,0 @@
from typing import Annotated, Optional
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Dropbox
from arcade_tdk.errors import ToolExecutionError
from arcade_dropbox.constants import Endpoint, ItemCategory
from arcade_dropbox.exceptions import DropboxApiError
from arcade_dropbox.utils import (
build_dropbox_json,
clean_dropbox_entries,
parse_dropbox_path,
send_dropbox_request,
)
@tool(
requires_auth=Dropbox(
scopes=["files.metadata.read"],
)
)
async def list_items_in_folder(
context: ToolContext,
folder_path: Annotated[
str,
"The path to the folder to list the contents of. E.g. '/AcmeInc/Reports'. "
"Defaults to an empty string (list items in the Dropbox root folder).",
] = "",
limit: Annotated[
int,
"The maximum number of items to return. Defaults to 100. Maximum allowed is 2000.",
] = 100,
cursor: Annotated[
Optional[str],
"The cursor token for the next page of results. "
"Defaults to None (returns the first page of results).",
] = None,
) -> Annotated[
dict, "Dictionary containing the list of files and folders in the specified folder path"
]:
"""Provides a dictionary containing the list of items in the specified folder path.
Note 1: when paginating, it is not necessary to provide any other argument besides the cursor.
Note 2: when paginating, any given item (file or folder) may be returned in multiple pages.
"""
limit = min(limit, 2000)
try:
result = await send_dropbox_request(
context.get_auth_token_or_empty(),
endpoint=Endpoint.LIST_FOLDER,
path=parse_dropbox_path(folder_path),
limit=limit,
cursor=cursor,
)
except DropboxApiError as api_error:
return {"error": api_error.message}
return {
"items": clean_dropbox_entries(result["entries"]),
"cursor": result.get("cursor"),
"has_more": result.get("has_more", False),
}
@tool(
requires_auth=Dropbox(
scopes=["files.metadata.read"],
)
)
async def search_files_and_folders(
context: ToolContext,
keywords: Annotated[
str,
"The keywords to search for. E.g. 'quarterly report'. "
"Maximum length allowed by the Dropbox API is 1000 characters. ",
],
search_in_folder_path: Annotated[
Optional[str],
"Restricts the search to the specified folder path. E.g. '/AcmeInc/Reports'. "
"Defaults to None (search in the entire Dropbox).",
] = None,
filter_by_category: Annotated[
Optional[list[ItemCategory]],
"Restricts the search to the specified category(ies) of items. "
"Provide None, one or multiple, if needed. Defaults to None (returns all categories).",
] = None,
limit: Annotated[
int,
"The maximum number of items to return. Defaults to 100. Maximum allowed is 1000.",
] = 100,
cursor: Annotated[
Optional[str],
"The cursor token for the next page of results. Defaults to None (first page of results).",
] = None,
) -> Annotated[dict, "List of items in the specified folder path matching the search criteria"]:
"""Returns a list of items in the specified folder path matching the search criteria.
Note 1: the Dropbox API will return up to 10,000 (ten thousand) items cumulatively across
multiple pagination requests using the cursor token.
Note 2: when paginating, it is not necessary to provide any other argument besides the cursor.
Note 3: when paginating, any given item (file or folder) may be returned in multiple pages.
"""
if len(keywords) > 1000:
raise ToolExecutionError(
"The keywords argument must be a string with up to 1000 characters."
)
limit = min(limit, 1000)
filter_by_category = filter_by_category or []
options = build_dropbox_json(
file_status="active",
filename_only=False,
path=parse_dropbox_path(search_in_folder_path),
max_results=limit,
file_categories=[category.value for category in filter_by_category],
)
try:
result = await send_dropbox_request(
context.get_auth_token_or_empty(),
endpoint=Endpoint.SEARCH_FILES,
query=keywords,
options=options,
cursor=cursor,
)
except DropboxApiError as api_error:
return {"error": api_error.message}
return {
"items": clean_dropbox_entries([
match["metadata"]["metadata"] for match in result["matches"]
]),
"cursor": result.get("cursor"),
"has_more": result.get("has_more", False),
}

View file

@ -1,47 +0,0 @@
from typing import Annotated, Optional
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Dropbox
from arcade_tdk.errors import ToolExecutionError
from arcade_dropbox.constants import Endpoint
from arcade_dropbox.exceptions import DropboxApiError
from arcade_dropbox.utils import parse_dropbox_path, send_dropbox_request
@tool(
requires_auth=Dropbox(
scopes=["files.content.read"],
)
)
async def download_file(
context: ToolContext,
file_path: Annotated[
Optional[str],
"The path of the file to download. E.g. '/AcmeInc/Reports/Q1_2025.txt'. Defaults to None.",
] = None,
file_id: Annotated[
Optional[str],
"The ID of the file to download. E.g. 'id:a4ayc_80_OEAAAAAAAAAYa'. Defaults to None.",
] = None,
) -> Annotated[dict, "Contents of the specified file"]:
"""Downloads the specified file.
Note: either one of `file_path` or `file_id` must be provided.
"""
if not file_path and not file_id:
raise ToolExecutionError("Either `file_path` or `file_id` must be provided.")
if file_path and file_id:
raise ToolExecutionError("Only one of `file_path` or `file_id` can be provided.")
try:
result = await send_dropbox_request(
context.get_auth_token_or_empty(),
endpoint=Endpoint.DOWNLOAD_FILE,
path=parse_dropbox_path(file_path) or file_id,
)
except DropboxApiError as api_error:
return {"error": api_error.message}
return {"file": result}

View file

@ -1,106 +0,0 @@
import json
from typing import Any, Optional
import httpx
from arcade_dropbox.constants import (
API_BASE_URL,
API_VERSION,
ENDPOINT_URL_MAP,
Endpoint,
EndpointType,
)
from arcade_dropbox.exceptions import DropboxApiError
def build_dropbox_url(endpoint_type: EndpointType, endpoint_path: str) -> str:
base_url = API_BASE_URL.format(endpoint_type=endpoint_type.value)
return f"{base_url}/{API_VERSION}/{endpoint_path.strip('/')}"
def build_dropbox_headers(token: Optional[str]) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"} if token else {}
def build_dropbox_json(**kwargs: Any) -> dict:
return {key: value for key, value in kwargs.items() if value is not None}
async def send_dropbox_request(
authorization_token: Optional[str],
endpoint: Endpoint,
**kwargs: Any,
) -> Any:
endpoint_type, endpoint_path = ENDPOINT_URL_MAP[endpoint]
url = build_dropbox_url(endpoint_type, endpoint_path)
headers = build_dropbox_headers(authorization_token)
json_data = build_dropbox_json(**kwargs)
if json_data.get("cursor"):
url += "/continue"
# If cursor is provided, every other argument must be ignored to avoid API error
json_data = {"cursor": json_data["cursor"]}
if endpoint_type == EndpointType.CONTENT:
headers["Dropbox-API-Arg"] = json.dumps(json_data)
json_data = {}
async with httpx.AsyncClient() as client:
request_args: dict[str, Any] = {"url": url, "headers": headers}
if json_data:
request_args["json"] = json_data
response = await client.post(**request_args)
try:
data = response.json()
except Exception:
data = {}
if response.status_code != 200:
raise DropboxApiError(
status_code=response.status_code,
error_summary=data.get("error_summary", response.text),
user_message=data.get("user_message"),
)
if endpoint_type == EndpointType.CONTENT:
data = json.loads(response.headers["Dropbox-API-Result"])
data = clean_dropbox_entry(data, default_type="file")
data["content"] = response.text
return data
return response.json()
def clean_dropbox_entry(entry: dict, default_type: Optional[str] = None) -> dict:
return {
"type": entry.get(".tag", default_type),
"id": entry.get("id"),
"name": entry.get("name"),
"path": entry.get("path_display"),
"size_in_bytes": entry.get("size"),
"modified_datetime": entry.get("server_modified"),
}
def clean_dropbox_entries(entries: list[dict]) -> list[dict]:
return [clean_dropbox_entry(entry) for entry in entries]
def parse_dropbox_path(path: Optional[str]) -> Optional[str]:
if not isinstance(path, str):
return ""
if not path:
return ""
if path in ["/", "\\"]:
return ""
# Normalize windows-style paths to unix-style paths
path = path.replace("\\", "/")
# Dropbox expects the path to always start with a slash
return "/" + path.strip("/")

View file

@ -1,45 +0,0 @@
from unittest.mock import patch
import pytest
from arcade_tdk 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(mocker):
with patch("arcade_dropbox.utils.httpx") as mock_httpx:
yield mock_httpx.AsyncClient().__aenter__.return_value
@pytest.fixture
def sample_folder_entry():
return {
".tag": "folder",
"name": "test.txt",
"path_display": "/TestFolder",
"path_lower": "/testfolder",
"id": "1234567890",
"client_modified": "2025-01-01T00:00:00Z",
"server_modified": "2025-01-01T00:00:00Z",
"rev": "1234567890",
}
@pytest.fixture
def sample_file_entry():
return {
".tag": "file",
"name": "test.txt",
"path_display": "/TestFile.txt",
"path_lower": "/testfile.txt",
"id": "1234567890",
"client_modified": "2025-01-01T00:00:00Z",
"server_modified": "2025-01-01T00:00:00Z",
"rev": "1234567890",
"size": 1024,
}

View file

@ -1,88 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_dropbox
from arcade_dropbox.critics import DropboxPathCritic
from arcade_dropbox.tools.files import download_file
rubric = EvalRubric(
fail_threshold=0.8,
warn_threshold=0.9,
)
catalog = ToolCatalog()
catalog.add_module(arcade_dropbox)
@tool_eval()
def download_file_eval_suite() -> EvalSuite:
"""Create an evaluation suite for the download_file tool."""
suite = EvalSuite(
name="download_file",
system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Download file in the root folder by file path",
user_message="Download the file test.txt from Dropbox",
expected_tool_calls=[
ExpectedToolCall(
func=download_file,
args={
"file_path": "test.txt",
"file_id": None,
},
),
],
critics=[
DropboxPathCritic(critic_field="file_path", weight=0.5),
BinaryCritic(critic_field="file_id", weight=0.5),
],
)
suite.add_case(
name="Download file with a sub-folder structure",
user_message="Download the file Q1report.ppt in the folder AcmeInc/Reports from Dropbox",
expected_tool_calls=[
ExpectedToolCall(
func=download_file,
args={
"file_path": "/AcmeInc/Reports/Q1report.ppt",
"file_id": None,
},
),
],
critics=[
DropboxPathCritic(critic_field="file_path", weight=0.5),
BinaryCritic(critic_field="file_id", weight=0.5),
],
)
suite.add_case(
name="Download file by ID",
user_message="Download the file id:a4ayc_80_OEAAAAAAAAAYa from Dropbox",
expected_tool_calls=[
ExpectedToolCall(
func=download_file,
args={
"file_path": None,
"file_id": "id:a4ayc_80_OEAAAAAAAAAYa",
},
),
],
critics=[
BinaryCritic(critic_field="file_path", weight=0.5),
BinaryCritic(critic_field="file_id", weight=0.5),
],
)
return suite

View file

@ -1,94 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_dropbox
from arcade_dropbox.critics import DropboxPathCritic
from arcade_dropbox.tools.browse import list_items_in_folder
rubric = EvalRubric(
fail_threshold=0.8,
warn_threshold=0.9,
)
catalog = ToolCatalog()
catalog.add_module(arcade_dropbox)
@tool_eval()
def list_items_in_folder_eval_suite() -> EvalSuite:
"""Create an evaluation suite for the list_items_in_folder tool."""
suite = EvalSuite(
name="list_items_in_folder",
system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="List items in the Dropbox root folder",
user_message="List the items in the Dropbox root folder",
expected_tool_calls=[
ExpectedToolCall(
func=list_items_in_folder,
args={
"folder_path": "",
"limit": 100,
"cursor": None,
},
),
],
critics=[
DropboxPathCritic(critic_field="folder_path", weight=0.6),
BinaryCritic(critic_field="limit", weight=0.2),
BinaryCritic(critic_field="cursor", weight=0.2),
],
)
suite.add_case(
name="List items in a sub-folder",
user_message="List the items in the folder AcmeInc/Reports",
expected_tool_calls=[
ExpectedToolCall(
func=list_items_in_folder,
args={
"folder_path": "/AcmeInc/Reports",
"limit": 100,
"cursor": None,
},
),
],
critics=[
DropboxPathCritic(critic_field="folder_path", weight=0.6),
BinaryCritic(critic_field="limit", weight=0.2),
BinaryCritic(critic_field="cursor", weight=0.2),
],
)
suite.add_case(
name="List items in a sub-folder with custom limit",
user_message="List the first 50 items in the folder AcmeInc/Reports",
expected_tool_calls=[
ExpectedToolCall(
func=list_items_in_folder,
args={
"folder_path": "/AcmeInc/Reports",
"limit": 50,
"cursor": None,
},
),
],
critics=[
DropboxPathCritic(critic_field="folder_path", weight=0.4),
BinaryCritic(critic_field="limit", weight=0.4),
BinaryCritic(critic_field="cursor", weight=0.2),
],
)
return suite

View file

@ -1,131 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_dropbox
from arcade_dropbox.constants import ItemCategory
from arcade_dropbox.critics import DropboxPathCritic
from arcade_dropbox.tools.browse import search_files_and_folders
rubric = EvalRubric(
fail_threshold=0.8,
warn_threshold=0.9,
)
catalog = ToolCatalog()
catalog.add_module(arcade_dropbox)
@tool_eval()
def search_files_and_folders_eval_suite() -> EvalSuite:
"""Create an evaluation suite for the search_files_and_folders tool."""
suite = EvalSuite(
name="list_items_in_folder",
system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Search for files about 'quarterly report' in my Dropbox",
user_message="Search for files about 'quarterly report' in my Dropbox",
expected_tool_calls=[
ExpectedToolCall(
func=search_files_and_folders,
args={
"keywords": "quarterly report",
"search_in_folder_path": None,
"filter_by_category": None,
"limit": 100,
"cursor": None,
},
),
],
critics=[
BinaryCritic(critic_field="keywords", weight=0.6),
BinaryCritic(critic_field="search_in_folder_path", weight=0.1),
BinaryCritic(critic_field="filter_by_category", weight=0.1),
BinaryCritic(critic_field="limit", weight=0.1),
BinaryCritic(critic_field="cursor", weight=0.1),
],
)
suite.add_case(
name="Search for files about 'quarterly report' in a sub-folder",
user_message="Search for files about 'quarterly report' in the folder AcmeInc/Reports",
expected_tool_calls=[
ExpectedToolCall(
func=search_files_and_folders,
args={
"keywords": "quarterly report",
"search_in_folder_path": "/AcmeInc/Reports",
"filter_by_category": None,
"limit": 100,
"cursor": None,
},
),
],
critics=[
BinaryCritic(critic_field="keywords", weight=0.35),
DropboxPathCritic(critic_field="search_in_folder_path", weight=0.35),
BinaryCritic(critic_field="filter_by_category", weight=0.1),
BinaryCritic(critic_field="limit", weight=0.1),
BinaryCritic(critic_field="cursor", weight=0.1),
],
)
suite.add_case(
name="Search for PDF files about 'quarterly report' in a sub-folder",
user_message="Search for PDF files about 'quarterly report' in the folder AcmeInc/Reports",
expected_tool_calls=[
ExpectedToolCall(
func=search_files_and_folders,
args={
"keywords": "quarterly report",
"search_in_folder_path": "/AcmeInc/Reports",
"filter_by_category": [ItemCategory.PDF.value],
"limit": 100,
"cursor": None,
},
),
],
critics=[
BinaryCritic(critic_field="keywords", weight=0.25),
DropboxPathCritic(critic_field="search_in_folder_path", weight=0.25),
BinaryCritic(critic_field="filter_by_category", weight=0.25),
BinaryCritic(critic_field="limit", weight=0.125),
BinaryCritic(critic_field="cursor", weight=0.125),
],
)
suite.add_case(
name="Search for PDF files about 'quarterly report' in a sub-folder",
user_message="Return the first 10 PDF files about 'quarterly report' in the folder AcmeInc/Reports",
expected_tool_calls=[
ExpectedToolCall(
func=search_files_and_folders,
args={
"keywords": "quarterly report",
"search_in_folder_path": "/AcmeInc/Reports",
"filter_by_category": [ItemCategory.PDF.value],
"limit": 10,
"cursor": None,
},
),
],
critics=[
BinaryCritic(critic_field="keywords", weight=0.2),
DropboxPathCritic(critic_field="search_in_folder_path", weight=0.2),
BinaryCritic(critic_field="filter_by_category", weight=0.2),
BinaryCritic(critic_field="limit", weight=0.2),
BinaryCritic(critic_field="cursor", weight=0.2),
],
)
return suite

View file

@ -1,57 +0,0 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_dropbox"
version = "0.1.4"
description = "Arcade tools designed for LLMs to interact with Dropbox"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"httpx>=0.27.2,<1.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"pytest-mock>=3.11.1,<3.12.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = {path = "../../", editable = true}
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
[tool.mypy]
files = [ "arcade_dropbox/**/*.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
[tool.hatch.build.targets.wheel]
packages = [ "arcade_dropbox",]

View file

@ -1,119 +0,0 @@
import json
from unittest.mock import MagicMock
import httpx
import pytest
from arcade_dropbox.tools.files import download_file
from arcade_dropbox.utils import clean_dropbox_entry
@pytest.fixture
def file_content_response_header():
return json.dumps({
"id": "123",
"name": "test.txt",
"path_display": "/test.txt",
"size": 1024,
"server_modified": "2021-01-01T00:00:00Z",
"content_hash": "1234567890",
"is_downloadable": True,
"rev": "a1c10ce0dd78",
"sharing_info": {
"modified_by": "dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc",
"parent_shared_folder_id": "84528192421",
"read_only": True,
},
})
@pytest.mark.asyncio
async def test_download_file_success(
mock_context,
mock_httpx_client,
file_content_response_header,
):
file_content = "test file content"
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.side_effect = ValueError("not json")
mock_httpx_response.headers = {"Dropbox-API-Result": file_content_response_header}
mock_httpx_response.text = file_content
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await download_file(
context=mock_context,
file_path="test.txt",
)
expected_response = clean_dropbox_entry(json.loads(file_content_response_header))
expected_response["content"] = file_content
expected_response["type"] = "file"
assert tool_response == {"file": expected_response}
@pytest.mark.asyncio
async def test_download_file_path_not_found(
mock_context,
mock_httpx_client,
):
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 409
mock_httpx_response.json.return_value = {"error_summary": "path/not_found"}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await download_file(
context=mock_context,
file_path="test.txt",
)
assert tool_response == {
"error": "The specified path was not found by Dropbox",
}
@pytest.mark.asyncio
async def test_download_file_unsupported_file(
mock_context,
mock_httpx_client,
):
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 409
mock_httpx_response.json.return_value = {"error_summary": "unsupported_file/not_supported"}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await download_file(
context=mock_context,
file_path="test.txt",
)
assert tool_response == {
"error": "The specified file is not supported for the requested operation",
}
@pytest.mark.asyncio
async def test_download_file_server_error(
mock_context,
mock_httpx_client,
):
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 500
mock_httpx_response.text = "500 Internal server error"
mock_httpx_response.json.side_effect = ValueError("not json")
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await download_file(
context=mock_context,
file_path="test.txt",
)
assert tool_response == {
"error": "500 Internal server error",
}

View file

@ -1,145 +0,0 @@
from unittest.mock import MagicMock
import httpx
import pytest
from arcade_dropbox.tools.browse import list_items_in_folder
from arcade_dropbox.utils import clean_dropbox_entries
@pytest.mark.asyncio
async def test_list_items_success_empty_folder(
mock_context,
mock_httpx_client,
):
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {"entries": [], "cursor": None, "has_more": False}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await list_items_in_folder(
context=mock_context,
folder_path="/path/to/folder",
)
assert tool_response == {
"items": [],
"cursor": None,
"has_more": False,
}
@pytest.mark.asyncio
async def test_list_items_success_with_folder_entries(
mock_context,
mock_httpx_client,
sample_folder_entry,
sample_file_entry,
):
entries = [sample_folder_entry, sample_file_entry]
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {"entries": entries, "cursor": None, "has_more": False}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await list_items_in_folder(
context=mock_context,
folder_path="/path/to/folder",
)
assert tool_response == {
"items": clean_dropbox_entries(entries),
"cursor": None,
"has_more": False,
}
@pytest.mark.asyncio
async def test_list_items_success_with_more_items_to_paginate(
mock_context,
mock_httpx_client,
sample_folder_entry,
sample_file_entry,
):
entries = [sample_folder_entry, sample_file_entry]
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {
"entries": entries,
"cursor": "cursor",
"has_more": True,
}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await list_items_in_folder(
context=mock_context,
folder_path="/path/to/folder",
)
assert tool_response == {
"items": clean_dropbox_entries(entries),
"cursor": "cursor",
"has_more": True,
}
@pytest.mark.asyncio
async def test_list_items_success_providing_cursor(
mock_context,
mock_httpx_client,
sample_folder_entry,
sample_file_entry,
):
entries = [sample_folder_entry, sample_file_entry]
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {
"entries": entries,
"cursor": "cursor2",
"has_more": True,
}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await list_items_in_folder(
context=mock_context,
folder_path="/path/to/folder",
cursor="cursor1",
limit=2,
)
assert tool_response == {
"items": clean_dropbox_entries(entries),
"cursor": "cursor2",
"has_more": True,
}
# Check that the request was made with the cursor and not the other arguments
mock_httpx_client.post.assert_called_with(
url="https://api.dropboxapi.com/2/files/list_folder/continue",
headers={"Authorization": "Bearer fake-token"},
json={"cursor": "cursor1"},
)
@pytest.mark.asyncio
async def test_list_items_path_not_found(
mock_context,
mock_httpx_client,
):
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 409
mock_httpx_response.json.return_value = {"error_summary": "path/not_found"}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await list_items_in_folder(
context=mock_context,
folder_path="/not/exist/folder",
)
assert tool_response == {
"error": "The specified path was not found by Dropbox",
}

View file

@ -1,286 +0,0 @@
from unittest.mock import MagicMock
import httpx
import pytest
from arcade_dropbox.constants import ItemCategory
from arcade_dropbox.tools.browse import search_files_and_folders
from arcade_dropbox.utils import clean_dropbox_entry
@pytest.fixture
def sample_folder_match(sample_folder_entry):
return {
"metadata": {
".tag": "metadata",
"metadata": sample_folder_entry,
}
}
@pytest.fixture
def sample_file_match(sample_file_entry):
return {
"metadata": {
".tag": "metadata",
"metadata": sample_file_entry,
}
}
@pytest.mark.asyncio
async def test_search_files_success_empty_results(
mock_context,
mock_httpx_client,
):
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {"matches": [], "cursor": None, "has_more": False}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await search_files_and_folders(
context=mock_context,
keywords="do not match anything",
)
assert tool_response == {
"items": [],
"cursor": None,
"has_more": False,
}
@pytest.mark.asyncio
async def test_search_files_success_with_matches(
mock_context,
mock_httpx_client,
sample_file_match,
sample_folder_match,
sample_file_entry,
sample_folder_entry,
):
matches = [
sample_file_match,
sample_folder_match,
]
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await search_files_and_folders(
context=mock_context,
keywords="test",
)
assert tool_response == {
"items": [
clean_dropbox_entry(sample_file_entry),
clean_dropbox_entry(sample_folder_entry),
],
"cursor": None,
"has_more": False,
}
@pytest.mark.asyncio
async def test_search_files_success_with_path_missing_leading_slash(
mock_context,
mock_httpx_client,
sample_file_match,
sample_folder_match,
sample_file_entry,
sample_folder_entry,
):
matches = [
sample_file_match,
sample_folder_match,
]
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await search_files_and_folders(
context=mock_context,
keywords="test",
search_in_folder_path="TestFolder",
)
assert tool_response == {
"items": [
clean_dropbox_entry(sample_file_entry),
clean_dropbox_entry(sample_folder_entry),
],
"cursor": None,
"has_more": False,
}
mock_httpx_client.post.assert_called_once_with(
url="https://api.dropboxapi.com/2/files/search_v2",
headers={"Authorization": "Bearer fake-token"},
json={
"query": "test",
"options": {
"file_categories": [],
"path": "/TestFolder",
"file_status": "active",
"filename_only": False,
"max_results": 100,
},
},
)
@pytest.mark.asyncio
async def test_search_files_success_with_more_results_to_paginate(
mock_context,
mock_httpx_client,
sample_file_match,
sample_folder_match,
sample_file_entry,
sample_folder_entry,
):
matches = [
sample_file_match,
sample_folder_match,
]
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {
"matches": matches,
"cursor": "cursor",
"has_more": True,
}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await search_files_and_folders(
context=mock_context,
keywords="test",
)
assert tool_response == {
"items": [
clean_dropbox_entry(sample_file_entry),
clean_dropbox_entry(sample_folder_entry),
],
"cursor": "cursor",
"has_more": True,
}
@pytest.mark.asyncio
async def test_search_files_success_providing_pagination_cursor(
mock_context,
mock_httpx_client,
sample_file_match,
sample_folder_match,
sample_file_entry,
sample_folder_entry,
):
matches = [
sample_file_match,
sample_folder_match,
]
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await search_files_and_folders(
context=mock_context,
keywords="test",
cursor="cursor",
)
assert tool_response == {
"items": [
clean_dropbox_entry(sample_file_entry),
clean_dropbox_entry(sample_folder_entry),
],
"cursor": None,
"has_more": False,
}
# Assert that the request was made with the correct cursor and not other arguments
mock_httpx_client.post.assert_called_once_with(
url="https://api.dropboxapi.com/2/files/search_v2/continue",
headers={"Authorization": "Bearer fake-token"},
json={"cursor": "cursor"},
)
@pytest.mark.asyncio
async def test_search_files_path_not_found(
mock_context,
mock_httpx_client,
):
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 409
mock_httpx_response.json.return_value = {"error_summary": "path/not_found"}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await search_files_and_folders(
context=mock_context,
keywords="test",
search_in_folder_path="/not/exist/folder",
)
assert tool_response == {
"error": "The specified path was not found by Dropbox",
}
@pytest.mark.asyncio
async def test_search_files_success_filtering_by_category(
mock_context,
mock_httpx_client,
sample_file_match,
sample_folder_match,
sample_file_entry,
sample_folder_entry,
):
matches = [
sample_file_match,
sample_folder_match,
]
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.status_code = 200
mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False}
mock_httpx_client.post.return_value = mock_httpx_response
tool_response = await search_files_and_folders(
context=mock_context,
keywords="test",
filter_by_category=[ItemCategory.PDF],
)
assert tool_response == {
"items": [
clean_dropbox_entry(sample_file_entry),
clean_dropbox_entry(sample_folder_entry),
],
"cursor": None,
"has_more": False,
}
mock_httpx_client.post.assert_called_once_with(
url="https://api.dropboxapi.com/2/files/search_v2",
headers={"Authorization": "Bearer fake-token"},
json={
"query": "test",
"options": {
"path": "",
"file_status": "active",
"filename_only": False,
"max_results": 100,
"file_categories": [ItemCategory.PDF.value],
},
},
)

View file

@ -1,18 +0,0 @@
files: ^.*/e2b/.*
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

View file

@ -1,47 +0,0 @@
target-version = "py310"
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

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025, Arcade AI
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.

View file

@ -1,55 +0,0 @@
.PHONY: help
help:
@echo "🛠️ github 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 uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv 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"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -1,3 +0,0 @@
from arcade_e2b.tools import create_static_matplotlib_chart, run_code
__all__ = ["create_static_matplotlib_chart", "run_code"]

View file

@ -1,10 +0,0 @@
from enum import Enum
# Models and enums for the e2b code interpreter
class E2BSupportedLanguage(str, Enum):
PYTHON = "python"
JAVASCRIPT = "js"
R = "r"
JAVA = "java"
BASH = "bash"

View file

@ -1,4 +0,0 @@
from arcade_e2b.tools.create_chart import create_static_matplotlib_chart
from arcade_e2b.tools.run_code import run_code
__all__ = ["create_static_matplotlib_chart", "run_code"]

View file

@ -1,31 +0,0 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from e2b_code_interpreter import Sandbox
# See https://e2b.dev/docs to learn more about E2B
# Note: Not recommended to use tool_choice='generate' with this tool
# since it contains base64 encoded image.
@tool(requires_secrets=["E2B_API_KEY"])
def create_static_matplotlib_chart(
context: ToolContext,
code: Annotated[str, "The Python code to run"],
) -> Annotated[dict, "A dictionary with the following keys: base64_image, logs, error"]:
"""
Run the provided Python code to generate a static matplotlib chart.
The resulting chart is returned as a base64 encoded image.
"""
api_key = context.get_secret("E2B_API_KEY")
with Sandbox(api_key=api_key) as sbx:
execution = sbx.run_code(code=code)
result = {
"base64_image": execution.results[0].png if execution.results else None,
"logs": execution.logs.to_json(),
"error": execution.error.to_json() if execution.error else None,
}
return result

View file

@ -1,27 +0,0 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from e2b_code_interpreter import Sandbox
from arcade_e2b.enums import E2BSupportedLanguage
# See https://e2b.dev/docs to learn more about E2B
@tool(requires_secrets=["E2B_API_KEY"])
def run_code(
context: ToolContext,
code: Annotated[str, "The code to run"],
language: Annotated[
E2BSupportedLanguage, "The language of the code"
] = E2BSupportedLanguage.PYTHON,
) -> Annotated[str, "The sandbox execution as a JSON string"]:
"""
Run code in a sandbox and return the output.
"""
api_key = context.get_secret("E2B_API_KEY")
with Sandbox(api_key=api_key) as sbx:
execution = sbx.run_code(code=code, language=language)
return str(execution.to_json())

View file

@ -1,120 +0,0 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
SimilarityCritic,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_e2b
from arcade_e2b.enums import E2BSupportedLanguage
from arcade_e2b.tools.create_chart import create_static_matplotlib_chart
from arcade_e2b.tools.run_code import run_code
merge_sort_code = """
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i, j = 0, 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
sample_list = ["banana", "apple", "cherry", "date", "elderberry"]
sorted_list = merge_sort(sample_list)
print("Sorted list:", sorted_list)
"""
matplotlib_chart_code = """
import matplotlib.pyplot as plt
labels = ['Apples', 'Bananas', 'Cherries', 'Dates']
sizes = [30, 25, 20, 25]
colors = ['red', 'yellow', 'purple', 'brown']
plt.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90)
plt.axis('equal')
plt.title('Fruit Distribution')
plt.savefig('fruit_pie_chart.png')
"""
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_e2b)
@tool_eval()
def e2b_eval_suite():
suite = EvalSuite(
name="E2B Tools Evaluation",
system_message="You are an AI assistant with access to E2B tools. Use them to help the user with their tasks.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Run code",
user_message=f"Can you please run my merge sort algo?\n\n{merge_sort_code}",
expected_tool_calls=[
ExpectedToolCall(
func=run_code,
args={
"code": merge_sort_code,
"language": E2BSupportedLanguage.PYTHON,
},
)
],
critics=[
SimilarityCritic(critic_field="code", weight=0.8),
BinaryCritic(critic_field="language", weight=0.2),
],
)
suite.add_case(
name="Create static matplotlib chart",
user_message=f"Run this code:\n\n{matplotlib_chart_code}",
expected_tool_calls=[
ExpectedToolCall(
func=create_static_matplotlib_chart,
args={
"code": matplotlib_chart_code,
},
)
],
critics=[
SimilarityCritic(critic_field="code", weight=1.0),
],
)
return suite

View file

@ -1,57 +0,0 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_e2b"
version = "2.0.0"
description = "Arcade.dev LLM tools for running code in a sandbox using E2B"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"e2b-code-interpreter>=1.0.1,<2.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"pytest-mock>=3.11.1,<3.12.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = {path = "../../", editable = true}
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
[tool.mypy]
files = [ "arcade_e2b/**/*.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
[tool.hatch.build.targets.wheel]
packages = [ "arcade_e2b",]

View file

@ -1,82 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from arcade_tdk import ToolContext, ToolSecretItem
from arcade_tdk.errors import ToolExecutionError
import arcade_e2b.tools.create_chart
import arcade_e2b.tools.run_code
from arcade_e2b.enums import E2BSupportedLanguage
@pytest.fixture
def mock_run_code_sandbox():
with patch("arcade_e2b.tools.run_code.Sandbox") as mock:
yield mock.return_value.__enter__.return_value
@pytest.fixture
def mock_create_chart_sandbox():
with patch("arcade_e2b.tools.create_chart.Sandbox") as mock:
yield mock.return_value.__enter__.return_value
@pytest.fixture
def mock_context():
return ToolContext(secrets=[ToolSecretItem(key="e2b_api_key", value="fake_api_key")])
def test_run_code_success(mock_run_code_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.to_json.return_value = '{"result": "success"}'
mock_run_code_sandbox.run_code.return_value = mock_execution
result = arcade_e2b.tools.run_code(
mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON
)
assert result == '{"result": "success"}'
def test_run_code_error(mock_run_code_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.to_json.side_effect = ToolExecutionError("Execution failed")
mock_run_code_sandbox.run_code.return_value = mock_execution
with pytest.raises(ToolExecutionError, match="Execution failed"):
arcade_e2b.tools.run_code(
mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON
)
def test_create_static_matplotlib_chart_success(mock_create_chart_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.results = [MagicMock(png="base64encodedimage")]
mock_execution.logs.to_json.return_value = '{"logs": "log data"}'
mock_execution.error = None
mock_create_chart_sandbox.run_code.return_value = mock_execution
result = arcade_e2b.tools.create_chart.create_static_matplotlib_chart(
mock_context, "import matplotlib.pyplot as plt"
)
assert result == {
"base64_image": "base64encodedimage",
"logs": '{"logs": "log data"}',
"error": None,
}
def test_create_static_matplotlib_chart_error(mock_create_chart_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.results = []
mock_execution.logs.to_json.return_value = '{"logs": "log data"}'
mock_execution.error.to_json.return_value = '{"error": "some error"}'
mock_create_chart_sandbox.run_code.return_value = mock_execution
result = arcade_e2b.tools.create_chart.create_static_matplotlib_chart(
mock_context, "import matplotlib.pyplot as plt"
)
assert result == {
"base64_image": None,
"logs": '{"logs": "log data"}',
"error": '{"error": "some error"}',
}

View file

@ -1,18 +0,0 @@
files: ^.*/firecrawl/.*
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

View file

@ -1,47 +0,0 @@
target-version = "py310"
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

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025, Arcade AI
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.

Some files were not shown because too many files have changed in this diff Show more