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
12 changes: 12 additions & 0 deletions agentex/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ paths:
default: desc
title: Order Direction
description: Order direction (asc or desc)
- name: agent_card_metadata
in: query
required: false
description: Object filtered against `registration_metadata.agent_card.metadata`
using exact key/value containment semantics (e.g. `{"permits_capable":true}`).
Sent on the wire as a JSON-encoded query string value.
content:
application/json:
schema:
type: object
additionalProperties: true
title: Agent Card Metadata
responses:
'200':
description: Successful Response
Expand Down
54 changes: 54 additions & 0 deletions agentex/src/api/routes/agents.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import json
import secrets
from collections.abc import AsyncIterator
from typing import Annotated

from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
Expand Down Expand Up @@ -107,11 +109,42 @@ async def get_agent_by_name(
return Agent.model_validate(agent_entity)


_AGENT_CARD_METADATA_DESCRIPTION = (
"Object filtered against `registration_metadata.agent_card.metadata` using "
'exact key/value containment semantics (e.g. `{"permits_capable":true}`). '
"Sent on the wire as a JSON-encoded query string value."
)


@router.get(
"",
response_model=list[Agent],
summary="List Agents",
description="List all registered agents, optionally filtered by query parameters.",
# Declared via `content: application/json` (instead of a plain string schema)
# so SDK generators expose a mapping-typed parameter and perform the JSON
# encoding themselves. FastAPI can't express content-encoded query params in
# the signature, so the runtime param below is schema-hidden and this block
# documents it.
openapi_extra={
"parameters": [
{
"name": "agent_card_metadata",
"in": "query",
"required": False,
"description": _AGENT_CARD_METADATA_DESCRIPTION,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": True,
"title": "Agent Card Metadata",
}
}
},
}
]
},
)
async def list_agents(
agents_use_case: DAgentsUseCase,
Expand All @@ -121,14 +154,35 @@ async def list_agents(
page_number: int = Query(1, description="Page number", ge=1),
order_by: str | None = Query(None, description="Field to order by"),
order_direction: str = Query("desc", description="Order direction (asc or desc)"),
agent_card_metadata: Annotated[
str | None,
Query(include_in_schema=False),
] = None,
):
"""List all registered agents."""
agent_card_metadata_filter: dict | None = None
if agent_card_metadata is not None:
try:
parsed = json.loads(agent_card_metadata)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=400,
detail=f"agent_card_metadata is not valid JSON: {e}",
) from e
if not isinstance(parsed, dict):
raise HTTPException(
status_code=400,
detail="agent_card_metadata must be a JSON object",
)
agent_card_metadata_filter = parsed

agent_entities = await agents_use_case.list(
task_id=task_id,
limit=limit,
page_number=page_number,
order_by=order_by,
order_direction=order_direction,
agent_card_metadata=agent_card_metadata_filter,
**{"id": _authorized_ids} if _authorized_ids is not None else {},
)
return [Agent.model_validate(agent_entity) for agent_entity in agent_entities]
Expand Down
26 changes: 24 additions & 2 deletions agentex/src/domain/repositories/agent_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,36 @@ async def list(
Args:
filters: Dictionary of filters to apply. Currently supports:
- task_id: Filter agents by task ID using the join table
- agent_card_metadata: Dict applied as an exact JSONB
containment filter (``@>``) against
``registration_metadata['agent_card']['metadata']``.
order_by: Field to order by
order_direction: Direction to order by (asc or desc)
"""
query = select(AgentORM)
if filters and "task_id" in filters:
# Pop out non-column filters that the base repository can't map to a
# single equality column, so its create_where_clauses_from_filters call
# doesn't see them.
filters = dict(filters) if filters else {}
task_id = filters.pop("task_id", None)
agent_card_metadata = filters.pop("agent_card_metadata", None)

if task_id is not None:
query = query.join(
TaskAgentORM, AgentORM.id == TaskAgentORM.agent_id
).where(TaskAgentORM.task_id == filters["task_id"])
).where(TaskAgentORM.task_id == task_id)
if agent_card_metadata is not None:
# Top-level JSONB `@>` with the caller's dict wrapped under the same
# nested shape it will occupy in the stored registration_metadata.
# `@>` matches when every key/value in the right operand exists at
# the same path in the left, so agents whose registration_metadata
# is NULL, missing `agent_card`, or missing `agent_card.metadata`
# are naturally excluded.
query = query.where(
AgentORM.registration_metadata.contains(
{"agent_card": {"metadata": agent_card_metadata}}
)
)
query = query.where(AgentORM.status != AgentStatus.DELETED)
return await super().list(
filters=filters,
Expand Down
6 changes: 6 additions & 0 deletions agentex/src/domain/use_cases/agents_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,16 @@ async def list(
task_id: str | None = None,
order_by: str | None = None,
order_direction: str = "desc",
agent_card_metadata: dict[str, Any] | None = None,
**filters,
) -> list[AgentEntity]:
if task_id is not None:
filters["task_id"] = task_id
if agent_card_metadata is not None:
# Reserved key consumed by the repository to apply a JSONB containment
# filter on `registration_metadata.agent_card.metadata`. Kept out of the
# generic column-equality path in `create_where_clauses_from_filters`.
filters["agent_card_metadata"] = agent_card_metadata

return await self.agent_repo.list(
filters=filters,
Expand Down
191 changes: 191 additions & 0 deletions agentex/tests/integration/api/agents/test_agents_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,194 @@ async def test_register_agent_duplicate_name_behavior(self, isolated_client):
agents_with_name = [a for a in agents if a["name"] == "duplicate-name-test"]
assert len(agents_with_name) == 1
assert agents_with_name[0]["description"] == "Second agent"

@pytest.mark.asyncio
async def test_list_agents_filters_by_agent_card_metadata(self, isolated_client):
"""`agent_card_metadata` returns only agents whose card metadata contains
the requested key/value pairs; agents without card metadata are excluded."""
# Given - three agents: one Permits-capable, one with different metadata,
# and one with no agent_card at all.
await isolated_client.post(
"/agents/register",
json={
"name": "card-metadata-permits",
"description": "opts into Permits",
"acp_url": "http://permits-agent:8000",
"acp_type": "sync",
"registration_metadata": {
"agent_card": {
"metadata": {"permits_capable": True, "region": "us"}
}
},
},
)
await isolated_client.post(
"/agents/register",
json={
"name": "card-metadata-other",
"description": "different capability",
"acp_url": "http://other-agent:8000",
"acp_type": "sync",
"registration_metadata": {
"agent_card": {"metadata": {"other_feature": True}}
},
},
)
await isolated_client.post(
"/agents/register",
json={
"name": "card-metadata-none",
"description": "no card metadata",
"acp_url": "http://plain-agent:8000",
"acp_type": "sync",
},
)

# When - filter by exact key/value present on only one agent
response = await isolated_client.get(
'/agents?agent_card_metadata={"permits_capable":true}'
)
assert response.status_code == 200
agents = response.json()
names = {a["name"] for a in agents}
assert names == {"card-metadata-permits"}

# And - non-matching value returns no agents (agent exists but with a
# different value for the same key does not match)
response = await isolated_client.get(
'/agents?agent_card_metadata={"permits_capable":false}'
)
assert response.status_code == 200
assert response.json() == []

# And - omitting the filter returns all non-deleted agents, including
# those without any agent_card metadata
response = await isolated_client.get("/agents")
assert response.status_code == 200
names_unfiltered = {a["name"] for a in response.json()}
assert {
"card-metadata-permits",
"card-metadata-other",
"card-metadata-none",
} <= names_unfiltered

@pytest.mark.asyncio
async def test_list_agents_agent_card_metadata_multi_key_containment(
self, isolated_client
):
"""Multi-key filter requires every key/value to be present (JSONB `@>`)."""
await isolated_client.post(
"/agents/register",
json={
"name": "card-metadata-multi",
"description": "multi",
"acp_url": "http://multi-agent:8000",
"acp_type": "sync",
"registration_metadata": {
"agent_card": {
"metadata": {
"permits_capable": True,
"region": "us",
"extra": "value",
}
}
},
},
)

# All requested keys match -> included
response = await isolated_client.get(
'/agents?agent_card_metadata={"permits_capable":true,"region":"us"}'
)
assert response.status_code == 200
assert {a["name"] for a in response.json()} == {"card-metadata-multi"}

# One requested key doesn't match -> excluded
response = await isolated_client.get(
'/agents?agent_card_metadata={"permits_capable":true,"region":"eu"}'
)
assert response.status_code == 200
assert response.json() == []

@pytest.mark.asyncio
async def test_list_agents_agent_card_metadata_combined_with_pagination(
self, isolated_client
):
"""The filter composes with existing pagination and ordering behavior."""
for i in range(3):
await isolated_client.post(
"/agents/register",
json={
"name": f"card-metadata-page-{i}",
"description": f"agent {i}",
"acp_url": f"http://page-agent-{i}:8000",
"acp_type": "sync",
"registration_metadata": {
"agent_card": {"metadata": {"permits_capable": True}}
},
},
)
# Unrelated agent that must not leak into filtered results
await isolated_client.post(
"/agents/register",
json={
"name": "card-metadata-page-noise",
"description": "noise",
"acp_url": "http://noise:8000",
"acp_type": "sync",
},
)

response = await isolated_client.get(
'/agents?agent_card_metadata={"permits_capable":true}&limit=2&page_number=1'
)
assert response.status_code == 200
page_one = response.json()
assert len(page_one) == 2
assert all(a["name"].startswith("card-metadata-page-") for a in page_one)
assert not any(a["name"] == "card-metadata-page-noise" for a in page_one)

@pytest.mark.asyncio
async def test_list_agents_agent_card_metadata_invalid_json_returns_400(
self, isolated_client
):
"""Malformed JSON in `agent_card_metadata` is rejected up front."""
response = await isolated_client.get("/agents?agent_card_metadata=not-json")
assert response.status_code == 400

response = await isolated_client.get("/agents?agent_card_metadata=[1,2,3]")
assert response.status_code == 400

@pytest.mark.asyncio
async def test_list_agents_agent_card_metadata_empty_object_requires_metadata(
self, isolated_client
):
"""An explicit `{}` filter still applies the containment predicate: agents
must have a card metadata object, but any contents match."""
await isolated_client.post(
"/agents/register",
json={
"name": "card-metadata-empty-with",
"description": "has card metadata",
"acp_url": "http://with-agent:8000",
"acp_type": "sync",
"registration_metadata": {
"agent_card": {"metadata": {"anything": "at-all"}}
},
},
)
await isolated_client.post(
"/agents/register",
json={
"name": "card-metadata-empty-without",
"description": "no card metadata",
"acp_url": "http://without-agent:8000",
"acp_type": "sync",
},
)

response = await isolated_client.get("/agents?agent_card_metadata={}")
assert response.status_code == 200
names = {a["name"] for a in response.json()}
assert "card-metadata-empty-with" in names
assert "card-metadata-empty-without" not in names
2 changes: 2 additions & 0 deletions agentex/tests/unit/api/test_agents_authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ async def test_authorized_ids_pushed_into_use_case(self):
page_number=1,
order_by=None,
order_direction="desc",
agent_card_metadata=None,
id=["agent-a", "agent-c"],
)

Expand All @@ -351,6 +352,7 @@ async def test_none_authorized_ids_passes_through_unfiltered(self):
page_number=1,
order_by=None,
order_direction="desc",
agent_card_metadata=None,
)


Expand Down
Loading
Loading