Skip to content

Commit 1a4faf9

Browse files
committed
feat(agent-card): add metadata field and expose list filter
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.
1 parent 0fa93b6 commit 1a4faf9

5 files changed

Lines changed: 85 additions & 1 deletion

File tree

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

Lines changed: 9 additions & 1 deletion
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
8+
from pydantic import BaseModel, Field
99

1010
if TYPE_CHECKING:
1111
from agentex.lib.sdk.state_machine.state import State
@@ -31,6 +31,10 @@ class AgentCard(BaseModel):
3131
data_events: list[str] = []
3232
input_types: list[str] = []
3333
output_schema: dict | None = None
34+
# 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.
37+
metadata: dict[str, Any] = Field(default_factory=dict)
3438

3539
@classmethod
3640
def from_states(
@@ -40,6 +44,7 @@ def from_states(
4044
output_event_model: type[BaseModel] | None = None,
4145
extra_input_types: list[str] | None = None,
4246
queries: list[str] | None = None,
47+
metadata: dict[str, Any] | None = None,
4348
) -> AgentCard:
4449
"""Build an AgentCard directly from a list[State] + initial_state.
4550
@@ -81,6 +86,7 @@ def from_states(
8186
data_events=data_events,
8287
input_types=sorted(derived_input_types | set(extra_input_types or [])),
8388
output_schema=output_schema,
89+
metadata=metadata or {},
8490
)
8591

8692
@classmethod
@@ -90,6 +96,7 @@ def from_state_machine(
9096
output_event_model: type[BaseModel] | None = None,
9197
extra_input_types: list[str] | None = None,
9298
queries: list[str] | None = None,
99+
metadata: dict[str, Any] | None = None,
93100
) -> AgentCard:
94101
"""Build an AgentCard from a StateMachine instance. Delegates to from_states()."""
95102
lifecycle = state_machine.get_lifecycle()
@@ -125,6 +132,7 @@ def from_state_machine(
125132
data_events=data_events,
126133
input_types=sorted(derived_input_types | set(extra_input_types or [])),
127134
output_schema=output_schema,
135+
metadata=metadata or {},
128136
)
129137

130138

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def retrieve(
116116
def list(
117117
self,
118118
*,
119+
agent_card_metadata: Optional[str] | Omit = omit,
119120
limit: int | Omit = omit,
120121
order_by: Optional[str] | Omit = omit,
121122
order_direction: str | Omit = omit,
@@ -132,6 +133,10 @@ def list(
132133
List all registered agents, optionally filtered by query parameters.
133134
134135
Args:
136+
agent_card_metadata: JSON-encoded object filtered against
137+
``registration_metadata.agent_card.metadata`` using exact key/value
138+
containment semantics.
139+
135140
limit: Limit
136141
137142
order_by: Field to order by
@@ -159,6 +164,7 @@ def list(
159164
timeout=timeout,
160165
query=maybe_transform(
161166
{
167+
"agent_card_metadata": agent_card_metadata,
162168
"limit": limit,
163169
"order_by": order_by,
164170
"order_direction": order_direction,
@@ -777,6 +783,7 @@ async def retrieve(
777783
async def list(
778784
self,
779785
*,
786+
agent_card_metadata: Optional[str] | Omit = omit,
780787
limit: int | Omit = omit,
781788
order_by: Optional[str] | Omit = omit,
782789
order_direction: str | Omit = omit,
@@ -793,6 +800,10 @@ async def list(
793800
List all registered agents, optionally filtered by query parameters.
794801
795802
Args:
803+
agent_card_metadata: JSON-encoded object filtered against
804+
``registration_metadata.agent_card.metadata`` using exact key/value
805+
containment semantics.
806+
796807
limit: Limit
797808
798809
order_by: Field to order by
@@ -820,6 +831,7 @@ async def list(
820831
timeout=timeout,
821832
query=await async_maybe_transform(
822833
{
834+
"agent_card_metadata": agent_card_metadata,
823835
"limit": limit,
824836
"order_by": order_by,
825837
"order_direction": order_direction,

‎src/agentex/types/agent_list_params.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010

1111
class AgentListParams(TypedDict, total=False):
12+
agent_card_metadata: Optional[str]
13+
"""JSON-encoded object filtered against ``registration_metadata.agent_card.metadata``
14+
with exact key/value containment semantics."""
15+
1216
limit: int
1317
"""Limit"""
1418

‎tests/api_resources/test_agents.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def test_method_list(self, client: Agentex) -> None:
7575
@parametrize
7676
def test_method_list_with_all_params(self, client: Agentex) -> None:
7777
agent = client.agents.list(
78+
agent_card_metadata="agent_card_metadata",
7879
limit=1,
7980
order_by="order_by",
8081
order_direction="order_direction",
@@ -469,6 +470,7 @@ async def test_method_list(self, async_client: AsyncAgentex) -> None:
469470
@parametrize
470471
async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None:
471472
agent = await async_client.agents.list(
473+
agent_card_metadata="agent_card_metadata",
472474
limit=1,
473475
order_by="order_by",
474476
order_direction="order_direction",

‎tests/lib/test_agent_card.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,42 @@ def test_defaults(self):
189189
assert card.data_events == []
190190
assert card.input_types == []
191191
assert card.output_schema is None
192+
assert card.metadata == {}
192193

193194
def test_serialization_roundtrip(self):
194195
card = AgentCard(input_types=["text"], data_events=["result"])
195196
dumped = card.model_dump()
196197
restored = AgentCard.model_validate(dumped)
197198
assert restored == card
198199

200+
def test_metadata_accepts_arbitrary_json_object(self):
201+
card = AgentCard(
202+
metadata={
203+
"permits_capable": True,
204+
"supported_workflows": ["submit", "review"],
205+
"limits": {"max_batch": 5},
206+
}
207+
)
208+
assert card.metadata == {
209+
"permits_capable": True,
210+
"supported_workflows": ["submit", "review"],
211+
"limits": {"max_batch": 5},
212+
}
213+
214+
def test_metadata_serialization_roundtrip(self):
215+
card = AgentCard(metadata={"permits_capable": True})
216+
dumped = card.model_dump()
217+
assert dumped["metadata"] == {"permits_capable": True}
218+
restored = AgentCard.model_validate(dumped)
219+
assert restored == card
220+
221+
def test_metadata_default_instances_are_independent(self):
222+
"""Each default metadata is its own dict, not a shared class-level object."""
223+
card_a = AgentCard()
224+
card_b = AgentCard()
225+
card_a.metadata["mutated"] = True
226+
assert card_b.metadata == {}
227+
199228

200229
# --- AgentCard.from_states ---
201230

@@ -247,6 +276,14 @@ def test_state_fields(self, sample_states):
247276
assert waiting.accepts == ["text", "doc_upload"]
248277
assert waiting.transitions == ["processing"]
249278

279+
def test_metadata_forwarded(self, sample_states):
280+
card = AgentCard.from_states(
281+
initial_state=SampleState.WAITING,
282+
states=sample_states,
283+
metadata={"permits_capable": True},
284+
)
285+
assert card.metadata == {"permits_capable": True}
286+
250287
def test_matches_from_state_machine(self, sample_states, sample_sm):
251288
"""from_states and from_state_machine should produce identical cards."""
252289
card_states = AgentCard.from_states(
@@ -315,6 +352,13 @@ def test_no_output_model(self, sample_sm):
315352
assert card.data_events == []
316353
assert card.output_schema is None
317354

355+
def test_metadata_forwarded(self, sample_sm):
356+
card = AgentCard.from_state_machine(
357+
state_machine=sample_sm,
358+
metadata={"permits_capable": True},
359+
)
360+
assert card.metadata == {"permits_capable": True}
361+
318362

319363
# --- register_agent agent_card merging ---
320364

@@ -370,6 +414,20 @@ async def test_agent_card_merged_into_metadata(self, mock_env_vars):
370414
assert metadata["agent_card"]["input_types"] == ["text"]
371415
assert metadata["agent_card"]["data_events"] == ["result"]
372416

417+
async def test_agent_card_metadata_propagates_through_registration(self, mock_env_vars):
418+
card = AgentCard(metadata={"permits_capable": True})
419+
mock_client = self._make_mock_client()
420+
421+
with patch("agentex.lib.utils.registration.httpx.AsyncClient", return_value=mock_client):
422+
from agentex.lib.utils.registration import register_agent
423+
424+
await register_agent(mock_env_vars, agent_card=card)
425+
426+
sent_data = mock_client.post.call_args.kwargs["json"]
427+
metadata = sent_data["registration_metadata"]
428+
429+
assert metadata["agent_card"]["metadata"] == {"permits_capable": True}
430+
373431
async def test_none_preserved_when_no_card(self, mock_env_vars):
374432
mock_client = self._make_mock_client()
375433

0 commit comments

Comments
 (0)