Skip to content

feat(agent-card): add metadata field and expose list filter - #502

Merged
declan-scale merged 5 commits into
nextfrom
declan-scale/agx1-1048-agent-card-metadata
Sep 14, 2026
Merged

declan-scale merged 5 commits into
nextfrom
declan-scale/agx1-1048-agent-card-metadata

Conversation

@declan-scale

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

Copy link
Copy Markdown
Contributor

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

Problem

The SDK's AgentCard has no free-form metadata field, so agents that want to advertise opt-in protocol capabilities (e.g. Permits' workflow submission protocol) have no place to attach that self-description data. The generated agents.list client also lacks the paired containment filter so consumers can't enumerate agents that opted in.

Linear: AGX1-1048

Change

  • Adds metadata: dict[str, Any] = Field(default_factory=dict) to AgentCard in src/agentex/lib/types/agent_card.py. Default factory keeps each instance's dict independent (no shared mutable default).
  • Threads an optional metadata= kwarg through AgentCard.from_states and AgentCard.from_state_machine so callers building a card from a state machine can attach metadata inline.
  • Serializes through the existing registration_metadata.agent_card path (agentex.lib.utils.registration.register_agent) — no wire-shape change required.
  • Exposes an agent_card_metadata: Optional[str] param on both sync and async agents.list (a JSON-encoded object matched with exact key/value containment on the server) and adds it to AgentListParams. The platform PR wires the server-side JSONB @> filter.

Test coverage added

  • tests/lib/test_agent_card.py
    • Default metadata is an empty dict and instances don't share the default.
    • metadata accepts arbitrary nested JSON and round-trips through model_dump / model_validate.
    • from_states and from_state_machine forward the value.
    • register_agent propagates metadata inside registration_metadata.agent_card.metadata on the outgoing HTTP request.
  • tests/api_resources/test_agents.py: extended the `with_all_params` list tests (sync + async) to pass `agent_card_metadata`. (Still `@pytest.mark.skip`-guarded like the rest of the mock-server tests.)

Test plan (for reviewer)

  • CI `Validate PR title` and `Validate PR base branch` pass (PR targets `next`, title uses Conventional Commits).
  • `./scripts/test` on `next` locally to run the AgentCard + api_resources tests.
  • Once the paired platform PR (feat(agentex): filter GET /agents by agent card metadata scale-agentex#411) is merged, verify Stainless regeneration keeps the manually-added `agent_card_metadata` param intact (or supersedes this file with an equivalent generated version — either is fine because the surface matches the OpenAPI spec).

Notes

RetriggerConfidence Score: 5/5

The PR appears safe to merge.

Summary

  • Agent cards accept nested metadata and preserve it through registration.
  • Temporal workers can pass an agent card into automatic registration.
  • Metadata filter mappings become stable, validated JSON strings.

Diagram

sequenceDiagram
    participant App
    participant Card as AgentCard
    participant Worker as AgentexWorker
    participant Backend as AgentEx backend
    participant Client as agents.list client

    App->>Card: Build with metadata
    App->>Worker: Start with agent_card
    Worker->>Backend: Register agent with card metadata
    App->>Client: Encode metadata filter
    Client->>Backend: "GET /agents?agent_card_metadata=JSON"
    Backend-->>Client: Return matching agents
Loading

Reviews (6) · Last reviewed commit: "docs(sdk): document server version requi..."

@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 AgentCard.metadata addition and registration propagation look good and provide the missing agent-authored descriptor path.

The list-filter SDK surface needs one change for the intended consumer contract. It is currently typed as Optional[str], which requires callers to know the wire encoding and manually json.dumps the metadata.

Please expose a mapping/JSON-object input and serialize it internally, so both sync and async clients support:

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

Please also add an exercised request-level test for both clients that asserts the mapping becomes a JSON-encoded query parameter. The current generated resource tests pass a sentinel string and are skip-guarded, so they do not prove this behavior.

There is also a currently failing Ruff import-order check in agent_card.py that needs to be fixed.

With that SDK change, these paired PRs provide enough plumbing for the permits harness to discover registered descriptors and remove input-contracts.generated.yaml.

@declan-scale

Copy link
Copy Markdown
Contributor Author

Addressed in 582600d.

Ruff import-order in agent_card.py — fixed. (from pydantic import Field, BaseModel; this repo length-sorts.) ./scripts/lint is now clean: ruff + pyright both pass.

The mapping-typed agents.list parameter — I went a different way than you asked, and here's why.

I checked what the generator actually produces from the platform PR's spec rather than assuming it worked:

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

The generator ignores content: application/json on query parameters and falls back to type: string (that's the Parameter/MissingSchema warning on the platform PR). So there is no spec shape that yields a mapping-typed query parameter here. Widening the parameter in resources/agents/agents.py by hand would give you the call shape today and get silently reverted on the next regeneration — which is exactly Greptile's open objection, and the 20_codegen_boundaries rule's "make it reproducible" requirement can't be satisfied for that edit.

Note also the API already has this exact pattern shipped: tasks.list(task_metadata=...) is Optional[str] carrying a JSON-encoded object. Making the card filter mapping-typed would have made the two inconsistent.

So the generated parameter stays Optional[str], the platform PR now declares it identically to task_metadata, and the ergonomics moved into the hand-written layer where they survive regeneration:

from agentex.lib.utils.metadata_filters import encode_metadata_filter

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

encode_metadata_filter takes a mapping, emits the compact wire form with key order pinned (same filter → same query value), and rejects NaN/Infinity up front so you get a clear local error rather than a round-trip 400. It's deliberately generic so task_metadata can use it too.

I also updated the generated docstrings to match the spec's parameter description verbatim, so a regeneration is now a no-op on agents.py and agent_list_params.py — which should close out Greptile's finding.

Exercised request-level tests — added. tests/lib/test_metadata_filters.py, 13 tests, all actually running (they build their own Agentex/AsyncAgentex against respx rather than using the skip-guarded mock-server fixtures):

  • sync client: the mapping reaches the wire as agent_card_metadata={"permits_capable":true,"region":"us"}
  • async client: same assertion
  • omitted filter is absent from the query string entirely
  • {} is sent verbatim rather than dropped as falsy (it's a meaningful filter server-side: the agent must have a card metadata object)
  • encoder unit coverage: type/nesting fidelity, stable key order, non-finite floats, non-mapping input, non-serializable values

50 passed across tests/lib/test_agent_card.py + tests/lib/test_metadata_filters.py.

If you'd still rather have client.agents.list(agent_card_metadata={...}) literally, the only reproducible route is moving the filter into a request body on the platform side (POST /agents/search), which the generator handles natively with full JSON type fidelity. Happy to do that — but it's a real API change and belongs in its own pass, not smuggled into this one.

@declan-scale

Copy link
Copy Markdown
Contributor Author

Re: "Generated filter is unreproducible" (src/agentex/resources/agents/agents.py:119) — this is now verifiably closed.

The platform PR's spec change has regenerated the Stainless preview, and both touched generated files in this PR are byte-identical to what the generator produces:

$ diff <preview>/src/agentex/types/agent_list_params.py    src/agentex/types/agent_list_params.py
agent_list_params.py: IDENTICAL
$ diff <preview>/src/agentex/resources/agents/agents.py    src/agentex/resources/agents/agents.py
agents.py: IDENTICAL

So a regeneration is a no-op here — no parameter gets dropped, no recurring conflict. The finding was correct about the original revision: it hand-edited generated files to a shape the spec did not produce. That's fixed by making the spec produce this shape rather than by hand-editing, and the docstrings were realigned to the spec's parameter description so even the prose matches.

The mapping ergonomics live in src/agentex/lib/utils/metadata_filters.py, on the manual side of the codegen boundary, per 20_codegen_boundaries.

Related: the platform PR's Stainless build also went from generate ⚠️ (regression) back to generate ✅ — the Parameter/MissingSchema warning is gone now that the parameter declares a real schema instead of content: application/json.

@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 AgentCard model and registration serialization are suitable, but our real Temporal deployment path exposes one remaining SDK gap.

FastACP.create(..., agent_card=card) automatically publishes the card during ACP startup, while AgentexWorker always calls register_agent(env_vars) without a card. Consumers using the standard worker therefore have to subclass a protected _register_agent() method or perform a second registration solely to publish identical metadata.

Please add an optional AgentCard constructor parameter to AgentexWorker, for example:

worker = AgentexWorker(
    task_queue=task_queue,
    agent_card=agent_card,
)

Store it on the worker and change the existing automatic path to:

await register_agent(env_vars, agent_card=self.agent_card)

The default must remain None, preserving existing callers and wire behavior. Please add focused tests proving:

  1. the default worker registration still calls register_agent without AgentCard metadata;
  2. a supplied card is forwarded exactly once by the existing automatic registration lifecycle; and
  3. the worker and FastACP paths serialize the same card shape.

This would let Permits delete its _AgentCardPublishingWorker protected-method override and the extra idempotent registration call. Descriptor construction remains Permits-owned; this request only makes publication consistent with the SDK's existing automatic registration model.

@declan-scale

Copy link
Copy Markdown
Contributor Author

Addressed in 6a80ede.

AgentexWorker now takes an optional agent_card. The constructor accepts agent_card: Any | None = None (same typing as FastACP.create), stores it on the worker, and the existing automatic path now calls register_agent(env_vars, agent_card=self.agent_card). Default is None, so existing callers and wire behavior are unchanged. Permits can drop the _AgentCardPublishingWorker override and the second registration:

worker = AgentexWorker(task_queue=task_queue, agent_card=agent_card)

Tests added (tests/lib/test_agentex_worker.py):

  1. the default worker's _register_agent calls register_agent with agent_card=None;
  2. a supplied card is forwarded exactly once through the full run() lifecycle (health-check server, temporal client, and Temporal Worker mocked; register_agent asserted awaited once with the card);
  3. the worker path and the BaseACPServer lifespan path produce byte-identical registration_metadata.agent_card payloads for the same card, captured at the httpx boundary from the real register_agent.

./scripts/lint clean (ruff + pyright); 67 tests pass across test_agentex_worker.py, test_agent_card.py, and test_metadata_filters.py.

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

LGTM

@declan-scale
declan-scale force-pushed the declan-scale/agx1-1048-agent-card-metadata branch from 6a80ede to d307717 Compare September 14, 2026 12:28

@jromualdez-scale jromualdez-scale left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The default_factory on AgentCard.metadata, keeping the generated files byte-identical to the Stainless preview and putting the ergonomics in lib/utils, and the worker agent_card passthrough are all the right calls. The respx tests and the worker-vs-FastACP payload equality test are the tests that actually prove the wire behavior, since the api_resources ones are skip-guarded. One non-blocking comment inline.

Comment thread src/agentex/lib/utils/metadata_filters.py
declan-scale and others added 5 commits September 14, 2026 13:16
Adds an optional `metadata: dict[str, Any]` field to the SDK's
`AgentCard` model (defaulting to an empty dict) and threads the value
through `AgentCard.from_states` / `AgentCard.from_state_machine` so
callers can attach opt-in capability flags without subclassing.

Also plumbs the paired platform `agent_card_metadata` list filter
through the Stainless-generated `agents.list` surface so consumers can
enumerate agents whose card metadata contains a given JSON object with
exact key/value semantics.

The card continues to serialize through the existing
`registration_metadata.agent_card` path — no wire-shape or database
migration is required.
Review follow-ups on the agents.list card metadata filter.

The generated `agent_card_metadata` parameter stays `Optional[str]`. The
platform spec declares it the same way the already-shipped
`tasks.list(task_metadata=...)` filter is declared -- a nullable string
carrying a JSON-encoded object -- because the generator does not honour a
`content: application/json` query parameter and falls back to `type: string`
regardless. Widening the parameter here would be an edit the next
regeneration silently reverts, so the ergonomics live in the hand-written
layer instead: `agentex.lib.utils.metadata_filters.encode_metadata_filter`
turns a mapping into the exact wire value, with key order pinned so the same
filter always produces the same query string, and rejects NaN/Infinity up
front rather than letting the server return a 400.

The generated docstrings are updated to match the parameter description in
the spec, so a regeneration is now a no-op on these files.

Adds exercised request-level tests (respx, sync and async) asserting the
encoded mapping actually reaches the query string, and that an empty-object
filter is sent verbatim rather than dropped as falsy -- `{}` is meaningful
server-side. These use their own clients rather than the skip-guarded
mock-server fixtures, so they run in CI.

Also fixes the failing ruff import-order check in agent_card.py.
Add an optional agent_card parameter to the AgentexWorker constructor. The
worker stores the card and forwards it through the existing automatic
register_agent call, so a Temporal worker can publish a card without a
subclass override or a second registration. The default stays None and the
wire behavior for existing callers does not change.

Add tests that prove:
- the default worker registers without card metadata;
- a supplied card reaches register_agent exactly once through run();
- the worker path and the FastACP lifespan path serialize the same card
  shape into registration_metadata.agent_card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…p for the card filter

Adds the minimum-server note to metadata_filters (older servers ignore the
unknown query parameter and return the full agent list) and syncs the
generated agent_card_metadata docstrings with the updated OpenAPI
description from scaleapi/scale-agentex#411.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@declan-scale
declan-scale force-pushed the declan-scale/agx1-1048-agent-card-metadata branch from 402d447 to 08c96f7 Compare September 14, 2026 17:19
@declan-scale
declan-scale merged commit 55a0178 into next Sep 14, 2026
48 checks passed
@declan-scale
declan-scale deleted the declan-scale/agx1-1048-agent-card-metadata branch September 14, 2026 18:01
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.

3 participants