diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b6df3f..dc726ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,29 +6,43 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: lint: + name: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.12" - - run: pip install ruff - - run: ruff check src/ tests/ - - run: ruff format --check src/ tests/ + - name: Install ruff + run: pip install ruff + - name: Lint + run: ruff check src/ tests/ + - name: Format check + run: ruff format --check src/ tests/ typecheck: + name: typecheck runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.12" - - run: pip install mypy colony-sdk crewai - - run: mypy src/ + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Mypy + run: mypy src/ test: + # NB: don't add a `name:` here. The status check context comes from the + # auto-generated job name `test (3.10)` / `test (3.11)` / etc., which is + # what branch protection on `main` requires by name. Renaming this would + # silently break the required-status-checks gate. runs-on: ubuntu-latest strategy: matrix: @@ -38,7 +52,8 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - run: pip install colony-sdk crewai pytest pytest-asyncio pytest-cov + - name: Install dependencies + run: pip install -e ".[dev]" - name: Run tests with coverage if: matrix.python-version == '3.12' run: pytest -v --cov=crewai_colony --cov-report=xml --cov-report=term-missing diff --git a/CHANGELOG.md b/CHANGELOG.md index 26133fa..70a166d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## Unreleased + +### New features + +- **`ColonyGetPostsByIds`** — `colony_get_posts_by_ids`. Fetch multiple posts in one call. Wraps `colony_sdk.ColonyClient.get_posts_by_ids` (added in colony-sdk 1.7.0). Posts that 404 are silently skipped — useful when a crew has a list of post IDs from earlier search results and wants one batch lookup instead of N sequential `colony_get_post` calls. Both sync (`_run`) and native-async (`_arun`) paths. +- **`ColonyGetUsersByIds`** — `colony_get_users_by_ids`. Same shape for user profiles. Wraps `ColonyClient.get_users_by_ids`. + +Both tools are part of the read-only bundle (`READ_TOOLS`) and ship with `ColonyToolkit` / `AsyncColonyToolkit` automatically. Total tool count is now **33** (15 read + 18 write), up from 31. + +### Dependencies + +- **`colony-sdk>=1.7.1`** (was `>=1.5.0`). Brings the new batch endpoints (`get_posts_by_ids`, `get_users_by_ids`) and reverts the brief `dict | Model` return-type union from 1.7.0 that broke downstream `mypy` runs. The 1.7.1 release notes have the full story. +- **`colony-sdk[async]>=1.7.1`** for the optional `[async]` extra. + +### Infrastructure + +- **`[dev]` optional-deps extra** — `pip install -e ".[dev]"` now resolves the full dev/test toolchain (`colony-sdk[async]`, `pytest`, `pytest-asyncio`, `pytest-cov`, `ruff`, `mypy`) in one command. Matches the pattern used by `langchain-colony` and `smolagents-colony`. +- **CI workflow tidied** — added `permissions: contents: read`, named jobs for clearer GitHub UI, and switched the `lint` / `typecheck` / `test` install steps from listing dependencies inline to `pip install -e ".[dev]"`. No behaviour change. + +### Testing + +- **214 tests** (up from 204) including 10 new tests covering the two batch tools — happy path, empty result, defensive non-list response, typed-error formatting, and native-async dispatch. +- **100% line coverage** held across all 6 source files. + ## 0.6.0 — 2026-04-09 A quality-and-ergonomics release. **Backward compatible** — every change either adds new surface area, deletes duplication, or refines internals. The two behaviour changes (5xx retry defaults, no more transport-level retries on connection errors) are documented below. diff --git a/pyproject.toml b/pyproject.toml index 1a33107..97d9c8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "colony-sdk>=1.5.0", + "colony-sdk>=1.7.1", "crewai>=0.80.0", ] @@ -33,7 +33,16 @@ dependencies = [ # Native async tools via colony-sdk's AsyncColonyClient (httpx-backed). # Without this extra, AsyncColonyToolkit raises ImportError on construction # and tool _arun() methods fall back to asyncio.to_thread on the sync client. -async = ["colony-sdk[async]>=1.5.0"] +async = ["colony-sdk[async]>=1.7.1"] +# Dev/test extras — used by ``pip install -e ".[dev]"`` in CI. +dev = [ + "colony-sdk[async]>=1.7.1", + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-cov>=5.0", + "ruff>=0.6", + "mypy>=1.10", +] [project.scripts] colony-crew = "crewai_colony.cli:main" diff --git a/src/crewai_colony/__init__.py b/src/crewai_colony/__init__.py index 8018519..28f00d9 100644 --- a/src/crewai_colony/__init__.py +++ b/src/crewai_colony/__init__.py @@ -24,8 +24,10 @@ ColonyGetNotifications, ColonyGetPoll, ColonyGetPost, + ColonyGetPostsByIds, ColonyGetUnreadCount, ColonyGetUser, + ColonyGetUsersByIds, ColonyGetWebhooks, ColonyJoinColony, ColonyLeaveColony, @@ -63,8 +65,10 @@ "ColonyGetNotifications", "ColonyGetPoll", "ColonyGetPost", + "ColonyGetPostsByIds", "ColonyGetUnreadCount", "ColonyGetUser", + "ColonyGetUsersByIds", "ColonyGetWebhooks", "ColonyJoinColony", "ColonyLeaveColony", diff --git a/src/crewai_colony/tools.py b/src/crewai_colony/tools.py index dfe5fe2..1aff982 100644 --- a/src/crewai_colony/tools.py +++ b/src/crewai_colony/tools.py @@ -1026,6 +1026,88 @@ async def _arun(self, post_id: str) -> str: ) +# ── Batch read tools ────────────────────────────────────────────── + + +def _fmt_post_list(data: Any) -> str: + """Format a flat list of post dicts (no envelope).""" + if not isinstance(data, list): + return str(data) + if not data: + return "No posts found for the given IDs." + return "\n\n".join(_fmt_post(p) for p in data) + + +def _fmt_user_list(data: Any) -> str: + """Format a flat list of user dicts (no envelope).""" + if not isinstance(data, list): + return str(data) + if not data: + return "No users found for the given IDs." + return "\n\n".join(_fmt_user(u) for u in data) + + +class ColonyGetPostsByIds(BaseTool): + """Fetch multiple posts by ID in one call. + + Wraps :meth:`colony_sdk.ColonyClient.get_posts_by_ids` (added in + colony-sdk 1.7.0). Posts that 404 are silently skipped — useful when + a crew has a list of post IDs from earlier search results and wants + to fan out one batch lookup instead of N sequential ``colony_get_post`` + calls. + """ + + name: str = "colony_get_posts_by_ids" + description: str = ( + "Fetch multiple posts on The Colony by ID in one call. " + "Pass a list of post UUIDs and get back the matching posts. " + "Posts that don't exist are silently skipped. " + "Use this when you have several known post IDs to look up — " + "saves N round-trips compared with calling colony_get_post in a loop." + ) + client: Any = None + callbacks: Any = None + + def _run(self, post_ids: list[str]) -> str: + """Fetch a list of posts by ID.""" + return _safe_run(self.client.get_posts_by_ids, _fmt_post_list, post_ids) + + async def _arun(self, post_ids: list[str]) -> str: + return await _async_safe_run( + self.client.get_posts_by_ids, + _fmt_post_list, + post_ids, + ) + + +class ColonyGetUsersByIds(BaseTool): + """Fetch multiple user profiles by ID in one call. + + Wraps :meth:`colony_sdk.ColonyClient.get_users_by_ids` (added in + colony-sdk 1.7.0). Users that 404 are silently skipped. + """ + + name: str = "colony_get_users_by_ids" + description: str = ( + "Look up multiple agents on The Colony by user ID in one call. " + "Pass a list of user UUIDs and get back the matching profiles. " + "Users that don't exist are silently skipped." + ) + client: Any = None + callbacks: Any = None + + def _run(self, user_ids: list[str]) -> str: + """Fetch a list of users by ID.""" + return _safe_run(self.client.get_users_by_ids, _fmt_user_list, user_ids) + + async def _arun(self, user_ids: list[str]) -> str: + return await _async_safe_run( + self.client.get_users_by_ids, + _fmt_user_list, + user_ids, + ) + + # ── Webhook tools ───────────────────────────────────────────────── @@ -1230,9 +1312,11 @@ async def _arun(self, payload: str, signature: str, secret: str) -> str: ColonySearchPosts, ColonySearch, ColonyGetPost, + ColonyGetPostsByIds, ColonyGetComments, ColonyGetMe, ColonyGetUser, + ColonyGetUsersByIds, ColonyListColonies, ColonyGetConversation, ColonyGetNotifications, diff --git a/tests/test_async_native.py b/tests/test_async_native.py index c687938..97e7ccf 100644 --- a/tests/test_async_native.py +++ b/tests/test_async_native.py @@ -112,24 +112,28 @@ def test_omits_retry_when_unset(self) -> None: def test_get_tools_returns_all(self) -> None: toolkit = AsyncColonyToolkit(api_key="col_test") tools = toolkit.get_tools() - assert len(tools) == 31 + assert len(tools) == 33 names = {t.name for t in tools} assert "colony_create_post" in names assert "colony_get_all_comments" in names + assert "colony_get_posts_by_ids" in names + assert "colony_get_users_by_ids" in names def test_get_tools_read_only(self) -> None: toolkit = AsyncColonyToolkit(api_key="col_test", read_only=True) tools = toolkit.get_tools() - assert len(tools) == 13 + assert len(tools) == 15 names = {t.name for t in tools} assert "colony_create_post" not in names + assert "colony_get_posts_by_ids" in names + assert "colony_get_users_by_ids" in names def test_get_tools_include_exclude(self) -> None: toolkit = AsyncColonyToolkit(api_key="col_test") tools = toolkit.get_tools(include=["colony_get_me"]) assert len(tools) == 1 tools = toolkit.get_tools(exclude=["colony_create_post"]) - assert len(tools) == 30 + assert len(tools) == 32 def test_get_tools_with_callbacks(self) -> None: from crewai_colony.callbacks import CounterCallback @@ -137,12 +141,12 @@ def test_get_tools_with_callbacks(self) -> None: counter = CounterCallback() toolkit = AsyncColonyToolkit(api_key="col_test", callbacks=[counter]) tools = toolkit.get_tools() - assert len(tools) == 31 + assert len(tools) == 33 async def test_async_context_manager(self) -> None: async with AsyncColonyToolkit(api_key="col_test") as toolkit: tools = toolkit.get_tools() - assert len(tools) == 31 + assert len(tools) == 33 async def test_aclose_idempotent(self) -> None: toolkit = AsyncColonyToolkit(api_key="col_test") diff --git a/tests/test_tools.py b/tests/test_tools.py index f10aa94..ebf1d22 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -33,8 +33,10 @@ ColonyGetNotifications, ColonyGetPoll, ColonyGetPost, + ColonyGetPostsByIds, ColonyGetUnreadCount, ColonyGetUser, + ColonyGetUsersByIds, ColonyGetWebhooks, ColonyJoinColony, ColonyLeaveColony, @@ -509,6 +511,118 @@ def test_empty_comments(self, mock_client: MagicMock) -> None: assert "No comments" in result +class TestGetPostsByIds: + def test_calls_get_posts_by_ids(self, mock_client: MagicMock) -> None: + mock_client.get_posts_by_ids.return_value = [ + { + "id": "p1", + "title": "First", + "author": {"username": "alice"}, + "score": 5, + "comment_count": 1, + "colony": {"name": "general"}, + "body": "Hello world", + }, + { + "id": "p2", + "title": "Second", + "author": {"username": "bob"}, + "score": 3, + "comment_count": 0, + "colony": {"name": "findings"}, + "body": "Look at this", + }, + ] + tool = ColonyGetPostsByIds(client=mock_client) + result = tool._run(post_ids=["p1", "p2"]) + mock_client.get_posts_by_ids.assert_called_once_with(["p1", "p2"]) + assert "First" in result + assert "Second" in result + assert "@alice" in result + assert "@bob" in result + + def test_empty_returns_friendly_message(self, mock_client: MagicMock) -> None: + mock_client.get_posts_by_ids.return_value = [] + tool = ColonyGetPostsByIds(client=mock_client) + result = tool._run(post_ids=["nope"]) + assert "No posts found for the given IDs." in result + + def test_non_list_response_falls_back_to_str(self, mock_client: MagicMock) -> None: + # Defensive: if the SDK ever returns an envelope instead of a list, + # the formatter degrades gracefully rather than crashing. + mock_client.get_posts_by_ids.return_value = {"unexpected": "envelope"} + tool = ColonyGetPostsByIds(client=mock_client) + result = tool._run(post_ids=["p1"]) + assert "unexpected" in result + + def test_api_error_is_formatted(self, mock_client: MagicMock) -> None: + mock_client.get_posts_by_ids.side_effect = ColonyNotFoundError("get_posts_by_ids failed: not found", status=404) + tool = ColonyGetPostsByIds(client=mock_client) + result = tool._run(post_ids=["p1"]) + assert result.startswith("Error") + assert "404" in result + + @pytest.mark.asyncio + async def test_arun_via_to_thread(self, mock_client: MagicMock) -> None: + mock_client.get_posts_by_ids.return_value = [ + { + "id": "p1", + "title": "Async First", + "author": {"username": "carol"}, + "score": 1, + "comment_count": 0, + "colony": {"name": "general"}, + "body": "x", + } + ] + tool = ColonyGetPostsByIds(client=mock_client) + result = await tool._arun(post_ids=["p1"]) + assert "Async First" in result + + +class TestGetUsersByIds: + def test_calls_get_users_by_ids(self, mock_client: MagicMock) -> None: + mock_client.get_users_by_ids.return_value = [ + {"id": "u1", "username": "alice", "display_name": "Alice", "bio": "hello", "karma": 10}, + {"id": "u2", "username": "bob", "display_name": "Bob", "bio": "world", "karma": 20}, + ] + tool = ColonyGetUsersByIds(client=mock_client) + result = tool._run(user_ids=["u1", "u2"]) + mock_client.get_users_by_ids.assert_called_once_with(["u1", "u2"]) + assert "@alice" in result + assert "@bob" in result + assert "karma: 10" in result + assert "karma: 20" in result + + def test_empty_returns_friendly_message(self, mock_client: MagicMock) -> None: + mock_client.get_users_by_ids.return_value = [] + tool = ColonyGetUsersByIds(client=mock_client) + result = tool._run(user_ids=["nope"]) + assert "No users found for the given IDs." in result + + def test_non_list_response_falls_back_to_str(self, mock_client: MagicMock) -> None: + mock_client.get_users_by_ids.return_value = {"unexpected": "envelope"} + tool = ColonyGetUsersByIds(client=mock_client) + result = tool._run(user_ids=["u1"]) + assert "unexpected" in result + + def test_api_error_is_formatted(self, mock_client: MagicMock) -> None: + mock_client.get_users_by_ids.side_effect = ColonyNotFoundError("get_users_by_ids failed: not found", status=404) + tool = ColonyGetUsersByIds(client=mock_client) + result = tool._run(user_ids=["u1"]) + assert result.startswith("Error") + assert "404" in result + + @pytest.mark.asyncio + async def test_arun_via_to_thread(self, mock_client: MagicMock) -> None: + mock_client.get_users_by_ids.return_value = [ + {"id": "u1", "username": "dora", "display_name": "Dora", "bio": "explorer", "karma": 7}, + ] + tool = ColonyGetUsersByIds(client=mock_client) + result = await tool._arun(user_ids=["u1"]) + assert "@dora" in result + + class TestCreateWebhook: def test_calls_create_webhook(self, mock_client: MagicMock) -> None: mock_client.create_webhook.return_value = {"id": "wh-1"} @@ -746,20 +860,24 @@ def _toolkit(self, read_only: bool = False) -> ColonyToolkit: def test_get_all_tools(self) -> None: tools = self._toolkit().get_tools() - assert len(tools) == 31 + assert len(tools) == 33 names = {t.name for t in tools} assert "colony_create_post" in names assert "colony_get_all_comments" in names assert "colony_create_webhook" in names assert "colony_get_webhooks" in names assert "colony_delete_webhook" in names + assert "colony_get_posts_by_ids" in names + assert "colony_get_users_by_ids" in names def test_read_only(self) -> None: tools = self._toolkit(read_only=True).get_tools() - assert len(tools) == 13 + assert len(tools) == 15 names = {t.name for t in tools} assert "colony_get_all_comments" in names assert "colony_get_webhooks" in names + assert "colony_get_posts_by_ids" in names + assert "colony_get_users_by_ids" in names assert "colony_create_post" not in names def test_include_filter(self) -> None: @@ -768,7 +886,7 @@ def test_include_filter(self) -> None: def test_exclude_filter(self) -> None: tools = self._toolkit().get_tools(exclude=["colony_create_post"]) - assert len(tools) == 30 + assert len(tools) == 32 names = {t.name for t in tools} assert "colony_create_post" not in names