feat(keycardai-a2a): new package split from keycardai-agents (ACC-230)#105
Conversation
Per the KEP "Decompose keycardai-agents", the A2A delegation surface moves out of keycardai-agents into a new keycardai-a2a package, structurally analogous to keycardai-mcp. Symbols available at the new namespace: - AgentServer, create_agent_card_server, serve_agent - DelegationClient, DelegationClientSync - AgentExecutor, SimpleExecutor, LambdaExecutor - KeycardToA2AExecutorBridge - ServiceDiscovery - AgentServiceConfig The bearer middleware in server/app.py also migrates from the deprecated BearerAuthMiddleware to the canonical KeycardAuthBackend + AuthenticationMiddleware pattern from keycardai-starlette. The keycardai-mcp dependency drops from this code path. Hard cut, no transitional bridge: ACC-232 confirms no known production users of keycardai.agents.* paths. The PKCE user-login client (AgentClient) is dropped entirely. Its capability already lives in keycardai-oauth as keycardai.oauth.pkce.authenticate (ACC-229 / #101). The duplicate in keycardai-agents is removed. What stays in keycardai-agents: the CrewAI integration only, with its imports repointed at keycardai.a2a. ACC-231 will move it to a dedicated keycardai-crewai; ACC-232 will archive the now-stub source directory. BREAKING: - from keycardai.agents import AgentServer, DelegationClient, ... becomes from keycardai.a2a import ... . - from keycardai.agents.client import AgentClient is gone; use keycardai.oauth.pkce.authenticate. - keycardai-agents 0.3.0 ships with the dependency set reduced to keycardai-a2a + pydantic, mirroring its now-CrewAI-only scope.
The first commit on this branch did the git mv's but staged the new files only; the Edit-tool modifications to the moved files (import rewrites, server/app.py bearer-wiring migration to KeycardAuthBackend + AuthenticationMiddleware, example pyproject swap from keycardai-agents to keycardai-a2a, conftest/tests import repoints, agents/__init__.py trim, agents/pyproject dep set, crewai integration repoint, top-level workspace sources, justfile test recipe, uv.lock refresh) all sat uncommitted. CI rejected the prior commit because the example pyproject still claimed keycardai-agents at packages/a2a/, conflicting with the real keycardai-agents at packages/agents/ in the workspace graph. This commit lands the actual migration content. Tests pass locally: keycardai-a2a 60/60, keycardai-agents 16/16, no regression in oauth / starlette / mcp / mcp-fastmcp / fastmcp; ruff workspace check clean.
This comment was marked as outdated.
This comment was marked as outdated.
…C-230) Aligns keycardai-a2a with the wrap-do-not-reinvent pattern used in keycardai-mcp and keycardai-starlette: customers implement a2a-sdk native async AgentExecutor directly; this package contributes only Keycard auth wiring, OAuth metadata discovery, and convenience composition. Drops the parallel-protocol surface inherited from the keycardai-agents move: - AgentExecutor protocol (sync execute(task, inputs)) and SimpleExecutor / LambdaExecutor implementations - KeycardToA2AExecutorBridge (the sync->async adapter that existed only to bridge our protocol to a2a-sdk) - Custom POST /invoke endpoint with bespoke InvokeRequest / InvokeResponse Pydantic models alongside the standard A2A JSONRPC interface - AgentServiceConfig.invoke_url (replaced by jsonrpc_url) and AgentServiceConfig.to_agent_card() (the 0.x dict-shape constructor) Migrates from a2a-sdk 0.x to 1.x natively: - pyproject pin a2a-sdk[http-server]>=1.0 - Server composition uses route factories (create_jsonrpc_routes, create_agent_card_routes) instead of the gone A2AStarletteApplication - Request handler is DefaultRequestHandlerV2 (alias DefaultRequestHandler) - AgentCard built from 1.x protobuf schema (supported_interfaces, AgentCapabilities streaming/push_notifications/extended_agent_card) - Example main.py uses a2a-sdk 1.x Client via create_client + A2ACardResolver Adds a KeycardServerCallContextBuilder that subclasses a2a-sdk default builder and stashes the verified KeycardUser plus access_token into ServerCallContext.state so AgentExecutor implementations can read the bearer token from context.call_context.state["access_token"] for downstream delegated token exchange. Tests: - a2a 44/44 pass - agents 16/16 pass with crewai extra - ruff clean Note: the high-level @auth.grant decorator parity with keycardai-mcp is not yet shipped here. Customers use DelegationClient (already in this package) for explicit server-to-server delegation. The decorator port is a follow-up.
This comment was marked as outdated.
This comment was marked as outdated.
Three blockers caught in fresh-eyes review:
1. release.yml tag-trigger list was hardcoded; *-keycardai-a2a was missing,
so the post-merge auto-bump would push the tag but the publish workflow
would never trigger. Trusted Publisher being registered would have been
moot.
2. DelegationClient.invoke_service hardcoded service_url + /invoke. The
wrap-aligned server only exposes /a2a/jsonrpc; calling invoke_service
against any 1.x server returned 404. The CrewAI delegation tool runs
through this code path. Both async and sync variants now build a
message/send JSONRPC envelope, POST it to /a2a/jsonrpc, and unwrap the
result to preserve the legacy {result, delegation_chain} shape so the
CrewAI integration keeps working unchanged.
3. discover_service in both DelegationClient and ServiceDiscovery validated
the 0.x card shape (required_fields = [name, endpoints, auth]). The 1.x
protobuf-derived JSON has none of endpoints / auth. Discovery against
any 1.x server raised ValueError. Validation now requires only "name";
transport / auth specifics live under supportedInterfaces and the OAuth
metadata routes.
Plus four important findings:
4. Test mocks across conftest.py, test_a2a_client.py, test_discovery.py,
and test_crewai_a2a.py used the old shape (endpoints/auth keys). Tests
passed because the validator wrongly accepted them. Mocks now use the
1.x JSON shape (supportedInterfaces, capabilities object, skills with
id/name).
5. A2AServiceClient and A2AServiceClientSync backward-compat aliases at
the bottom of delegation.py contradicted the "hard cut, no transitional
bridge" stance in the PR description. Removed.
6. TestJsonRpcAuthGate.test_jsonrpc_requires_authorization asserted
status_code in (400, 401). 400 means the JSONRPC dispatcher saw the
request and bailed on the body shape, not that the auth gate caught
it. Pinned to == 401 with a WWW-Authenticate header check so the gate
contract is enforced.
7. Zero coverage existed for _KeycardServerCallContextBuilder propagating
the verified KeycardUser plus access_token into ServerCallContext.state.
Added two unit tests that build the context directly: one with a
KeycardUser asserting state["access_token"] is set, one with an
UnauthenticatedUser asserting state["access_token"] is absent (so an
executor reading it sees None rather than a stale token).
Tests:
- a2a 47/47 (was 44; +3 new wrap-coverage tests)
- agents 16/16 with crewai extra
- ruff clean
This comment was marked as outdated.
This comment was marked as outdated.
| ) | ||
|
|
||
|
|
||
| class AgentServer: |
There was a problem hiding this comment.
This is not blocking, but I wonder what is the actual utility of this? In what case I would spin up the entire server using Keycard?
There was a problem hiding this comment.
Also now we have starlette dep in the a2a package. So another question, if there is a utility, should this be actually in keycardai-starlette?
…ACC-230) Per Kamil's review on PR #105: AgentServer / create_agent_card_server / serve_agent presupposed customers want a fresh Starlette app dedicated to the agent service. The wrap-don't-reinvent stance, taken seriously, says: customers already have an a2a-sdk app in their head; we ship primitives that slot Keycard auth into THAT, not a parallel server. Public surface change: Dropped: AgentServer, create_agent_card_server, serve_agent Promoted to public (renamed off the underscore prefix): EagerKeycardAuthBackend KeycardServerCallContextBuilder build_agent_card_from_config AgentServiceConfig trimmed: dropped agent_executor (DefaultRequestHandler takes its own), port and host (uvicorn's job), status_url (no /status in the primitives layer). The composed-server flow moves to a runnable example at packages/a2a/examples/keycard_protected_server/. README quickstart rewritten to show primitive composition into an existing app; greenfield users follow the example. Tests: a2a 44/44 (was 47; net -3 from dropping the /status endpoint tests and the port-validation test) agents 16/16 with crewai extra ruff clean This change is breaking, but the package is 0.1.0-pre-publish so no customer is on these names yet.
This comment was marked as outdated.
This comment was marked as outdated.
…hBackend into KeycardAuthBackend kwarg (ACC-230) Per Kamil's second review observation on PR #105: with keycardai-a2a now depending on keycardai-starlette, the question of WHERE these primitives live matters. EagerKeycardAuthBackend was a 5-line subclass that flipped one branch of KeycardAuthBackend.authenticate to raise on missing Authorization, with no a2a-sdk specifics. The behavior is a policy choice ("this mount requires auth"), not a different kind of backend. Collapsed to a kwarg on the existing class: KeycardAuthBackend(verifier) # default, # mixed-route KeycardAuthBackend(verifier, require_authentication=True) # all-paths-protected The OAuth metadata bypass (RFC 9728 §2 / RFC 8414 §3) takes precedence over the kwarg: even with require_authentication=True, requests to /.well-known/oauth-* and /.well-known/jwks.json still pass through anonymously per spec. New parametrized test asserts this. Net effect: - One class instead of two; existing KeycardAuthBackend(verifier) callers unchanged. - keycardai-a2a no longer ships EagerKeycardAuthBackend; the kwarg is used directly in tests, the example, and the README quickstart. - Migration story is zero churn for existing users; new behavior is opt-in via the kwarg. Tests: starlette 40 passed (+2 new tests for the kwarg semantics) a2a 44 passed agents 16 passed ruff clean
📦 Release PreviewThis analysis shows the expected release impact: 📈 Expected Version Changes📋 Package Details[
{
"package_name": "keycardai-a2a",
"package_dir": "packages/a2a",
"has_changes": true,
"current_version": "0.1.0",
"next_version": "0.2.0",
"increment": "MINOR"
}
]📝 Changelog PreviewThis comment was automatically generated by the release preview workflow. |
|
On the first comment: agreed, dropped On the second comment: yes, the keycardai-starlette dep stays, but I went through the primitives keycardai-a2a was shipping and decomposed where each genuinely belongs:
The remaining keycardai-starlette imports in this package are limited to |
…allel surface (ACC-231) Addresses fresh-eyes review on PR #106. The previous commit moved CrewAIExecutor verbatim, which carried the 0.x sync execute(task, inputs) signature and forced the README quickstart to hand-write the exact parallel-protocol bridge that #105 was reshaped to remove. Ships as the 0.1.0 PyPI surface, the README example becomes the canonical pattern users copy, so the gap had to close before publish. CrewAIExecutor now subclasses a2a.server.agent_execution.AgentExecutor with async execute(self, context, event_queue) and slots straight into DefaultRequestHandler(agent_executor=...). The README quickstart drops the hand-written async wrapper. execute() does four things in order: 1. Read context.call_context.state["access_token"] (set by keycardai.a2a.KeycardServerCallContextBuilder), call set_delegation_token so synchronous CrewAI tools see it; warn if absent so misconfigured deployments are visible in logs. 2. Build a fresh Crew via the factory. 3. Run crew.kickoff() on a worker thread via asyncio.to_thread. crew.kickoff is synchronous and CPU/IO-bound for seconds at a time; calling it directly inside an async def starves uvicorn. The contextvar set in step 1 propagates into the worker via contextvars.copy_context (which asyncio.to_thread does for us), so ServiceDelegationTool._run can still read the token. The class docstring spells this out so a future reader does not swap to_thread for a raw ThreadPoolExecutor. 4. Wrap the string result in an A2A Message (Role.ROLE_AGENT) and enqueue it. Drops the dead set_token_context flag and set_token_for_delegation method on CrewAIExecutor: both existed only for the Keycard AgentExecutor protocol that #105 deleted. Nothing in the new world calls them. Drops the dead try/except around `from crewai import Crew`: crewai is a hard dependency in pyproject.toml, not an extra, so the except branch is unreachable. Trims the Topic :: Security classifier from pyproject.toml: copy-paste from keycardai-a2a, but a CrewAI integration is not security software. Adds 8 new tests (TestCrewAIExecutor + TestSetDelegationToken): - subclass check (isinstance(executor, AgentExecutor)) - execute runs crew.kickoff with the user input as task - execute enqueues a Message with the crew result - execute propagates access_token from RequestContext into the contextvar, observed from inside crew.kickoff via asyncio.to_thread context inheritance - execute warns when access_token is absent - execute does not block the event loop (probe inside kickoff confirms asyncio.get_running_loop() raises in the worker thread) - cancel returns None - set_delegation_token writes to the public contextvar Tests: crewai 24 passed (was 16; +8) a2a 44 passed agents 0 collected (empty package, expected) ruff clean
Removes packages/agents/ entirely. The PKCE user-login client moved to keycardai-oauth (#101); the A2A delegation surface moved to keycardai-a2a (#105). The CrewAI integration that briefly remained had no users; the extraction PR (#106) was closed without merging in favor of customers wiring CrewAI to Keycard primitives directly. Drops keycardai-agents from the root workspace sources and the release.yml tag allowlist so accidental tags cannot trigger republish. keycardai-agents on PyPI is frozen at 0.2.0 and is being yanked separately. Updates packages/a2a/README.md to drop the now-stale references to a forthcoming keycardai-crewai package and the in-progress archival. BREAKING: any "from keycardai.agents.*" import fails. No production users. Closes ACC-232.
Summary
Per the KEP Decompose keycardai-agents, the A2A delegation surface moves out of
keycardai-agentsinto a newkeycardai-a2apackage. Following Kamil's wrap-don't-reinvent stance forkeycardai-starlette, this PR ships keycardai-a2a as a thin set of primitives overa2a-sdk1.x rather than a server abstraction. Customers compose these primitives with a2a-sdk's standard route factories and request handler in their own Starlette / FastAPI app.Public surface in
keycardai.a2a:AgentServiceConfig— service identity + Keycard credentials + agent card metadataKeycardServerCallContextBuilder—ServerCallContextBuilderthat propagates the verified bearer token ontoServerCallContext.statebuild_agent_card_from_config— produces a 1.x protobufAgentCardDelegationClient,DelegationClientSync— outbound server-to-server delegationServiceDiscovery— inbound.well-known/agent-card.jsonresolutionFor the auth backend itself, customers use
keycardai.starlette.KeycardAuthBackend(verifier, require_authentication=True)on the JSONRPC mount. The kwarg flips the default mixed-route behavior to "every path on this mount needs auth," matching the JSONRPC dispatcher's lack of a per-route gate.For greenfield users wanting an all-in-one runnable composition, see
packages/a2a/examples/keycard_protected_server/main.py.Seven commits on this branch
feat(keycardai-a2a)!:— mechanical move fromkeycardai-agents. Bearer wiring migrates from the deprecatedBearerAuthMiddlewaretoAuthenticationMiddleware(backend=KeycardAuthBackend(verifier), on_error=keycard_on_error).keycardai-mcpdependency drops from the moved code path.AgentClient(PKCE user-login) is deleted; the canonical PKCE entry now lives inkeycardai-oauthper feat(keycardai-oauth): add high-level PKCE user-login flow (ACC-229) #101 / ACC-229.fix(keycardai-a2a):— fixup for Edit-tool changes that didn't get staged in commit 1 (CI initially failed because the example pyproject still pointed atkeycardai-agents).refactor(keycardai-a2a)!:— wrap-aligned refactor + a2a-sdk 1.x migration. Dropped a parallelAgentExecutorprotocol with aKeycardToA2AExecutorBridgeand a customPOST /invokeendpoint with bespokeInvokeRequest/InvokeResponsePydantic models. Migrated to a2a-sdk 1.x:create_jsonrpc_routes+create_agent_card_routes+DefaultRequestHandler; the agent card is built from 1.x's protobuf schema. Added a_KeycardServerCallContextBuilderto propagate the verifiedKeycardUserplus bareaccess_tokenintoServerCallContext.state.fix(keycardai-a2a):— addressed fresh-eyes review findings. Three blockers: (a)release.ymladds*-keycardai-a2ato the tag-trigger allowlist; (b)DelegationClient.invoke_service(async + sync) ports to A2A JSONRPC at/a2a/jsonrpc; (c) card validation reduces toname-only since 1.x cards have noendpoints/authkeys. Plus four important findings: alias removal, test mock updates to 1.x shape, auth-gate tightening to== 401withWWW-Authenticateheader check, and propagation tests for the context builder.refactor(keycardai-a2a)!:— addressed Kamil's first review comment. DroppedAgentServer,create_agent_card_server,serve_agentfrom the public surface. Promoted_EagerKeycardAuthBackendand_KeycardServerCallContextBuilderto public (and_build_agent_cardasbuild_agent_card_from_config).AgentServiceConfigtrimmed to removeagent_executor,port,host, andstatus_url. Composed-server flow moved to a new example atpackages/a2a/examples/keycard_protected_server/.fix(keycardai-a2a):— ruff import-organization auto-fix.refactor(keycardai-starlette,keycardai-a2a):— addressed Kamil's second review comment ("does this primitive belong in keycardai-starlette?"). CollapsedEagerKeycardAuthBackendinto arequire_authentication=Falsekwarg on the existingKeycardAuthBackend. The kwarg is a general-purpose policy choice, not a new abstraction, so it lives on the existing class in keycardai-starlette. The keycardai-a2a public surface dropsEagerKeycardAuthBackend; customers passKeycardAuthBackend(verifier, require_authentication=True)directly. Two new tests in keycardai-starlette cover the kwarg behavior plus the RFC 9728/8414 metadata-bypass precedence.Hard cut, no transitional bridge
ACC-232 confirms no known production users of
keycardai.agents.*paths. Anyone importing fromkeycardai.agents.server.*,keycardai.agents.config,keycardai.agents.client.discovery, orkeycardai.agents.client.oauthupdates tokeycardai.a2a.*(orkeycardai.oauth.pkce.authenticatefor the AgentClient case).keycardai-agents0.3.0 ships with dependency set reduced tokeycardai-a2a+pydantic, mirroring its now-CrewAI-only scope; the CrewAI integration is repointed atkeycardai.a2a.Reviewer notes
require_authentication=Truekwarg onKeycardAuthBackendexists because the default backend behavior returnsNoneon missing Authorization headers (correct for mixed-route apps, since@requires("authenticated")decorations gate per-route). The agent server's/a2a/jsonrpcmount has no per-route gate, so anonymous requests must 401 unconditionally. The kwarg also bypasses the OAuth metadata paths (RFC 9728 / RFC 8414) so/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-serverremain anonymously reachable on a require-auth mount.to_agent_card()dict-builder method onAgentServiceConfigwas deleted because the 0.x dict shape doesn't match a2a-sdk 1.x's protobuf schema. The replacementbuild_agent_card_from_config()lives inserver/app.py(public).@auth.grant(resource)decorator parity withkeycardai-mcpis not in this PR. Customers haveDelegationClientfor explicit delegation today; the decorator port doesn't compose cleanly with a2a-sdk'sexecute(self, context, event_queue)signature and lands in a follow-up.Test plan
cd packages/a2a && uv run --extra test pytest -q→ 44 passedcd packages/starlette && uv run --extra test pytest -q→ no regression; +2 tests forrequire_authenticationkwargcd packages/agents && uv run --extra test --extra crewai pytest -q→ 16 passedoauth,mcp,mcp-fastmcp,fastmcptest suitesuv run ruff checkfrom repo root → cleanfrom keycardai.a2a import AgentServiceConfig, DelegationClient, ServiceDiscovery, KeycardServerCallContextBuilder, build_agent_card_from_configsucceeds; the dropped names (AgentServer,create_agent_card_server,serve_agent,EagerKeycardAuthBackend) raiseImportErrorgrep -rn "BearerAuthMiddleware\|A2AStarletteApplication\|InvokeRequest\|InvokeResponse" --include="*.py" packages/a2a/→ zero hitsPre-merge checklist
keycardai-a2ain the keycard org (same routine askeycardai-fastmcpfor feat(keycardai-fastmcp): rename keycardai-mcp-fastmcp via bridge package (ACC-233) #102). Without it, the post-merge auto-bump publishes a tag but the OIDC publish step fails.Follow-ups
keycardai-crewai.packages/agents/source directory.@auth.grant(resource)decorator parity with keycardai-mcp.keycardai-a2a 0.1.0and the KEP's end state ("keycardai-agents ceases to exist") is reached.