Skip to content

Commit 55a0178

Browse files
declan-scaleclaude
andauthored
feat(agent-card): add metadata field and expose list filter (#502)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ebb632a commit 55a0178

7 files changed

Lines changed: 403 additions & 3 deletions

File tree

‎src/agentex/lib/core/temporal/workers/worker.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def __init__(
178178
metrics_headers: dict[str, str] | None = None,
179179
metrics_use_http: bool = False,
180180
metrics_temporality_delta: bool = False,
181+
agent_card: Any | None = None,
181182
):
182183
self.task_queue = task_queue
183184
self.activity_handles = []
@@ -196,6 +197,7 @@ def __init__(
196197
self.metrics_temporality_delta = metrics_temporality_delta
197198
self.payload_codec = payload_codec
198199
self.data_converter = data_converter
200+
self.agent_card = agent_card
199201

200202
@overload
201203
async def run(
@@ -312,6 +314,6 @@ async def _register_agent(self):
312314
# the worker process never goes through the ACP server lifespan, so it needs its
313315
# own guard (mirrors base_acp_server.lifespan_context).
314316
await assert_backend_compatible(env_vars.AGENTEX_BASE_URL)
315-
await register_agent(env_vars)
317+
await register_agent(env_vars, agent_card=self.agent_card)
316318
else:
317319
logger.warning("AGENTEX_BASE_URL not set, skipping worker registration")

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

Lines changed: 10 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 Field, BaseModel
99

1010
if TYPE_CHECKING:
1111
from agentex.lib.sdk.state_machine.state import State
@@ -31,6 +31,11 @@ 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). 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``.
38+
metadata: dict[str, Any] = Field(default_factory=dict)
3439

3540
@classmethod
3641
def from_states(
@@ -40,6 +45,7 @@ def from_states(
4045
output_event_model: type[BaseModel] | None = None,
4146
extra_input_types: list[str] | None = None,
4247
queries: list[str] | None = None,
48+
metadata: dict[str, Any] | None = None,
4349
) -> AgentCard:
4450
"""Build an AgentCard directly from a list[State] + initial_state.
4551
@@ -81,6 +87,7 @@ def from_states(
8187
data_events=data_events,
8288
input_types=sorted(derived_input_types | set(extra_input_types or [])),
8389
output_schema=output_schema,
90+
metadata=metadata or {},
8491
)
8592

8693
@classmethod
@@ -90,6 +97,7 @@ def from_state_machine(
9097
output_event_model: type[BaseModel] | None = None,
9198
extra_input_types: list[str] | None = None,
9299
queries: list[str] | None = None,
100+
metadata: dict[str, Any] | None = None,
93101
) -> AgentCard:
94102
"""Build an AgentCard from a StateMachine instance. Delegates to from_states()."""
95103
lifecycle = state_machine.get_lifecycle()
@@ -125,6 +133,7 @@ def from_state_machine(
125133
data_events=data_events,
126134
input_types=sorted(derived_input_types | set(extra_input_types or [])),
127135
output_schema=output_schema,
136+
metadata=metadata or {},
128137
)
129138

130139

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
The ``agent_card_metadata`` filter requires an Agentex server that includes
18+
scaleapi/scale-agentex#411. Older servers ignore the unknown query parameter
19+
and return the full unfiltered agent list rather than erroring, and the SDK's
20+
startup backend-contract check does not guard against this.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
from typing import Any, Mapping
27+
28+
__all__ = ["encode_metadata_filter"]
29+
30+
31+
def encode_metadata_filter(metadata: Mapping[str, Any]) -> str:
32+
"""Encode a metadata filter mapping into the wire form the platform expects.
33+
34+
Args:
35+
metadata: The key/value pairs the target's metadata object must contain.
36+
Values may be any JSON type; matching is exact containment, so
37+
``{"permits_capable": True}`` matches a stored JSON ``true`` but not
38+
the string ``"true"``. An empty mapping matches any target that has
39+
a metadata object at all.
40+
41+
Returns:
42+
A compact JSON object string, with keys sorted so the same filter always
43+
produces the same query value.
44+
45+
Raises:
46+
TypeError: If ``metadata`` is not a mapping, or contains a value that
47+
isn't JSON-serializable.
48+
ValueError: If a value is a non-finite float. ``NaN`` and ``Infinity``
49+
aren't valid JSON and the server rejects them with a 400, so fail
50+
here with a clearer message instead.
51+
"""
52+
if not isinstance(metadata, Mapping):
53+
raise TypeError(f"metadata must be a mapping, got {type(metadata).__name__}")
54+
55+
try:
56+
return json.dumps(metadata, allow_nan=False, separators=(",", ":"), sort_keys=True)
57+
except ValueError as exc:
58+
raise ValueError(f"metadata filter is not encodable as JSON: {exc}") from exc

‎tests/lib/core/temporal/workers/test_worker_version_guard.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def test_guard_runs_before_register_agent(monkeypatch):
4040
await _worker()._register_agent()
4141

4242
guard.assert_awaited_once_with("http://backend")
43-
register.assert_awaited_once_with(env)
43+
register.assert_awaited_once_with(env, agent_card=None)
4444
assert order == ["guard", "register"] # guard must precede registration
4545

4646

‎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)