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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""add_agents_registration_metadata_gin_index

Revision ID: b7d3e1f4a2c6
Revises: c4e8b2a7f91d
Create Date: 2026-09-03 12:00:00.000000

Supports the ``agent_card_metadata`` filter on ``GET /agents``, which applies a
JSONB containment predicate (``registration_metadata @> ...``). Discovery
clients poll that filtered endpoint, while agent registration writes are
comparatively rare. A ``jsonb_path_ops`` GIN index on the full
``registration_metadata`` column serves the containment operator directly, so
the polling read path does not degrade into a sequential scan as the agent
registry grows.

Safety:
- Index built with CREATE INDEX CONCURRENTLY inside an autocommit_block, so no
long write lock is taken on ``agents``.
- IF NOT EXISTS on both upgrade and downgrade makes re-runs a no-op.
- ``jsonb_path_ops`` matches the query's ``@>`` operator and is smaller and
faster for it than the default GIN opclass; the index intentionally covers
the whole column rather than an expression, mirroring the existing
``ix_tasks_metadata_gin`` index and the exact predicate the repository emits.
"""

from collections.abc import Sequence

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "b7d3e1f4a2c6"
down_revision: str | None = "c4e8b2a7f91d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_INDEX = "ix_agents_registration_metadata_gin"


def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_INDEX} "
"ON agents USING GIN (registration_metadata jsonb_path_ops)"
)


def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {_INDEX}")
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
schema:
anyOf:
- type: string
- type: 'null'
description: 'JSON-encoded object used to filter agents on `registration_metadata.agent_card.metadata`
via JSONB containment. Example: {"permits_capable": true}.'
title: Agent Card Metadata
description: 'JSON-encoded object used to filter agents on `registration_metadata.agent_card.metadata`
via JSONB containment. Example: {"permits_capable": true}.'
responses:
'200':
description: Successful Response
Expand Down
63 changes: 63 additions & 0 deletions agentex/src/api/routes/agents.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import json
import math
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,6 +110,57 @@ async def get_agent_by_name(
return Agent.model_validate(agent_entity)


_AGENT_CARD_METADATA_DESCRIPTION = (
"JSON-encoded object used to filter agents on "
"`registration_metadata.agent_card.metadata` via JSONB containment. "
'Example: {"permits_capable": true}.'
)


def _reject_json_constant(name: str) -> float:
"""Reject the non-standard ``NaN``/``Infinity`` literals ``json`` accepts."""
raise ValueError(f"{name} is not a valid JSON number")


def _parse_finite_float(raw: str) -> float:
"""Reject float literals whose magnitude overflows to infinity (e.g. ``1e1000000``)."""
value = float(raw)
if not math.isfinite(value):
raise ValueError(f"{raw} is out of range for a JSON number")
return value


def _parse_agent_card_metadata(raw: str) -> dict:
"""Decode the JSON-encoded ``agent_card_metadata`` query value into a dict.

Python's ``json`` module accepts values that aren't interoperable JSON --
the bare ``NaN``/``Infinity`` constants, float literals that overflow to
infinity, and integers too large to render. Those all satisfy an
``isinstance(..., dict)`` check but blow up further down at the JSONB bind
parameter, turning caller error into an uncontrolled 500. Reject them here
so every malformed input surfaces as a 400.
"""
try:
parsed = json.loads(
raw,
parse_constant=_reject_json_constant,
parse_float=_parse_finite_float,
)
except ValueError as exc:
# json.JSONDecodeError subclasses ValueError, as do the hook rejections
# above and CPython's integer-string conversion limit.
raise HTTPException(
status_code=400,
detail=f"agent_card_metadata is not valid JSON: {exc}",
) from exc
if not isinstance(parsed, dict):
raise HTTPException(
status_code=400,
detail="agent_card_metadata must be a JSON object",
)
return parsed


@router.get(
"",
response_model=list[Agent],
Expand All @@ -121,14 +175,23 @@ 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(description=_AGENT_CARD_METADATA_DESCRIPTION),
] = None,
):
"""List all registered agents."""
agent_card_metadata_filter: dict | None = None
if agent_card_metadata is not None:
agent_card_metadata_filter = _parse_agent_card_metadata(agent_card_metadata)

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
Loading
Loading