diff --git a/CHAGELOG.md b/CHAGELOG.md index 83e7c8ceb..33a5e493d 100644 --- a/CHAGELOG.md +++ b/CHAGELOG.md @@ -15,6 +15,12 @@ Keep proposed and unmerged changes out of released sections. ## [Unreleased] +- Proposed [PR #1050](https://github.com/BitterSecurity/Decepticon/pull/1050): + page `assistants.search` in the MCP `decepticon_list_graphs` tool so every + exposed graph is returned. The LangGraph SDK defaults to `limit=10`, which + silently hid agents (`recon`, `soundwave`, the `decepticon` orchestrator, …) + on installs shipping more than ten graphs — the `standard` bundle ships 22. + Source only, unreleased. - Proposed [PR #1054](https://github.com/BitterSecurity/Decepticon/pull/1054): surface budget usage in the REPL footer from BudgetMiddleware's `budget_warning` custom event. The footer line is extracted into a diff --git a/packages/decepticon/decepticon/mcp_server/engagements.py b/packages/decepticon/decepticon/mcp_server/engagements.py index 3acf620dd..bfe41ee44 100644 --- a/packages/decepticon/decepticon/mcp_server/engagements.py +++ b/packages/decepticon/decepticon/mcp_server/engagements.py @@ -98,9 +98,24 @@ def _ensure_client(self) -> Any: return self._client async def list_graphs(self) -> list[GraphInfo]: - """Return the engagement graphs (assistants) the connected server exposes.""" + """Return the engagement graphs (assistants) the connected server exposes. + + Pages through ``assistants.search`` to the end. The LangGraph SDK + defaults to ``limit=10``, so a single call silently truncated installs + that expose more than ten graphs (the OSS ``standard`` bundle alone + ships 22), hiding agents such as ``recon``, ``soundwave`` and the + ``decepticon`` orchestrator from callers. + """ client = self._ensure_client() - assistants = await client.assistants.search() + page_size = 100 + assistants: list[Any] = [] + offset = 0 + while True: + page = await client.assistants.search(limit=page_size, offset=offset) + assistants.extend(page) + if len(page) < page_size: + break + offset += page_size return [ GraphInfo( assistant_id=str(entry.get("assistant_id", "")), diff --git a/packages/decepticon/tests/unit/mcp_server/test_engagements.py b/packages/decepticon/tests/unit/mcp_server/test_engagements.py index d197e4551..889141b19 100644 --- a/packages/decepticon/tests/unit/mcp_server/test_engagements.py +++ b/packages/decepticon/tests/unit/mcp_server/test_engagements.py @@ -63,8 +63,17 @@ async def join_stream( class _FakeAssistants: - async def search(self) -> list[dict[str, Any]]: - return [{"assistant_id": "a-1", "graph_id": "decepticon", "name": "decepticon"}] + def __init__(self, assistants: list[dict[str, Any]] | None = None) -> None: + self._assistants = ( + assistants + if assistants is not None + else [{"assistant_id": "a-1", "graph_id": "decepticon", "name": "decepticon"}] + ) + self.search_calls: list[tuple[int, int]] = [] + + async def search(self, *, limit: int = 10, offset: int = 0) -> list[dict[str, Any]]: + self.search_calls.append((limit, offset)) + return self._assistants[offset : offset + limit] class _FakeClient: @@ -74,10 +83,11 @@ def __init__( runs: list[dict[str, Any]] | None = None, threads: list[dict[str, Any]] | None = None, stream: list[_Part] | None = None, + assistants: list[dict[str, Any]] | None = None, ) -> None: self.threads = _FakeThreads(threads) self.runs = _FakeRuns(runs, stream) - self.assistants = _FakeAssistants() + self.assistants = _FakeAssistants(assistants) def _config() -> ServerConfig: @@ -267,3 +277,16 @@ async def test_list_graphs_maps_assistants() -> None: graphs = await client.list_graphs() assert graphs[0].graph_id == "decepticon" assert graphs[0].assistant_id == "a-1" + + +async def test_list_graphs_pages_past_sdk_default_limit() -> None: + # More graphs than the LangGraph SDK's default page size — the client must + # page to the end instead of silently returning only the first page. + many = [{"assistant_id": f"a-{n}", "graph_id": f"g{n}", "name": f"g{n}"} for n in range(150)] + fake = _FakeClient(assistants=many) + client = EngagementClient(_config(), client=fake) + graphs = await client.list_graphs() + assert len(graphs) == 150 + assert graphs[-1].graph_id == "g149" + # Explicit limit/offset paging, not a single bare search() call. + assert fake.assistants.search_calls == [(100, 0), (100, 100)]