Skip to content

Commit 582600d

Browse files
committed
fix(agent-card): add a mapping helper for the metadata filter, fix ruff
Review follow-ups on the agents.list card metadata filter. The generated `agent_card_metadata` parameter stays `Optional[str]`. The platform spec declares it the same way the already-shipped `tasks.list(task_metadata=...)` filter is declared -- a nullable string carrying a JSON-encoded object -- because the generator does not honour a `content: application/json` query parameter and falls back to `type: string` regardless. Widening the parameter here would be an edit the next regeneration silently reverts, so the ergonomics live in the hand-written layer instead: `agentex.lib.utils.metadata_filters.encode_metadata_filter` turns a mapping into the exact wire value, with key order pinned so the same filter always produces the same query string, and rejects NaN/Infinity up front rather than letting the server return a 400. The generated docstrings are updated to match the parameter description in the spec, so a regeneration is now a no-op on these files. Adds exercised request-level tests (respx, sync and async) asserting the encoded mapping actually reaches the query string, and that an empty-object filter is sent verbatim rather than dropped as falsy -- `{}` is meaningful server-side. These use their own clients rather than the skip-guarded mock-server fixtures, so they run in CI. Also fixes the failing ruff import-order check in agent_card.py.
1 parent 1a4faf9 commit 582600d

5 files changed

Lines changed: 180 additions & 11 deletions

File tree

‎src/agentex/lib/types/agent_card.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from enum import Enum
66
from typing import TYPE_CHECKING, Any, get_args, get_origin
77

8-
from pydantic import BaseModel, Field
8+
from pydantic import Field, BaseModel
99

1010
if TYPE_CHECKING:
1111
from agentex.lib.sdk.state_machine.state import State
@@ -32,8 +32,9 @@ class AgentCard(BaseModel):
3232
input_types: list[str] = []
3333
output_schema: dict | None = None
3434
# Free-form JSON object for opt-in self-description (e.g. protocol-specific
35-
# capability flags) that callers can filter agents by via
36-
# ``GET /agents?agent_card_metadata=...``. Not interpreted by the platform.
35+
# capability flags). Not interpreted by the platform, but callers can filter
36+
# agents on it with ``agents.list(agent_card_metadata=...)`` -- see
37+
# ``agentex.lib.utils.metadata_filters.encode_metadata_filter``.
3738
metadata: dict[str, Any] = Field(default_factory=dict)
3839

3940
@classmethod
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Helpers for the platform's JSON-encoded metadata filter query parameters.
2+
3+
The containment filters on ``agents.list(agent_card_metadata=...)`` and
4+
``tasks.list(task_metadata=...)`` carry their filter as a JSON-encoded object
5+
inside a single query string value, so the generated clients type them as
6+
``str``. Encoding by hand is easy to get subtly wrong -- Python's ``json``
7+
happily emits ``NaN``/``Infinity``, which the server rejects with a 400 -- so
8+
these helpers do it once, here, in the hand-written layer where they survive
9+
SDK regeneration.
10+
11+
from agentex.lib.utils.metadata_filters import encode_metadata_filter
12+
13+
client.agents.list(
14+
agent_card_metadata=encode_metadata_filter({"permits_capable": True}),
15+
)
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
from typing import Any, Mapping
22+
23+
__all__ = ["encode_metadata_filter"]
24+
25+
26+
def encode_metadata_filter(metadata: Mapping[str, Any]) -> str:
27+
"""Encode a metadata filter mapping into the wire form the platform expects.
28+
29+
Args:
30+
metadata: The key/value pairs the target's metadata object must contain.
31+
Values may be any JSON type; matching is exact containment, so
32+
``{"permits_capable": True}`` matches a stored JSON ``true`` but not
33+
the string ``"true"``. An empty mapping matches any target that has
34+
a metadata object at all.
35+
36+
Returns:
37+
A compact JSON object string, with keys sorted so the same filter always
38+
produces the same query value.
39+
40+
Raises:
41+
TypeError: If ``metadata`` is not a mapping, or contains a value that
42+
isn't JSON-serializable.
43+
ValueError: If a value is a non-finite float. ``NaN`` and ``Infinity``
44+
aren't valid JSON and the server rejects them with a 400, so fail
45+
here with a clearer message instead.
46+
"""
47+
if not isinstance(metadata, Mapping):
48+
raise TypeError(f"metadata must be a mapping, got {type(metadata).__name__}")
49+
50+
try:
51+
return json.dumps(metadata, allow_nan=False, separators=(",", ":"), sort_keys=True)
52+
except ValueError as exc:
53+
raise ValueError(f"metadata filter is not encodable as JSON: {exc}") from exc

‎src/agentex/resources/agents/agents.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ def list(
133133
List all registered agents, optionally filtered by query parameters.
134134
135135
Args:
136-
agent_card_metadata: JSON-encoded object filtered against
137-
``registration_metadata.agent_card.metadata`` using exact key/value
138-
containment semantics.
136+
agent_card_metadata: JSON-encoded object used to filter agents on
137+
`registration_metadata.agent_card.metadata` via JSONB containment. Example:
138+
{"permits_capable": true}.
139139
140140
limit: Limit
141141
@@ -800,9 +800,9 @@ async def list(
800800
List all registered agents, optionally filtered by query parameters.
801801
802802
Args:
803-
agent_card_metadata: JSON-encoded object filtered against
804-
``registration_metadata.agent_card.metadata`` using exact key/value
805-
containment semantics.
803+
agent_card_metadata: JSON-encoded object used to filter agents on
804+
`registration_metadata.agent_card.metadata` via JSONB containment. Example:
805+
{"permits_capable": true}.
806806
807807
limit: Limit
808808

‎src/agentex/types/agent_list_params.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010

1111
class AgentListParams(TypedDict, total=False):
1212
agent_card_metadata: Optional[str]
13-
"""JSON-encoded object filtered against ``registration_metadata.agent_card.metadata``
14-
with exact key/value containment semantics."""
13+
"""
14+
JSON-encoded object used to filter agents on
15+
`registration_metadata.agent_card.metadata` via JSONB containment. Example:
16+
{"permits_capable": true}.
17+
"""
1518

1619
limit: int
1720
"""Limit"""

‎tests/lib/test_metadata_filters.py‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
import httpx
6+
import respx
7+
import pytest
8+
9+
from agentex import Agentex, AsyncAgentex
10+
from agentex.lib.utils.metadata_filters import encode_metadata_filter
11+
12+
BASE_URL = "http://127.0.0.1:4010"
13+
API_KEY = "My API Key"
14+
15+
16+
class TestEncodeMetadataFilter:
17+
def test_encodes_a_json_object(self) -> None:
18+
assert encode_metadata_filter({"permits_capable": True}) == '{"permits_capable":true}'
19+
20+
def test_empty_mapping_encodes_to_an_empty_object(self) -> None:
21+
assert encode_metadata_filter({}) == "{}"
22+
23+
def test_key_order_is_stable(self) -> None:
24+
assert (
25+
encode_metadata_filter({"region": "us", "permits_capable": True})
26+
== encode_metadata_filter({"permits_capable": True, "region": "us"})
27+
== '{"permits_capable":true,"region":"us"}'
28+
)
29+
30+
def test_preserves_json_types_and_nesting(self) -> None:
31+
encoded = encode_metadata_filter({"flag": True, "count": 3, "ratio": 1.5, "nested": {"a": [1, "two", None]}})
32+
assert json.loads(encoded) == {
33+
"flag": True,
34+
"count": 3,
35+
"ratio": 1.5,
36+
"nested": {"a": [1, "two", None]},
37+
}
38+
39+
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
40+
def test_rejects_non_finite_floats(self, value: float) -> None:
41+
# The server rejects these with a 400; fail locally with a clearer message.
42+
with pytest.raises(ValueError, match="not encodable as JSON"):
43+
encode_metadata_filter({"x": value})
44+
45+
def test_rejects_a_non_mapping(self) -> None:
46+
with pytest.raises(TypeError, match="must be a mapping"):
47+
encode_metadata_filter([("permits_capable", True)]) # type: ignore[arg-type]
48+
49+
def test_rejects_a_non_serializable_value(self) -> None:
50+
with pytest.raises(TypeError):
51+
encode_metadata_filter({"x": object()})
52+
53+
54+
class TestAgentCardMetadataOnTheWire:
55+
"""The encoded filter has to survive the client's query-string serialization.
56+
57+
The generated `agents.list` parameter is a plain `str` (the platform spec
58+
declares a JSON-encoded string, matching the shipped `task_metadata`
59+
filter), so these assert the exact query value the server will parse.
60+
"""
61+
62+
@respx.mock(base_url=BASE_URL)
63+
def test_sync_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None:
64+
route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[]))
65+
66+
with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client:
67+
client.agents.list(
68+
agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}),
69+
limit=5,
70+
)
71+
72+
params = route.calls.last.request.url.params
73+
raw = params["agent_card_metadata"]
74+
assert raw == '{"permits_capable":true,"region":"us"}'
75+
assert json.loads(raw) == {"permits_capable": True, "region": "us"}
76+
assert params["limit"] == "5"
77+
78+
@respx.mock(base_url=BASE_URL)
79+
async def test_async_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None:
80+
route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[]))
81+
82+
async with AsyncAgentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client:
83+
await client.agents.list(
84+
agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}),
85+
limit=5,
86+
)
87+
88+
params = route.calls.last.request.url.params
89+
raw = params["agent_card_metadata"]
90+
assert raw == '{"permits_capable":true,"region":"us"}'
91+
assert json.loads(raw) == {"permits_capable": True, "region": "us"}
92+
assert params["limit"] == "5"
93+
94+
@respx.mock(base_url=BASE_URL)
95+
def test_omitted_filter_is_absent_from_the_query(self, respx_mock: respx.MockRouter) -> None:
96+
route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[]))
97+
98+
with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client:
99+
client.agents.list()
100+
101+
assert "agent_card_metadata" not in route.calls.last.request.url.params
102+
103+
@respx.mock(base_url=BASE_URL)
104+
def test_empty_object_filter_is_sent_verbatim(self, respx_mock: respx.MockRouter) -> None:
105+
"""`{}` is a meaningful filter server-side (agent must have card metadata),
106+
so it must reach the wire rather than being dropped as falsy."""
107+
route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[]))
108+
109+
with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client:
110+
client.agents.list(agent_card_metadata=encode_metadata_filter({}))
111+
112+
assert route.calls.last.request.url.params["agent_card_metadata"] == "{}"

0 commit comments

Comments
 (0)