diff --git a/src/agentex/lib/types/agent_card.py b/src/agentex/lib/types/agent_card.py index def4464c6..d0af817a5 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 Field, BaseModel if TYPE_CHECKING: from agentex.lib.sdk.state_machine.state import State @@ -31,6 +31,11 @@ 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). Not interpreted by the platform, but callers can filter + # agents on it with ``agents.list(agent_card_metadata=...)`` -- see + # ``agentex.lib.utils.metadata_filters.encode_metadata_filter``. + metadata: dict[str, Any] = Field(default_factory=dict) @classmethod def from_states( @@ -40,6 +45,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 +87,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 +97,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 +133,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/lib/utils/metadata_filters.py b/src/agentex/lib/utils/metadata_filters.py new file mode 100644 index 000000000..9fccd5b97 --- /dev/null +++ b/src/agentex/lib/utils/metadata_filters.py @@ -0,0 +1,53 @@ +"""Helpers for the platform's JSON-encoded metadata filter query parameters. + +The containment filters on ``agents.list(agent_card_metadata=...)`` and +``tasks.list(task_metadata=...)`` carry their filter as a JSON-encoded object +inside a single query string value, so the generated clients type them as +``str``. Encoding by hand is easy to get subtly wrong -- Python's ``json`` +happily emits ``NaN``/``Infinity``, which the server rejects with a 400 -- so +these helpers do it once, here, in the hand-written layer where they survive +SDK regeneration. + + from agentex.lib.utils.metadata_filters import encode_metadata_filter + + client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True}), + ) +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + +__all__ = ["encode_metadata_filter"] + + +def encode_metadata_filter(metadata: Mapping[str, Any]) -> str: + """Encode a metadata filter mapping into the wire form the platform expects. + + Args: + metadata: The key/value pairs the target's metadata object must contain. + Values may be any JSON type; matching is exact containment, so + ``{"permits_capable": True}`` matches a stored JSON ``true`` but not + the string ``"true"``. An empty mapping matches any target that has + a metadata object at all. + + Returns: + A compact JSON object string, with keys sorted so the same filter always + produces the same query value. + + Raises: + TypeError: If ``metadata`` is not a mapping, or contains a value that + isn't JSON-serializable. + ValueError: If a value is a non-finite float. ``NaN`` and ``Infinity`` + aren't valid JSON and the server rejects them with a 400, so fail + here with a clearer message instead. + """ + if not isinstance(metadata, Mapping): + raise TypeError(f"metadata must be a mapping, got {type(metadata).__name__}") + + try: + return json.dumps(metadata, allow_nan=False, separators=(",", ":"), sort_keys=True) + except ValueError as exc: + raise ValueError(f"metadata filter is not encodable as JSON: {exc}") from exc diff --git a/src/agentex/resources/agents/agents.py b/src/agentex/resources/agents/agents.py index 924fab3fb..f24464880 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 used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. + 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 used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. + 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..11fc8f949 100644 --- a/src/agentex/types/agent_list_params.py +++ b/src/agentex/types/agent_list_params.py @@ -9,6 +9,13 @@ class AgentListParams(TypedDict, total=False): + agent_card_metadata: Optional[str] + """ + JSON-encoded object used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. + """ + 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() diff --git a/tests/lib/test_metadata_filters.py b/tests/lib/test_metadata_filters.py new file mode 100644 index 000000000..34398187f --- /dev/null +++ b/tests/lib/test_metadata_filters.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json + +import httpx +import respx +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.lib.utils.metadata_filters import encode_metadata_filter + +BASE_URL = "http://127.0.0.1:4010" +API_KEY = "My API Key" + + +class TestEncodeMetadataFilter: + def test_encodes_a_json_object(self) -> None: + assert encode_metadata_filter({"permits_capable": True}) == '{"permits_capable":true}' + + def test_empty_mapping_encodes_to_an_empty_object(self) -> None: + assert encode_metadata_filter({}) == "{}" + + def test_key_order_is_stable(self) -> None: + assert ( + encode_metadata_filter({"region": "us", "permits_capable": True}) + == encode_metadata_filter({"permits_capable": True, "region": "us"}) + == '{"permits_capable":true,"region":"us"}' + ) + + def test_preserves_json_types_and_nesting(self) -> None: + encoded = encode_metadata_filter({"flag": True, "count": 3, "ratio": 1.5, "nested": {"a": [1, "two", None]}}) + assert json.loads(encoded) == { + "flag": True, + "count": 3, + "ratio": 1.5, + "nested": {"a": [1, "two", None]}, + } + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_rejects_non_finite_floats(self, value: float) -> None: + # The server rejects these with a 400; fail locally with a clearer message. + with pytest.raises(ValueError, match="not encodable as JSON"): + encode_metadata_filter({"x": value}) + + def test_rejects_a_non_mapping(self) -> None: + with pytest.raises(TypeError, match="must be a mapping"): + encode_metadata_filter([("permits_capable", True)]) # type: ignore[arg-type] + + def test_rejects_a_non_serializable_value(self) -> None: + with pytest.raises(TypeError): + encode_metadata_filter({"x": object()}) + + +class TestAgentCardMetadataOnTheWire: + """The encoded filter has to survive the client's query-string serialization. + + The generated `agents.list` parameter is a plain `str` (the platform spec + declares a JSON-encoded string, matching the shipped `task_metadata` + filter), so these assert the exact query value the server will parse. + """ + + @respx.mock(base_url=BASE_URL) + def test_sync_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}), + limit=5, + ) + + params = route.calls.last.request.url.params + raw = params["agent_card_metadata"] + assert raw == '{"permits_capable":true,"region":"us"}' + assert json.loads(raw) == {"permits_capable": True, "region": "us"} + assert params["limit"] == "5" + + @respx.mock(base_url=BASE_URL) + async def test_async_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + async with AsyncAgentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + await client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}), + limit=5, + ) + + params = route.calls.last.request.url.params + raw = params["agent_card_metadata"] + assert raw == '{"permits_capable":true,"region":"us"}' + assert json.loads(raw) == {"permits_capable": True, "region": "us"} + assert params["limit"] == "5" + + @respx.mock(base_url=BASE_URL) + def test_omitted_filter_is_absent_from_the_query(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list() + + assert "agent_card_metadata" not in route.calls.last.request.url.params + + @respx.mock(base_url=BASE_URL) + def test_empty_object_filter_is_sent_verbatim(self, respx_mock: respx.MockRouter) -> None: + """`{}` is a meaningful filter server-side (agent must have card metadata), + so it must reach the wire rather than being dropped as falsy.""" + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list(agent_card_metadata=encode_metadata_filter({})) + + assert route.calls.last.request.url.params["agent_card_metadata"] == "{}"