From f141349dac92c5718ba73b4ac34faecf6205dfaa Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 20:46:39 +0000 Subject: [PATCH 01/10] feat: add ARDHF toolset wrapping HuggingFace Agent Finder (ARD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AgentFinderToolset (BaseToolset) that wraps the HuggingFace Agent Finder as an ADK toolset, enabling agents to discover agents, skills, MCP servers, and other agentic resources at runtime. Tools provided: - search_agents: search ARD registries by natural-language query - get_agent_card: fetch a specific artifact by URL Supports both remote (HTTP to any ARD registry) and local (in-process via hf-agentfinder package) modes. Includes: - src/google/adk_community/tools/ardhf/ — toolset implementation - contributing/samples/ardhf/ — sample agent with README - tests/unittests/tools/ardhf/ — 26 tests (unit + integration against hf-agentfinder challenge server) - pyproject.toml — ardhf optional dependency Prepared for rename to ARD (Agentic Resource Discovery). Reference: https://github.com/huggingface/hf-agentfinder --- contributing/samples/ardhf/README.md | 119 ++++++ contributing/samples/ardhf/agent.py | 148 +++++++ pyproject.toml | 1 + .../adk_community/tools/ardhf/__init__.py | 42 ++ .../tools/ardhf/ardhf_toolset.py | 304 ++++++++++++++ tests/unittests/tools/ardhf/__init__.py | 13 + .../tools/ardhf/test_ardhf_toolset.py | 381 ++++++++++++++++++ 7 files changed, 1008 insertions(+) create mode 100644 contributing/samples/ardhf/README.md create mode 100644 contributing/samples/ardhf/agent.py create mode 100644 src/google/adk_community/tools/ardhf/__init__.py create mode 100644 src/google/adk_community/tools/ardhf/ardhf_toolset.py create mode 100644 tests/unittests/tools/ardhf/__init__.py create mode 100644 tests/unittests/tools/ardhf/test_ardhf_toolset.py diff --git a/contributing/samples/ardhf/README.md b/contributing/samples/ardhf/README.md new file mode 100644 index 0000000..4a7cca8 --- /dev/null +++ b/contributing/samples/ardhf/README.md @@ -0,0 +1,119 @@ +# ARDHF — Agent Finder (ARD) Toolset for ADK + +## Overview + +ARDHF wraps [HuggingFace's Agent Finder](https://github.com/huggingface/hf-agentfinder) +(ARD — Agentic Resource Discovery) as an ADK `BaseToolset`. It gives any ADK +agent the ability to **search for and discover** agents, skills, MCP servers, +and other agentic resources at runtime. + +The toolset provides two tools: + +| Tool | Description | +|---|---| +| `search_agents` | Search ARD registries by natural-language query | +| `get_agent_card` | Fetch a specific artifact (agent card, skill, MCP descriptor) by URL | + +## Sample Inputs + +- `Find MCP servers for image processing` + +- `Search for code review agents` + + *Returns skill and agent entries related to code review.* + +- `What tools are available for background removal?` + +- `Get the agent card at https://huggingface.co/api/agentfinder/skills/huggingface/rembg/SKILL.md` + + *Fetches the full skill markdown for the rembg Space.* + +## How To + +### Install + +```bash +pip install google-adk-community +# Optional, for local (in-process) mode: +pip install hf-agentfinder +``` + +### Basic usage + +```python +from google.adk import Agent +from google.adk_community.tools.ardhf import AgentFinderToolset + +agent = Agent( + name="my_agent", + instruction="Search for tools when you need a capability.", + tools=[AgentFinderToolset()], +) +``` + +### Remote vs local mode + +By default, the toolset sends HTTP requests to the hosted HuggingFace Agent +Finder registry. For in-process search (no network calls), install +`hf-agentfinder` and set `local=True`: + +```python +toolset = AgentFinderToolset(local=True) +``` + +Or set environment variables: + +```bash +export ARDHF_LOCAL=1 +export HF_TOKEN=hf_... # optional, for authenticated access +``` + +### Custom registry URL + +Point to any ARD-compatible registry: + +```python +toolset = AgentFinderToolset( + registry_url="http://localhost:8090", # e.g. challenge server +) +``` + +### Running the sample + +```bash +# With adk web +adk web contributing/samples/ardhf + +# Or directly +python -m contributing.samples.ardhf.agent +``` + +### Using the challenge server for deterministic testing + +The `hf-agentfinder` package includes a deterministic challenge server: + +```bash +# Terminal 1: start the challenge server +pip install hf-agentfinder +hf-agentfinder challenge serve --port 8090 + +# Terminal 2: run the agent against it +ARDHF_REGISTRY_URL=http://127.0.0.1:8090 \ + python -m contributing.samples.ardhf.agent +``` + +## Architecture + +``` +AgentFinderToolset (BaseToolset) +├── search_agents(query, artifact_type?, limit?) +│ ├── remote: HTTP POST to registry /search +│ └── local: agentfinder.server.search_agent_finder() +└── get_agent_card(url) + └── HTTP GET to artifact URL +``` + +## Related + +- [HuggingFace Agent Finder](https://github.com/huggingface/hf-agentfinder) — ARD reference implementation +- [ADK BaseToolset](https://google.github.io/adk-docs/) — ADK toolset documentation diff --git a/contributing/samples/ardhf/agent.py b/contributing/samples/ardhf/agent.py new file mode 100644 index 0000000..ee415e8 --- /dev/null +++ b/contributing/samples/ardhf/agent.py @@ -0,0 +1,148 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sample agent using the ARDHF toolset. + +Demonstrates searching HuggingFace's Agent Finder (ARD) registries for +agents, skills, MCP servers, and other agentic resources. + +Usage:: + + adk web contributing/samples/ardhf + +Or with the challenge server for deterministic results:: + + hf-agentfinder challenge serve --port 8090 & + ARDHF_REGISTRY_URL=http://127.0.0.1:8090 \ + adk web contributing/samples/ardhf +""" + +from __future__ import annotations + +import asyncio +import os + +from google.adk import Agent +from google.adk.runners import InMemoryRunner +from google.adk.tools.tool_context import ToolContext +from google.genai import types + +from google.adk_community.tools.ardhf import AgentFinderToolset + +# -- Configuration --------------------------------------------------------- + +_registry_url = os.environ.get( + "ARDHF_REGISTRY_URL", + "https://huggingface.co/api/agentfinder", +) +_local = os.environ.get("ARDHF_LOCAL", "").lower() in ( + "1", + "true", + "yes", +) +_token = os.environ.get("HF_TOKEN") + + +# -- Helper tool ----------------------------------------------------------- + + +async def summarise_findings( + tool_context: ToolContext, + findings: str, +) -> str: + """Format discovery findings into a structured report. + + Args: + findings: JSON string of search results and inspected cards to + summarise. + + Returns: + A formatted markdown report of the findings. + """ + return ( + "## Discovery Report\n\n" + f"{findings}\n\n" + "_Report generated by ARDHF discovery agent._" + ) + + +# -- Agent ----------------------------------------------------------------- + +agent_finder_toolset = AgentFinderToolset( + registry_url=_registry_url, + token=_token, + local=_local, +) + +root_agent = Agent( + name="ardhf_discovery_agent", + description=( + "An agent that searches for agentic resources using the " + "HuggingFace Agent Finder (ARD) registry." + ), + instruction=( + "You are a discovery agent. Your job is to help users find " + "agents, skills, MCP servers, and other agentic resources by " + "searching the Agent Finder (ARD) registry.\n\n" + "When a user asks for a capability, use the search_agents tool " + "to find relevant resources. You can filter by artifact type:\n" + "- 'skill' for AI skills\n" + "- 'mcp' for MCP servers\n" + "- 'space' for HuggingFace Spaces\n" + "- 'a2a' for A2A agent cards\n\n" + "After finding results, summarise what you found, including the " + "name, description, type, and URL of each result.\n\n" + "If a user wants more details about a specific result, use the " + "get_agent_card tool with the result's URL to fetch its full " + "descriptor or skill markdown.\n\n" + "Use summarise_findings to create a structured report." + ), + tools=[agent_finder_toolset, summarise_findings], +) + + +# -- Main ------------------------------------------------------------------ + + +async def main() -> None: + """Run the discovery agent with a sample query.""" + runner = InMemoryRunner( + agent=root_agent, + app_name="ardhf_demo", + ) + session = await runner.session_service.create_session( + user_id="demo_user", + app_name="ardhf_demo", + ) + + prompt = "Find MCP servers for image processing" + print(f"User: {prompt}") + + async for event in runner.run_async( + user_id="demo_user", + session_id=session.id, + new_message=types.Content( + role="user", + parts=[types.Part.from_text(text=prompt)], + ), + ): + if event.content and event.content.parts: + for part in event.content.parts: + text = getattr(part, "text", None) + if text: + print(f"{event.author}: {text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 3bf1112..faadfc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ sdc-agents = [ "sdc-agents>=4.3.3; python_version >= '3.11'", ] spraay = ["web3>=6.0.0"] +ardhf = ["hf-agentfinder>=1.0.0"] [tool.pyink] diff --git a/src/google/adk_community/tools/ardhf/__init__.py b/src/google/adk_community/tools/ardhf/__init__.py new file mode 100644 index 0000000..30f6c63 --- /dev/null +++ b/src/google/adk_community/tools/ardhf/__init__.py @@ -0,0 +1,42 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ARDHF — Agent Finder (ARD) toolset for ADK. + +Wraps the HuggingFace Agent Finder +(https://github.com/huggingface/hf-agentfinder) as an ADK BaseToolset, +giving agents the ability to discover agents, skills, MCP servers, and +other agentic resources at runtime. + +Usage:: + + from google.adk_community.tools.ardhf import AgentFinderToolset + from google.adk import Agent + + agent = Agent( + name="discovery_agent", + instruction="Search for tools when you need a capability.", + tools=[AgentFinderToolset()], + ) + +Prepared for rename to ARD (Agentic Resource Discovery). +""" + +from google.adk_community.tools.ardhf.ardhf_toolset import ( + AgentFinderToolset, +) + +__all__ = [ + "AgentFinderToolset", +] diff --git a/src/google/adk_community/tools/ardhf/ardhf_toolset.py b/src/google/adk_community/tools/ardhf/ardhf_toolset.py new file mode 100644 index 0000000..f667956 --- /dev/null +++ b/src/google/adk_community/tools/ardhf/ardhf_toolset.py @@ -0,0 +1,304 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ARDHF toolset — wraps HuggingFace Agent Finder (ARD) as BaseToolset. + +Supports two modes: + +* **remote** (default) — HTTP POST to any ARD-compatible registry + endpoint (e.g. the hosted ``hf-agentfinder``). +* **local** — uses the ``agentfinder`` Python package in-process for + zero-latency, offline-capable search. + +The toolset exposes two tools to the agent: + +* ``search_agents`` — search ARD registries for agents, skills, MCP + servers, and other agentic resources. +* ``get_agent_card`` — fetch a specific artifact (agent card, skill + markdown, MCP server descriptor) by URL. + +Prepared for rename to ARD (Agentic Resource Discovery). +Reference: https://github.com/huggingface/hf-agentfinder +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, List, Optional, Union +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import Request as UrlRequest +from urllib.request import urlopen + +from google.adk.agents.readonly_context import ReadonlyContext +from google.adk.tools.base_tool import BaseTool +from google.adk.tools.base_toolset import BaseToolset, ToolPredicate +from google.adk.tools.function_tool import FunctionTool +from google.adk.tools.tool_context import ToolContext + +logger = logging.getLogger(__name__) + +# Default hosted HuggingFace Agent Finder registry. +_DEFAULT_REGISTRY_URL = "https://huggingface.co/api/agentfinder" + +# HTTP timeout for remote requests (seconds). +_HTTP_TIMEOUT = 30 + + +def _registry_search_url(registry_url: str) -> str: + """Normalise a registry base URL to its ``/search`` endpoint.""" + normalised = registry_url.rstrip("/") + if normalised.endswith("/search"): + return normalised + return urljoin(f"{normalised}/", "search") + + +def _artifact_type_for_kind(kind: str) -> Optional[str]: + """Map a human-friendly kind label to its ARD media type.""" + types = { + "skill": "application/ai-skill", + "mcp": "application/mcp-server+json", + "space": "application/vnd.huggingface.space+json", + "a2a": "application/a2a-agent-card+json", + } + return types.get(kind) + + +def _remote_search( + registry_url: str, + query: str, + *, + artifact_type: Optional[str] = None, + limit: int = 10, + token: Optional[str] = None, +) -> dict[str, Any]: + """POST a SearchRequest to a remote ARD registry and return raw JSON.""" + search_query: dict[str, Any] = {"text": query} + if artifact_type is not None: + search_query["filter"] = {"type": [artifact_type]} + + request_body = { + "query": search_query, + "pageSize": limit, + } + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "adk-ardhf/0.1", + } + if token is not None: + headers["Authorization"] = f"Bearer {token}" + + url = _registry_search_url(registry_url) + req = UrlRequest( + url, + data=json.dumps(request_body).encode("utf-8"), + headers=headers, + method="POST", + ) + with urlopen(req, timeout=_HTTP_TIMEOUT) as response: # noqa: S310 + return json.loads(response.read().decode("utf-8")) + + +def _remote_fetch( + url: str, *, token: Optional[str] = None +) -> str: + """GET an artifact URL and return its text content.""" + headers = {"User-Agent": "adk-ardhf/0.1"} + if token is not None: + headers["Authorization"] = f"Bearer {token}" + + req = UrlRequest(url, headers=headers) + with urlopen(req, timeout=_HTTP_TIMEOUT) as response: # noqa: S310 + return response.read().decode("utf-8") + + +def _local_search( + query: str, + *, + artifact_type: Optional[str] = None, + limit: int = 10, + token: Optional[str] = None, +) -> dict[str, Any]: + """Search using the in-process ``agentfinder`` package.""" + try: + from agentfinder.models import SearchQuery, SearchRequest + from agentfinder.server import search_agent_finder + except ImportError as exc: + raise ImportError( + "Local mode requires the 'hf-agentfinder' package. " + "Install it with: pip install hf-agentfinder" + ) from exc + + search_filter: dict[str, Any] = {} + if artifact_type is not None: + search_filter["type"] = [artifact_type] + + request = SearchRequest( + query=SearchQuery(text=query, filter=search_filter), + pageSize=limit, + ) + response = search_agent_finder(request, token=token) + return json.loads( + response.model_dump_json( + exclude_none=True, exclude_defaults=True + ) + ) + + +class AgentFinderToolset(BaseToolset): + """ADK BaseToolset wrapping HuggingFace Agent Finder (ARD). + + Provides ``search_agents`` and ``get_agent_card`` tools to any ADK + agent. + + Args: + registry_url: ARD registry URL for remote mode. Ignored when + ``local=True``. + token: Optional Bearer token for authenticated registry access. + local: When ``True``, use the ``agentfinder`` Python package + in-process instead of making HTTP requests. + tool_filter: Optional filter to select which tools are exposed. + tool_name_prefix: Optional prefix for tool names. + """ + + def __init__( + self, + *, + registry_url: str = _DEFAULT_REGISTRY_URL, + token: Optional[str] = None, + local: bool = False, + tool_filter: Optional[Union[ToolPredicate, List[str]]] = None, + tool_name_prefix: Optional[str] = None, + ) -> None: + super().__init__( + tool_filter=tool_filter, + tool_name_prefix=tool_name_prefix, + ) + self._registry_url = registry_url + self._token = token + self._local = local + + # -- Tool implementations ----------------------------------------------- + + async def _search_agents( + self, + tool_context: ToolContext, + query: str, + artifact_type: Optional[str] = None, + limit: int = 10, + ) -> dict[str, Any]: + """Search ARD registries for agents, skills, and MCP servers. + + Args: + query: Natural-language search query describing what you need, + e.g. "remove image background" or "code review agent". + artifact_type: Optional filter by artifact kind. Supported + values: ``skill``, ``mcp``, ``space``, ``a2a``, or a raw + media type like ``application/mcp-server+json``. When + omitted, all artifact types are returned. + limit: Maximum number of results to return (1-100, default 10). + + Returns: + A dictionary with ``results`` (list of matching entries) and + optionally ``referrals`` (list of additional registries). + """ + # Resolve human-friendly kind to media type. + resolved_type = ( + _artifact_type_for_kind(artifact_type) if artifact_type else None + ) + if resolved_type is None and artifact_type is not None: + # Assume it is already a raw media type string. + resolved_type = artifact_type + + try: + if self._local: + return _local_search( + query, + artifact_type=resolved_type, + limit=limit, + token=self._token, + ) + return _remote_search( + self._registry_url, + query, + artifact_type=resolved_type, + limit=limit, + token=self._token, + ) + except (HTTPError, URLError, TimeoutError) as exc: + logger.warning("ARD search failed: %s", exc) + return {"error": f"Search request failed: {exc}"} + except ImportError as exc: + return {"error": str(exc)} + + async def _get_agent_card( + self, + tool_context: ToolContext, + url: str, + ) -> dict[str, Any]: + """Fetch a specific agent card or artifact by URL. + + Args: + url: The full URL of the artifact to fetch. This is typically + the ``url`` field from a search result entry. + + Returns: + A dictionary with the artifact content. For markdown artifacts + (skills), the content is returned under a ``content`` key. + For JSON artifacts, the parsed object is returned directly. + """ + try: + raw = _remote_fetch(url, token=self._token) + except (HTTPError, URLError, TimeoutError) as exc: + logger.warning("ARD fetch failed for %s: %s", url, exc) + return {"error": f"Failed to fetch {url}: {exc}"} + + # Try to parse as JSON; fall back to returning raw text. + try: + return json.loads(raw) + except json.JSONDecodeError: + return { + "content": raw, + "url": url, + "content_type": "text/markdown", + } + + # -- BaseToolset interface ---------------------------------------------- + + async def get_tools( + self, + readonly_context: Optional[ReadonlyContext] = None, + ) -> list[BaseTool]: + """Return the search_agents and get_agent_card tools.""" + tools: list[BaseTool] = [ + FunctionTool(self._search_agents), + FunctionTool(self._get_agent_card), + ] + + # Rename the tools to use cleaner names (strip leading underscore). + for tool in tools: + if tool.name.startswith("_"): + tool.name = tool.name[1:] + + if readonly_context is not None: + tools = [ + tool + for tool in tools + if self._is_tool_selected(tool, readonly_context) + ] + + return tools diff --git a/tests/unittests/tools/ardhf/__init__.py b/tests/unittests/tools/ardhf/__init__.py new file mode 100644 index 0000000..58d482e --- /dev/null +++ b/tests/unittests/tools/ardhf/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unittests/tools/ardhf/test_ardhf_toolset.py b/tests/unittests/tools/ardhf/test_ardhf_toolset.py new file mode 100644 index 0000000..2836904 --- /dev/null +++ b/tests/unittests/tools/ardhf/test_ardhf_toolset.py @@ -0,0 +1,381 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the ARDHF toolset. + +Unit tests run without any external services. Integration tests +require the hf-agentfinder challenge server (deterministic fixtures, +no API keys needed):: + + pip install hf-agentfinder + hf-agentfinder challenge serve --port 8090 + pytest tests/unittests/tools/ardhf/ -v +""" + +from __future__ import annotations + +import json +import os +from unittest.mock import AsyncMock + +import pytest + +from google.adk_community.tools.ardhf.ardhf_toolset import ( + AgentFinderToolset, + _artifact_type_for_kind, + _registry_search_url, + _remote_fetch, + _remote_search, +) + +# ------------------------------------------------------------------ # +# Configuration # +# ------------------------------------------------------------------ # + +CHALLENGE_URL = os.environ.get( + "ARDHF_TEST_REGISTRY_URL", "http://127.0.0.1:8090" +) + + +# ------------------------------------------------------------------ # +# Unit tests (no server needed) # +# ------------------------------------------------------------------ # + + +class TestRegistrySearchUrl: + """Tests for URL normalisation helper.""" + + def test_appends_search_to_base_url(self): + """A bare base URL gets /search appended.""" + assert ( + _registry_search_url("http://localhost:8090") + == "http://localhost:8090/search" + ) + + def test_preserves_existing_search_path(self): + """A URL already ending in /search is returned unchanged.""" + url = "http://localhost:8090/registries/tools/search" + assert _registry_search_url(url) == url + + def test_strips_trailing_slash(self): + """Trailing slashes are normalised before appending.""" + assert ( + _registry_search_url("http://localhost:8090/") + == "http://localhost:8090/search" + ) + + +class TestArtifactTypeForKind: + """Tests for kind-to-media-type mapping.""" + + def test_skill_kind(self): + assert _artifact_type_for_kind("skill") == "application/ai-skill" + + def test_mcp_kind(self): + assert ( + _artifact_type_for_kind("mcp") + == "application/mcp-server+json" + ) + + def test_space_kind(self): + assert ( + _artifact_type_for_kind("space") + == "application/vnd.huggingface.space+json" + ) + + def test_a2a_kind(self): + assert ( + _artifact_type_for_kind("a2a") + == "application/a2a-agent-card+json" + ) + + def test_unknown_kind_returns_none(self): + assert _artifact_type_for_kind("unknown") is None + + +class TestToolsetGetTools: + """Tests for AgentFinderToolset.get_tools without a running server.""" + + @pytest.mark.asyncio + async def test_returns_two_tools(self): + """The toolset exposes search_agents and get_agent_card.""" + toolset = AgentFinderToolset() + + tools = await toolset.get_tools() + + assert len(tools) == 2 + names = {tool.name for tool in tools} + assert names == {"search_agents", "get_agent_card"} + + @pytest.mark.asyncio + async def test_tools_have_descriptions(self): + """Each tool has a non-empty description.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + + for tool in tools: + assert tool.description, f"Tool {tool.name} has no description" + + @pytest.mark.asyncio + async def test_tool_name_prefix(self): + """tool_name_prefix is applied to all tool names.""" + toolset = AgentFinderToolset(tool_name_prefix="ard") + + tools = await toolset.get_tools_with_prefix() + + names = {tool.name for tool in tools} + assert "ard_search_agents" in names + assert "ard_get_agent_card" in names + + @pytest.mark.asyncio + async def test_search_handles_connection_error(self): + """search_agents returns an error dict for unreachable servers.""" + toolset = AgentFinderToolset( + registry_url="http://127.0.0.1:19999" + ) + tools = await toolset.get_tools() + search_tool = next(t for t in tools if t.name == "search_agents") + + mock_context = AsyncMock() + result = await search_tool.run_async( + args={"query": "test", "limit": 5}, + tool_context=mock_context, + ) + + assert "error" in result + + +# ------------------------------------------------------------------ # +# Integration tests (require challenge server) # +# ------------------------------------------------------------------ # + + +def _challenge_server_available() -> bool: + """Check if the challenge server is reachable.""" + try: + from urllib.request import urlopen + + with urlopen( # noqa: S310 + f"{CHALLENGE_URL}/health", timeout=2 + ) as resp: + data = json.loads(resp.read()) + return data.get("status") == "ok" + except Exception: + return False + + +challenge_server = pytest.mark.skipif( + not _challenge_server_available(), + reason=f"Challenge server not available at {CHALLENGE_URL}", +) + + +@challenge_server +class TestRemoteSearchAgainstChallenge: + """Integration tests against the challenge server fixtures.""" + + def test_search_returns_results(self): + """A search query returns a non-empty results list.""" + response = _remote_search( + CHALLENGE_URL, "find tools", limit=5 + ) + + assert "results" in response + assert len(response["results"]) > 0 + + def test_search_results_have_required_fields(self): + """Each result contains ARD-required fields.""" + response = _remote_search( + CHALLENGE_URL, "find tools", limit=5 + ) + + for result in response["results"]: + assert "identifier" in result + assert "displayName" in result + assert "type" in result + assert "score" in result + + def test_search_with_mcp_filter(self): + """Filtering by MCP type returns only MCP server results.""" + response = _remote_search( + CHALLENGE_URL, + "find tools", + artifact_type="application/mcp-server+json", + limit=10, + ) + + for result in response["results"]: + assert result["type"] == "application/mcp-server+json" + + def test_search_with_skill_filter(self): + """Filtering by skill type returns only skill results.""" + response = _remote_search( + CHALLENGE_URL, + "find tools", + artifact_type="application/ai-skill", + limit=10, + ) + + for result in response["results"]: + assert result["type"] == "application/ai-skill" + + def test_search_returns_referrals(self): + """The challenge server returns referrals to sub-registries.""" + response = _remote_search( + CHALLENGE_URL, "find tools", limit=5 + ) + + assert "referrals" in response + assert len(response["referrals"]) > 0 + + def test_search_respects_limit(self): + """The pageSize parameter limits the number of results.""" + response = _remote_search( + CHALLENGE_URL, "find tools", limit=2 + ) + + assert len(response["results"]) <= 2 + + def test_sub_registry_search(self): + """Searching a sub-registry returns its specific results.""" + response = _remote_search( + f"{CHALLENGE_URL}/registries/tools", + "find tools", + limit=10, + ) + + assert "results" in response + assert len(response["results"]) > 0 + + def test_empty_registry_returns_no_results(self): + """The empty sub-registry returns an empty results list.""" + response = _remote_search( + f"{CHALLENGE_URL}/registries/empty", + "anything", + limit=10, + ) + + assert response["results"] == [] + + +@challenge_server +class TestRemoteFetchAgainstChallenge: + """Integration tests for fetching artifacts from challenge server.""" + + def test_fetch_skill_artifact(self): + """Fetching a skill URL returns markdown content.""" + content = _remote_fetch( + f"{CHALLENGE_URL}/artifacts/skills/triage-skill/SKILL.md" + ) + + assert "triage-skill" in content + assert "Challenge fixture skill" in content + + def test_fetch_mcp_artifact(self): + """Fetching an MCP artifact URL returns a JSON descriptor.""" + content = _remote_fetch( + f"{CHALLENGE_URL}/artifacts/mcp/echo-tools" + ) + + data = json.loads(content) + assert data["name"] == "echo-tools" + assert "tools" in data + + +@challenge_server +class TestToolsetAgainstChallenge: + """Integration tests for AgentFinderToolset against challenge.""" + + @pytest.mark.asyncio + async def test_search_agents_tool(self): + """search_agents returns results from the challenge server.""" + toolset = AgentFinderToolset(registry_url=CHALLENGE_URL) + tools = await toolset.get_tools() + search_tool = next( + t for t in tools if t.name == "search_agents" + ) + + mock_context = AsyncMock() + result = await search_tool.run_async( + args={"query": "find tools", "limit": 5}, + tool_context=mock_context, + ) + + assert "results" in result + assert len(result["results"]) > 0 + + @pytest.mark.asyncio + async def test_get_agent_card_json(self): + """get_agent_card returns parsed JSON for MCP artifacts.""" + toolset = AgentFinderToolset(registry_url=CHALLENGE_URL) + tools = await toolset.get_tools() + fetch_tool = next( + t for t in tools if t.name == "get_agent_card" + ) + + mock_context = AsyncMock() + result = await fetch_tool.run_async( + args={ + "url": f"{CHALLENGE_URL}/artifacts/mcp/echo-tools" + }, + tool_context=mock_context, + ) + + assert result["name"] == "echo-tools" + + @pytest.mark.asyncio + async def test_get_agent_card_markdown(self): + """get_agent_card returns markdown content for skill artifacts.""" + toolset = AgentFinderToolset(registry_url=CHALLENGE_URL) + tools = await toolset.get_tools() + fetch_tool = next( + t for t in tools if t.name == "get_agent_card" + ) + + mock_context = AsyncMock() + result = await fetch_tool.run_async( + args={ + "url": ( + f"{CHALLENGE_URL}/artifacts/skills/" + "triage-skill/SKILL.md" + ) + }, + tool_context=mock_context, + ) + + assert "content" in result + assert "triage-skill" in result["content"] + + @pytest.mark.asyncio + async def test_search_with_kind_resolution(self): + """search_agents resolves human-friendly kind names.""" + toolset = AgentFinderToolset(registry_url=CHALLENGE_URL) + tools = await toolset.get_tools() + search_tool = next( + t for t in tools if t.name == "search_agents" + ) + + mock_context = AsyncMock() + result = await search_tool.run_async( + args={ + "query": "find tools", + "artifact_type": "mcp", + "limit": 10, + }, + tool_context=mock_context, + ) + + assert "results" in result + for entry in result["results"]: + assert entry["type"] == "application/mcp-server+json" From 89cea33af7be48289fb2484bc1be2d4c57bbfbc4 Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 21:19:37 +0000 Subject: [PATCH 02/10] feat: add connect_agent tool for A2A agent interaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the connect_agent tool to AgentFinderToolset, completing the discover → inspect → connect flow. The tool resolves an A2A agent card URL, creates a client via the a2a SDK, sends a message, and returns the response text. - New tool: connect_agent(agent_card_url, message) in ardhf_toolset.py - Helper: _extract_text_from_a2a_response for parsing A2A responses - Updated ardhf sample to demonstrate full search → connect workflow - New ardhf_dynamic_agents sample showing orchestrator pattern - Unit tests for connect_agent and text extraction helper --- contributing/samples/ardhf/README.md | 20 +- contributing/samples/ardhf/agent.py | 44 ++-- .../samples/ardhf_dynamic_agents/README.md | 102 +++++++++ .../samples/ardhf_dynamic_agents/agent.py | 141 ++++++++++++ .../tools/ardhf/ardhf_toolset.py | 167 +++++++++++++- .../tools/ardhf/test_ardhf_toolset.py | 209 +++++++++++++++++- 6 files changed, 650 insertions(+), 33 deletions(-) create mode 100644 contributing/samples/ardhf_dynamic_agents/README.md create mode 100644 contributing/samples/ardhf_dynamic_agents/agent.py diff --git a/contributing/samples/ardhf/README.md b/contributing/samples/ardhf/README.md index 4a7cca8..364a513 100644 --- a/contributing/samples/ardhf/README.md +++ b/contributing/samples/ardhf/README.md @@ -4,15 +4,16 @@ ARDHF wraps [HuggingFace's Agent Finder](https://github.com/huggingface/hf-agentfinder) (ARD — Agentic Resource Discovery) as an ADK `BaseToolset`. It gives any ADK -agent the ability to **search for and discover** agents, skills, MCP servers, -and other agentic resources at runtime. +agent the ability to **search for, discover, and connect to** agents, skills, +MCP servers, and other agentic resources at runtime. -The toolset provides two tools: +The toolset provides three tools: | Tool | Description | |---|---| | `search_agents` | Search ARD registries by natural-language query | | `get_agent_card` | Fetch a specific artifact (agent card, skill, MCP descriptor) by URL | +| `connect_agent` | Send a message to a remote A2A agent and return the response | ## Sample Inputs @@ -28,12 +29,18 @@ The toolset provides two tools: *Fetches the full skill markdown for the rembg Space.* +- `Find A2A agents for code review and connect to the best one` + + *Searches for A2A agents, picks the best match, and sends a message via the A2A protocol.* + ## How To ### Install ```bash pip install google-adk-community +# For A2A agent connectivity: +pip install 'google-adk[a2a]' # Optional, for local (in-process) mode: pip install hf-agentfinder ``` @@ -109,11 +116,14 @@ AgentFinderToolset (BaseToolset) ├── search_agents(query, artifact_type?, limit?) │ ├── remote: HTTP POST to registry /search │ └── local: agentfinder.server.search_agent_finder() -└── get_agent_card(url) - └── HTTP GET to artifact URL +├── get_agent_card(url) +│ └── HTTP GET to artifact URL +└── connect_agent(agent_card_url, message) + └── A2A protocol: resolve card → create client → send message ``` ## Related - [HuggingFace Agent Finder](https://github.com/huggingface/hf-agentfinder) — ARD reference implementation - [ADK BaseToolset](https://google.github.io/adk-docs/) — ADK toolset documentation +- [A2A Protocol](https://github.com/a2aproject/a2a-spec) — Agent-to-Agent protocol specification diff --git a/contributing/samples/ardhf/agent.py b/contributing/samples/ardhf/agent.py index ee415e8..fadeed7 100644 --- a/contributing/samples/ardhf/agent.py +++ b/contributing/samples/ardhf/agent.py @@ -14,8 +14,9 @@ """Sample agent using the ARDHF toolset. -Demonstrates searching HuggingFace's Agent Finder (ARD) registries for -agents, skills, MCP servers, and other agentic resources. +Demonstrates the full discover -> inspect -> connect flow using the +HuggingFace Agent Finder (ARD) registries. The agent can search for +agents, inspect their cards, and connect to remote A2A agents. Usage:: @@ -88,25 +89,32 @@ async def summarise_findings( root_agent = Agent( name="ardhf_discovery_agent", description=( - "An agent that searches for agentic resources using the " - "HuggingFace Agent Finder (ARD) registry." + "An agent that discovers and connects to agentic resources " + "using the HuggingFace Agent Finder (ARD) registry." ), instruction=( "You are a discovery agent. Your job is to help users find " "agents, skills, MCP servers, and other agentic resources by " - "searching the Agent Finder (ARD) registry.\n\n" - "When a user asks for a capability, use the search_agents tool " - "to find relevant resources. You can filter by artifact type:\n" - "- 'skill' for AI skills\n" - "- 'mcp' for MCP servers\n" - "- 'space' for HuggingFace Spaces\n" - "- 'a2a' for A2A agent cards\n\n" - "After finding results, summarise what you found, including the " - "name, description, type, and URL of each result.\n\n" - "If a user wants more details about a specific result, use the " - "get_agent_card tool with the result's URL to fetch its full " - "descriptor or skill markdown.\n\n" - "Use summarise_findings to create a structured report." + "searching the Agent Finder (ARD) registry, and optionally " + "connect to and interact with discovered A2A agents.\n\n" + "## Tools\n\n" + "You have three discovery tools:\n" + "1. **search_agents** — search for resources by capability\n" + "2. **get_agent_card** — inspect a specific resource's card\n" + "3. **connect_agent** — send a message to a remote A2A agent\n\n" + "## Workflow\n\n" + "When a user asks for a capability:\n" + "1. Use search_agents to find relevant resources. You can " + "filter by artifact type: 'skill', 'mcp', 'space', or 'a2a'.\n" + "2. Summarise the results — name, description, type, and URL.\n" + "3. If a user wants more details about a specific result, use " + "get_agent_card with the result's URL.\n" + "4. If a user wants to interact with a discovered A2A agent, " + "use connect_agent with the agent card URL and the user's " + "message. Only use this for results whose type is " + "'application/a2a-agent-card+json'.\n\n" + "Use summarise_findings to create a structured report when " + "presenting multiple results." ), tools=[agent_finder_toolset, summarise_findings], ) @@ -126,7 +134,7 @@ async def main() -> None: app_name="ardhf_demo", ) - prompt = "Find MCP servers for image processing" + prompt = "Find A2A agents for code review and connect to the best one" print(f"User: {prompt}") async for event in runner.run_async( diff --git a/contributing/samples/ardhf_dynamic_agents/README.md b/contributing/samples/ardhf_dynamic_agents/README.md new file mode 100644 index 0000000..641e5e6 --- /dev/null +++ b/contributing/samples/ardhf_dynamic_agents/README.md @@ -0,0 +1,102 @@ +# ARDHF Dynamic Agents — Search, Connect, Use + +## Overview + +This sample demonstrates an orchestrator agent that **dynamically discovers +and delegates to remote A2A agents** at runtime using the ARDHF toolset. + +Unlike a traditional multi-agent system where sub-agents are hardcoded at +build time, this orchestrator discovers capable agents on the fly: + +1. User sends a request the orchestrator can't handle alone. +2. Orchestrator searches ARD registries for a capable A2A agent. +3. Orchestrator inspects the agent card to verify compatibility. +4. Orchestrator delegates the task via the A2A protocol. +5. Orchestrator returns the result to the user. + +This is the **discover -> connect -> use** pattern — agents finding and +collaborating with other agents without prior configuration. + +## Sample Inputs + +- `I need to remove the background from an image` + + *The orchestrator searches for image processing agents, finds one with + background removal capability, and delegates via A2A.* + +- `Review this code for security issues` + + *Searches for code review / security audit agents and delegates.* + +- `Translate this document from English to Japanese` + + *Finds translation agents and delegates the task.* + +## How To + +### Install + +```bash +pip install google-adk-community +# Required for A2A agent connectivity: +pip install 'google-adk[a2a]' +``` + +### Run + +```bash +# With adk web +adk web contributing/samples/ardhf_dynamic_agents + +# Or directly +python -m contributing.samples.ardhf_dynamic_agents.agent +``` + +### With the challenge server + +```bash +# Terminal 1 +hf-agentfinder challenge serve --port 8090 + +# Terminal 2 +ARDHF_REGISTRY_URL=http://127.0.0.1:8090 \ + adk web contributing/samples/ardhf_dynamic_agents +``` + +## Architecture + +``` +User + │ + ▼ +┌──────────────────────────────┐ +│ dynamic_orchestrator │ +│ (no built-in domain skills) │ +│ │ +│ Tools: │ +│ ├── search_agents ─────────┼──► ARD Registry +│ ├── get_agent_card ────────┼──► Agent Card URL +│ └── connect_agent ─────────┼──► Remote A2A Agent +└──────────────────────────────┘ + │ + ▼ + ┌──────────────┐ + │ Remote Agent │ + │ (via A2A) │ + └──────────────┘ +``` + +## Key Concepts + +- **Dynamic discovery** — The orchestrator doesn't know about sub-agents + at build time. It discovers them at runtime through ARD search. +- **A2A protocol** — Communication with remote agents uses the standard + Agent-to-Agent protocol, enabling interoperability across frameworks. +- **Graceful fallback** — If no suitable agent is found, or if connection + fails, the orchestrator reports this clearly to the user. + +## Related + +- [ARDHF basic sample](../ardhf/) — Simpler sample focusing on discovery only. +- [HuggingFace Agent Finder](https://github.com/huggingface/hf-agentfinder) — ARD reference implementation. +- [A2A Protocol](https://github.com/a2aproject/a2a-spec) — Agent-to-Agent protocol specification. diff --git a/contributing/samples/ardhf_dynamic_agents/agent.py b/contributing/samples/ardhf_dynamic_agents/agent.py new file mode 100644 index 0000000..51800d7 --- /dev/null +++ b/contributing/samples/ardhf_dynamic_agents/agent.py @@ -0,0 +1,141 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Orchestrator agent that dynamically discovers and delegates to A2A agents. + +This sample demonstrates the dream UX for agentic resource discovery: +an agent that receives tasks it cannot handle itself, dynamically +searches for a capable remote A2A agent, and delegates the work via the +A2A protocol — all within a single conversation. + +The flow: + 1. User asks the orchestrator something it can't do alone. + 2. Orchestrator uses ``search_agents`` to find capable remote agents. + 3. Orchestrator uses ``get_agent_card`` to inspect the best match. + 4. Orchestrator uses ``connect_agent`` to delegate the task via A2A. + 5. Orchestrator returns the result to the user. + +Usage:: + + adk web contributing/samples/ardhf_dynamic_agents + +Or with the challenge server:: + + hf-agentfinder challenge serve --port 8090 & + ARDHF_REGISTRY_URL=http://127.0.0.1:8090 \ + adk web contributing/samples/ardhf_dynamic_agents +""" + +from __future__ import annotations + +import asyncio +import os + +from google.adk import Agent +from google.adk.runners import InMemoryRunner +from google.genai import types + +from google.adk_community.tools.ardhf import AgentFinderToolset + +# -- Configuration --------------------------------------------------------- + +_registry_url = os.environ.get( + "ARDHF_REGISTRY_URL", + "https://huggingface.co/api/agentfinder", +) +_token = os.environ.get("HF_TOKEN") + +# -- Agent ----------------------------------------------------------------- + +agent_finder_toolset = AgentFinderToolset( + registry_url=_registry_url, + token=_token, +) + +root_agent = Agent( + name="dynamic_orchestrator", + description=( + "An orchestrator agent that dynamically discovers and " + "delegates to remote A2A agents at runtime." + ), + instruction=( + "You are a smart orchestrator. You do not have built-in " + "domain expertise — instead, you dynamically find and " + "delegate to specialised remote agents.\n\n" + "## How you work\n\n" + "When a user asks you to do something:\n\n" + "1. **Search** — Use search_agents to find remote agents " + "capable of handling the request. Filter by " + "artifact_type='a2a' to find A2A-compatible agents.\n\n" + "2. **Evaluate** — Review the search results. Pick the " + "best match based on the agent's description, capabilities, " + "and relevance to the user's request.\n\n" + "3. **Inspect** — Use get_agent_card with the chosen " + "agent's URL to fetch its full card and verify it can " + "handle the task.\n\n" + "4. **Delegate** — Use connect_agent with the agent card " + "URL and a clear, well-formed message describing what the " + "user needs. Translate the user's request into a specific " + "task for the remote agent.\n\n" + "5. **Report** — Present the remote agent's response to the " + "user, noting which agent handled the task.\n\n" + "## Guidelines\n\n" + "- Always search before delegating — don't assume you know " + "which agent to use.\n" + "- If search returns no suitable A2A agents, tell the user " + "what you searched for and that no matching agents were " + "found.\n" + "- If connect_agent fails, report the error and suggest " + "alternatives from the search results.\n" + "- Be transparent about delegation — tell the user you are " + "routing their request to a specialised agent." + ), + tools=[agent_finder_toolset], +) + + +# -- Main ------------------------------------------------------------------ + + +async def main() -> None: + """Run the orchestrator with a sample query.""" + runner = InMemoryRunner( + agent=root_agent, + app_name="ardhf_dynamic_demo", + ) + session = await runner.session_service.create_session( + user_id="demo_user", + app_name="ardhf_dynamic_demo", + ) + + prompt = "I need to remove the background from an image" + print(f"User: {prompt}") + + async for event in runner.run_async( + user_id="demo_user", + session_id=session.id, + new_message=types.Content( + role="user", + parts=[types.Part.from_text(text=prompt)], + ), + ): + if event.content and event.content.parts: + for part in event.content.parts: + text = getattr(part, "text", None) + if text: + print(f"{event.author}: {text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/google/adk_community/tools/ardhf/ardhf_toolset.py b/src/google/adk_community/tools/ardhf/ardhf_toolset.py index f667956..57e4d33 100644 --- a/src/google/adk_community/tools/ardhf/ardhf_toolset.py +++ b/src/google/adk_community/tools/ardhf/ardhf_toolset.py @@ -21,12 +21,14 @@ * **local** — uses the ``agentfinder`` Python package in-process for zero-latency, offline-capable search. -The toolset exposes two tools to the agent: +The toolset exposes three tools to the agent: * ``search_agents`` — search ARD registries for agents, skills, MCP servers, and other agentic resources. * ``get_agent_card`` — fetch a specific artifact (agent card, skill markdown, MCP server descriptor) by URL. +* ``connect_agent`` — send a message to a remote A2A agent and return + the response, enabling the full discover → connect → use flow. Prepared for rename to ARD (Agentic Resource Discovery). Reference: https://github.com/huggingface/hf-agentfinder @@ -36,9 +38,10 @@ import json import logging +import uuid from typing import Any, List, Optional, Union from urllib.error import HTTPError, URLError -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from urllib.request import Request as UrlRequest from urllib.request import urlopen @@ -159,11 +162,57 @@ def _local_search( ) +def _extract_text_from_a2a_response(a2a_response: Any) -> list[str]: + """Extract text strings from an A2A client response event. + + The response from ``A2AClient.send_message`` can be either a tuple + of ``(Task, update)`` or an ``A2AMessage``. This helper walks the + parts and collects all text content. + """ + texts: list[str] = [] + + try: + from a2a.types import Message as A2AMessage + from a2a.types import TextPart as A2ATextPart + except ImportError: + return texts + + def _extract_from_parts( + parts: Optional[list[Any]], + ) -> None: + if not parts: + return + for part in parts: + root = getattr(part, "root", None) + if isinstance(root, A2ATextPart): + texts.append(root.text) + + if isinstance(a2a_response, tuple): + task = a2a_response[0] + if task is not None: + # Extract from task artifacts. + for artifact in getattr(task, "artifacts", None) or []: + _extract_from_parts(getattr(artifact, "parts", None)) + # Extract from task status message. + status = getattr(task, "status", None) + if status is not None: + status_msg = getattr(status, "message", None) + if status_msg is not None: + _extract_from_parts( + getattr(status_msg, "parts", None) + ) + elif isinstance(a2a_response, A2AMessage): + _extract_from_parts(getattr(a2a_response, "parts", None)) + + return texts + + class AgentFinderToolset(BaseToolset): """ADK BaseToolset wrapping HuggingFace Agent Finder (ARD). - Provides ``search_agents`` and ``get_agent_card`` tools to any ADK - agent. + Provides ``search_agents``, ``get_agent_card``, and + ``connect_agent`` tools to any ADK agent, enabling the full + *discover → inspect → connect* workflow. Args: registry_url: ARD registry URL for remote mode. Ignored when @@ -277,16 +326,124 @@ async def _get_agent_card( "content_type": "text/markdown", } + async def _connect_agent( + self, + tool_context: ToolContext, + agent_card_url: str, + message: str, + ) -> dict[str, Any]: + """Send a message to a remote A2A agent and return the response. + + Use this after ``search_agents`` and ``get_agent_card`` to + interact with a discovered A2A agent. The agent card URL should + be for an artifact with media type + ``application/a2a-agent-card+json``. + + Args: + agent_card_url: Full URL to the remote agent's A2A agent card + (typically the ``url`` field from a search result whose + ``type`` is ``application/a2a-agent-card+json``). + message: The message to send to the remote agent. + + Returns: + A dictionary with ``response`` (the agent's reply text), + ``agent_name``, and ``agent_url``. On failure, returns a + dictionary with an ``error`` key. + """ + try: + from a2a.client.card_resolver import ( + A2ACardResolver, + ) + from a2a.client.client import ( + ClientConfig as A2AClientConfig, + ) + from a2a.client.client_factory import ( + ClientFactory as A2AClientFactory, + ) + from a2a.types import Message as A2AMessage + from a2a.types import Part as A2APart + from a2a.types import TextPart as A2ATextPart + import httpx + except ImportError: + return { + "error": ( + "A2A dependencies are not installed. Install them" + " with: pip install 'google-adk[a2a]'" + ) + } + + try: + parsed = urlparse(agent_card_url) + if not parsed.scheme or not parsed.netloc: + return {"error": f"Invalid agent card URL: {agent_card_url}"} + + base_url = f"{parsed.scheme}://{parsed.netloc}" + relative_path = parsed.path + + async with httpx.AsyncClient( + timeout=httpx.Timeout(timeout=float(_HTTP_TIMEOUT)) + ) as http_client: + # Resolve the agent card. + resolver = A2ACardResolver( + httpx_client=http_client, + base_url=base_url, + ) + agent_card = await resolver.get_agent_card( + relative_card_path=relative_path, + ) + + # Create an A2A client for this agent. + factory = A2AClientFactory( + config=A2AClientConfig(httpx_client=http_client), + ) + a2a_client = factory.create(agent_card) + + # Build and send the message. + request = A2AMessage( + message_id=str(uuid.uuid4()), + parts=[A2APart(root=A2ATextPart(text=message))], + role="user", + ) + + response_texts: list[str] = [] + async for a2a_response in a2a_client.send_message( + request=request, + ): + response_texts.extend( + _extract_text_from_a2a_response(a2a_response) + ) + + agent_name = getattr(agent_card, "name", "unknown") + response_text = "\n".join(response_texts) if response_texts else "" + + return { + "response": response_text, + "agent_name": agent_name, + "agent_url": agent_card_url, + } + + except Exception as exc: + logger.warning( + "A2A connect failed for %s: %s", agent_card_url, exc + ) + return { + "error": ( + f"Failed to communicate with agent at" + f" {agent_card_url}: {exc}" + ), + } + # -- BaseToolset interface ---------------------------------------------- async def get_tools( self, readonly_context: Optional[ReadonlyContext] = None, ) -> list[BaseTool]: - """Return the search_agents and get_agent_card tools.""" + """Return the search_agents, get_agent_card, and connect_agent tools.""" tools: list[BaseTool] = [ FunctionTool(self._search_agents), FunctionTool(self._get_agent_card), + FunctionTool(self._connect_agent), ] # Rename the tools to use cleaner names (strip leading underscore). diff --git a/tests/unittests/tools/ardhf/test_ardhf_toolset.py b/tests/unittests/tools/ardhf/test_ardhf_toolset.py index 2836904..5cfd5f6 100644 --- a/tests/unittests/tools/ardhf/test_ardhf_toolset.py +++ b/tests/unittests/tools/ardhf/test_ardhf_toolset.py @@ -27,13 +27,14 @@ import json import os -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from google.adk_community.tools.ardhf.ardhf_toolset import ( AgentFinderToolset, _artifact_type_for_kind, + _extract_text_from_a2a_response, _registry_search_url, _remote_fetch, _remote_search, @@ -108,15 +109,15 @@ class TestToolsetGetTools: """Tests for AgentFinderToolset.get_tools without a running server.""" @pytest.mark.asyncio - async def test_returns_two_tools(self): - """The toolset exposes search_agents and get_agent_card.""" + async def test_returns_three_tools(self): + """The toolset exposes search_agents, get_agent_card, and connect_agent.""" toolset = AgentFinderToolset() tools = await toolset.get_tools() - assert len(tools) == 2 + assert len(tools) == 3 names = {tool.name for tool in tools} - assert names == {"search_agents", "get_agent_card"} + assert names == {"search_agents", "get_agent_card", "connect_agent"} @pytest.mark.asyncio async def test_tools_have_descriptions(self): @@ -137,6 +138,7 @@ async def test_tool_name_prefix(self): names = {tool.name for tool in tools} assert "ard_search_agents" in names assert "ard_get_agent_card" in names + assert "ard_connect_agent" in names @pytest.mark.asyncio async def test_search_handles_connection_error(self): @@ -156,6 +158,203 @@ async def test_search_handles_connection_error(self): assert "error" in result +class TestExtractTextFromA2aResponse: + """Tests for the _extract_text_from_a2a_response helper.""" + + def test_extracts_text_from_message(self): + """Text is extracted from an A2AMessage with TextPart.""" + try: + from a2a.types import Message as A2AMessage + from a2a.types import Part as A2APart + from a2a.types import TextPart as A2ATextPart + except ImportError: + pytest.skip("a2a SDK not installed") + + msg = A2AMessage( + message_id="test-1", + parts=[A2APart(root=A2ATextPart(text="Hello from agent"))], + role="agent", + ) + + texts = _extract_text_from_a2a_response(msg) + + assert texts == ["Hello from agent"] + + def test_extracts_text_from_task_tuple(self): + """Text is extracted from a (Task, None) tuple response.""" + try: + from a2a.types import Artifact + from a2a.types import Part as A2APart + from a2a.types import Task as A2ATask + from a2a.types import TaskState + from a2a.types import TaskStatus + from a2a.types import TextPart as A2ATextPart + except ImportError: + pytest.skip("a2a SDK not installed") + + task = A2ATask( + id="task-1", + contextId="ctx-1", + status=TaskStatus(state=TaskState.completed), + artifacts=[ + Artifact( + artifactId="art-1", + parts=[ + A2APart(root=A2ATextPart(text="Task result")) + ], + ) + ], + ) + + texts = _extract_text_from_a2a_response((task, None)) + + assert "Task result" in texts + + def test_returns_empty_for_unknown_type(self): + """An unknown response type returns an empty list.""" + texts = _extract_text_from_a2a_response("unexpected") + assert texts == [] + + def test_returns_empty_when_a2a_not_installed(self): + """Returns empty list when a2a SDK is not available.""" + with patch.dict( + "sys.modules", {"a2a": None, "a2a.types": None} + ): + # Re-import to pick up the patched modules. + texts = _extract_text_from_a2a_response("anything") + assert texts == [] + + +class TestConnectAgent: + """Tests for the connect_agent tool.""" + + @pytest.mark.asyncio + async def test_connect_agent_tool_exists(self): + """The toolset exposes a connect_agent tool.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + + names = {tool.name for tool in tools} + assert "connect_agent" in names + + @pytest.mark.asyncio + async def test_connect_agent_has_description(self): + """The connect_agent tool has a non-empty description.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + connect_tool = next( + t for t in tools if t.name == "connect_agent" + ) + + assert connect_tool.description + + @pytest.mark.asyncio + async def test_connect_agent_invalid_url(self): + """connect_agent returns error for invalid URLs.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + connect_tool = next( + t for t in tools if t.name == "connect_agent" + ) + + mock_context = AsyncMock() + result = await connect_tool.run_async( + args={ + "agent_card_url": "not-a-valid-url", + "message": "hello", + }, + tool_context=mock_context, + ) + + assert "error" in result + + @pytest.mark.asyncio + async def test_connect_agent_unreachable_host(self): + """connect_agent returns error for unreachable agents.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + connect_tool = next( + t for t in tools if t.name == "connect_agent" + ) + + mock_context = AsyncMock() + result = await connect_tool.run_async( + args={ + "agent_card_url": ( + "http://127.0.0.1:19999/.well-known/agent.json" + ), + "message": "hello", + }, + tool_context=mock_context, + ) + + assert "error" in result + + @pytest.mark.asyncio + async def test_connect_agent_success_with_mocked_a2a(self): + """connect_agent returns response text from a mocked A2A agent.""" + try: + from a2a.types import AgentCard + from a2a.types import Message as A2AMessage + from a2a.types import Part as A2APart + from a2a.types import TextPart as A2ATextPart + except ImportError: + pytest.skip("a2a SDK not installed") + + mock_agent_card = MagicMock(spec=AgentCard) + mock_agent_card.name = "test-agent" + mock_agent_card.url = "http://localhost:9999/a2a" + + mock_response = A2AMessage( + message_id="resp-1", + parts=[A2APart(root=A2ATextPart(text="I can help!"))], + role="agent", + ) + + async def mock_send_message(**kwargs): + yield mock_response + + mock_client = MagicMock() + mock_client.send_message = mock_send_message + + mock_factory = MagicMock() + mock_factory.create.return_value = mock_client + + mock_resolver = AsyncMock() + mock_resolver.get_agent_card.return_value = mock_agent_card + + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + connect_tool = next( + t for t in tools if t.name == "connect_agent" + ) + + mock_context = AsyncMock() + + with ( + patch( + "a2a.client.card_resolver.A2ACardResolver", + return_value=mock_resolver, + ) as _, + patch( + "a2a.client.client_factory.ClientFactory", + return_value=mock_factory, + ) as _, + ): + result = await connect_tool.run_async( + args={ + "agent_card_url": ( + "http://localhost:9999/.well-known/agent.json" + ), + "message": "Can you help?", + }, + tool_context=mock_context, + ) + + assert result["response"] == "I can help!" + assert result["agent_name"] == "test-agent" + + # ------------------------------------------------------------------ # # Integration tests (require challenge server) # # ------------------------------------------------------------------ # From 374cd3b2605ea8ddfc2982b228b4f634d5fb04bd Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 21:43:12 +0000 Subject: [PATCH 03/10] feat: rename search tools and add type-specific aliases --- contributing/samples/ardhf/README.md | 262 ++++++++++++++---- contributing/samples/ardhf/agent.py | 17 +- .../samples/ardhf_dynamic_agents/README.md | 27 +- .../samples/ardhf_dynamic_agents/agent.py | 6 +- .../tools/ardhf/ardhf_toolset.py | 151 ++++++++-- .../tools/ardhf/test_ardhf_toolset.py | 84 +++++- 6 files changed, 434 insertions(+), 113 deletions(-) diff --git a/contributing/samples/ardhf/README.md b/contributing/samples/ardhf/README.md index 364a513..1042bad 100644 --- a/contributing/samples/ardhf/README.md +++ b/contributing/samples/ardhf/README.md @@ -1,129 +1,269 @@ -# ARDHF — Agent Finder (ARD) Toolset for ADK +# ARDHF — Agentic Resource Discovery (ARD) Toolset for ADK ## Overview ARDHF wraps [HuggingFace's Agent Finder](https://github.com/huggingface/hf-agentfinder) (ARD — Agentic Resource Discovery) as an ADK `BaseToolset`. It gives any ADK -agent the ability to **search for, discover, and connect to** agents, skills, -MCP servers, and other agentic resources at runtime. +agent the ability to **discover, inspect, and connect to** agents, skills, +MCP servers, HuggingFace Spaces, and other agentic resources at runtime. -The toolset provides three tools: +The core workflow is **discover → inspect → connect**: + +1. **Discover** — search ARD registries for resources matching a natural-language query. +2. **Inspect** — fetch the full artifact (agent card, skill markdown, MCP descriptor) by URL. +3. **Connect** — send a message to a remote A2A agent and get a response. + +## Quick Start + +```python +from google.adk import Agent +from google.adk_community.tools.ardhf import AgentFinderToolset + +agent = Agent( + name="my_agent", + instruction="Search for tools when you need a capability.", + tools=[AgentFinderToolset()], +) +``` + +That's it — the agent now has access to all discovery tools and can search, +inspect, and connect to agentic resources. + +## Available Tools | Tool | Description | |---|---| -| `search_agents` | Search ARD registries by natural-language query | -| `get_agent_card` | Fetch a specific artifact (agent card, skill, MCP descriptor) by URL | +| `search_ards` | Search ARD registries across all artifact types (agents, skills, MCP servers, Spaces) | +| `search_agents` | Search filtered to A2A agents (`application/a2a-agent-card+json`) | +| `search_skills` | Search filtered to skills (`application/ai-skill`) | +| `search_tools` | Search filtered to MCP servers (`application/mcp-server+json`) | +| `search_spaces` | Search filtered to HuggingFace Spaces (`application/vnd.huggingface.space+json`) | +| `get_agent_card` | Fetch a specific artifact (agent card, skill markdown, MCP descriptor) by URL | | `connect_agent` | Send a message to a remote A2A agent and return the response | -## Sample Inputs +The `search_agents`, `search_skills`, `search_tools`, and `search_spaces` +tools are convenience aliases — each calls the same core search logic with +the artifact type pre-set. Use `search_ards` when you want to search across +all types at once. + +## Realistic Scenarios + +### Finding and using a Skill + +> "Find a code review skill and apply it to my PR" + +1. The agent calls `search_skills('code review')` to find skills related to code review. +2. It picks the best match and calls `get_agent_card(url)` to fetch the full skill markdown. +3. It reads the skill instructions and applies them to the user's code. + +``` +User: Find a skill for reviewing Python code +Agent: [calls search_skills('Python code review')] +Agent: Found "python-review-skill" — fetching details... +Agent: [calls get_agent_card('https://huggingface.co/.../SKILL.md')] +Agent: Here's what the skill covers: ... +``` + +### Finding and using an MCP Tool + +> "Find a database query tool" -- `Find MCP servers for image processing` +1. The agent calls `search_tools('database query')` to find MCP servers. +2. It calls `get_agent_card(url)` to fetch the MCP server descriptor. +3. The descriptor contains tool definitions that can be connected via `McpToolset`. + +``` +User: Find tools for querying SQL databases +Agent: [calls search_tools('SQL database query')] +Agent: Found "sql-executor" MCP server with tools: execute_query, list_tables +Agent: [calls get_agent_card('https://..../mcp-descriptor.json')] +Agent: The server exposes these tools: ... +``` -- `Search for code review agents` +### Finding and using a Skill + Tool together - *Returns skill and agent entries related to code review.* +> "Find both a triage skill and a labeling tool for my issues" -- `What tools are available for background removal?` +1. The agent calls `search_skills('issue triage')` to find a triage skill. +2. It calls `search_tools('issue labeling')` to find a labeling MCP server. +3. It combines the skill's instructions with the tool's capabilities. -- `Get the agent card at https://huggingface.co/api/agentfinder/skills/huggingface/rembg/SKILL.md` +### Connecting to a Remote A2A Agent + +> "Find an image generation agent and ask it to make a logo" + +1. The agent calls `search_agents('image generation')` to find A2A agents. +2. It inspects the best match with `get_agent_card(url)`. +3. It delegates the task with `connect_agent(url, 'Create a minimalist logo for a coffee shop')`. +4. The remote agent processes the request and returns the result. + +``` +User: Find an agent that can generate images and make me a logo +Agent: [calls search_agents('image generation')] +Agent: Found "image-gen-agent" — connecting... +Agent: [calls connect_agent('https://.../agent.json', 'Create a minimalist logo for a coffee shop')] +Agent: The image generation agent responded with: ... +``` - *Fetches the full skill markdown for the rembg Space.* +### Discovering HuggingFace Spaces -- `Find A2A agents for code review and connect to the best one` +> "Find a text-to-speech Space" - *Searches for A2A agents, picks the best match, and sends a message via the A2A protocol.* +1. The agent calls `search_spaces('text to speech')` to find HF Spaces. +2. It calls `get_agent_card(url)` to fetch the Space metadata. +3. It presents the Space info (URL, description, capabilities) to the user. -## How To +``` +User: Find a Space for text to speech +Agent: [calls search_spaces('text to speech')] +Agent: Found "bark-tts" Space — here are the details: ... +``` + +## Configuration + +### Registry URL + +By default, the toolset queries the hosted HuggingFace Agent Finder registry. +Point to any ARD-compatible registry: + +```python +toolset = AgentFinderToolset( + registry_url="http://localhost:8090", +) +``` -### Install +Or set the environment variable: ```bash -pip install google-adk-community -# For A2A agent connectivity: -pip install 'google-adk[a2a]' -# Optional, for local (in-process) mode: -pip install hf-agentfinder +export ARDHF_REGISTRY_URL=http://localhost:8090 ``` -### Basic usage +### Authentication + +Pass a HuggingFace token for authenticated registry access: ```python -from google.adk import Agent -from google.adk_community.tools.ardhf import AgentFinderToolset +toolset = AgentFinderToolset(token="hf_...") +``` -agent = Agent( - name="my_agent", - instruction="Search for tools when you need a capability.", - tools=[AgentFinderToolset()], -) +Or set the environment variable: + +```bash +export HF_TOKEN=hf_... ``` -### Remote vs local mode +### Local mode -By default, the toolset sends HTTP requests to the hosted HuggingFace Agent -Finder registry. For in-process search (no network calls), install -`hf-agentfinder` and set `local=True`: +For in-process, offline-capable search (no HTTP requests), install the +`hf-agentfinder` package and enable local mode: ```python toolset = AgentFinderToolset(local=True) ``` -Or set environment variables: +Or set the environment variable: ```bash export ARDHF_LOCAL=1 -export HF_TOKEN=hf_... # optional, for authenticated access ``` -### Custom registry URL +### Environment variables summary -Point to any ARD-compatible registry: +| Variable | Description | +|---|---| +| `ARDHF_REGISTRY_URL` | Override the default registry URL | +| `HF_TOKEN` | Bearer token for authenticated registry access | +| `ARDHF_LOCAL` | Set to `1` / `true` / `yes` to enable local mode | + +## Customizations + +### Filtering exposed tools + +Use `tool_filter` to expose only specific tools to the agent: ```python +# Only expose search and inspect tools (no connect) toolset = AgentFinderToolset( - registry_url="http://localhost:8090", # e.g. challenge server + tool_filter=["search_ards", "search_agents", "get_agent_card"], ) ``` -### Running the sample +### Tool name prefix -```bash -# With adk web -adk web contributing/samples/ardhf +Add a prefix to avoid name collisions with other toolsets: + +```python +toolset = AgentFinderToolset(tool_name_prefix="ard") +# Tools become: ard_search_ards, ard_search_agents, etc. +``` + +### Multiple registries + +Use multiple toolset instances to search different registries: -# Or directly -python -m contributing.samples.ardhf.agent +```python +hf_toolset = AgentFinderToolset( + registry_url="https://huggingface.co/api/agentfinder", + tool_name_prefix="hf", +) +internal_toolset = AgentFinderToolset( + registry_url="https://internal.example.com/ard", + tool_name_prefix="internal", +) + +agent = Agent( + name="multi_registry_agent", + instruction="Search multiple registries for the best tool.", + tools=[hf_toolset, internal_toolset], +) ``` -### Using the challenge server for deterministic testing +### Combining with other ADK toolsets -The `hf-agentfinder` package includes a deterministic challenge server: +ARDHF works alongside any other ADK toolset: + +```python +from google.adk.tools.mcp_tool.mcp_toolset import McpToolset + +agent = Agent( + name="combined_agent", + instruction="Use discovery and local tools together.", + tools=[AgentFinderToolset(), McpToolset(...)], +) +``` + +## Testing + +### Using the HF challenge server + +The `hf-agentfinder` package includes a deterministic challenge server with +fixed fixtures — no API keys or network access needed: ```bash # Terminal 1: start the challenge server pip install hf-agentfinder hf-agentfinder challenge serve --port 8090 -# Terminal 2: run the agent against it +# Terminal 2: run the sample agent against it ARDHF_REGISTRY_URL=http://127.0.0.1:8090 \ - python -m contributing.samples.ardhf.agent + adk web contributing/samples/ardhf ``` -## Architecture +### Running the unit tests -``` -AgentFinderToolset (BaseToolset) -├── search_agents(query, artifact_type?, limit?) -│ ├── remote: HTTP POST to registry /search -│ └── local: agentfinder.server.search_agent_finder() -├── get_agent_card(url) -│ └── HTTP GET to artifact URL -└── connect_agent(agent_card_url, message) - └── A2A protocol: resolve card → create client → send message +```bash +# Unit tests (no server needed) +pytest tests/unittests/tools/ardhf/ -v + +# Integration tests (start challenge server first) +hf-agentfinder challenge serve --port 8090 & +pytest tests/unittests/tools/ardhf/ -v ``` -## Related +## References +- [ARD Specification](https://github.com/nichochar/ard-spec) — Agentic Resource Discovery specification - [HuggingFace Agent Finder](https://github.com/huggingface/hf-agentfinder) — ARD reference implementation -- [ADK BaseToolset](https://google.github.io/adk-docs/) — ADK toolset documentation +- [ai-catalog](https://github.com/nichochar/ai-catalog) — Curated agentic resource catalog +- [ADK Documentation](https://google.github.io/adk-docs/) — Google Agent Development Kit - [A2A Protocol](https://github.com/a2aproject/a2a-spec) — Agent-to-Agent protocol specification diff --git a/contributing/samples/ardhf/agent.py b/contributing/samples/ardhf/agent.py index fadeed7..a1ff23a 100644 --- a/contributing/samples/ardhf/agent.py +++ b/contributing/samples/ardhf/agent.py @@ -98,14 +98,19 @@ async def summarise_findings( "searching the Agent Finder (ARD) registry, and optionally " "connect to and interact with discovered A2A agents.\n\n" "## Tools\n\n" - "You have three discovery tools:\n" - "1. **search_agents** — search for resources by capability\n" - "2. **get_agent_card** — inspect a specific resource's card\n" - "3. **connect_agent** — send a message to a remote A2A agent\n\n" + "You have several discovery tools:\n" + "- **search_ards** — search across all artifact types\n" + "- **search_agents** — search for A2A agents only\n" + "- **search_skills** — search for skills only\n" + "- **search_tools** — search for MCP servers only\n" + "- **search_spaces** — search for HuggingFace Spaces only\n" + "- **get_agent_card** — inspect a specific resource's card\n" + "- **connect_agent** — send a message to a remote A2A agent\n\n" "## Workflow\n\n" "When a user asks for a capability:\n" - "1. Use search_agents to find relevant resources. You can " - "filter by artifact type: 'skill', 'mcp', 'space', or 'a2a'.\n" + "1. Use the appropriate search tool — search_ards for broad " + "queries, or a specific alias (search_agents, search_skills, " + "search_tools, search_spaces) when the type is known.\n" "2. Summarise the results — name, description, type, and URL.\n" "3. If a user wants more details about a specific result, use " "get_agent_card with the result's URL.\n" diff --git a/contributing/samples/ardhf_dynamic_agents/README.md b/contributing/samples/ardhf_dynamic_agents/README.md index 641e5e6..5ce1474 100644 --- a/contributing/samples/ardhf_dynamic_agents/README.md +++ b/contributing/samples/ardhf_dynamic_agents/README.md @@ -9,14 +9,27 @@ Unlike a traditional multi-agent system where sub-agents are hardcoded at build time, this orchestrator discovers capable agents on the fly: 1. User sends a request the orchestrator can't handle alone. -2. Orchestrator searches ARD registries for a capable A2A agent. -3. Orchestrator inspects the agent card to verify compatibility. -4. Orchestrator delegates the task via the A2A protocol. +2. Orchestrator uses `search_agents` to find a capable A2A agent (or + `search_ards` for a broader search across all artifact types). +3. Orchestrator inspects the agent card with `get_agent_card` to verify compatibility. +4. Orchestrator delegates the task via `connect_agent` using the A2A protocol. 5. Orchestrator returns the result to the user. -This is the **discover -> connect -> use** pattern — agents finding and +This is the **discover → connect → use** pattern — agents finding and collaborating with other agents without prior configuration. +## Available Tools + +| Tool | Description | +|---|---| +| `search_ards` | Search across all artifact types | +| `search_agents` | Search filtered to A2A agents | +| `search_skills` | Search filtered to skills | +| `search_tools` | Search filtered to MCP servers | +| `search_spaces` | Search filtered to HuggingFace Spaces | +| `get_agent_card` | Fetch a specific artifact by URL | +| `connect_agent` | Send a message to a remote A2A agent | + ## Sample Inputs - `I need to remove the background from an image` @@ -74,7 +87,11 @@ User │ (no built-in domain skills) │ │ │ │ Tools: │ -│ ├── search_agents ─────────┼──► ARD Registry +│ ├── search_ards ───────────┼──► ARD Registry (all types) +│ ├── search_agents ─────────┼──► ARD Registry (A2A only) +│ ├── search_skills ─────────┼──► ARD Registry (skills only) +│ ├── search_tools ──────────┼──► ARD Registry (MCP only) +│ ├── search_spaces ─────────┼──► ARD Registry (Spaces only) │ ├── get_agent_card ────────┼──► Agent Card URL │ └── connect_agent ─────────┼──► Remote A2A Agent └──────────────────────────────┘ diff --git a/contributing/samples/ardhf_dynamic_agents/agent.py b/contributing/samples/ardhf_dynamic_agents/agent.py index 51800d7..94d8d3a 100644 --- a/contributing/samples/ardhf_dynamic_agents/agent.py +++ b/contributing/samples/ardhf_dynamic_agents/agent.py @@ -75,9 +75,9 @@ "delegate to specialised remote agents.\n\n" "## How you work\n\n" "When a user asks you to do something:\n\n" - "1. **Search** — Use search_agents to find remote agents " - "capable of handling the request. Filter by " - "artifact_type='a2a' to find A2A-compatible agents.\n\n" + "1. **Search** — Use search_agents to find A2A-compatible " + "remote agents capable of handling the request. For broader " + "searches across all artifact types, use search_ards.\n\n" "2. **Evaluate** — Review the search results. Pick the " "best match based on the agent's description, capabilities, " "and relevance to the user's request.\n\n" diff --git a/src/google/adk_community/tools/ardhf/ardhf_toolset.py b/src/google/adk_community/tools/ardhf/ardhf_toolset.py index 57e4d33..2a6e668 100644 --- a/src/google/adk_community/tools/ardhf/ardhf_toolset.py +++ b/src/google/adk_community/tools/ardhf/ardhf_toolset.py @@ -21,16 +21,19 @@ * **local** — uses the ``agentfinder`` Python package in-process for zero-latency, offline-capable search. -The toolset exposes three tools to the agent: - -* ``search_agents`` — search ARD registries for agents, skills, MCP - servers, and other agentic resources. +The toolset exposes the following tools to the agent: + +* ``search_ards`` — search ARD registries across all artifact types + (agents, skills, MCP servers, spaces). +* ``search_agents`` — convenience alias: search filtered to A2A agents. +* ``search_skills`` — convenience alias: search filtered to skills. +* ``search_tools`` — convenience alias: search filtered to MCP servers. +* ``search_spaces`` — convenience alias: search filtered to HF Spaces. * ``get_agent_card`` — fetch a specific artifact (agent card, skill markdown, MCP server descriptor) by URL. * ``connect_agent`` — send a message to a remote A2A agent and return the response, enabling the full discover → connect → use flow. -Prepared for rename to ARD (Agentic Resource Discovery). Reference: https://github.com/huggingface/hf-agentfinder """ @@ -210,7 +213,8 @@ def _extract_from_parts( class AgentFinderToolset(BaseToolset): """ADK BaseToolset wrapping HuggingFace Agent Finder (ARD). - Provides ``search_agents``, ``get_agent_card``, and + Provides ``search_ards``, ``search_agents``, ``search_skills``, + ``search_tools``, ``search_spaces``, ``get_agent_card``, and ``connect_agent`` tools to any ADK agent, enabling the full *discover → inspect → connect* workflow. @@ -241,30 +245,15 @@ def __init__( self._token = token self._local = local - # -- Tool implementations ----------------------------------------------- + # -- Internal search logic ----------------------------------------------- - async def _search_agents( + async def _do_search( self, - tool_context: ToolContext, query: str, artifact_type: Optional[str] = None, limit: int = 10, ) -> dict[str, Any]: - """Search ARD registries for agents, skills, and MCP servers. - - Args: - query: Natural-language search query describing what you need, - e.g. "remove image background" or "code review agent". - artifact_type: Optional filter by artifact kind. Supported - values: ``skill``, ``mcp``, ``space``, ``a2a``, or a raw - media type like ``application/mcp-server+json``. When - omitted, all artifact types are returned. - limit: Maximum number of results to return (1-100, default 10). - - Returns: - A dictionary with ``results`` (list of matching entries) and - optionally ``referrals`` (list of additional registries). - """ + """Core search logic shared by all search tools.""" # Resolve human-friendly kind to media type. resolved_type = ( _artifact_type_for_kind(artifact_type) if artifact_type else None @@ -294,6 +283,114 @@ async def _search_agents( except ImportError as exc: return {"error": str(exc)} + # -- Tool implementations ----------------------------------------------- + + async def _search_ards( + self, + tool_context: ToolContext, + query: str, + artifact_type: Optional[str] = None, + limit: int = 10, + ) -> dict[str, Any]: + """Search ARD registries across all artifact types. + + Args: + query: Natural-language search query describing what you need, + e.g. "remove image background" or "code review". + artifact_type: Optional filter by artifact kind. Supported + values: ``skill``, ``mcp``, ``space``, ``a2a``, or a raw + media type like ``application/mcp-server+json``. When + omitted, all artifact types are returned. + limit: Maximum number of results to return (1-100, default 10). + + Returns: + A dictionary with ``results`` (list of matching entries) and + optionally ``referrals`` (list of additional registries). + """ + return await self._do_search( + query, artifact_type=artifact_type, limit=limit + ) + + async def _search_agents( + self, + tool_context: ToolContext, + query: str, + limit: int = 10, + ) -> dict[str, Any]: + """Search ARD registries for A2A agents only. + + Args: + query: Natural-language search query describing the agent + capability you need, e.g. "code review" or "translation". + limit: Maximum number of results to return (1-100, default 10). + + Returns: + A dictionary with ``results`` filtered to A2A agents and + optionally ``referrals``. + """ + return await self._do_search(query, artifact_type="a2a", limit=limit) + + async def _search_skills( + self, + tool_context: ToolContext, + query: str, + limit: int = 10, + ) -> dict[str, Any]: + """Search ARD registries for skills only. + + Args: + query: Natural-language search query describing the skill you + need, e.g. "code review" or "triage issues". + limit: Maximum number of results to return (1-100, default 10). + + Returns: + A dictionary with ``results`` filtered to skills and optionally + ``referrals``. + """ + return await self._do_search( + query, artifact_type="skill", limit=limit + ) + + async def _search_tools( + self, + tool_context: ToolContext, + query: str, + limit: int = 10, + ) -> dict[str, Any]: + """Search ARD registries for MCP servers only. + + Args: + query: Natural-language search query describing the tool you + need, e.g. "database query" or "image processing". + limit: Maximum number of results to return (1-100, default 10). + + Returns: + A dictionary with ``results`` filtered to MCP servers and + optionally ``referrals``. + """ + return await self._do_search(query, artifact_type="mcp", limit=limit) + + async def _search_spaces( + self, + tool_context: ToolContext, + query: str, + limit: int = 10, + ) -> dict[str, Any]: + """Search ARD registries for HuggingFace Spaces only. + + Args: + query: Natural-language search query describing the Space you + need, e.g. "text to speech" or "image generation". + limit: Maximum number of results to return (1-100, default 10). + + Returns: + A dictionary with ``results`` filtered to HuggingFace Spaces and + optionally ``referrals``. + """ + return await self._do_search( + query, artifact_type="space", limit=limit + ) + async def _get_agent_card( self, tool_context: ToolContext, @@ -439,9 +536,13 @@ async def get_tools( self, readonly_context: Optional[ReadonlyContext] = None, ) -> list[BaseTool]: - """Return the search_agents, get_agent_card, and connect_agent tools.""" + """Return all search, inspect, and connect tools.""" tools: list[BaseTool] = [ + FunctionTool(self._search_ards), FunctionTool(self._search_agents), + FunctionTool(self._search_skills), + FunctionTool(self._search_tools), + FunctionTool(self._search_spaces), FunctionTool(self._get_agent_card), FunctionTool(self._connect_agent), ] diff --git a/tests/unittests/tools/ardhf/test_ardhf_toolset.py b/tests/unittests/tools/ardhf/test_ardhf_toolset.py index 5cfd5f6..20b8ad7 100644 --- a/tests/unittests/tools/ardhf/test_ardhf_toolset.py +++ b/tests/unittests/tools/ardhf/test_ardhf_toolset.py @@ -109,15 +109,23 @@ class TestToolsetGetTools: """Tests for AgentFinderToolset.get_tools without a running server.""" @pytest.mark.asyncio - async def test_returns_three_tools(self): - """The toolset exposes search_agents, get_agent_card, and connect_agent.""" + async def test_returns_all_tools(self): + """The toolset exposes all search aliases plus get_agent_card and connect_agent.""" toolset = AgentFinderToolset() tools = await toolset.get_tools() - assert len(tools) == 3 + assert len(tools) == 7 names = {tool.name for tool in tools} - assert names == {"search_agents", "get_agent_card", "connect_agent"} + assert names == { + "search_ards", + "search_agents", + "search_skills", + "search_tools", + "search_spaces", + "get_agent_card", + "connect_agent", + } @pytest.mark.asyncio async def test_tools_have_descriptions(self): @@ -136,18 +144,22 @@ async def test_tool_name_prefix(self): tools = await toolset.get_tools_with_prefix() names = {tool.name for tool in tools} + assert "ard_search_ards" in names assert "ard_search_agents" in names + assert "ard_search_skills" in names + assert "ard_search_tools" in names + assert "ard_search_spaces" in names assert "ard_get_agent_card" in names assert "ard_connect_agent" in names @pytest.mark.asyncio - async def test_search_handles_connection_error(self): - """search_agents returns an error dict for unreachable servers.""" + async def test_search_ards_handles_connection_error(self): + """search_ards returns an error dict for unreachable servers.""" toolset = AgentFinderToolset( registry_url="http://127.0.0.1:19999" ) tools = await toolset.get_tools() - search_tool = next(t for t in tools if t.name == "search_agents") + search_tool = next(t for t in tools if t.name == "search_ards") mock_context = AsyncMock() result = await search_tool.run_async( @@ -157,6 +169,52 @@ async def test_search_handles_connection_error(self): assert "error" in result + @pytest.mark.asyncio + async def test_search_agents_filters_to_a2a(self): + """search_agents is a convenience alias filtering to A2A agents.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + search_tool = next( + t for t in tools if t.name == "search_agents" + ) + + # The tool should not accept an artifact_type parameter + # (it's hardcoded to a2a). + assert search_tool.name == "search_agents" + + @pytest.mark.asyncio + async def test_search_skills_filters_to_skill(self): + """search_skills is a convenience alias filtering to skills.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + search_tool = next( + t for t in tools if t.name == "search_skills" + ) + + assert search_tool.name == "search_skills" + + @pytest.mark.asyncio + async def test_search_tools_filters_to_mcp(self): + """search_tools is a convenience alias filtering to MCP servers.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + search_tool = next( + t for t in tools if t.name == "search_tools" + ) + + assert search_tool.name == "search_tools" + + @pytest.mark.asyncio + async def test_search_spaces_filters_to_space(self): + """search_spaces is a convenience alias filtering to HF Spaces.""" + toolset = AgentFinderToolset() + tools = await toolset.get_tools() + search_tool = next( + t for t in tools if t.name == "search_spaces" + ) + + assert search_tool.name == "search_spaces" + class TestExtractTextFromA2aResponse: """Tests for the _extract_text_from_a2a_response helper.""" @@ -497,12 +555,12 @@ class TestToolsetAgainstChallenge: """Integration tests for AgentFinderToolset against challenge.""" @pytest.mark.asyncio - async def test_search_agents_tool(self): - """search_agents returns results from the challenge server.""" + async def test_search_ards_tool(self): + """search_ards returns results from the challenge server.""" toolset = AgentFinderToolset(registry_url=CHALLENGE_URL) tools = await toolset.get_tools() search_tool = next( - t for t in tools if t.name == "search_agents" + t for t in tools if t.name == "search_ards" ) mock_context = AsyncMock() @@ -557,12 +615,12 @@ async def test_get_agent_card_markdown(self): assert "triage-skill" in result["content"] @pytest.mark.asyncio - async def test_search_with_kind_resolution(self): - """search_agents resolves human-friendly kind names.""" + async def test_search_ards_with_kind_resolution(self): + """search_ards resolves human-friendly kind names.""" toolset = AgentFinderToolset(registry_url=CHALLENGE_URL) tools = await toolset.get_tools() search_tool = next( - t for t in tools if t.name == "search_agents" + t for t in tools if t.name == "search_ards" ) mock_context = AsyncMock() From e1c87b7f2af2f0cf76ab28f99def9167b4b00b3b Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 21:59:27 +0000 Subject: [PATCH 04/10] fix: wrap blocking I/O in asyncio.to_thread, add URL scheme validation - Wrap _remote_search, _remote_fetch, and _local_search calls in asyncio.to_thread to avoid blocking the event loop - Add URL scheme validation (http/https only) to _get_agent_card and _connect_agent to prevent SSRF via file:// URLs - Replace Optional/List/Union with modern X | None / list / X | Y syntax - Clamp limit parameter to valid range (1-100) in _do_search - Extract _KIND_TO_MEDIA_TYPE as module-level constant - Fix import ordering in _connect_agent (httpx before a2a) - Make get_tools idempotent by assigning names at tool creation - Strengthen alias tests to verify artifact_type delegation - Add tests for limit clamping, URL scheme rejection, and fetch errors --- .../tools/ardhf/ardhf_toolset.py | 92 +++++----- .../tools/ardhf/test_ardhf_toolset.py | 161 +++++++++++++++--- 2 files changed, 189 insertions(+), 64 deletions(-) diff --git a/src/google/adk_community/tools/ardhf/ardhf_toolset.py b/src/google/adk_community/tools/ardhf/ardhf_toolset.py index 2a6e668..06cc57b 100644 --- a/src/google/adk_community/tools/ardhf/ardhf_toolset.py +++ b/src/google/adk_community/tools/ardhf/ardhf_toolset.py @@ -39,10 +39,11 @@ from __future__ import annotations +import asyncio import json import logging import uuid -from typing import Any, List, Optional, Union +from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urljoin, urlparse from urllib.request import Request as UrlRequest @@ -71,24 +72,26 @@ def _registry_search_url(registry_url: str) -> str: return urljoin(f"{normalised}/", "search") -def _artifact_type_for_kind(kind: str) -> Optional[str]: +_KIND_TO_MEDIA_TYPE: dict[str, str] = { + "skill": "application/ai-skill", + "mcp": "application/mcp-server+json", + "space": "application/vnd.huggingface.space+json", + "a2a": "application/a2a-agent-card+json", +} + + +def _artifact_type_for_kind(kind: str) -> str | None: """Map a human-friendly kind label to its ARD media type.""" - types = { - "skill": "application/ai-skill", - "mcp": "application/mcp-server+json", - "space": "application/vnd.huggingface.space+json", - "a2a": "application/a2a-agent-card+json", - } - return types.get(kind) + return _KIND_TO_MEDIA_TYPE.get(kind) def _remote_search( registry_url: str, query: str, *, - artifact_type: Optional[str] = None, + artifact_type: str | None = None, limit: int = 10, - token: Optional[str] = None, + token: str | None = None, ) -> dict[str, Any]: """POST a SearchRequest to a remote ARD registry and return raw JSON.""" search_query: dict[str, Any] = {"text": query} @@ -120,7 +123,7 @@ def _remote_search( def _remote_fetch( - url: str, *, token: Optional[str] = None + url: str, *, token: str | None = None ) -> str: """GET an artifact URL and return its text content.""" headers = {"User-Agent": "adk-ardhf/0.1"} @@ -135,9 +138,9 @@ def _remote_fetch( def _local_search( query: str, *, - artifact_type: Optional[str] = None, + artifact_type: str | None = None, limit: int = 10, - token: Optional[str] = None, + token: str | None = None, ) -> dict[str, Any]: """Search using the in-process ``agentfinder`` package.""" try: @@ -181,7 +184,7 @@ def _extract_text_from_a2a_response(a2a_response: Any) -> list[str]: return texts def _extract_from_parts( - parts: Optional[list[Any]], + parts: list[Any] | None, ) -> None: if not parts: return @@ -232,10 +235,10 @@ def __init__( self, *, registry_url: str = _DEFAULT_REGISTRY_URL, - token: Optional[str] = None, + token: str | None = None, local: bool = False, - tool_filter: Optional[Union[ToolPredicate, List[str]]] = None, - tool_name_prefix: Optional[str] = None, + tool_filter: ToolPredicate | list[str] | None = None, + tool_name_prefix: str | None = None, ) -> None: super().__init__( tool_filter=tool_filter, @@ -250,10 +253,13 @@ def __init__( async def _do_search( self, query: str, - artifact_type: Optional[str] = None, + artifact_type: str | None = None, limit: int = 10, ) -> dict[str, Any]: """Core search logic shared by all search tools.""" + # Clamp limit to the valid range. + limit = max(1, min(limit, 100)) + # Resolve human-friendly kind to media type. resolved_type = ( _artifact_type_for_kind(artifact_type) if artifact_type else None @@ -264,13 +270,15 @@ async def _do_search( try: if self._local: - return _local_search( + return await asyncio.to_thread( + _local_search, query, artifact_type=resolved_type, limit=limit, token=self._token, ) - return _remote_search( + return await asyncio.to_thread( + _remote_search, self._registry_url, query, artifact_type=resolved_type, @@ -289,7 +297,7 @@ async def _search_ards( self, tool_context: ToolContext, query: str, - artifact_type: Optional[str] = None, + artifact_type: str | None = None, limit: int = 10, ) -> dict[str, Any]: """Search ARD registries across all artifact types. @@ -407,8 +415,14 @@ async def _get_agent_card( (skills), the content is returned under a ``content`` key. For JSON artifacts, the parsed object is returned directly. """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return {"error": f"Invalid URL scheme: {url}"} + try: - raw = _remote_fetch(url, token=self._token) + raw = await asyncio.to_thread( + _remote_fetch, url, token=self._token + ) except (HTTPError, URLError, TimeoutError) as exc: logger.warning("ARD fetch failed for %s: %s", url, exc) return {"error": f"Failed to fetch {url}: {exc}"} @@ -448,6 +462,7 @@ async def _connect_agent( dictionary with an ``error`` key. """ try: + import httpx from a2a.client.card_resolver import ( A2ACardResolver, ) @@ -460,7 +475,6 @@ async def _connect_agent( from a2a.types import Message as A2AMessage from a2a.types import Part as A2APart from a2a.types import TextPart as A2ATextPart - import httpx except ImportError: return { "error": ( @@ -471,7 +485,7 @@ async def _connect_agent( try: parsed = urlparse(agent_card_url) - if not parsed.scheme or not parsed.netloc: + if parsed.scheme not in ("http", "https") or not parsed.netloc: return {"error": f"Invalid agent card URL: {agent_card_url}"} base_url = f"{parsed.scheme}://{parsed.netloc}" @@ -534,23 +548,23 @@ async def _connect_agent( async def get_tools( self, - readonly_context: Optional[ReadonlyContext] = None, + readonly_context: ReadonlyContext | None = None, ) -> list[BaseTool]: """Return all search, inspect, and connect tools.""" - tools: list[BaseTool] = [ - FunctionTool(self._search_ards), - FunctionTool(self._search_agents), - FunctionTool(self._search_skills), - FunctionTool(self._search_tools), - FunctionTool(self._search_spaces), - FunctionTool(self._get_agent_card), - FunctionTool(self._connect_agent), + tool_defs = [ + (self._search_ards, "search_ards"), + (self._search_agents, "search_agents"), + (self._search_skills, "search_skills"), + (self._search_tools, "search_tools"), + (self._search_spaces, "search_spaces"), + (self._get_agent_card, "get_agent_card"), + (self._connect_agent, "connect_agent"), ] - - # Rename the tools to use cleaner names (strip leading underscore). - for tool in tools: - if tool.name.startswith("_"): - tool.name = tool.name[1:] + tools: list[BaseTool] = [] + for func, name in tool_defs: + tool = FunctionTool(func) + tool.name = name + tools.append(tool) if readonly_context is not None: tools = [ diff --git a/tests/unittests/tools/ardhf/test_ardhf_toolset.py b/tests/unittests/tools/ardhf/test_ardhf_toolset.py index 20b8ad7..1204d6b 100644 --- a/tests/unittests/tools/ardhf/test_ardhf_toolset.py +++ b/tests/unittests/tools/ardhf/test_ardhf_toolset.py @@ -170,50 +170,161 @@ async def test_search_ards_handles_connection_error(self): assert "error" in result @pytest.mark.asyncio - async def test_search_agents_filters_to_a2a(self): - """search_agents is a convenience alias filtering to A2A agents.""" + async def test_search_agents_delegates_with_a2a_type(self): + """search_agents passes artifact_type='a2a' to the search logic.""" toolset = AgentFinderToolset() - tools = await toolset.get_tools() - search_tool = next( - t for t in tools if t.name == "search_agents" + + with patch.object( + toolset, "_do_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = {"results": []} + mock_context = AsyncMock() + await toolset._search_agents( + mock_context, query="test", limit=5 + ) + + mock_search.assert_called_once_with( + "test", artifact_type="a2a", limit=5 ) - # The tool should not accept an artifact_type parameter - # (it's hardcoded to a2a). - assert search_tool.name == "search_agents" + @pytest.mark.asyncio + async def test_search_skills_delegates_with_skill_type(self): + """search_skills passes artifact_type='skill' to the search logic.""" + toolset = AgentFinderToolset() + + with patch.object( + toolset, "_do_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = {"results": []} + mock_context = AsyncMock() + await toolset._search_skills( + mock_context, query="test", limit=5 + ) + + mock_search.assert_called_once_with( + "test", artifact_type="skill", limit=5 + ) @pytest.mark.asyncio - async def test_search_skills_filters_to_skill(self): - """search_skills is a convenience alias filtering to skills.""" + async def test_search_tools_delegates_with_mcp_type(self): + """search_tools passes artifact_type='mcp' to the search logic.""" toolset = AgentFinderToolset() - tools = await toolset.get_tools() - search_tool = next( - t for t in tools if t.name == "search_skills" + + with patch.object( + toolset, "_do_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = {"results": []} + mock_context = AsyncMock() + await toolset._search_tools( + mock_context, query="test", limit=5 + ) + + mock_search.assert_called_once_with( + "test", artifact_type="mcp", limit=5 + ) + + @pytest.mark.asyncio + async def test_search_spaces_delegates_with_space_type(self): + """search_spaces passes artifact_type='space' to the search logic.""" + toolset = AgentFinderToolset() + + with patch.object( + toolset, "_do_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = {"results": []} + mock_context = AsyncMock() + await toolset._search_spaces( + mock_context, query="test", limit=5 + ) + + mock_search.assert_called_once_with( + "test", artifact_type="space", limit=5 ) - assert search_tool.name == "search_skills" + +class TestDoSearch: + """Tests for the _do_search core logic.""" + + @pytest.mark.asyncio + async def test_limit_clamped_to_valid_range(self): + """Limit values outside 1-100 are clamped.""" + toolset = AgentFinderToolset() + + with patch( + "google.adk_community.tools.ardhf.ardhf_toolset" + "._remote_search", + return_value={"results": []}, + ) as mock_search: + await toolset._do_search("test", limit=0) + _, kwargs = mock_search.call_args + assert kwargs["limit"] == 1 + + await toolset._do_search("test", limit=200) + _, kwargs = mock_search.call_args + assert kwargs["limit"] == 100 @pytest.mark.asyncio - async def test_search_tools_filters_to_mcp(self): - """search_tools is a convenience alias filtering to MCP servers.""" + async def test_raw_media_type_passed_through(self): + """A raw media type string is used as-is when kind lookup fails.""" + toolset = AgentFinderToolset() + + with patch( + "google.adk_community.tools.ardhf.ardhf_toolset" + "._remote_search", + return_value={"results": []}, + ) as mock_search: + await toolset._do_search( + "test", + artifact_type="application/custom+json", + ) + _, kwargs = mock_search.call_args + assert ( + kwargs["artifact_type"] == "application/custom+json" + ) + + @pytest.mark.asyncio + async def test_get_agent_card_returns_error_for_unreachable(self): + """get_agent_card returns error dict for unreachable URLs.""" toolset = AgentFinderToolset() tools = await toolset.get_tools() - search_tool = next( - t for t in tools if t.name == "search_tools" + fetch_tool = next( + t for t in tools if t.name == "get_agent_card" + ) + + mock_context = AsyncMock() + result = await fetch_tool.run_async( + args={"url": "http://127.0.0.1:19999/nonexistent"}, + tool_context=mock_context, ) - assert search_tool.name == "search_tools" + assert "error" in result @pytest.mark.asyncio - async def test_search_spaces_filters_to_space(self): - """search_spaces is a convenience alias filtering to HF Spaces.""" + async def test_get_agent_card_rejects_file_url(self): + """get_agent_card rejects file:// URLs to prevent SSRF.""" toolset = AgentFinderToolset() - tools = await toolset.get_tools() - search_tool = next( - t for t in tools if t.name == "search_spaces" + mock_context = AsyncMock() + + result = await toolset._get_agent_card( + mock_context, url="file:///etc/passwd" + ) + + assert "error" in result + assert "Invalid URL scheme" in result["error"] + + @pytest.mark.asyncio + async def test_connect_agent_rejects_file_url(self): + """connect_agent rejects non-HTTP URLs.""" + toolset = AgentFinderToolset() + mock_context = AsyncMock() + + result = await toolset._connect_agent( + mock_context, + agent_card_url="ftp://example.com/agent.json", + message="hello", ) - assert search_tool.name == "search_spaces" + assert "error" in result class TestExtractTextFromA2aResponse: From 8398af39a23a830406ac063dae1851b61f407e25 Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 22:01:20 +0000 Subject: [PATCH 05/10] fix: handle JSONDecodeError in search, simplify local_search serialization - Add json.JSONDecodeError to _do_search exception handling so malformed registry responses return an error dict instead of crashing - Replace model_dump_json + json.loads with model_dump in _local_search to avoid unnecessary serialize-then-deserialize roundtrip - Remove unnecessary `as _` in test patch context managers - Fix 80-char line limit violation in test docstring --- src/google/adk_community/tools/ardhf/ardhf_toolset.py | 8 +++----- tests/unittests/tools/ardhf/test_ardhf_toolset.py | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/google/adk_community/tools/ardhf/ardhf_toolset.py b/src/google/adk_community/tools/ardhf/ardhf_toolset.py index 06cc57b..da38b02 100644 --- a/src/google/adk_community/tools/ardhf/ardhf_toolset.py +++ b/src/google/adk_community/tools/ardhf/ardhf_toolset.py @@ -161,10 +161,8 @@ def _local_search( pageSize=limit, ) response = search_agent_finder(request, token=token) - return json.loads( - response.model_dump_json( - exclude_none=True, exclude_defaults=True - ) + return response.model_dump( + exclude_none=True, exclude_defaults=True ) @@ -285,7 +283,7 @@ async def _do_search( limit=limit, token=self._token, ) - except (HTTPError, URLError, TimeoutError) as exc: + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc: logger.warning("ARD search failed: %s", exc) return {"error": f"Search request failed: {exc}"} except ImportError as exc: diff --git a/tests/unittests/tools/ardhf/test_ardhf_toolset.py b/tests/unittests/tools/ardhf/test_ardhf_toolset.py index 1204d6b..f9319a9 100644 --- a/tests/unittests/tools/ardhf/test_ardhf_toolset.py +++ b/tests/unittests/tools/ardhf/test_ardhf_toolset.py @@ -110,7 +110,7 @@ class TestToolsetGetTools: @pytest.mark.asyncio async def test_returns_all_tools(self): - """The toolset exposes all search aliases plus get_agent_card and connect_agent.""" + """All seven tools are exposed by default.""" toolset = AgentFinderToolset() tools = await toolset.get_tools() @@ -504,11 +504,11 @@ async def mock_send_message(**kwargs): patch( "a2a.client.card_resolver.A2ACardResolver", return_value=mock_resolver, - ) as _, + ), patch( "a2a.client.client_factory.ClientFactory", return_value=mock_factory, - ) as _, + ), ): result = await connect_tool.run_async( args={ From bf4cd984faa4da1b0b12ff3c8ff76a0307ad8e99 Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 22:01:50 +0000 Subject: [PATCH 06/10] test: add coverage for get_agent_card content handling and local mode - Test JSON and markdown content handling in _get_agent_card - Test local mode delegates to _local_search - Test local mode ImportError returns error dict --- .../tools/ardhf/test_ardhf_toolset.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/unittests/tools/ardhf/test_ardhf_toolset.py b/tests/unittests/tools/ardhf/test_ardhf_toolset.py index f9319a9..820ce62 100644 --- a/tests/unittests/tools/ardhf/test_ardhf_toolset.py +++ b/tests/unittests/tools/ardhf/test_ardhf_toolset.py @@ -327,6 +327,82 @@ async def test_connect_agent_rejects_file_url(self): assert "error" in result +class TestGetAgentCard: + """Tests for the get_agent_card tool's content handling.""" + + @pytest.mark.asyncio + async def test_returns_parsed_json_for_json_content(self): + """JSON content is parsed and returned as a dict.""" + toolset = AgentFinderToolset() + json_content = '{"name": "test-tool", "version": "1.0"}' + + with patch( + "google.adk_community.tools.ardhf.ardhf_toolset" + "._remote_fetch", + return_value=json_content, + ): + mock_context = AsyncMock() + result = await toolset._get_agent_card( + mock_context, url="https://example.com/tool.json" + ) + + assert result["name"] == "test-tool" + assert result["version"] == "1.0" + + @pytest.mark.asyncio + async def test_returns_markdown_for_non_json_content(self): + """Non-JSON content is returned as raw text under 'content'.""" + toolset = AgentFinderToolset() + md_content = "# Skill\n\nThis is a skill." + + with patch( + "google.adk_community.tools.ardhf.ardhf_toolset" + "._remote_fetch", + return_value=md_content, + ): + mock_context = AsyncMock() + result = await toolset._get_agent_card( + mock_context, + url="https://example.com/SKILL.md", + ) + + assert result["content"] == md_content + assert result["content_type"] == "text/markdown" + + @pytest.mark.asyncio + async def test_local_mode_delegates_to_local_search(self): + """Local mode calls _local_search instead of _remote_search.""" + toolset = AgentFinderToolset(local=True) + + with patch( + "google.adk_community.tools.ardhf.ardhf_toolset" + "._local_search", + return_value={"results": []}, + ) as mock_local: + await toolset._do_search("test query", limit=5) + + mock_local.assert_called_once_with( + "test query", + artifact_type=None, + limit=5, + token=None, + ) + + @pytest.mark.asyncio + async def test_local_mode_import_error_returns_error(self): + """Local mode returns error when agentfinder is not installed.""" + toolset = AgentFinderToolset(local=True) + + with patch( + "google.adk_community.tools.ardhf.ardhf_toolset" + "._local_search", + side_effect=ImportError("agentfinder not installed"), + ): + result = await toolset._do_search("test query") + + assert "error" in result + + class TestExtractTextFromA2aResponse: """Tests for the _extract_text_from_a2a_response helper.""" From 4a1a69df4f6b03e28d37c7dfdf8ff832b6aeab8f Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 22:02:56 +0000 Subject: [PATCH 07/10] chore: add missing __init__.py for tests/unittests/tools package --- tests/unittests/tools/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/unittests/tools/__init__.py diff --git a/tests/unittests/tools/__init__.py b/tests/unittests/tools/__init__.py new file mode 100644 index 0000000..58d482e --- /dev/null +++ b/tests/unittests/tools/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. From 5338a49e1b7089e482baedd89d59c7b4cee1769d Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 22:18:10 +0000 Subject: [PATCH 08/10] feat: add configurable allowed_schemes for URL validation Add allowed_schemes parameter to AgentFinderToolset, defaulting to ("http", "https") for security. Users can opt in to additional schemes for dev/testing (file://) or gRPC support (grpc://, grpcs://). Usage: AgentFinderToolset() # strict: http/https only AgentFinderToolset(allowed_schemes=["http", "https", "file"]) AgentFinderToolset(allowed_schemes=["http", "https", "grpc", "grpcs"]) --- .../tools/ardhf/ardhf_toolset.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/google/adk_community/tools/ardhf/ardhf_toolset.py b/src/google/adk_community/tools/ardhf/ardhf_toolset.py index da38b02..37f7d37 100644 --- a/src/google/adk_community/tools/ardhf/ardhf_toolset.py +++ b/src/google/adk_community/tools/ardhf/ardhf_toolset.py @@ -63,6 +63,9 @@ # HTTP timeout for remote requests (seconds). _HTTP_TIMEOUT = 30 +# Default allowed URL schemes (secure by default). +_DEFAULT_ALLOWED_SCHEMES = frozenset(("http", "https")) + def _registry_search_url(registry_url: str) -> str: """Normalise a registry base URL to its ``/search`` endpoint.""" @@ -225,6 +228,11 @@ class AgentFinderToolset(BaseToolset): token: Optional Bearer token for authenticated registry access. local: When ``True``, use the ``agentfinder`` Python package in-process instead of making HTTP requests. + allowed_schemes: URL schemes permitted for ``get_agent_card`` + and ``connect_agent``. Defaults to ``("http", "https")`` + for security (prevents SSRF via ``file://`` etc.). Set to + ``("http", "https", "file")`` for local development or + ``("http", "https", "grpc", "grpcs")`` for gRPC support. tool_filter: Optional filter to select which tools are exposed. tool_name_prefix: Optional prefix for tool names. """ @@ -235,6 +243,7 @@ def __init__( registry_url: str = _DEFAULT_REGISTRY_URL, token: str | None = None, local: bool = False, + allowed_schemes: tuple[str, ...] | list[str] | None = None, tool_filter: ToolPredicate | list[str] | None = None, tool_name_prefix: str | None = None, ) -> None: @@ -245,6 +254,10 @@ def __init__( self._registry_url = registry_url self._token = token self._local = local + self._allowed_schemes = frozenset( + allowed_schemes if allowed_schemes is not None + else _DEFAULT_ALLOWED_SCHEMES + ) # -- Internal search logic ----------------------------------------------- @@ -414,8 +427,8 @@ async def _get_agent_card( For JSON artifacts, the parsed object is returned directly. """ parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - return {"error": f"Invalid URL scheme: {url}"} + if parsed.scheme not in self._allowed_schemes: + return {"error": f"URL scheme '{parsed.scheme}' not allowed: {url}"} try: raw = await asyncio.to_thread( @@ -483,7 +496,7 @@ async def _connect_agent( try: parsed = urlparse(agent_card_url) - if parsed.scheme not in ("http", "https") or not parsed.netloc: + if parsed.scheme not in self._allowed_schemes or not parsed.netloc: return {"error": f"Invalid agent card URL: {agent_card_url}"} base_url = f"{parsed.scheme}://{parsed.netloc}" From 3529b236027d2bb910fc091ff3db5f7800c416df Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Sun, 14 Jun 2026 22:26:16 +0000 Subject: [PATCH 09/10] docs: show proper ADK app structure, document A2A conversation lifecycle - README now shows agent.py + adk web/run workflow (proper ADK app) - connect_agent docs explain long-running tasks and A2A conversation lifecycle - Added RemoteA2aAgent example for multi-turn follow-up after discovery - Updated toolset docstring with lifecycle notes --- contributing/samples/ardhf/README.md | 58 +++++++++++++++---- .../tools/ardhf/ardhf_toolset.py | 6 ++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/contributing/samples/ardhf/README.md b/contributing/samples/ardhf/README.md index 1042bad..876329e 100644 --- a/contributing/samples/ardhf/README.md +++ b/contributing/samples/ardhf/README.md @@ -11,23 +11,37 @@ The core workflow is **discover → inspect → connect**: 1. **Discover** — search ARD registries for resources matching a natural-language query. 2. **Inspect** — fetch the full artifact (agent card, skill markdown, MCP descriptor) by URL. -3. **Connect** — send a message to a remote A2A agent and get a response. +3. **Connect** — send a message to a remote A2A agent and get a response (which may be the start of a long-running A2A conversation). ## Quick Start +### 1. Create the agent (`agent.py`) + ```python from google.adk import Agent from google.adk_community.tools.ardhf import AgentFinderToolset -agent = Agent( - name="my_agent", - instruction="Search for tools when you need a capability.", +root_agent = Agent( + name="discovery_agent", + description="An agent that discovers and connects to agentic resources.", + instruction="Search for agents, skills, and tools when you need a capability.", tools=[AgentFinderToolset()], ) ``` -That's it — the agent now has access to all discovery tools and can search, -inspect, and connect to agentic resources. +### 2. Run the app + +```bash +# Interactive web UI +adk web . + +# Or run programmatically +adk run . +``` + +The agent is an ADK app — serve it with `adk web` for the interactive UI, +or `adk run` for CLI mode. The toolset provides all discovery and connection +tools automatically. ## Available Tools @@ -39,7 +53,7 @@ inspect, and connect to agentic resources. | `search_tools` | Search filtered to MCP servers (`application/mcp-server+json`) | | `search_spaces` | Search filtered to HuggingFace Spaces (`application/vnd.huggingface.space+json`) | | `get_agent_card` | Fetch a specific artifact (agent card, skill markdown, MCP descriptor) by URL | -| `connect_agent` | Send a message to a remote A2A agent and return the response | +| `connect_agent` | Send a message to a remote A2A agent — may return an immediate response or start a long-running task with its own lifecycle | The `search_agents`, `search_skills`, `search_tools`, and `search_spaces` tools are convenience aliases — each calls the same core search logic with @@ -105,6 +119,30 @@ Agent: [calls connect_agent('https://.../agent.json', 'Create a minimalist logo Agent: The image generation agent responded with: ... ``` +**Note on A2A conversations:** `connect_agent` sends a single message and +collects the response, but the remote agent may return a **long-running task** +with its own lifecycle (submitted → working → completed). The response you +get back may be the final result or an intermediate status. This initial +exchange is the **beginning of an A2A conversation** — for multi-turn +interactions with a discovered agent, consider using ADK's `RemoteA2aAgent` +directly with the agent card URL returned by `get_agent_card`: + +```python +from google.adk.agents.remote_a2a_agent import RemoteA2aAgent + +# After discovering an agent via search_agents + get_agent_card: +remote = RemoteA2aAgent( + name="discovered_agent", + agent_card="https://example.com/.well-known/agent.json", +) + +# Use as a sub-agent for ongoing A2A conversation +orchestrator = Agent( + name="orchestrator", + sub_agents=[remote], +) +``` + ### Discovering HuggingFace Spaces > "Find a text-to-speech Space" @@ -244,9 +282,9 @@ fixed fixtures — no API keys or network access needed: pip install hf-agentfinder hf-agentfinder challenge serve --port 8090 -# Terminal 2: run the sample agent against it -ARDHF_REGISTRY_URL=http://127.0.0.1:8090 \ - adk web contributing/samples/ardhf +# Terminal 2: run the sample app against it +cd contributing/samples/ardhf +ARDHF_REGISTRY_URL=http://127.0.0.1:8090 adk web . ``` ### Running the unit tests diff --git a/src/google/adk_community/tools/ardhf/ardhf_toolset.py b/src/google/adk_community/tools/ardhf/ardhf_toolset.py index 37f7d37..b860aae 100644 --- a/src/google/adk_community/tools/ardhf/ardhf_toolset.py +++ b/src/google/adk_community/tools/ardhf/ardhf_toolset.py @@ -461,6 +461,12 @@ async def _connect_agent( be for an artifact with media type ``application/a2a-agent-card+json``. + Note: The remote agent may return an immediate response or start + a long-running task (submitted → working → completed). This + call collects available response text, but the exchange is the + beginning of an A2A conversation — for multi-turn interactions, + use ``RemoteA2aAgent`` directly with the discovered agent card URL. + Args: agent_card_url: Full URL to the remote agent's A2A agent card (typically the ``url`` field from a search result whose From c5019ea8905509c04e82a06907775bcae5a52e19 Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Wed, 1 Jul 2026 03:13:38 +0000 Subject: [PATCH 10/10] chore: trigger CLA re-check