From 445bcc1f7602c0d22e31af125363888a53289acc Mon Sep 17 00:00:00 2001 From: SyedMohamedHyder Date: Fri, 25 Jul 2025 14:10:23 +0530 Subject: [PATCH 1/4] Agent Card Builder boiler plate code --- src/agents/agent_card_builder.py | 261 +++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 src/agents/agent_card_builder.py diff --git a/src/agents/agent_card_builder.py b/src/agents/agent_card_builder.py new file mode 100644 index 000000000..0ee41f59f --- /dev/null +++ b/src/agents/agent_card_builder.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from a2a.types import AgentCapabilities, AgentCard, AgentSkill + +from .agent import Agent +from .run_context import RunContextWrapper + +if TYPE_CHECKING: + from .handoffs import Handoff + from .run_context import TContext + + +NO_CONTEXT: RunContextWrapper[None] = RunContextWrapper[None](context=None) + + +@dataclass +class AgentCardBuilder: + """Builder class for creating AgentCard instances from Agent configurations. + + This class provides methods to extract and build agent capabilities, skills, + and metadata into a structured AgentCard format. It handles complex agent + hierarchies with tools and handoffs, generating comprehensive skill + descriptions and orchestration capabilities. + + The builder supports: + - Tool-based skill extraction + - Handoff capability mapping + - Orchestration skill generation + - Recursive agent traversal for complex workflows + """ + + agent: Agent + """The agent to build a card for.""" + + url: str + """The URL where the agent can be accessed.""" + + version: str + """Version string for the agent.""" + + capabilities: AgentCapabilities = field(default_factory=AgentCapabilities) + """Capabilities specification for the agent.""" + + default_input_modes: list[str] = field(default_factory=lambda: ["text/plain"]) + """Default input modes for the agent, e.g., 'text', 'image', etc.""" + + default_output_modes: list[str] = field(default_factory=lambda: ["text/plain"]) + """Default output modes for the agent, e.g., 'text', 'image', etc.""" + + async def build_tool_skills(self, agent: Agent) -> list[AgentSkill]: + """Build skills from the agent's available tools. + + Args: + agent: The agent to extract tool skills from. + + Returns: + A list of AgentSkill objects representing the agent's tools. + """ + tools = await agent.get_all_tools(NO_CONTEXT) + + if not tools: + return [] + + skills = [] + for tool in tools: + skill = AgentSkill( + id=f"{agent.name}-{tool.name}", + name=tool.name, + description=getattr(tool, "description", None) or f"Tool: {tool.name}", + tags=tool.name.split("_"), + ) + skills.append(skill) + + return skills + + async def build_handoff_skills(self, agent: Agent) -> list[AgentSkill]: + """Build skills from the agent's handoff capabilities. + + Args: + agent: The agent to extract handoff skills from. + + Returns: + A list of AgentSkill objects representing handoff capabilities. + """ + if not agent.handoffs: + return [] + + skills = [] + visited_agents = {agent.name} # Track to prevent circular dependencies + + for handoff in agent.handoffs: + if getattr(handoff, "name", None) in visited_agents: + continue + + handoff_skills = await self._build_handoff_skills_recursive( + handoff, visited_agents.copy() + ) + skills.extend(handoff_skills) + + return skills + + async def _build_handoff_skills_recursive( + self, handoff: Agent[Any] | Handoff[TContext, Any], visited_agents: set[str] + ) -> list[AgentSkill]: + """Recursively build skills for a handoff agent. + + Args: + handoff: The handoff to build skills for. + visited_agents: Set of already visited agent names to prevent cycles. + + Returns: + List of skills for the handoff agent. + """ + handoff_name = getattr(handoff, "name", None) + + if handoff_name in visited_agents: + # Circular dependency detected - return empty list to prevent infinite recursion + return [] + + if handoff_name: + visited_agents.add(handoff_name) + if hasattr(handoff, "name"): + return await self.build_agent_skills(handoff) # type: ignore[arg-type] + + return [] + + async def build_orchestration_skill(self, agent: Agent) -> AgentSkill | None: + """Build an orchestration skill that describes the agent's coordination capabilities. + + This method creates a comprehensive skill description that encompasses both + tool usage and handoff capabilities, providing a high-level view of the + agent's orchestration abilities. + + Args: + agent: The agent to build orchestration skill for. + + Returns: + An AgentSkill describing orchestration capabilities, or None if no coordination needed. + """ + handoff_descriptions = self._build_handoff_descriptions(agent) + tool_descriptions = await self._build_tool_descriptions(agent) + + if not handoff_descriptions and not tool_descriptions: + return None + + sections = [] + if handoff_descriptions: + sections.append("Handoffs:\n" + "\n".join(handoff_descriptions)) + + if tool_descriptions: + sections.append("Tools:\n" + "\n".join(tool_descriptions)) + + description = ( + f"Orchestrates across multiple tools and agents for {agent.name}. " + "Coordinates requests and delegates tasks appropriately:\n" + "\n\n".join(sections) + ).strip() + + return AgentSkill( + id=f"{agent.name}_orchestration", + name=f"{agent.name}: Orchestration", + description=description, + tags=["handoff", "orchestration", "coordination"], + ) + + def _build_handoff_descriptions(self, agent: Agent) -> list[str]: + """Build descriptions for agent handoffs.""" + return [ + f"- {getattr(handoff, 'name', 'Unknown')}: " + f"{getattr(handoff, 'handoff_description', None) or 'No description available'}".strip() + for handoff in agent.handoffs + ] + + async def _build_tool_descriptions(self, agent: Agent) -> list[str]: + """Build descriptions for agent tools.""" + tools = await agent.get_all_tools(NO_CONTEXT) + return [ + f"- {tool.name}: " + f"{getattr(tool, 'description', None) or 'No description available'}".strip() + for tool in tools + ] + + async def build_agent_skills(self, agent: Agent) -> list[AgentSkill]: + """Build all skills for a given agent. + + This method coordinates the extraction of all skill types from an agent, + including tool-based skills and orchestration capabilities. It ensures + comprehensive coverage of the agent's functionality. + + Args: + agent: The agent to build skills for. + + Returns: + A list of all AgentSkill objects for the agent. + """ + skills: list[AgentSkill] = [] + + # Build tool-based skills + tool_skills = await self.build_tool_skills(agent) + skills.extend(tool_skills) + + # Build orchestration skill if the agent has coordination capabilities + if agent.handoffs or tool_skills: + orchestration = await self.build_orchestration_skill(agent) + if orchestration: + skills.append(orchestration) + + return skills + + async def build_skills(self) -> list[AgentSkill]: + """Build all skills for the configured agent and its handoffs. + + This is the main coordination method that builds a comprehensive skill set + including both direct agent capabilities and transitive handoff skills. + + Returns: + A comprehensive list of all AgentSkill objects. + """ + agent_skills_task = self.build_agent_skills(self.agent) + handoff_skills_task = self.build_handoff_skills(self.agent) + + agent_skills, handoff_skills = await asyncio.gather( + agent_skills_task, handoff_skills_task + ) + + all_skills = [*agent_skills, *handoff_skills] + + unique_skills_dict = {} + for skill in all_skills: + if skill.id not in unique_skills_dict: + unique_skills_dict[skill.id] = skill + + return list(unique_skills_dict.values()) + + async def build(self) -> AgentCard: + """Build the complete AgentCard for the configured agent. + + This method creates a comprehensive agent card that includes all extracted + skills, capabilities, and metadata. It serves as the main entry point for + converting an Agent configuration into a structured AgentCard format. + + Returns: + A fully constructed AgentCard with all skills and metadata. + """ + skills = await self.build_skills() + + card = AgentCard( + name=self.agent.name, + capabilities=self.capabilities, + default_input_modes=self.default_input_modes, + default_output_modes=self.default_output_modes, + description=self.agent.handoff_description or f"Agent: {self.agent.name}", + skills=skills, + url=self.url, + version=self.version, + ) + + return card From d5cfc016d3006917cd89c8099a856cb6caf6422d Mon Sep 17 00:00:00 2001 From: SyedMohamedHyder Date: Fri, 25 Jul 2025 14:11:47 +0530 Subject: [PATCH 2/4] UTCs added for agent card builder --- tests/test_agent_card_builder.py | 744 +++++++++++++++++++++++++++++++ 1 file changed, 744 insertions(+) create mode 100644 tests/test_agent_card_builder.py diff --git a/tests/test_agent_card_builder.py b/tests/test_agent_card_builder.py new file mode 100644 index 000000000..37ac73dd9 --- /dev/null +++ b/tests/test_agent_card_builder.py @@ -0,0 +1,744 @@ +from typing import Any + +import pytest +from a2a.types import AgentCapabilities, AgentCard + +from agents.agent import Agent +from agents.agent_card_builder import AgentCardBuilder +from agents.tool import function_tool + + +# Test fixtures and mock tools +@function_tool +def mock_tool_1() -> str: + """Test tool 1 description.""" + return "tool_1_result" + + +@function_tool +def mock_tool_2() -> str: + """Test tool 2 description.""" + return "tool_2_result" + + +@function_tool +def complex_tool_name() -> str: + """Complex tool with underscores.""" + return "complex_result" + + +@function_tool +def tool_without_description() -> str: + return "no_description_result" + + +class TestAgentCardBuilder: + """Test suite for AgentCardBuilder class.""" + + @pytest.fixture + def simple_agent(self) -> Agent: + """Create a simple agent for testing.""" + return Agent( + name="SimpleAgent", + handoff_description="A simple test agent", + tools=[mock_tool_1, mock_tool_2], + ) + + @pytest.fixture + def agent_without_tools(self) -> Agent: + """Create an agent without tools.""" + return Agent(name="EmptyAgent", handoff_description="Agent without tools") + + @pytest.fixture + def agent_with_handoffs(self, simple_agent: Agent) -> Agent: + """Create an agent with handoffs.""" + handoff_agent = Agent( + name="HandoffTarget", + handoff_description="Target for handoffs", + tools=[complex_tool_name], + ) + return Agent( + name="MainAgent", + handoff_description="Agent with handoffs", + tools=[mock_tool_1], + handoffs=[handoff_agent], + ) + + @pytest.fixture + def complex_agent_hierarchy(self) -> Agent: + """Create a complex agent hierarchy for testing.""" + # Leaf agents + leaf_agent_1 = Agent( + name="LeafAgent1", handoff_description="Leaf agent 1", tools=[mock_tool_1, mock_tool_2] + ) + + leaf_agent_2 = Agent( + name="LeafAgent2", handoff_description="Leaf agent 2", tools=[complex_tool_name] + ) + + # Middle tier agent + middle_agent = Agent( + name="MiddleAgent", + handoff_description="Middle tier agent", + tools=[tool_without_description], + handoffs=[leaf_agent_1, leaf_agent_2], + ) + + # Root agent + root_agent = Agent( + name="RootAgent", + handoff_description="Root agent with complex hierarchy", + tools=[mock_tool_1], + handoffs=[middle_agent, leaf_agent_2], # Includes duplicate leaf_agent_2 + ) + + return root_agent + + @pytest.fixture + def circular_dependency_agents(self) -> tuple[Agent, Agent]: + """Create agents with circular dependencies.""" + # This will create a circular reference for testing + agent_a = Agent(name="AgentA", handoff_description="Agent A") + agent_b = Agent(name="AgentB", handoff_description="Agent B") + + # Create circular dependency + agent_a.handoffs = [agent_b] + agent_b.handoffs = [agent_a] + + return agent_a, agent_b + + @pytest.fixture + def basic_builder(self, simple_agent: Agent) -> AgentCardBuilder: + """Create a basic AgentCardBuilder.""" + return AgentCardBuilder( + agent=simple_agent, url="https://example.com/agent", version="1.0.0" + ) + + @pytest.fixture + def full_featured_builder(self, complex_agent_hierarchy: Agent) -> AgentCardBuilder: + """Create a fully featured AgentCardBuilder.""" + capabilities = AgentCapabilities() + return AgentCardBuilder( + agent=complex_agent_hierarchy, + url="https://example.com/complex-agent", + capabilities=capabilities, + default_input_modes=["text/plain", "image/jpeg"], + default_output_modes=["text/plain", "application/json"], + version="2.1.0", + ) + + @pytest.mark.parametrize( + "agent_fixture,expected_count,expected_skills", + [ + ( + "simple_agent", + 2, + [ + { + "id": "SimpleAgent-mock_tool_1", + "name": "mock_tool_1", + "description": "Test tool 1 description.", + "tags": ["mock", "tool", "1"], + }, + { + "id": "SimpleAgent-mock_tool_2", + "name": "mock_tool_2", + "description": "Test tool 2 description.", + "tags": ["mock", "tool", "2"], + }, + ], + ), + ("agent_without_tools", 0, []), + ], + ids=["with_tools", "no_tools"], + ) + @pytest.mark.asyncio + async def test_build_tool_skills( + self, + basic_builder: AgentCardBuilder, + agent_fixture: str, + expected_count: int, + expected_skills: list[dict[str, Any]], + request: pytest.FixtureRequest, + ) -> None: + """Test building tool skills when agent has/doesn't have tools.""" + agent = request.getfixturevalue(agent_fixture) + skills = await basic_builder.build_tool_skills(agent) + + assert len(skills) == expected_count + + for i, expected_skill in enumerate(expected_skills): + skill = skills[i] + assert skill.id == expected_skill["id"] + assert skill.name == expected_skill["name"] + assert skill.description == expected_skill["description"] + assert skill.tags == expected_skill["tags"] + + @pytest.mark.asyncio + async def test_build_tool_skills_complex_names(self, basic_builder: AgentCardBuilder) -> None: + """Test building tool skills with complex tool names.""" + agent_with_complex_tool = Agent( + name="ComplexAgent", tools=[complex_tool_name, tool_without_description] + ) + + skills = await basic_builder.build_tool_skills(agent_with_complex_tool) + + assert len(skills) == 2 + + # Check complex tool + complex_skill = skills[0] + assert complex_skill.id == "ComplexAgent-complex_tool_name" + assert complex_skill.name == "complex_tool_name" + assert complex_skill.description == "Complex tool with underscores." + assert complex_skill.tags == ["complex", "tool", "name"] + + # Check tool without description + no_desc_skill = skills[1] + assert no_desc_skill.id == "ComplexAgent-tool_without_description" + assert no_desc_skill.name == "tool_without_description" + assert no_desc_skill.description == "Tool: tool_without_description" # Fallback description + assert no_desc_skill.tags == ["tool", "without", "description"] + + @pytest.mark.parametrize( + "agent_fixture,expected_skills_count", + [ + ("simple_agent", 0), # no handoffs + ("agent_with_handoffs", 1), # has handoffs + ], + ids=["no_handoffs", "with_handoffs"], + ) + @pytest.mark.asyncio + async def test_build_handoff_skills( + self, + basic_builder: AgentCardBuilder, + agent_fixture: str, + expected_skills_count: int, + request: pytest.FixtureRequest, + ) -> None: + """Test building handoff skills when agent has/doesn't have handoffs.""" + agent = request.getfixturevalue(agent_fixture) + skills = await basic_builder.build_handoff_skills(agent) + + if expected_skills_count == 0: + assert skills == [] + else: + assert len(skills) > 0 + + @pytest.mark.asyncio + async def test_build_handoff_skills_circular_dependency( + self, basic_builder: AgentCardBuilder, circular_dependency_agents: tuple[Agent, Agent] + ) -> None: + """Test building handoff skills with circular dependencies.""" + agent_a, agent_b = circular_dependency_agents + + # Should handle circular dependencies gracefully + skills = await basic_builder.build_handoff_skills(agent_a) + + # Should not hang or throw errors due to circular reference + assert isinstance(skills, list) + + @pytest.mark.asyncio + async def test_build_handoff_skills_recursive(self, basic_builder: AgentCardBuilder) -> None: + """Test recursive handoff skill building.""" + # Create a three-level hierarchy + level_3_agent = Agent( + name="Level3Agent", handoff_description="Third level agent", tools=[mock_tool_1] + ) + + level_2_agent = Agent( + name="Level2Agent", + handoff_description="Second level agent", + tools=[mock_tool_2], + handoffs=[level_3_agent], + ) + + level_1_agent = Agent( + name="Level1Agent", handoff_description="First level agent", handoffs=[level_2_agent] + ) + + skills = await basic_builder.build_handoff_skills(level_1_agent) + + # Should collect skills from all levels + assert len(skills) > 0 + + @pytest.mark.parametrize( + "agent_type,expected_skill_not_none,expected_id,expected_name,expected_tags,expected_in_description,expected_not_in_description", + [ + ( + "agent_with_handoffs", + True, + "MainAgent_orchestration", + "MainAgent: Orchestration", + ["handoff", "orchestration", "coordination"], + [ + "Orchestrates across multiple tools and agents for MainAgent", + "Handoffs:", + "Tools:", + ], + [], + ), + ( + "tools_only", + True, + "SimpleAgent_orchestration", + "SimpleAgent: Orchestration", + ["orchestration"], + ["Tools:"], + ["Handoffs:"], + ), + ], + ids=["with_handoffs_and_tools", "tools_only"], + ) + @pytest.mark.asyncio + async def test_build_orchestration_skill_with_capabilities( + self, + basic_builder: AgentCardBuilder, + simple_agent: Agent, + agent_with_handoffs: Agent, + agent_type: str, + expected_skill_not_none: bool, + expected_id: str, + expected_name: str, + expected_tags: list[str], + expected_in_description: list[str], + expected_not_in_description: list[str], + ) -> None: + """Test building orchestration skill when agent has coordination capabilities.""" + agent_map = {"agent_with_handoffs": agent_with_handoffs, "tools_only": simple_agent} + + agent = agent_map[agent_type] + skill = await basic_builder.build_orchestration_skill(agent) + + if expected_skill_not_none: + assert skill is not None + assert skill.id == expected_id + assert skill.name == expected_name + + for tag in expected_tags: + assert tag in skill.tags + + for text in expected_in_description: + assert text in skill.description + + for text in expected_not_in_description: + assert text not in skill.description + else: + assert skill is None + + @pytest.mark.parametrize( + "agent_config,expected_skill_none", + [ + ({"name": "MinimalAgent", "handoff_description": "Agent without capabilities"}, True), + ( + { + "name": "HandoffOnlyAgent", + "handoff_description": "Agent with only handoffs", + "handoffs": True, + }, + False, + ), + ], + ids=["no_capabilities", "handoffs_only"], + ) + @pytest.mark.asyncio + async def test_build_orchestration_skill_edge_cases( + self, + basic_builder: AgentCardBuilder, + agent_config: dict[str, Any], + expected_skill_none: bool, + ) -> None: + """Test building orchestration skill for edge cases.""" + if agent_config.get("handoffs"): + handoff_target = Agent(name="Target", tools=[mock_tool_1]) + agent = Agent( + name=agent_config["name"], + handoff_description=agent_config["handoff_description"], + handoffs=[handoff_target], + ) + else: + agent = Agent( + name=agent_config["name"], handoff_description=agent_config["handoff_description"] + ) + + skill = await basic_builder.build_orchestration_skill(agent) + + if expected_skill_none: + assert skill is None + else: + assert skill is not None + assert "Handoffs:" in skill.description + + @pytest.mark.parametrize( + "agent_config,expected_descriptions", + [ + ( + { + "name": "MainAgent", + "handoffs": [ + {"name": "HandoffTarget", "handoff_description": "Target for handoffs"} + ], + }, + ["- HandoffTarget: Target for handoffs"], + ), + ( + {"name": "TestAgent", "handoffs": [{"name": "NoDescHandoff"}]}, + ["- NoDescHandoff: No description available"], + ), + ], + ids=["with_description", "no_description"], + ) + def test_build_handoff_descriptions( + self, + basic_builder: AgentCardBuilder, + agent_config: dict[str, Any], + expected_descriptions: list[str], + ) -> None: + """Test building handoff descriptions.""" + handoffs = [] + for handoff_config in agent_config["handoffs"]: + handoff = Agent( + name=handoff_config["name"], + handoff_description=handoff_config.get("handoff_description"), + ) + handoffs.append(handoff) + + agent = Agent(name=agent_config["name"], handoffs=handoffs) # type: ignore[arg-type] + + descriptions = basic_builder._build_handoff_descriptions(agent) + + assert len(descriptions) == len(expected_descriptions) + for expected_desc in expected_descriptions: + assert expected_desc in descriptions[0] + + @pytest.mark.parametrize( + "agent_fixture,expected_descriptions", + [ + ( + "simple_agent", + [ + "- mock_tool_1: Test tool 1 description.", + "- mock_tool_2: Test tool 2 description.", + ], + ), + ("agent_without_tools", []), + ], + ids=["with_tools", "no_tools"], + ) + @pytest.mark.asyncio + async def test_build_tool_descriptions( + self, + basic_builder: AgentCardBuilder, + agent_fixture: str, + expected_descriptions: list[str], + request: pytest.FixtureRequest, + ) -> None: + """Test building tool descriptions.""" + agent = request.getfixturevalue(agent_fixture) + descriptions = await basic_builder._build_tool_descriptions(agent) + + assert descriptions == expected_descriptions + + @pytest.mark.parametrize( + "agent_fixture,min_expected_skills,has_orchestration", + [ + ("simple_agent", 2, True), # tools + orchestration + ("agent_without_tools", 0, False), # no tools, no handoffs = no skills + ], + ids=["with_tools", "without_capabilities"], + ) + @pytest.mark.asyncio + async def test_build_agent_skills( + self, + basic_builder: AgentCardBuilder, + agent_fixture: str, + min_expected_skills: int, + has_orchestration: bool, + request: pytest.FixtureRequest, + ) -> None: + """Test building all skills for an agent.""" + agent = request.getfixturevalue(agent_fixture) + skills = await basic_builder.build_agent_skills(agent) + + assert len(skills) >= min_expected_skills + + orchestration_skills = [s for s in skills if "orchestration" in s.tags] + if has_orchestration: + assert len(orchestration_skills) == 1 + else: + assert len(orchestration_skills) == 0 + + @pytest.mark.asyncio + async def test_build_skills_deduplication( + self, full_featured_builder: AgentCardBuilder + ) -> None: + """Test that build_skills properly deduplicates skills.""" + skills = await full_featured_builder.build_skills() + + # Collect all skill IDs + skill_ids = [skill.id for skill in skills] + + # Should have no duplicates + assert len(skill_ids) == len(set(skill_ids)) + + @pytest.mark.asyncio + async def test_build_skills_comprehensive( + self, full_featured_builder: AgentCardBuilder + ) -> None: + """Test comprehensive skill building.""" + skills = await full_featured_builder.build_skills() + + assert len(skills) > 0 + + # Should contain both agent skills and handoff skills + skill_names = [skill.name for skill in skills] + + # Check that we have skills from different sources + assert any("orchestration" in name.lower() for name in skill_names) + + @pytest.mark.parametrize( + "builder_fixture,expected_name,expected_description,expected_url,expected_version,expected_input_modes,expected_output_modes", + [ + ( + "basic_builder", + "SimpleAgent", + "A simple test agent", + "https://example.com/agent", + "1.0.0", + ["text/plain"], + ["text/plain"], + ), + ( + "full_featured_builder", + "RootAgent", + "Root agent with complex hierarchy", + "https://example.com/complex-agent", + "2.1.0", + ["text/plain", "image/jpeg"], + ["text/plain", "application/json"], + ), + ], + ids=["minimal_config", "full_featured_config"], + ) + @pytest.mark.asyncio + async def test_build_card( + self, + builder_fixture: str, + expected_name: str, + expected_description: str, + expected_url: str, + expected_version: str, + expected_input_modes: list[str], + expected_output_modes: list[str], + request: pytest.FixtureRequest, + ) -> None: + """Test building agent card with different configurations.""" + builder: AgentCardBuilder = request.getfixturevalue(builder_fixture) + card = await builder.build() + + assert isinstance(card, AgentCard) + assert card.name == expected_name + assert card.description == expected_description + assert card.url == expected_url + assert card.version == expected_version + assert card.capabilities is not None + assert card.default_input_modes == expected_input_modes + assert card.default_output_modes == expected_output_modes + assert len(card.skills) > 0 + + @pytest.mark.asyncio + async def test_build_card_fallback_description(self) -> None: + """Test building agent card when no handoff_description is provided.""" + agent_no_desc = Agent(name="NoDescAgent", tools=[mock_tool_1]) + builder = AgentCardBuilder(agent=agent_no_desc, url="https://example.com", version="1.5.0") + + card = await builder.build() + + assert card.description == "Agent: NoDescAgent" + + @pytest.mark.parametrize( + "builder_config,expected_version,has_custom_capabilities", + [ + ({"url": "https://minimal.com", "version": "0.1.0"}, "0.1.0", False), + ( + {"url": "https://custom.com", "version": "1.2.3", "capabilities": "custom"}, + "1.2.3", + True, + ), + ], + ids=["minimal_params", "custom_capabilities"], + ) + @pytest.mark.asyncio + async def test_builder_with_different_parameter_combinations( + self, builder_config: dict[str, Any], expected_version: str, has_custom_capabilities: bool + ) -> None: + """Test builder with various parameter combinations.""" + agent = Agent(name="TestAgent", tools=[mock_tool_1]) + + kwargs = { + "agent": agent, + "url": builder_config["url"], + "version": builder_config["version"], + } + + if has_custom_capabilities: + custom_capabilities = AgentCapabilities() + kwargs["capabilities"] = custom_capabilities + + builder = AgentCardBuilder(**kwargs) + card = await builder.build() + + assert card.version == expected_version + assert card.capabilities is not None + + if has_custom_capabilities: + assert card.capabilities == custom_capabilities + + @pytest.mark.asyncio + async def test_empty_agent_hierarchy(self) -> None: + """Test with completely empty agent hierarchy.""" + empty_agent = Agent(name="EmptyAgent") + builder = AgentCardBuilder(agent=empty_agent, url="https://empty.com", version="0.0.1") + + card = await builder.build() + + assert card.name == "EmptyAgent" + assert len(card.skills) == 0 # No tools, no handoffs = no skills + + @pytest.mark.asyncio + async def test_deeply_nested_handoffs(self) -> None: + """Test with deeply nested handoff hierarchy.""" + # Create a 5-level deep hierarchy + agents: list[Agent] = [] + for i in range(5): + agent = Agent( + name=f"Level{i}Agent", + handoff_description=f"Agent at level {i}", + tools=[mock_tool_1] if i % 2 == 0 else [mock_tool_2], + ) + if i > 0: + agent.handoffs = [agents[i - 1]] + agents.append(agent) + + root_agent = agents[-1] + builder = AgentCardBuilder(agent=root_agent, url="https://deep.com", version="2.0.0") + + skills = await builder.build_skills() + + # Should handle deep nesting without issues + assert len(skills) > 0 + + @pytest.mark.asyncio + async def test_build_with_special_characters_in_names(self) -> None: + """Test building skills with special characters in names.""" + + @function_tool + def tool_with_special_chars() -> str: + """Tool with special characters in description: !@#$%^&*()""" + return "special" + + agent = Agent( + name="Agent-With-Dashes", + handoff_description="Agent with special chars: !@#$%", + tools=[tool_with_special_chars], + ) + + builder = AgentCardBuilder(agent=agent, url="https://special.com", version="1.2.3-alpha") + card = await builder.build() + + assert card.name == "Agent-With-Dashes" + assert len(card.skills) > 0 + + # Check skill ID formation with special characters + tool_skill = [s for s in card.skills if "tool_with_special_chars" in s.name][0] + assert tool_skill.id == "Agent-With-Dashes-tool_with_special_chars" + + @pytest.mark.asyncio + async def test_large_number_of_tools(self) -> None: + """Test building skills with a large number of tools.""" + # Create many tools + tools = [] + for i in range(50): + # Create a properly named function for each tool + def create_tool_func(index: int): + def tool_func() -> str: + return f"tool_{index}" + + tool_func.__name__ = f"tool_{index}" + tool_func.__doc__ = f"Tool number {index}" + return tool_func + + tool = function_tool(create_tool_func(i)) + tools.append(tool) + + agent = Agent(name="ManyToolsAgent", tools=tools) # type: ignore[arg-type] + builder = AgentCardBuilder(agent=agent, url="https://many.com", version="1.0.0") + + skills = await builder.build_skills() + + # Should handle many tools efficiently + # We expect at least 50 tool skills + 1 orchestration skill + assert len(skills) >= 50 # At least one skill per tool + + @pytest.mark.parametrize( + "builder_config,expected_capabilities_is_custom,expected_input_modes,expected_output_modes,expected_version", + [ + ( + {"url": "https://test.com", "version": "1.0.0"}, + False, + ["text/plain"], + ["text/plain"], + "1.0.0", + ), + ( + { + "url": "https://custom.com", + "version": "3.0.0", + "capabilities": "custom", + "default_input_modes": ["text/plain", "audio/wav"], + "default_output_modes": ["application/json"], + }, + True, + ["text/plain", "audio/wav"], + ["application/json"], + "3.0.0", + ), + ], + ids=["default_factory_values", "custom_values"], + ) + def test_builder_dataclass_fields( + self, + builder_config: dict[str, Any], + expected_capabilities_is_custom: bool, + expected_input_modes: list[str], + expected_output_modes: list[str], + expected_version: str, + ) -> None: + """Test that AgentCardBuilder dataclass fields work correctly.""" + agent = Agent(name="TestAgent") + + kwargs = { + "agent": agent, + "url": builder_config["url"], + "version": builder_config["version"], + } + + custom_capabilities = None + if expected_capabilities_is_custom: + custom_capabilities = AgentCapabilities() + kwargs["capabilities"] = custom_capabilities + + if "default_input_modes" in builder_config: + kwargs["default_input_modes"] = builder_config["default_input_modes"] + + if "default_output_modes" in builder_config: + kwargs["default_output_modes"] = builder_config["default_output_modes"] + + builder = AgentCardBuilder(**kwargs) + + assert builder.capabilities is not None + assert builder.default_input_modes == expected_input_modes + assert builder.default_output_modes == expected_output_modes + assert builder.version == expected_version + + if expected_capabilities_is_custom: + assert builder.capabilities == custom_capabilities From b144a1719e9fe50778c8fb0d9c29cbc9894c2727 Mon Sep 17 00:00:00 2001 From: SyedMohamedHyder Date: Fri, 25 Jul 2025 14:12:31 +0530 Subject: [PATCH 3/4] uv dependencies for a2a updated --- pyproject.toml | 1 + uv.lock | 65 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f21040b45..1bfba8d18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ voice = ["numpy>=2.2.0, <3; python_version>='3.10'", "websockets>=15.0, <16"] viz = ["graphviz>=0.17"] litellm = ["litellm>=1.67.4.post1, <2"] realtime = ["websockets>=15.0, <16"] +a2a = ["a2a-sdk>=0.2.16; python_version >= '3.10'"] [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index e24040d4a..9a8494218 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,25 @@ resolution-markers = [ "python_full_version < '3.10'", ] +[[package]] +name = "a2a-sdk" +version = "0.2.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi", marker = "python_full_version >= '3.10'" }, + { name = "httpx", marker = "python_full_version >= '3.10'" }, + { name = "httpx-sse", marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-api", marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-sdk", marker = "python_full_version >= '3.10'" }, + { name = "pydantic", marker = "python_full_version >= '3.10'" }, + { name = "sse-starlette", marker = "python_full_version >= '3.10'" }, + { name = "starlette", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/3b/8fd1e3fe28606712c203b968a6fe2c8e7944b6df9e65c28976c66c19286c/a2a_sdk-0.2.16.tar.gz", hash = "sha256:d9638c71674183f32fe12f8865015e91a563a90a3aa9ed43020f1b23164862b3", size = 179006, upload-time = "2025-07-21T19:51:14.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/92/16bfbc2ef0ef037c5860ef3b13e482aeb1860b9643bf833ed522c995f639/a2a_sdk-0.2.16-py3-none-any.whl", hash = "sha256:54782eab3d0ad0d5842bfa07ff78d338ea836f1259ece51a825c53193c67c7d0", size = 103090, upload-time = "2025-07-21T19:51:12.613Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -1495,6 +1514,9 @@ dependencies = [ ] [package.optional-dependencies] +a2a = [ + { name = "a2a-sdk", marker = "python_full_version >= '3.10'" }, +] litellm = [ { name = "litellm" }, ] @@ -1536,6 +1558,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "a2a-sdk", marker = "python_full_version >= '3.10' and extra == 'a2a'", specifier = ">=0.2.16" }, { name = "graphviz", marker = "extra == 'viz'", specifier = ">=0.17" }, { name = "griffe", specifier = ">=1.5.6,<2" }, { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.67.4.post1,<2" }, @@ -1549,7 +1572,7 @@ requires-dist = [ { name = "websockets", marker = "extra == 'realtime'", specifier = ">=15.0,<16" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] -provides-extras = ["voice", "viz", "litellm", "realtime"] +provides-extras = ["voice", "viz", "litellm", "realtime", "a2a"] [package.metadata.requires-dev] dev = [ @@ -1577,6 +1600,46 @@ dev = [ { name = "websockets" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/c9/4509bfca6bb43220ce7f863c9f791e0d5001c2ec2b5867d48586008b3d96/opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe", size = 64778, upload-time = "2025-07-11T12:23:28.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/5a/3f8d078dbf55d18442f6a2ecedf6786d81d7245844b2b20ce2b8ad6f0307/opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06", size = 65566, upload-time = "2025-07-11T12:23:07.944Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-semantic-conventions", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/cf/1eb2ed2ce55e0a9aa95b3007f26f55c7943aeef0a783bb006bdd92b3299e/opentelemetry_sdk-1.35.0.tar.gz", hash = "sha256:2a400b415ab68aaa6f04e8a6a9f6552908fb3090ae2ff78d6ae0c597ac581954", size = 160871, upload-time = "2025-07-11T12:23:39.566Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/4f/8e32b757ef3b660511b638ab52d1ed9259b666bdeeceba51a082ce3aea95/opentelemetry_sdk-1.35.0-py3-none-any.whl", hash = "sha256:223d9e5f5678518f4842311bb73966e0b6db5d1e0b74e35074c052cd2487f800", size = 119379, upload-time = "2025-07-11T12:23:24.521Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.56b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/8e/214fa817f63b9f068519463d8ab46afd5d03b98930c39394a37ae3e741d0/opentelemetry_semantic_conventions-0.56b0.tar.gz", hash = "sha256:c114c2eacc8ff6d3908cb328c811eaf64e6d68623840be9224dc829c4fd6c2ea", size = 124221, upload-time = "2025-07-11T12:23:40.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/3f/e80c1b017066a9d999efffe88d1cce66116dcf5cb7f80c41040a83b6e03b/opentelemetry_semantic_conventions-0.56b0-py3-none-any.whl", hash = "sha256:df44492868fd6b482511cc43a942e7194be64e94945f572db24df2e279a001a2", size = 201625, upload-time = "2025-07-11T12:23:25.63Z" }, +] + [[package]] name = "packaging" version = "24.2" From bd5478c5bc5347734b599b315f5f53f075c70056 Mon Sep 17 00:00:00 2001 From: SyedMohamedHyder Date: Fri, 25 Jul 2025 14:12:49 +0530 Subject: [PATCH 4/4] Documentation updated to reflect the new `a2a` features. --- docs/a2a.md | 214 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/agents.md | 6 ++ mkdocs.yml | 1 + 3 files changed, 221 insertions(+) create mode 100644 docs/a2a.md diff --git a/docs/a2a.md b/docs/a2a.md new file mode 100644 index 000000000..727e0b0d2 --- /dev/null +++ b/docs/a2a.md @@ -0,0 +1,214 @@ +# Agent-to-Agent (A2A) Protocol + +The Agent-to-Agent protocol provides standardized interfaces for describing, sharing, and integrating AI agents across different systems. This protocol enables seamless interoperability between agent frameworks and platforms. + +## Agent Cards + +Agent cards provide a standardized way to describe and export agent capabilities, making it easier to share, document, and integrate agents across different systems. The [`AgentCardBuilder`][agents.agent_card_builder.AgentCardBuilder] class converts agent configurations into structured `AgentCard` objects that describe skills, capabilities, and metadata. + +### Installation + +Install the optional `a2a` dependency group: + +```bash +pip install "openai-agents[a2a]" +``` + +### Basic Usage + +```python +from agents import Agent, function_tool +from agents.agent_card_builder import AgentCardBuilder + +@function_tool +def search_web(query: str) -> str: + """Search the web for information.""" + return f"Search results for: {query}" + +@function_tool +def send_email(to: str, subject: str, body: str) -> str: + """Send an email to a recipient.""" + return f"Email sent to {to}" + +# Create an agent with tools +research_agent = Agent( + name="Research Assistant", + instructions="Help users research topics and communicate findings", + handoff_description="An AI assistant that can research topics and send email summaries", + tools=[search_web, send_email], +) + +# Build the agent card +builder = AgentCardBuilder( + agent=research_agent, + url="https://api.example.com/agents/research", + version="1.0.0", +) + +card = await builder.build() +print(f"Agent: {card.name}") +print(f"Description: {card.description}") +print(f"Skills: {[skill.name for skill in card.skills]}") +``` + +### Advanced Configuration + +You can customize the agent card with additional capabilities and metadata: + +```python +from a2a.types import AgentCapabilities + +# Configure custom capabilities +capabilities = AgentCapabilities( + input_modes=["text", "audio"], + output_modes=["text", "image"], + supports_streaming=True, +) + +builder = AgentCardBuilder( + agent=research_agent, + url="https://api.example.com/agents/research", + version="2.1.0", + capabilities=capabilities, + default_input_modes=["text/plain", "audio/wav"], + default_output_modes=["text/plain", "image/png"], +) + +card = await builder.build() +``` + +### Agent Hierarchies and Handoffs + +The card builder automatically handles complex agent hierarchies with handoffs, creating comprehensive skill descriptions that include both direct tools and orchestration capabilities: + +```python +# Create specialized agents +email_agent = Agent( + name="Email Specialist", + instructions="Handle email operations", + tools=[send_email], +) + +web_agent = Agent( + name="Web Researcher", + instructions="Perform web research", + tools=[search_web], +) + +# Create orchestrator agent with handoffs +coordinator = Agent( + name="Research Coordinator", + instructions="Coordinate research and communication tasks", + handoff_description="Orchestrates research and email workflows", + handoffs=[email_agent, web_agent], +) + +# Build card for the coordinator +builder = AgentCardBuilder( + agent=coordinator, + url="https://api.example.com/agents/coordinator", + version="1.0.0", +) + +card = await builder.build() + +# The card will include: +# - Orchestration skills describing coordination capabilities +# - Skills from handoff agents (email and web tools) +# - Proper skill deduplication across the hierarchy +``` + +### Understanding Generated Skills + +The agent card builder creates several types of skills: + +1. **Tool Skills**: Direct mappings from agent tools to skills +2. **Handoff Skills**: Skills representing capabilities of handoff agents +3. **Orchestration Skills**: High-level skills describing coordination and workflow capabilities + +Each skill includes: +- **ID**: Unique identifier (e.g., `"ResearchAgent-search_web"`) +- **Name**: Human-readable name derived from tool/capability name +- **Description**: Detailed description from tool docstrings or agent descriptions +- **Tags**: Automatically generated tags for categorization + +!!! note + + The card builder automatically handles circular dependencies in agent hierarchies and deduplicates skills to prevent redundancy in the final card. + +### Working with Agent Cards + +Once you have an `AgentCard`, you can use it for various purposes: + +#### Serialization and Sharing + +```python +import json + +# Convert card to dictionary for serialization +card_dict = card.model_dump() + +# Serialize to JSON +card_json = json.dumps(card_dict, indent=2) + +# Save to file +with open("research_agent_card.json", "w") as f: + f.write(card_json) +``` + +#### Capability Discovery + +```python +# Check agent capabilities +print(f"Supports streaming: {card.capabilities.supports_streaming}") +print(f"Input modes: {card.capabilities.input_modes}") +print(f"Output modes: {card.capabilities.output_modes}") + +# List all skills +for skill in card.skills: + print(f"- {skill.name}: {skill.description}") + print(f" Tags: {skill.tags}") +``` + +## AgentCardBuilder API Reference + +The [`AgentCardBuilder`][agents.agent_card_builder.AgentCardBuilder] class provides fine-grained control over card generation: + +### Core Methods + +- `build_tool_skills(agent)`: Extract skills from agent tools +- `build_handoff_skills(agent)`: Extract skills from handoff capabilities +- `build_orchestration_skill(agent)`: Generate orchestration skills for coordination +- `build_agent_skills(agent)`: Build comprehensive skills for a single agent +- `build_skills()`: Build all skills including transitive handoff skills +- `build()`: Generate the complete `AgentCard` + +### Customization Options + +- `capabilities`: Configure agent capabilities (streaming, input/output modes) +- `default_input_modes`/`default_output_modes`: Set default communication modes +- `url`: Specify where the agent can be accessed +- `version`: Set agent version for tracking and compatibility + +## Best Practices + +### Card Design + +1. **Descriptive Names**: Use clear, descriptive names for agents and tools +2. **Rich Descriptions**: Provide detailed descriptions in tool docstrings and agent configurations +3. **Proper Versioning**: Use semantic versioning for agent cards +4. **Capability Accuracy**: Ensure capabilities accurately reflect agent abilities + +### Integration Patterns + +1. **A2A Server Integration**: Agent cards are used by A2A servers to describe agent capabilities for discovery +2. **Protocol Compliance**: Cards provide standardized metadata format for A2A protocol compatibility +3. **Capability Discovery**: Enable automatic discovery of agent skills and supported interaction modes +4. **Version Management**: Support versioning for agent evolution and compatibility tracking + +### Performance Considerations + +1. **Skill Deduplication**: The builder automatically deduplicates skills across agent hierarchies +2. **Circular Dependency Handling**: Built-in protection against infinite recursion in agent graphs +3. **Async Processing**: All card building operations are async for better performance +4. **Caching**: Consider caching built cards for frequently accessed agents diff --git a/docs/agents.md b/docs/agents.md index d6b719824..6e2b047dc 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -133,6 +133,12 @@ robot_agent = pirate_agent.clone( ) ``` +## Agent-to-Agent (A2A) Protocol + +The OpenAI Agents SDK supports the Agent-to-Agent protocol for standardized agent interoperability. This includes agent cards for describing and sharing agent capabilities across different systems. + +For detailed information about A2A features including agent card creation, see the [A2A documentation](a2a.md). + ## Forcing tool use Supplying a list of tools doesn't always mean the LLM will use a tool. You can force tool use by setting [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]. Valid values are: diff --git a/mkdocs.yml b/mkdocs.yml index be4976be4..965a54063 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ plugins: - Examples: examples.md - Documentation: - agents.md + - a2a.md - running_agents.md - sessions.md - results.md