From e6129c7bed814c2949a2955a06617d360b28e47d Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Sun, 12 Apr 2026 17:12:56 +0100 Subject: [PATCH 1/4] Add batch read tools and bump colony-sdk to 1.7.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two batch read tools that landed in colony-sdk 1.7.0 (`get_posts_by_ids` / `get_users_by_ids`) so crews can fan out known-ID lookups in one call instead of N sequential `get_post`s. - ColonyGetPostsByIds → colony_get_posts_by_ids - ColonyGetUsersByIds → colony_get_users_by_ids Both wired into READ_TOOLS so they ship automatically with ColonyToolkit / AsyncColonyToolkit. Tool count is now 33 (15 read + 18 write), up from 31. Bumps the colony-sdk floor to >=1.7.1 (skipping the brief 1.7.0 `dict | Model` return-type union that broke downstream mypy runs). Adds a `[dev]` optional-deps extra so `pip install -e ".[dev]"` resolves the full dev/test toolchain in one command, matching the pattern used by langchain-colony and smolagents-colony. 214 tests (was 204), 100% line coverage held. mypy clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 23 +++++++ pyproject.toml | 13 +++- src/crewai_colony/__init__.py | 4 ++ src/crewai_colony/tools.py | 84 +++++++++++++++++++++++ tests/test_async_native.py | 14 ++-- tests/test_tools.py | 124 +++++++++++++++++++++++++++++++++- 6 files changed, 252 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26133fa..80cc5b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # 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 update to actually use this extra is in flight as a follow-up — needs `workflow` scope on the bot token.) + +### 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 From ffc1ff8d8aef6041ab382a14336ff2e05001255b Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Sun, 12 Apr 2026 17:22:07 +0100 Subject: [PATCH 2/4] Standardise CI to use [dev] extra and named jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the CI workflow in line with langchain-colony and smolagents-colony: - Named jobs for clearer GitHub UI (lint / typecheck / Test (Python X.Y)). - Explicit `permissions: contents: read` at the top level. - Install steps switched from listing dependencies inline (`pip install mypy colony-sdk crewai`, `pip install colony-sdk crewai pytest pytest-asyncio pytest-cov`) to `pip install -e ".[dev]"`, which now resolves the full toolchain in one step thanks to the [dev] extra added in the previous commit. No behaviour change — the same jobs run with the same Python matrix on the same triggers. Pure tidy-up. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b6df3f..d7508ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,29 +6,40 @@ 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: + name: Test (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: matrix: @@ -38,7 +49,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 From 32ea83257ff5af0d6f53f92ebf142bb0122f428f Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Sun, 12 Apr 2026 17:24:26 +0100 Subject: [PATCH 3/4] Restore CI tidy-up note in CHANGELOG The CI workflow standardisation landed in the previous commit after all, so put the changelog entry back. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cc5b0..70a166d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ Both tools are part of the read-only bundle (`READ_TOOLS`) and ship with `Colony ### 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 update to actually use this extra is in flight as a follow-up — needs `workflow` scope on the bot token.) +- **`[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 From ae1f281e11bddb726d12fc3e520aaaecbc2a2788 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Sun, 12 Apr 2026 17:46:36 +0100 Subject: [PATCH 4/4] Drop test job name so status context matches branch protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch protection on main requires status contexts named `test (3.10)` / `test (3.11)` / `test (3.12)` / `test (3.13)` — the lowercase, auto-generated form GitHub produces from a matrix when no explicit `name:` is set. The previous commit added `name: Test (Python ${{ matrix.python-version }})` which produced contexts like `Test (Python 3.10)` instead, and those weren't matching the required-status-checks list. PR #19 was sitting green-but-blocked because the required contexts were never reported. Fix: drop the `name:` line and let the matrix generate the expected contexts. Added a comment so the next person who tries to "tidy up" the job name doesn't repeat the same mistake. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7508ab..dc726ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,10 @@ jobs: run: mypy src/ test: - name: Test (Python ${{ matrix.python-version }}) + # 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: