From 1a4faf94629937ce6ff45d46af24d4d1595a6f40 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Thu, 27 Aug 2026 15:51:26 +0000 Subject: [PATCH] feat(agent-card): add metadata field and expose list filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `metadata: dict[str, Any]` field to the SDK's `AgentCard` model (defaulting to an empty dict) and threads the value through `AgentCard.from_states` / `AgentCard.from_state_machine` so callers can attach opt-in capability flags without subclassing. Also plumbs the paired platform `agent_card_metadata` list filter through the Stainless-generated `agents.list` surface so consumers can enumerate agents whose card metadata contains a given JSON object with exact key/value semantics. The card continues to serialize through the existing `registration_metadata.agent_card` path — no wire-shape or database migration is required. --- src/agentex/lib/types/agent_card.py | 10 ++++- src/agentex/resources/agents/agents.py | 12 ++++++ src/agentex/types/agent_list_params.py | 4 ++ tests/api_resources/test_agents.py | 2 + tests/lib/test_agent_card.py | 58 ++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/agentex/lib/types/agent_card.py b/src/agentex/lib/types/agent_card.py index def4464c6..eeed6bfdb 100644 --- a/src/agentex/lib/types/agent_card.py +++ b/src/agentex/lib/types/agent_card.py @@ -5,7 +5,7 @@ from enum import Enum from typing import TYPE_CHECKING, Any, get_args, get_origin -from pydantic import BaseModel +from pydantic import BaseModel, Field if TYPE_CHECKING: from agentex.lib.sdk.state_machine.state import State @@ -31,6 +31,10 @@ class AgentCard(BaseModel): data_events: list[str] = [] input_types: list[str] = [] output_schema: dict | None = None + # Free-form JSON object for opt-in self-description (e.g. protocol-specific + # capability flags) that callers can filter agents by via + # ``GET /agents?agent_card_metadata=...``. Not interpreted by the platform. + metadata: dict[str, Any] = Field(default_factory=dict) @classmethod def from_states( @@ -40,6 +44,7 @@ def from_states( output_event_model: type[BaseModel] | None = None, extra_input_types: list[str] | None = None, queries: list[str] | None = None, + metadata: dict[str, Any] | None = None, ) -> AgentCard: """Build an AgentCard directly from a list[State] + initial_state. @@ -81,6 +86,7 @@ def from_states( data_events=data_events, input_types=sorted(derived_input_types | set(extra_input_types or [])), output_schema=output_schema, + metadata=metadata or {}, ) @classmethod @@ -90,6 +96,7 @@ def from_state_machine( output_event_model: type[BaseModel] | None = None, extra_input_types: list[str] | None = None, queries: list[str] | None = None, + metadata: dict[str, Any] | None = None, ) -> AgentCard: """Build an AgentCard from a StateMachine instance. Delegates to from_states().""" lifecycle = state_machine.get_lifecycle() @@ -125,6 +132,7 @@ def from_state_machine( data_events=data_events, input_types=sorted(derived_input_types | set(extra_input_types or [])), output_schema=output_schema, + metadata=metadata or {}, ) diff --git a/src/agentex/resources/agents/agents.py b/src/agentex/resources/agents/agents.py index 924fab3fb..242af5385 100644 --- a/src/agentex/resources/agents/agents.py +++ b/src/agentex/resources/agents/agents.py @@ -116,6 +116,7 @@ def retrieve( def list( self, *, + agent_card_metadata: Optional[str] | Omit = omit, limit: int | Omit = omit, order_by: Optional[str] | Omit = omit, order_direction: str | Omit = omit, @@ -132,6 +133,10 @@ def list( List all registered agents, optionally filtered by query parameters. Args: + agent_card_metadata: JSON-encoded object filtered against + ``registration_metadata.agent_card.metadata`` using exact key/value + containment semantics. + limit: Limit order_by: Field to order by @@ -159,6 +164,7 @@ def list( timeout=timeout, query=maybe_transform( { + "agent_card_metadata": agent_card_metadata, "limit": limit, "order_by": order_by, "order_direction": order_direction, @@ -777,6 +783,7 @@ async def retrieve( async def list( self, *, + agent_card_metadata: Optional[str] | Omit = omit, limit: int | Omit = omit, order_by: Optional[str] | Omit = omit, order_direction: str | Omit = omit, @@ -793,6 +800,10 @@ async def list( List all registered agents, optionally filtered by query parameters. Args: + agent_card_metadata: JSON-encoded object filtered against + ``registration_metadata.agent_card.metadata`` using exact key/value + containment semantics. + limit: Limit order_by: Field to order by @@ -820,6 +831,7 @@ async def list( timeout=timeout, query=await async_maybe_transform( { + "agent_card_metadata": agent_card_metadata, "limit": limit, "order_by": order_by, "order_direction": order_direction, diff --git a/src/agentex/types/agent_list_params.py b/src/agentex/types/agent_list_params.py index 084fdfaed..5d3b4bd28 100644 --- a/src/agentex/types/agent_list_params.py +++ b/src/agentex/types/agent_list_params.py @@ -9,6 +9,10 @@ class AgentListParams(TypedDict, total=False): + agent_card_metadata: Optional[str] + """JSON-encoded object filtered against ``registration_metadata.agent_card.metadata`` + with exact key/value containment semantics.""" + limit: int """Limit""" diff --git a/tests/api_resources/test_agents.py b/tests/api_resources/test_agents.py index 501dcb662..4fb6ea4e2 100644 --- a/tests/api_resources/test_agents.py +++ b/tests/api_resources/test_agents.py @@ -75,6 +75,7 @@ def test_method_list(self, client: Agentex) -> None: @parametrize def test_method_list_with_all_params(self, client: Agentex) -> None: agent = client.agents.list( + agent_card_metadata="agent_card_metadata", limit=1, order_by="order_by", order_direction="order_direction", @@ -469,6 +470,7 @@ async def test_method_list(self, async_client: AsyncAgentex) -> None: @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: agent = await async_client.agents.list( + agent_card_metadata="agent_card_metadata", limit=1, order_by="order_by", order_direction="order_direction", diff --git a/tests/lib/test_agent_card.py b/tests/lib/test_agent_card.py index 5d57f9e8e..f9a99ffc5 100644 --- a/tests/lib/test_agent_card.py +++ b/tests/lib/test_agent_card.py @@ -189,6 +189,7 @@ def test_defaults(self): assert card.data_events == [] assert card.input_types == [] assert card.output_schema is None + assert card.metadata == {} def test_serialization_roundtrip(self): card = AgentCard(input_types=["text"], data_events=["result"]) @@ -196,6 +197,34 @@ def test_serialization_roundtrip(self): restored = AgentCard.model_validate(dumped) assert restored == card + def test_metadata_accepts_arbitrary_json_object(self): + card = AgentCard( + metadata={ + "permits_capable": True, + "supported_workflows": ["submit", "review"], + "limits": {"max_batch": 5}, + } + ) + assert card.metadata == { + "permits_capable": True, + "supported_workflows": ["submit", "review"], + "limits": {"max_batch": 5}, + } + + def test_metadata_serialization_roundtrip(self): + card = AgentCard(metadata={"permits_capable": True}) + dumped = card.model_dump() + assert dumped["metadata"] == {"permits_capable": True} + restored = AgentCard.model_validate(dumped) + assert restored == card + + def test_metadata_default_instances_are_independent(self): + """Each default metadata is its own dict, not a shared class-level object.""" + card_a = AgentCard() + card_b = AgentCard() + card_a.metadata["mutated"] = True + assert card_b.metadata == {} + # --- AgentCard.from_states --- @@ -247,6 +276,14 @@ def test_state_fields(self, sample_states): assert waiting.accepts == ["text", "doc_upload"] assert waiting.transitions == ["processing"] + def test_metadata_forwarded(self, sample_states): + card = AgentCard.from_states( + initial_state=SampleState.WAITING, + states=sample_states, + metadata={"permits_capable": True}, + ) + assert card.metadata == {"permits_capable": True} + def test_matches_from_state_machine(self, sample_states, sample_sm): """from_states and from_state_machine should produce identical cards.""" card_states = AgentCard.from_states( @@ -315,6 +352,13 @@ def test_no_output_model(self, sample_sm): assert card.data_events == [] assert card.output_schema is None + def test_metadata_forwarded(self, sample_sm): + card = AgentCard.from_state_machine( + state_machine=sample_sm, + metadata={"permits_capable": True}, + ) + assert card.metadata == {"permits_capable": True} + # --- register_agent agent_card merging --- @@ -370,6 +414,20 @@ async def test_agent_card_merged_into_metadata(self, mock_env_vars): assert metadata["agent_card"]["input_types"] == ["text"] assert metadata["agent_card"]["data_events"] == ["result"] + async def test_agent_card_metadata_propagates_through_registration(self, mock_env_vars): + card = AgentCard(metadata={"permits_capable": True}) + mock_client = self._make_mock_client() + + with patch("agentex.lib.utils.registration.httpx.AsyncClient", return_value=mock_client): + from agentex.lib.utils.registration import register_agent + + await register_agent(mock_env_vars, agent_card=card) + + sent_data = mock_client.post.call_args.kwargs["json"] + metadata = sent_data["registration_metadata"] + + assert metadata["agent_card"]["metadata"] == {"permits_capable": True} + async def test_none_preserved_when_no_card(self, mock_env_vars): mock_client = self._make_mock_client()