diff --git a/contributing/samples/ardhf/README.md b/contributing/samples/ardhf/README.md new file mode 100644 index 0000000..3af31b0 --- /dev/null +++ b/contributing/samples/ardhf/README.md @@ -0,0 +1,334 @@ +# ARDHF — Agentic Resource Discovery (ARD) Toolset for ADK + + + +## Overview + +ARDHF wraps [HuggingFace Discover](https://github.com/huggingface/hf-discover) +([ARD](https://agenticresourcediscovery.org) — [Agentic Resource Discovery](https://developers.googleblog.com/announcing-the-agentic-resource-discovery-specification/)) as an ADK `BaseToolset`. It gives any ADK +agent the ability to **discover, inspect, and connect to** agents, skills, +MCP servers, HuggingFace Spaces, and other agentic resources at runtime. + +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 (which may be the start of a long-running A2A conversation). + +## Quick Start + +### 1. Clone and install + +```bash +# Clone the repo and check out the PR branch +git clone https://github.com/google/adk-python-community.git +cd adk-python-community + +# Create a virtual environment and install with the ardhf extra +uv venv .venv +source .venv/bin/activate +uv pip install -e ".[ardhf]" +``` + +### 2. Run the sample agent + +```bash +cd contributing/samples/ardhf +adk web . +``` + +Open `http://localhost:8765` in your browser and try: *"Search for code review skills"* + +### 3. (Optional) Use the challenge server for offline testing + +```bash +# Install hf-discover for the deterministic challenge server +uv pip install hf-discover + +# Start the challenge server (separate terminal) +hf-discover challenge serve --port 8090 + +# Run the sample against it +cd contributing/samples/ardhf +ARDHF_REGISTRY_URL=http://127.0.0.1:8090 adk web . +``` + +### Minimal agent code + +```python +from google.adk import Agent +from google.adk_community.tools.ardhf import AgentFinderToolset + +root_agent = Agent( + name="discovery_agent", + model="gemini-flash-latest", + instruction="Search for agents, skills, and tools when you need a capability.", + tools=[AgentFinderToolset()], +) +``` + +The toolset provides all discovery and connection tools automatically. + +## Available Tools + +| Tool | Description | +|---|---| +| `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-card+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 — 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 +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" + +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: ... +``` + +### Finding and using a Skill + Tool together + +> "Find both a triage skill and a labeling tool for my issues" + +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. + +### 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: ... +``` + +**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" + +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. + +``` +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 Discover registry. +Point to any ARD-compatible registry: + +```python +toolset = AgentFinderToolset( + registry_url="http://localhost:8090", +) +``` + +Or set the environment variable: + +```bash +export ARDHF_REGISTRY_URL=http://localhost:8090 +``` + +### Authentication + +Pass a HuggingFace token for authenticated registry access: + +```python +toolset = AgentFinderToolset(token="hf_...") +``` + +Or set the environment variable: + +```bash +export HF_TOKEN=hf_... +``` + +### Local mode + +For in-process, offline-capable search (no HTTP requests), install the +`hf-discover` package and enable local mode: + +```python +toolset = AgentFinderToolset(local=True) +``` + +Or set the environment variable: + +```bash +export ARDHF_LOCAL=1 +``` + +### Environment variables summary + +| 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( + tool_filter=["search_ards", "search_agents", "get_agent_card"], +) +``` + +### Tool name prefix + +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: + +```python +hf_toolset = AgentFinderToolset( + registry_url="https://huggingface-hf-discover.hf.space", + tool_name_prefix="hf", +) +github_toolset = AgentFinderToolset( + registry_url="https://agentfinder.github.com/api/v1", + tool_name_prefix="gh", +) + +agent = Agent( + name="multi_registry_agent", + instruction="Search multiple registries for the best tool.", + tools=[hf_toolset, github_toolset], +) +``` + +### Combining with other ADK toolsets + +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-discover` 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-discover +hf-discover challenge serve --port 8090 + +# 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 + +```bash +# Unit tests (no server needed) +pytest tests/unittests/tools/ardhf/ -v + +# Integration tests (start challenge server first) +hf-discover challenge serve --port 8090 & +pytest tests/unittests/tools/ardhf/ -v +``` + +## References + +- [Announcing the Agentic Resource Discovery Specification](https://developers.googleblog.com/announcing-the-agentic-resource-discovery-specification/) — Google Developers Blog +- [ARD — Agentic Resource Discovery](https://agenticresourcediscovery.org) — Official ARD specification and documentation +- [ARD Specification (GitHub)](https://github.com/ards-project/ard-spec) — ARD spec repository +- [HuggingFace Discover](https://github.com/huggingface/hf-discover) — ARD reference implementation +- [ADK Documentation](https://google.github.io/adk-docs/) — Google Agent Development Kit +- [A2A Protocol](https://github.com/google/A2A) — Agent-to-Agent protocol specification diff --git a/contributing/samples/ardhf/agent.py b/contributing/samples/ardhf/agent.py new file mode 100644 index 0000000..355cea0 --- /dev/null +++ b/contributing/samples/ardhf/agent.py @@ -0,0 +1,162 @@ +# 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 the full discover -> inspect -> connect flow using the +HuggingFace Discover (ARD) registries. The agent can search for +agents, inspect their cards, and connect to remote A2A agents. + +Usage:: + + adk web contributing/samples/ardhf + +Or with the challenge server for deterministic results:: + + hf-discover 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-hf-discover.hf.space", +) +_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", + model="gemini-flash-latest", + description=( + "An agent that discovers and connects to agentic resources " + "using the HuggingFace Discover (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 HuggingFace Discover (ARD) registry, and optionally " + "connect to and interact with discovered A2A agents.\n\n" + "## Tools\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 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" + "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], +) + + +# -- 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 A2A agents for code review and connect to the best one" + 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/contributing/samples/ardhf_dynamic_agents/README.md b/contributing/samples/ardhf_dynamic_agents/README.md new file mode 100644 index 0000000..923cabf --- /dev/null +++ b/contributing/samples/ardhf_dynamic_agents/README.md @@ -0,0 +1,250 @@ +# ARDHF Dynamic Agents — Discover, Connect, Use + +## Overview + +This sample demonstrates an orchestrator agent that **dynamically discovers +and uses remote resources** at runtime using the ARDHF toolset. It supports +all three resource types available through +[ARD (Agentic Resource Discovery)](https://agenticresourcediscovery.org): + +| Resource Type | Media Type | How It's Used | +|---|---|---| +| **A2A Agent** | `application/a2a-agent-card+json` | Delegate tasks via the A2A protocol | +| **MCP Server** | `application/mcp-server-card+json` | Connect to remote tool servers | +| **Skill** | `application/ai-skill` | Fetch and follow SKILL.md instructions | + +Unlike a traditional multi-agent system where sub-agents are hardcoded at +build time, this orchestrator discovers capable resources on the fly and +uses each one according to its type. + +## The Three Patterns + +### Pattern 1: A2A Agent Delegation + +For tasks requiring domain expertise, the orchestrator finds and delegates +to a remote A2A agent: + +``` +User: "How do I register an agent with AgentMsg?" + +Orchestrator: + 1. search_agents("messaging agent registration") → finds AgentMsg Support + 2. get_agent_card(url) → inspects capabilities + 3. connect_agent(url, "How do I register?") → gets detailed answer via A2A + 4. Returns the answer to the user +``` + +**Live example** — The [AgentMsg Support Agent](https://agentmsg.net) is a +public A2A agent that answers questions about agent registration, messaging, +and encryption: + +``` +User: "Ask the AgentMsg support agent how to register" + +Agent: I'll connect to the AgentMsg Support agent via A2A... + [calls connect_agent with the agent card URL] + + Here's how to register an agent with AgentMsg: + 1. POST to /auth/request with your agent details + 2. Wait for admin approval + 3. Retrieve your Bearer token + ... +``` + +The agent card URL for the AgentMsg Support agent: +``` +https://agentmsg-support-462816930018.us-central1.run.app/.well-known/agent-card.json +``` + +### Pattern 2: MCP Server Tools + +For tasks requiring specific tools, the orchestrator finds MCP servers and +reports their endpoints: + +``` +User: "I need a tool to analyze code complexity" + +Orchestrator: + 1. search_tools("code complexity analysis") → finds MCP servers + 2. get_agent_card(url) → fetches server descriptor with endpoint + 3. Reports the MCP server URL and available tools to the user +``` + +MCP servers found via ARD search can be connected to using ADK's +`McpToolset` with `SseConnectionParams` or `StreamableHTTPConnectionParams`: + +```python +from google.adk.tools.mcp_tool.mcp_toolset import ( + McpToolset, + SseConnectionParams, +) + +mcp_tools = McpToolset( + connection_params=SseConnectionParams( + url="https://example.com/mcp", + ), +) +worker = Agent(name="mcp_worker", tools=[mcp_tools]) +``` + +### Pattern 3: Skill Instructions + +For tasks with established workflows, the orchestrator fetches and follows +skill instructions: + +``` +User: "How do I deploy a model to HuggingFace Spaces?" + +Orchestrator: + 1. search_skills("deploy huggingface spaces") → finds relevant skills + 2. get_agent_card(url) → fetches SKILL.md content + 3. Follows the step-by-step instructions to guide the user +``` + +## 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 | + +## How To + +### Install + +```bash +pip install google-adk-community +# Required for A2A agent connectivity: +pip install 'google-adk[a2a]' +``` + +### Run + +```bash +# With adk web (interactive UI) +adk web contributing/samples/ardhf_dynamic_agents + +# Or directly (runs the A2A demo) +python -m contributing.samples.ardhf_dynamic_agents.agent +``` + +### With the challenge server + +```bash +# Terminal 1 +hf-discover challenge serve --port 8090 + +# Terminal 2 +ARDHF_REGISTRY_URL=http://127.0.0.1:8090 \ + adk web contributing/samples/ardhf_dynamic_agents +``` + +## Sample Conversations + +### A2A: Ask the AgentMsg Support agent + +``` +User: Ask the AgentMsg support agent how to register an agent + +Agent: I'll connect to the AgentMsg Support agent to get registration + instructions. + + [searches → finds agent card → connects via A2A] + + The AgentMsg Support agent says: + + To register an agent with AgentMsg: + 1. POST to /auth/request with your agent_id, name, and description + 2. Wait for admin approval (check status via /auth/status/{token}) + 3. Once approved, retrieve your Bearer token (shown only once) + 4. Use the token in Authorization headers for all API calls + + For local development, register directly via POST /agents/register. +``` + +### MCP: Find a code analysis tool + +``` +User: Find me a tool for analyzing code complexity + +Agent: Let me search for MCP servers that provide code analysis tools. + + [searches → finds Code-Complexity-Analyzer MCP server] + + I found an MCP server for code complexity analysis. You can + connect to it at: + https://example.hf.space/mcp/code-complexity/server.json + + To use it in your agent, add it as an McpToolset: + McpToolset(connection_params=SseConnectionParams(url="...")) +``` + +### Skills: Follow deployment instructions + +``` +User: How do I use ZeroGPU on HuggingFace? + +Agent: Let me search for skills related to ZeroGPU. + + [searches → finds zerogpu skill → fetches SKILL.md] + + Based on the ZeroGPU skill instructions: + 1. Add @spaces.GPU decorator to your GPU functions + 2. Use gr.Blocks() for your Gradio interface + 3. Deploy to a ZeroGPU-enabled Space + ... +``` + +## Architecture + +``` +User + | + v ++----------------------------------+ +| dynamic_orchestrator | +| (no built-in domain skills) | +| | +| Tools: | +| +-- 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 ------------+--> Artifact URL +| +-- connect_agent ------------+--> Remote A2A Agent ++----------------------------------+ + | | | + v v v + +-----------+ +------------+ +----------+ + | A2A Agent | | MCP Server | | Skill | + | (via A2A | | (tool | | (SKILL.md| + | JSON-RPC)| | endpoint) | | content)| + +-----------+ +------------+ +----------+ +``` + +## Key Concepts + +- **Dynamic discovery** -- The orchestrator doesn't know about resources + at build time. It discovers them at runtime through ARD search. +- **Type-aware delegation** -- Different resource types are used + differently: A2A agents receive delegated tasks, MCP servers provide + tools, and Skills provide instructions. +- **A2A protocol** -- Communication with remote agents uses the standard + Agent-to-Agent protocol with JSON-RPC fallback, enabling interoperability + across frameworks. +- **Graceful fallback** -- If no suitable resource 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. +- [ARD -- Agentic Resource Discovery](https://agenticresourcediscovery.org) -- Official ARD specification and documentation. +- [HuggingFace Discover](https://github.com/huggingface/hf-discover) -- ARD reference implementation. +- [A2A Protocol](https://github.com/google/A2A) -- Agent-to-Agent protocol specification. +- [AgentMsg](https://agentmsg.net) -- Store-and-forward message relay for AI agents (first public A2A agent). diff --git a/contributing/samples/ardhf_dynamic_agents/agent.py b/contributing/samples/ardhf_dynamic_agents/agent.py new file mode 100644 index 0000000..0120222 --- /dev/null +++ b/contributing/samples/ardhf_dynamic_agents/agent.py @@ -0,0 +1,192 @@ +# 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 that discovers and dynamically uses A2A agents, MCP tools, and Skills. + +This sample demonstrates the full discover-and-use pattern for all three +types of agentic resources available through ARD (Agentic Resource Discovery): + + **A2A Agents** — Remote agents that speak the Agent-to-Agent protocol. + The orchestrator uses ``connect_agent`` to send a message and get + a response via A2A JSON-RPC, enabling cross-framework delegation. + + **MCP Servers** — Remote tool servers that expose tools via the Model + Context Protocol. The orchestrator can connect to an MCP server + and make its tools available for function calling. + + **Skills** — Markdown instructions (SKILL.md) that describe how to + accomplish a task. The orchestrator fetches the skill content + and follows the instructions directly. + +The flow: + 1. User asks the orchestrator something it can't do alone. + 2. Orchestrator searches ARD registries for capable resources. + 3. Based on the resource type found: + - A2A agent -> delegate via ``connect_agent`` + - MCP server -> connect and use its tools + - Skill -> fetch SKILL.md and follow its instructions + 4. Orchestrator returns the result to the user. + +Usage:: + + adk web contributing/samples/ardhf_dynamic_agents + +Or with the challenge server:: + + hf-discover challenge serve --port 8090 & + ARDHF_REGISTRY_URL=http://127.0.0.1:8090 \ + adk web contributing/samples/ardhf_dynamic_agents + +With a known A2A agent:: + + python -m contributing.samples.ardhf_dynamic_agents.agent +""" + +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-hf-discover.hf.space", +) +_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 " + "uses remote A2A agents, MCP tools, and Skills at runtime." + ), + instruction=( + "You are a smart orchestrator. You do not have built-in " + "domain expertise -- instead, you dynamically find and " + "use specialised remote resources.\n\n" + "## Resource Types\n\n" + "ARD registries contain three types of resources you can " + "discover and use:\n\n" + "### 1. A2A Agents (application/a2a-agent-card+json)\n" + "Remote agents that accept messages via the A2A protocol. " + "Use `search_agents` to find them and `connect_agent` to " + "send a message and get a response. This is the best " + "option when you need an agent with domain expertise to " + "handle a task end-to-end.\n\n" + "**Example flow:**\n" + "1. `search_agents('messaging support')` -> finds agents\n" + "2. `get_agent_card(url)` -> inspect capabilities\n" + "3. `connect_agent(url, 'How do I register?')` -> get answer\n\n" + "### 2. MCP Servers (application/mcp-server-card+json)\n" + "Remote tool servers that expose callable tools. Use " + "`search_tools` to find them and `get_agent_card` to fetch " + "the server descriptor with its endpoint URL. Then tell " + "the user the available MCP endpoint so they can connect " + "their agent to it. MCP servers provide specific tools " + "(like code analysis, database queries) rather than full " + "agent capabilities.\n\n" + "**Example flow:**\n" + "1. `search_tools('code complexity analysis')` -> finds servers\n" + "2. `get_agent_card(url)` -> get server details and endpoint\n" + "3. Report the MCP endpoint URL and available tools to the user\n\n" + "### 3. Skills (application/ai-skill)\n" + "Markdown instructions (SKILL.md) that describe how to " + "accomplish a specific task. Use `search_skills` to find " + "them and `get_agent_card` to fetch the skill content. " + "Then follow the instructions in the skill markdown to " + "help the user. Skills are like recipes -- they tell you " + "what to do step by step.\n\n" + "**Example flow:**\n" + "1. `search_skills('deploy to huggingface')` -> finds skills\n" + "2. `get_agent_card(url)` -> fetch SKILL.md content\n" + "3. Follow the instructions to help the user\n\n" + "## Known Resources\n\n" + "- **AgentMsg Support Agent** (A2A): An agent that answers " + "questions about AgentMsg, a store-and-forward message relay " + "for AI agents. Agent card URL: " + "https://agentmsg-support-462816930018.us-central1.run.app" + "/.well-known/agent-card.json\n\n" + "## Guidelines\n\n" + "- Always search before delegating -- don't assume you know " + "which resource to use.\n" + "- Use `search_ards` for a broad search across all types, " + "or use the type-specific search tools for targeted results.\n" + "- If the user asks about a known resource (like AgentMsg), " + "you can use `connect_agent` directly with its URL.\n" + "- If search returns no suitable resources, tell the user " + "what you searched for and that no matching resources 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 resource." + ), + tools=[agent_finder_toolset], +) + + +# -- Main ------------------------------------------------------------------ + + +async def main() -> None: + """Run the orchestrator with a sample A2A 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", + ) + + # Demo: Ask the AgentMsg Support agent a question via A2A. + prompt = ( + "Ask the AgentMsg Support agent how to register an agent " + "with AgentMsg. Its agent card is at: " + "https://agentmsg-support-462816930018.us-central1.run.app" + "/.well-known/agent-card.json" + ) + 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 a03bdca..83ca072 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-discover>=1.0.0"] hitl = [ "aiosqlite>=0.20.0", "fastapi>=0.110.0", 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..baf2976 --- /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 — HuggingFace Discover (ARD) toolset for ADK. + +Wraps HuggingFace Discover +(https://github.com/huggingface/hf-discover) 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..071ad5e --- /dev/null +++ b/src/google/adk_community/tools/ardhf/ardhf_toolset.py @@ -0,0 +1,692 @@ +# 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 Discover (ARD) as BaseToolset. + +Supports two modes: + +* **remote** (default) — HTTP POST to any ARD-compatible registry + endpoint (e.g. the hosted ``hf-discover``). +* **local** — uses the ``discover`` Python package in-process for + zero-latency, offline-capable search. + +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. + +Reference: https://github.com/huggingface/hf-discover +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin, urlparse +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 Discover registry. +_DEFAULT_REGISTRY_URL = "https://huggingface-hf-discover.hf.space" + +# 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.""" + normalised = registry_url.rstrip("/") + if normalised.endswith("/search"): + return normalised + return urljoin(f"{normalised}/", "search") + + +_KIND_TO_MEDIA_TYPES: dict[str, list[str]] = { + "skill": ["application/ai-skill"], + "mcp": [ + "application/mcp-server-card+json", + "application/mcp-server+json", + ], + "space": ["application/vnd.huggingface.space+json"], + "a2a": ["application/a2a-agent-card+json"], +} + + +def _artifact_types_for_kind(kind: str) -> list[str] | None: + """Map a human-friendly kind label to its ARD media type(s).""" + return _KIND_TO_MEDIA_TYPES.get(kind) + + +def _remote_search( + registry_url: str, + query: str, + *, + artifact_types: list[str] | None = None, + limit: int = 10, + 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} + if artifact_types is not None: + search_query["filter"] = {"type": artifact_types} + + 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: str | None = 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: str | None = None, + limit: int = 10, + token: str | None = None, +) -> dict[str, Any]: + """Search using the in-process ``discover`` package.""" + try: + from discover.models import SearchQuery, SearchRequest + from discover.server import search_discover + except ImportError as exc: + raise ImportError( + "Local mode requires the 'hf-discover' package. " + "Install it with: pip install hf-discover" + ) 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_discover(request, token=token) + return response.model_dump( + exclude_none=True, exclude_defaults=True + ) + + +def _extract_text_from_a2a_response(a2a_response: Any) -> list[str]: + """Extract text strings from an A2A client StreamResponse. + + The a2a-sdk 1.x ``send_message`` yields ``StreamResponse`` protobuf + messages. Each may contain a ``task`` (with artifacts and status) or + a ``message`` (with parts). This helper walks through and collects + all text content. + """ + texts: list[str] = [] + + def _extract_from_parts( + parts: Any, + ) -> None: + if not parts: + return + for part in parts: + # Protobuf Part: check if the 'text' oneof is set. + if hasattr(part, "HasField") and part.HasField("text"): + texts.append(part.text) + # Pydantic Part (older SDKs): check for root.text. + elif hasattr(part, "root") and hasattr(part.root, "text"): + texts.append(part.root.text) + + # StreamResponse has oneof: task, message, status_update, artifact_update. + if hasattr(a2a_response, "HasField"): + # Protobuf StreamResponse + if a2a_response.HasField("task"): + task = a2a_response.task + for artifact in task.artifacts: + _extract_from_parts(artifact.parts) + if task.HasField("status") and task.status.HasField("message"): + _extract_from_parts(task.status.message.parts) + elif a2a_response.HasField("message"): + _extract_from_parts(a2a_response.message.parts) + elif isinstance(a2a_response, tuple): + # Legacy tuple format (Task, update) + task = a2a_response[0] + if task is not None: + for artifact in getattr(task, "artifacts", None) or []: + _extract_from_parts(getattr(artifact, "parts", None)) + 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)) + + return texts + + +class AgentFinderToolset(BaseToolset): + """ADK BaseToolset wrapping HuggingFace Discover (ARD). + + 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. + + 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 ``discover`` 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. + """ + + def __init__( + self, + *, + 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: + super().__init__( + tool_filter=tool_filter, + tool_name_prefix=tool_name_prefix, + ) + 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 ----------------------------------------------- + + async def _do_search( + self, + query: str, + 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(s). + resolved_types = ( + _artifact_types_for_kind(artifact_type) if artifact_type else None + ) + if resolved_types is None and artifact_type is not None: + # Assume it is already a raw media type string. + resolved_types = [artifact_type] + + try: + if self._local: + return await asyncio.to_thread( + _local_search, + query, + artifact_type=resolved_types[0] if resolved_types else None, + limit=limit, + token=self._token, + ) + return await asyncio.to_thread( + _remote_search, + self._registry_url, + query, + artifact_types=resolved_types, + limit=limit, + token=self._token, + ) + 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: + return {"error": str(exc)} + + # -- Tool implementations ----------------------------------------------- + + async def search_ards( + self, + tool_context: ToolContext, + query: str, + artifact_type: str | None = 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-card+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, + 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. + """ + parsed = urlparse(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( + _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", + } + + 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``. + + 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 + ``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: + import httpx + 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, + Part as A2APart, + Role as A2ARole, + SendMessageConfiguration, + SendMessageRequest, + ) + except ImportError: + return { + "error": ( + "A2A dependencies are not installed. Install them" + " with: pip install 'google-adk[a2a]'" + ) + } + + try: + parsed = urlparse(agent_card_url) + 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}" + 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, + ) + agent_name = getattr(agent_card, "name", "unknown") + + # Try the SDK-based A2A client first. + try: + factory = A2AClientFactory( + config=A2AClientConfig(httpx_client=http_client), + ) + a2a_client = factory.create(agent_card) + + # a2a-sdk >=1.x uses protobuf: Part(text=...) and + # SendMessageRequest wrapping a Message. + a2a_msg = A2AMessage( + message_id=str(uuid.uuid4()), + parts=[A2APart(text=message)], + role=A2ARole.ROLE_USER, + ) + request = SendMessageRequest( + message=a2a_msg, + configuration=SendMessageConfiguration(), + ) + + 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) + ) + + 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 sdk_exc: + # SDK call failed (e.g. interface URL differs from card + # URL and has SSL / connectivity issues). Fall back to + # raw JSON-RPC POST at the card URL's base. + logger.info( + "SDK A2A call failed (%s), trying JSON-RPC " + "fallback to %s", + sdk_exc, + base_url, + ) + return await self._connect_agent_jsonrpc_fallback( + http_client=http_client, + base_url=base_url, + agent_name=agent_name, + agent_card_url=agent_card_url, + message=message, + ) + + 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}" + ), + } + + async def _connect_agent_jsonrpc_fallback( + self, + *, + http_client: Any, + base_url: str, + agent_name: str, + agent_card_url: str, + message: str, + ) -> dict[str, Any]: + """Send an A2A message via raw JSON-RPC POST. + + This fallback is used when the SDK-based client fails (e.g. due to + the agent card's interface URL differing from the card fetch URL + with SSL or connectivity issues). We POST a ``message/send`` + JSON-RPC request directly to the card URL's base. + """ + jsonrpc_payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "message/send", + "params": { + "message": { + "messageId": str(uuid.uuid4()), + "role": "user", + "parts": [{"kind": "text", "text": message}], + }, + }, + } + try: + resp = await http_client.post( + f"{base_url}/", + json=jsonrpc_payload, + headers={"Content-Type": "application/json"}, + ) + resp.raise_for_status() + data = resp.json() + + # Extract text from the JSON-RPC result. + result = data.get("result", {}) + texts: list[str] = [] + for artifact in result.get("artifacts", []): + for part in artifact.get("parts", []): + if part.get("kind") == "text" and "text" in part: + texts.append(part["text"]) + + # Also check status message. + status = result.get("status", {}) + status_msg = status.get("message", {}) + if isinstance(status_msg, dict): + for part in status_msg.get("parts", []): + if part.get("kind") == "text" and "text" in part: + texts.append(part["text"]) + + return { + "response": "\n".join(texts) if texts else "", + "agent_name": agent_name, + "agent_url": agent_card_url, + "method": "jsonrpc_fallback", + } + except Exception as exc: + return { + "error": ( + f"Failed to communicate with agent at" + f" {agent_card_url}: {exc}" + ), + } + + # -- BaseToolset interface ---------------------------------------------- + + async def get_tools( + self, + readonly_context: ReadonlyContext | None = None, + ) -> list[BaseTool]: + """Return all search, inspect, and connect tools.""" + tool_funcs = [ + self.search_ards, + self.search_agents, + self.search_skills, + self.search_tools, + self.search_spaces, + self.get_agent_card, + self.connect_agent, + ] + tools: list[BaseTool] = [FunctionTool(func) for func in tool_funcs] + + 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/__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. 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..6d6dd4b --- /dev/null +++ b/tests/unittests/tools/ardhf/test_ardhf_toolset.py @@ -0,0 +1,839 @@ +# 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-discover challenge server (deterministic fixtures, +no API keys needed):: + + pip install hf-discover + hf-discover challenge serve --port 8090 + pytest tests/unittests/tools/ardhf/ -v +""" + +from __future__ import annotations + +import json +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from google.adk_community.tools.ardhf.ardhf_toolset import ( + AgentFinderToolset, + _artifact_types_for_kind, + _extract_text_from_a2a_response, + _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 TestArtifactTypesForKind: + """Tests for kind-to-media-type mapping.""" + + def test_skill_kind(self): + assert _artifact_types_for_kind("skill") == ["application/ai-skill"] + + def test_mcp_kind(self): + types = _artifact_types_for_kind("mcp") + assert "application/mcp-server-card+json" in types + assert "application/mcp-server+json" in types + + def test_space_kind(self): + assert _artifact_types_for_kind("space") == [ + "application/vnd.huggingface.space+json" + ] + + def test_a2a_kind(self): + assert _artifact_types_for_kind("a2a") == [ + "application/a2a-agent-card+json" + ] + + def test_unknown_kind_returns_none(self): + assert _artifact_types_for_kind("unknown") is None + + +class TestToolsetGetTools: + """Tests for AgentFinderToolset.get_tools without a running server.""" + + @pytest.mark.asyncio + async def test_returns_all_tools(self): + """All seven tools are exposed by default.""" + toolset = AgentFinderToolset() + + tools = await toolset.get_tools() + + assert len(tools) == 7 + names = {tool.name for tool in tools} + 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): + """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_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_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_ards") + + mock_context = AsyncMock() + result = await search_tool.run_async( + args={"query": "test", "limit": 5}, + tool_context=mock_context, + ) + + assert "error" in result + + @pytest.mark.asyncio + async def test_search_agents_delegates_with_a2a_type(self): + """search_agents passes artifact_type='a2a' 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_agents( + mock_context, query="test", limit=5 + ) + + mock_search.assert_called_once_with( + "test", artifact_type="a2a", limit=5 + ) + + @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_tools_delegates_with_mcp_type(self): + """search_tools passes artifact_type='mcp' 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_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 + ) + + +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_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_types"] == ["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() + 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 "error" in result + + @pytest.mark.asyncio + async def test_get_agent_card_rejects_file_url(self): + """get_agent_card rejects file:// URLs to prevent SSRF.""" + toolset = AgentFinderToolset() + mock_context = AsyncMock() + + result = await toolset.get_agent_card( + mock_context, url="file:///etc/passwd" + ) + + assert "error" in result + assert "not allowed" 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 "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 hf-discover is not installed.""" + toolset = AgentFinderToolset(local=True) + + with patch( + "google.adk_community.tools.ardhf.ardhf_toolset" + "._local_search", + side_effect=ImportError("hf-discover not installed"), + ): + result = await toolset._do_search("test query") + + assert "error" in result + + +class TestExtractTextFromA2aResponse: + """Tests for the _extract_text_from_a2a_response helper.""" + + def test_extracts_text_from_stream_response_message(self): + """Text is extracted from a StreamResponse with a message.""" + try: + from a2a.types import ( + Message as A2AMessage, + Part as A2APart, + Role as A2ARole, + StreamResponse, + ) + except ImportError: + pytest.skip("a2a SDK not installed") + + stream_resp = StreamResponse() + msg = A2AMessage( + message_id="test-1", + parts=[A2APart(text="Hello from agent")], + role=A2ARole.ROLE_AGENT, + ) + stream_resp.message.CopyFrom(msg) + + texts = _extract_text_from_a2a_response(stream_resp) + + assert texts == ["Hello from agent"] + + def test_extracts_text_from_stream_response_task(self): + """Text is extracted from a StreamResponse with a task.""" + try: + from a2a.types import ( + Artifact, + Part as A2APart, + StreamResponse, + Task as A2ATask, + TaskState, + TaskStatus, + ) + except ImportError: + pytest.skip("a2a SDK not installed") + + stream_resp = StreamResponse() + task = A2ATask( + id="task-1", + context_id="ctx-1", + ) + task.status.CopyFrom( + TaskStatus(state=TaskState.TASK_STATE_COMPLETED) + ) + artifact = Artifact(artifact_id="art-1") + artifact.parts.append(A2APart(text="Task result")) + task.artifacts.append(artifact) + stream_resp.task.CopyFrom(task) + + texts = _extract_text_from_a2a_response(stream_resp) + + assert "Task result" in texts + + def test_extracts_text_from_legacy_tuple(self): + """Text is extracted from a legacy (task, update) tuple.""" + # Simulate a legacy pydantic-style response with mock objects + # that have root.text but NOT HasField (pydantic path). + mock_part = MagicMock(spec=["root"]) + mock_part.root = MagicMock(spec=["text"]) + mock_part.root.text = "Legacy result" + mock_artifact = MagicMock(spec=["parts"]) + mock_artifact.parts = [mock_part] + mock_task = MagicMock(spec=["artifacts", "status"]) + mock_task.artifacts = [mock_artifact] + mock_task.status = None + + texts = _extract_text_from_a2a_response((mock_task, None)) + + assert "Legacy 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 == [] + + +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, + Message as A2AMessage, + Part as A2APart, + Role as A2ARole, + StreamResponse, + ) + except ImportError: + pytest.skip("a2a SDK not installed") + + mock_agent_card = MagicMock(spec=AgentCard) + mock_agent_card.name = "test-agent" + + # Build a StreamResponse with a message containing text. + stream_resp = StreamResponse() + msg = A2AMessage( + message_id="resp-1", + parts=[A2APart(text="I can help!")], + role=A2ARole.ROLE_AGENT, + ) + stream_resp.message.CopyFrom(msg) + + async def mock_send_message(**kwargs): + yield stream_resp + + 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, + ), + patch( + "a2a.client.client_factory.ClientFactory", + return_value=mock_factory, + ), + ): + 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) # +# ------------------------------------------------------------------ # + + +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-card+json", + limit=10, + ) + + for result in response["results"]: + assert result["type"] == "application/mcp-server-card+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_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_ards" + ) + + 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_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_ards" + ) + + 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-card+json"