Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/agentex/lib/types/agent_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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 {},
)


Expand Down
53 changes: 53 additions & 0 deletions src/agentex/lib/utils/metadata_filters.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions src/agentex/resources/agents/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/agentex/types/agent_list_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down
2 changes: 2 additions & 0 deletions tests/api_resources/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions tests/lib/test_agent_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,42 @@ 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"])
dumped = card.model_dump()
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 ---

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ---

Expand Down Expand Up @@ -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()

Expand Down
112 changes: 112 additions & 0 deletions tests/lib/test_metadata_filters.py
Original file line number Diff line number Diff line change
@@ -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"] == "{}"
Loading