Skip to content

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

Open
declan-scale wants to merge 4 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 4 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 metadata published in their agent cards.

  • Parses and validates the JSON-encoded agent_card_metadata query parameter.
  • Applies nested JSONB containment while preserving authorization, task, status, ordering, and pagination filters.
  • Adds a concurrent GIN index for the new containment query.
  • Extends API, repository, authorization, pagination, malformed-input, empty-object, and re-registration coverage.
  • Updates the OpenAPI specification with the new query parameter.

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 optional metadata filter and forwards the parsed value to the use case.
agentex/src/domain/use_cases/agents_use_case.py Preserves both empty and non-empty metadata objects under the repository’s reserved filter key.
agentex/src/domain/repositories/agent_repository.py Removes special filters from generic column handling and applies nested JSONB containment whenever metadata was explicitly supplied.
agentex/database/migrations/alembic/versions/2026_09_03_1200_add_agents_registration_metadata_gin_index_b7d3e1f4a2c6.py Adds and removes the supporting JSONB GIN index concurrently within Alembic autocommit blocks.
agentex/openapi.yaml Documents the optional JSON-encoded agent-card metadata query parameter.
agentex/tests/integration/api/agents/test_agents_api.py Covers containment, pagination, invalid JSON, empty-object filtering, and metadata replacement or withdrawal.
agentex/tests/unit/api/test_agents_authz.py Updates authorization-route expectations for the new optional use-case argument.
agentex/tests/unit/use_cases/test_agents_use_case.py Exercises metadata containment and omitted-filter behavior against the repository-backed use case.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Route as GET /agents
    participant UseCase as AgentsUseCase
    participant Repository as AgentRepository
    participant DB as PostgreSQL
    Client->>Route: agent_card_metadata JSON
    Route->>Route: Parse and validate object
    Route->>UseCase: list(agent_card_metadata)
    UseCase->>Repository: list(filters)
    Repository->>DB: "registration_metadata @> nested agent card metadata"
    DB-->>Repository: Authorized matching agents
    Repository-->>Client: Paginated agent list
Loading

Reviews (5): Last reviewed commit: "feat(agentex): add a GIN index for the a..." | Re-trigger Greptile

Context used (3)

@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/f16cb2e764ccdb27db931b1411ab5ac6c7a7436b/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/adef4791b0a3a6c1cd7849f0124401374541b0d6/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-03 18:16:58 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

@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 filtering behavior and validation changes look good, and the local Permits vertical-slice test passed against this exact head.

One production concern remains before we adopt this as a polled workflow registry: the new containment query runs against agents.registration_metadata, but the PR does not add a supporting JSONB index. Every Permits harness replica will periodically page through this filtered endpoint, while agent registrations are comparatively write-light. Without an index, that creates a recurring full scan as the registry grows.

Please add a backward-compatible Alembic migration for a concurrent GIN index matching the current predicate, for example:

CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_agents_registration_metadata_gin
ON agents
USING gin (registration_metadata jsonb_path_ops);

Please use Alembic's autocommit support because CREATE INDEX CONCURRENTLY cannot run inside the normal migration transaction, and make the downgrade concurrent as well.

I recommend indexing the full registration_metadata expression used by the current query rather than introducing an expression/partial index in this PR. That keeps the implementation aligned with the existing registration_metadata @> ... predicate and avoids an API or repository-query redesign.

Please also add the normal migration-level verification used by this repository. No Redis caching, registry revision key, or pagination change is requested here; Permits will own its refresh interval, jitter, last-known-good snapshot, and submission provenance.

@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.

One additional regression case would make the paired SDK worker change safe to rely on.

The SDK follow-up will pass an optional AgentCard through AgentexWorker's existing automatic /agents/register call rather than issuing a second registration. Please add an integration test proving idempotent re-registration of the same agent replaces the stored top-level registration_metadata.agent_card and immediately changes metadata-filter results:

  1. register the agent with card metadata A;
  2. verify filter A matches;
  3. re-register the same agent identity with card metadata B;
  4. verify filter A no longer matches and filter B does.

The current top-level existing_metadata.update(registration_metadata) implementation appears to provide this behavior already, so this should primarily lock the release-update contract rather than require an API redesign.

Please also document or test the supported way to withdraw a previously published AgentCard during rollback. Omitting registration_metadata currently preserves existing metadata, which can leave a stale discoverable descriptor after reverting to a release that no longer publishes a card. An explicit {"agent_card": null} registration may already provide the required non-breaking clearing behavior; if that is the intended contract, please lock it with a test.

This is separate from the previously requested GIN migration, and does not require registry webhooks or a revision API.

… re-registration contract

Add a concurrent GIN index (jsonb_path_ops) on agents.registration_metadata.
The agent_card_metadata filter applies a JSONB containment predicate on this
column, and discovery clients poll the filtered endpoint. The index keeps
that read path off a sequential scan. The migration uses an autocommit block
for CREATE INDEX CONCURRENTLY on both upgrade and downgrade.

Add two integration tests that lock the registration contract for discovery:
- Re-registration of the same agent replaces the stored agent_card, and the
  metadata filter reflects the new card immediately.
- Registration with {"agent_card": null} withdraws a published card, while a
  registration that omits registration_metadata preserves it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@declan-scale

Copy link
Copy Markdown
Collaborator Author

Addressed both reviews in d801b33.

GIN index migration — added. 2026_09_03_1200_add_agents_registration_metadata_gin_index_b7d3e1f4a2c6.py creates ix_agents_registration_metadata_gin on the full registration_metadata column with jsonb_path_ops, exactly matching the @> predicate the repository emits. The upgrade and the downgrade both run CONCURRENTLY inside autocommit_block, with IF NOT EXISTS / IF EXISTS guards. The repo migration linter (migration_lint.py) passes. I also verified the migration against a throwaway Postgres 16 container: alembic upgrade head creates the index with the expected definition, and alembic downgrade -1 removes it.

Re-registration contract — locked with tests.

  • test_reregistration_replaces_agent_card_and_filter_results: register with card A, filter A matches; re-register the same agent with card B, filter A returns empty and filter B matches.
  • test_reregistration_with_null_agent_card_withdraws_from_discovery: an explicit {"agent_card": null} registration clears the published card, so filter and {} filter both stop matching while the agent itself stays listed. The test also proves that omitting registration_metadata preserves the existing card, so the clearing is opt-in and non-breaking.

Both behaviors come from the existing top-level existing_metadata.update(...) merge (agents_use_case.py:162-165); no API change was needed. Full test_agents_api.py suite: 28 passed.

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