Skip to content

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

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

feat(agent-card): add metadata field and expose list filter#502
declan-scale wants to merge 3 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

Greptile Summary

The PR adds free-form AgentCard metadata, propagates it through Temporal worker registration, and exposes metadata containment filtering on synchronous and asynchronous agent-list APIs.

  • Adds AgentCard metadata construction, serialization, and registration coverage.
  • Adds a deterministic helper for encoding JSON metadata filters.
  • Adds the agent-card metadata query parameter to both client modes and their request type.
  • Extends Temporal worker registration to publish an optional AgentCard.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
src/agentex/lib/types/agent_card.py Adds independently allocated metadata and forwards it through both AgentCard factory methods.
src/agentex/lib/core/temporal/workers/worker.py Accepts an optional AgentCard and forwards it through the existing startup registration lifecycle.
src/agentex/lib/utils/metadata_filters.py Adds stable compact JSON encoding with explicit rejection of non-finite floats.
src/agentex/resources/agents/agents.py Adds matching synchronous and asynchronous agent-card metadata query parameters.
src/agentex/types/agent_list_params.py Extends the generated agent-list query shape with the optional metadata filter.
tests/lib/test_agent_card.py Covers metadata defaults, round trips, factory forwarding, and registration payload propagation.
tests/lib/test_agentex_worker.py Covers default and supplied AgentCard behavior across worker registration.
tests/lib/test_metadata_filters.py Covers encoding validation and exact sync/async query-string transmission.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Card[AgentCard metadata] --> Worker[Temporal worker]
  Card --> ACP[FastACP server]
  Worker --> Registration[Agent registration]
  ACP --> Registration
  Registration --> Stored[registration_metadata.agent_card.metadata]
  Filter[agents.list agent_card_metadata] --> API[Agents API]
  API --> Stored
Loading

Reviews (3): Last reviewed commit: "feat(worker): let AgentexWorker publish ..." | Re-trigger Greptile

Context used (3)

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.

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

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

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

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