Skip to content

feat(agentex): filter GET /agents by agent card metadata - #411

Open
declan-scale wants to merge 3 commits into
mainfrom
declan-scale/agx1-1048-agent-card-metadata-filter
Open

feat(agentex): filter GET /agents by agent card metadata#411
declan-scale wants to merge 3 commits into
mainfrom
declan-scale/agx1-1048-agent-card-metadata-filter

Conversation

@declan-scale

@declan-scale declan-scale commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

🏆 Brought to you by the Golden Agent (Try it out)

Problem

AgentCard publishes self-description data through registration_metadata.agent_card, but GET /agents has no way to filter on its contents. Discovery flows that want to enumerate agents opting into a specific protocol capability (e.g. Permits' workflow submission protocol) have no server-side hook and must fetch everything client-side.

Linear: AGX1-1048

Change

  • Adds an optional agent_card_metadata query parameter to GET /agents. The value is a JSON-encoded object; malformed JSON or non-object payloads return 400.
  • The route parses the JSON and forwards it to AgentsUseCase.list, which reserves the key agent_card_metadata in the repository filters dict.
  • AgentRepository.list applies a JSONB @> filter at the top level:
    registration_metadata @> jsonb_build_object('agent_card', jsonb_build_object('metadata', :value))
    Wrapping under the same nested shape as the stored card means agents whose registration_metadata is NULL, missing agent_card, or missing agent_card.metadata are naturally excluded, and every requested key/value must be present at the correct nesting level.
  • No DB migration — agents.registration_metadata is already JSONB.
  • Existing pagination, ordering, task filtering and authorization behavior are preserved (the filter composes with the pre-existing task_id join, authorization id set, and status != DELETED clause).
  • openapi.yaml regenerated by hand to reflect the new query parameter; the paired SDK PR consumes the same spec.

Test coverage added

  • Integration tests in tests/integration/api/agents/test_agents_api.py:
    • Matching key/value returns only opted-in agents; agents with different values or no card at all are excluded.
    • Non-matching value returns an empty list.
    • Multi-key filter requires containment of every key/value.
    • The filter composes with limit/page_number.
    • Malformed JSON and non-object payloads return 400.
  • Unit tests in tests/unit/use_cases/test_agents_use_case.py that seed agents directly via the repository and exercise the use-case-to-repo plumbing against real Postgres (single-key, multi-key, absent-card, and omitted-filter cases). Tests use a per-invocation tag so they are safe against session-scoped container reuse.

Test plan (for reviewer, since local yarn/uv installs are skipped per Golden Agent policy)

  • CI unit tests (make test-unit) pass, including the two new use-case tests.
  • CI integration tests (make test-integration) pass, including the four new API tests.
  • Manually curl GET /agents?agent_card_metadata={\"permits_capable\":true} against a dev backend seeded with an agent card and confirm only that agent is returned.
  • Confirm openapi.yaml still matches the FastAPI-generated spec (make gen-openapi should produce no further diff).

Out of scope / follow-ups

  • Range queries, arbitrary operators, or a query language beyond exact containment.
  • Deployment-scoped cards or resolving card metadata through production_deployment_id.
  • Performance indexing — the endpoint uses top-level containment on an already-JSONB column and current agent counts don't warrant a GIN index yet. Revisit if listing at scale becomes a bottleneck.

Greptile Summary

The PR adds server-side filtering of agents by JSON-encoded Agent Card metadata while preserving existing authorization, deletion, ordering, and pagination constraints.

  • Parses and validates the new agent_card_metadata query parameter, returning 400 for malformed or non-object JSON.
  • Applies nested JSONB containment against registration_metadata.agent_card.metadata.
  • Documents the query parameter in OpenAPI and adds API, authorization, and use-case coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
agentex/src/api/routes/agents.py Adds strict JSON-object parsing for the metadata query parameter and forwards the parsed value without collapsing an explicit empty object.
agentex/src/domain/use_cases/agents_use_case.py Reserves and forwards the optional metadata filter to the repository while preserving existing list filters.
agentex/src/domain/repositories/agent_repository.py Applies nested JSONB containment for every supplied metadata object, including {}, while retaining authorization, task, and soft-deletion filtering.
agentex/openapi.yaml Documents the optional JSON-encoded Agent Card metadata query parameter.
agentex/tests/integration/api/agents/test_agents_api.py Covers matching, nonmatching, multi-key, pagination, invalid JSON, non-finite numbers, and explicit empty-object behavior.

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant R as GET /agents route
    participant U as AgentsUseCase
    participant DB as AgentRepository/PostgreSQL
    C->>R: agent_card_metadata JSON query
    R->>R: Parse and validate object
    R->>U: list(agent_card_metadata, authorized IDs)
    U->>DB: list(filters)
    DB->>DB: Apply JSONB containment, authorization, and status filters
    DB-->>U: Matching agents
    U-->>R: Agent entities
    R-->>C: 200 agent list
Loading

Reviews (4): Last reviewed commit: "fix(agentex): reject non-finite JSON in ..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the agentex-sdk SDKs with the following commit messages.

openapi

feat(api): add agent_card_metadata query parameter

python

feat(api): add agent_card_metadata parameter to agents list method

typescript

feat(api): add agent_card_metadata parameter to list agents method

Edit this comment to update them. They will appear in their respective SDK's changelogs.

agentex-sdk-openapi studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅

agentex-sdk-typescript studio · code · diff

Your SDK build had at least one "warning" diagnostic, but this did not represent a regression.
generate ⚠️build ✅ (prev: build ⏭️) → lint ✅ (prev: lint ⏭️) → test ✅

npm install https://pkg.stainless.com/s/agentex-sdk-typescript/1bf2d54eae899dc568d9799bd10d5edea82fd019/dist.tar.gz
agentex-sdk-python studio · code · diff

Your SDK build had at least one "warning" diagnostic, but this did not represent a regression.
generate ⚠️build ✅lint ✅test ✅

pip install https://pkg.stainless.com/s/agentex-sdk-python/d11c77882ddf92744fc94a6a2cf46b73212b3336/agentex_client-0.25.0-py3-none-any.whl

This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-09-01 15:44:08 UTC

Comment thread agentex/src/domain/repositories/agent_repository.py Outdated

@basselatscale basselatscale left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSONB containment direction is right, and the integration coverage proves the important subset behavior: an agent whose card contains additional metadata still matches {"permits_capable": true}.

A few changes are needed before this is ready:

  1. list_agents() now fails when called directly without agent_card_metadata. Its default is a FastAPI Query object, so json.loads(agent_card_metadata) raises TypeError. This is currently failing the two authorization unit tests. Please use the Annotated[..., Query(...)] = None form, or otherwise ensure the Python default is actually None, and keep the direct-call tests passing.

  2. In AgentRepository.list, use if agent_card_metadata is not None: rather than a truthiness check. Otherwise an explicitly supplied {} silently bypasses the metadata predicate and includes agents with missing metadata.

  3. Please ensure the OpenAPI/SDK contract supports an ergonomic mapping input rather than requiring every caller to manually json.dumps it. The required consumer shape is:

    client.agents.list(
    agent_card_metadata={"permits_capable": True},
    )

If the wire parameter must remain JSON encoded, the generated/client layer should perform that encoding. The current string schema generates string-typed SDK parameters.

Once those are fixed, this server-side capability is sufficient for our immediate goal: discovering AgentCard-published workflow descriptors and removing the generated input-contract bundle.

@declan-scale

Copy link
Copy Markdown
Collaborator Author

Addressed all three review items in 52b3277:

  1. Direct-call default: agent_card_metadata now uses Annotated[str | None, Query(...)] = None, so calling list_agents() directly gets a real None default. The two authz unit tests pass again (updated to expect the forwarded agent_card_metadata=None kwarg).
  2. Empty-object bypass: the repository now applies the containment predicate on is not None, so an explicit {} requires a card metadata object to be present. Added an integration test covering this.
  3. SDK contract: the query parameter is now declared in the spec with content: application/json and an object schema (via openapi_extra; the runtime string param is schema-hidden). That's the OpenAPI signal for a mapping-typed SDK parameter that the client JSON-encodes on the wire, so client.agents.list(agent_card_metadata={"permits_capable": True}) becomes the generated shape. openapi.yaml regenerated via make gen-openapi.

@basselatscale basselatscale left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review — all three original items are addressed ✓

Two new warnings worth confirming before merge:

1. Non-finite JSON values in agent_card_metadata
Python's json.loads() accepts NaN, Infinity, and extreme exponents like 1e1000000 which aren't valid interoperable JSON. These will pass the current isinstance(parsed, dict) check but can fail at the PostgreSQL JSONB binding layer with an uncontrolled 500 instead of a clean 400. Low likelihood in practice, but a defense-in-depth gap. Could be a follow-up — e.g. json.loads(agent_card_metadata, parse_constant=lambda _: None) + a math.isfinite walk, or just parse_float=decimal.Decimal.

2. Stainless SDK Parameter/MissingSchema warning
The Stainless bot reports "Defaulted parameter to type: string because no schema was defined" on all three SDK previews. Can you confirm the generated Python and TypeScript SDK type signatures actually expose agent_card_metadata as a dict/object (not str)? If the preview SDKs are correct the warning is cosmetic, but if they defaulted to str it defeats the intent of the content: application/json spec encoding.

@declan-scale

Copy link
Copy Markdown
Collaborator Author

Both follow-up warnings addressed in 1a4c251.

1. Non-finite JSON values — fixed.

json.loads now runs with parse_constant and parse_float hooks that reject anything that isn't interoperable JSON, so these all return a clean 400 instead of reaching the JSONB bind parameter:

input before after
{"x": NaN} 500 400
{"x": Infinity} / {"x": -Infinity} 500 400
{"x": 1e1000000} (overflows to inf) 500 400
{"x": <5000-digit int>} 500 400

Nested cases ({"x": [1, NaN]}, {"x": {"nested": Infinity}}) are covered too, since the hooks fire at every level. Added as a parametrized integration test. Note the same latent gap exists on GET /tasks?task_metadata=, which parses with a bare json.loads — happy to fix that in a follow-up rather than widen this PR.

2. Stainless Parameter/MissingSchema — not cosmetic. You were right to ask.

I checked the preview build directly rather than assuming:

# stainless-sdks/agentex-sdk-python @ preview/.../agx1-1048-agent-card-metadata-filter
# src/agentex/types/agent_list_params.py
class AgentListParams(TypedDict, total=False):
    agent_card_metadata: str

So the generator ignores content: application/json on a query parameter entirely — that's exactly what the warning was reporting — and defaults to type: string. The mapping-typed parameter never materialized, and the openapi_extra block was buying nothing while costing a build regression.

I've dropped it. agent_card_metadata is now declared the same way the already-shipped GET /tasks?task_metadata= filter is declared: a nullable string carrying a JSON-encoded object.

- name: agent_card_metadata
  in: query
  required: false
  schema:
    anyOf:
      - type: string
      - type: 'null'
    description: 'JSON-encoded object used to filter agents on ...'

That clears the warning, makes the two containment filters consistent, and means the generated client surface is reproducible from the spec with no hand-editing.

The ergonomics you asked for now live in the SDK's hand-written layer instead of fighting the generator — see the paired SDK PR, which adds encode_metadata_filter:

from agentex.lib.utils.metadata_filters import encode_metadata_filter

client.agents.list(
    agent_card_metadata=encode_metadata_filter({"permits_capable": True}),
)

It's one call rather than a bare json.dumps, it pins key order, and it rejects NaN/Infinity client-side so you get a clear local error instead of a round-trip 400. If you'd rather have client.agents.list(agent_card_metadata={...}) literally, the only way to get there reproducibly is to move the filter into a request body (a POST /agents/search) — worth doing if we expect this filter surface to grow, but it's a bigger change than this PR and I'd rather do it deliberately than smuggle it in here.

Tests: 26 passed in tests/integration/api/agents/test_agents_api.py, 22 in tests/unit/api/test_agents_authz.py, 8 in tests/unit/use_cases/test_agents_use_case.py. make gen-openapi produces no further diff.

declan-scale and others added 3 commits September 1, 2026 11:39
Adds an optional `agent_card_metadata` query parameter to `GET /agents`
that applies an exact JSONB containment (`@>`) filter against
`registration_metadata.agent_card.metadata`. Agents whose card is
missing or does not contain every requested key/value are excluded; the
existing pagination, ordering, task filtering and authorization behavior
are preserved.

Enables discovery flows where consumers publish opt-in capability flags
via the AgentCard and need to enumerate only agents that advertise them.
- Use Annotated[str | None, Query(...)] = None so list_agents() called
  directly (outside FastAPI) defaults to None instead of a Query object
- Apply the containment predicate on `is not None` so an explicit {}
  filter still requires a card metadata object to be present
- Declare the query parameter with `content: application/json` and an
  object schema so SDK generators expose a mapping-typed parameter and
  perform the JSON wire encoding themselves

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tent-typed param

Two follow-up review items on the agent card metadata filter.

Python's json module accepts values that are not interoperable JSON: the
bare NaN/Infinity constants, float literals that overflow to infinity
(1e1000000), and integers too large for CPython to render. All of them
satisfy the isinstance(..., dict) check and only fail later at the JSONB
bind parameter, turning caller error into an uncontrolled 500. Parse with
parse_constant/parse_float hooks that reject them so every malformed input
surfaces as a 400.

The parameter was declared with content: application/json in the hope that
SDK generators would expose it as a mapping and do the JSON encoding
themselves. They do not: the generator reports "no schema was defined" and
falls back to type: string, so the generated client parameter is a plain
str either way. Drop the content-typed override and declare it the same way
the already-shipped GET /tasks?task_metadata= filter is declared -- a
nullable string carrying a JSON-encoded object -- which clears the
generator warning and keeps the two containment filters consistent.
@declan-scale
declan-scale force-pushed the declan-scale/agx1-1048-agent-card-metadata-filter branch from 1a4c251 to ed1708f Compare September 1, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants