From 7aad0a6d7d82e9b1f361f7e5a1232d52f3a278d2 Mon Sep 17 00:00:00 2001 From: Larry-Osakwe Date: Thu, 30 Apr 2026 11:06:44 -0700 Subject: [PATCH] chore(keycardai-agents)!: archive package (ACC-232) 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. --- .github/workflows/release.yml | 1 - packages/a2a/README.md | 2 +- packages/agents/CHANGELOG.md | 12 - packages/agents/README.md | 534 ------------------ packages/agents/pyproject.toml | 97 ---- packages/agents/src/keycardai/__init__.py | 2 - .../agents/src/keycardai/agents/__init__.py | 24 - .../keycardai/agents/integrations/__init__.py | 17 - .../keycardai/agents/integrations/crewai.py | 413 -------------- packages/agents/tests/__init__.py | 1 - .../agents/tests/integrations/__init__.py | 1 - .../tests/integrations/test_crewai_a2a.py | 406 ------------- pyproject.toml | 1 - uv.lock | 38 -- 14 files changed, 1 insertion(+), 1548 deletions(-) delete mode 100644 packages/agents/CHANGELOG.md delete mode 100644 packages/agents/README.md delete mode 100644 packages/agents/pyproject.toml delete mode 100644 packages/agents/src/keycardai/__init__.py delete mode 100644 packages/agents/src/keycardai/agents/__init__.py delete mode 100644 packages/agents/src/keycardai/agents/integrations/__init__.py delete mode 100644 packages/agents/src/keycardai/agents/integrations/crewai.py delete mode 100644 packages/agents/tests/__init__.py delete mode 100644 packages/agents/tests/integrations/__init__.py delete mode 100644 packages/agents/tests/integrations/test_crewai_a2a.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff9b37a..47166de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,6 @@ on: - '*-keycardai-mcp' - '*-keycardai-mcp-fastmcp' - '*-keycardai-fastmcp' - - '*-keycardai-agents' - '*-keycardai-a2a' jobs: diff --git a/packages/a2a/README.md b/packages/a2a/README.md index b926f31..b25f803 100644 --- a/packages/a2a/README.md +++ b/packages/a2a/README.md @@ -119,4 +119,4 @@ For a runnable greenfield example (no existing app), see `examples/keycard_prote ## History -This package was extracted from the original `keycardai-agents` package (KEP: Decompose keycardai-agents). The PKCE user-login client moved to `keycardai-oauth`; the CrewAI integration moves to a forthcoming `keycardai-crewai`; the `keycardai-agents` source directory is being archived. +This package was extracted from the original `keycardai-agents` package (KEP: Decompose keycardai-agents). The PKCE user-login client moved to `keycardai-oauth`. The `keycardai-agents` package itself is archived. diff --git a/packages/agents/CHANGELOG.md b/packages/agents/CHANGELOG.md deleted file mode 100644 index 84b6dc9..0000000 --- a/packages/agents/CHANGELOG.md +++ /dev/null @@ -1,12 +0,0 @@ -## 0.2.0-keycardai-agents (2026-04-27) - - -- fix(keycardai-agents): restore test suite (#100) -- Closes ACC-236. Surfaced by ACC-234 wait-and-see verification: keycardai-agents tests would not run on a fresh checkout. -- Three pre-existing breaks fixed: -- 1. a2a-sdk constraint pinned to <1.0. The unbounded >=0.3.22 resolved to 1.0.x today; a2a-sdk 1.0 moved A2AStarletteApplication out of a2a.server.apps.jsonrpc, which keycardai-agents/server/app.py imports. Pinning is the cheap fix because keycardai-agents is being decomposed and archived in ACC-229..232; the replacement keycardai-a2a package will be written against a2a-sdk 1.x natively. -- 2. tests/integrations/test_crewai_a2a.py imported from keycardai.agents.integrations.crewai_a2a, which does not exist. The module is keycardai.agents.integrations.crewai. Three import/patch references updated. -- 3. test_tool_run_with_task_and_inputs asserted "pr_number" was a top-level key in the task dict; the actual contract puts task_inputs under task["inputs"]. Assertion updated to match the implementation. -- Verified: 85/85 agents tests pass. mcp 560/560, starlette 49/49, oauth 208/208 unaffected. - -## 0.1.1-keycardai-agents (2026-01-07) diff --git a/packages/agents/README.md b/packages/agents/README.md deleted file mode 100644 index ce57109..0000000 --- a/packages/agents/README.md +++ /dev/null @@ -1,534 +0,0 @@ -# KeycardAI Agents - -Framework-agnostic agent service SDK for A2A (Agent-to-Agent) delegation with Keycard OAuth authentication. - -## Requirements - -- **Python 3.10 or greater** -- Virtual environment (recommended) - -## Features - -- 🔐 **Built-in OAuth**: Automatic JWKS validation, token exchange, delegation chains -- 🌐 **Dual Protocol Support**: A2A JSONRPC + custom REST endpoints (same executor powers both) -- 🔧 **Framework Agnostic**: Supports CrewAI, LangChain, custom via `AgentExecutor` protocol -- 🔄 **Service Delegation**: RFC 8693 token exchange preserves user context -- 👤 **User Auth**: PKCE OAuth flow with browser-based login - -## A2A Protocol Integration - -We use [a2a-python SDK](https://github.com/a2aproject/a2a-python) for protocol compliance while adding production-ready authentication: - -- ✅ **Full A2A JSONRPC support** - Standards-compliant `/a2a/jsonrpc` endpoint -- ✅ **Plus simpler REST endpoint** - Custom `/invoke` for easier integration -- ✅ **Production OAuth layer** - BearerAuthMiddleware, JWKS, token exchange (A2A SDK has none) -- ✅ **Delegation chain tracking** - JWT-based audit trail for service-to-service calls -- ✅ **Dual protocol support** - Same executor powers both JSONRPC and REST endpoints - -**Result**: A2A standards compliance + Keycard security + flexible APIs = Best of both worlds - -## Installation - -```bash -pip install keycardai-agents - -# With CrewAI support -pip install 'keycardai-agents[crewai]' -``` - -## Quick Start - -### CrewAI Service - -```python -import os -from crewai import Agent, Crew, Task -from keycardai.agents import AgentServiceConfig -from keycardai.agents.integrations.crewai import CrewAIExecutor -from keycardai.agents.server import serve_agent - -def create_my_crew(): - agent = Agent(role="Assistant", goal="Help users", backstory="AI helper") - task = Task(description="{task}", agent=agent, expected_output="Response") - return Crew(agents=[agent], tasks=[task]) - -config = AgentServiceConfig( - service_name="My Service", - client_id=os.getenv("CLIENT_ID"), - client_secret=os.getenv("CLIENT_SECRET"), - identity_url="http://localhost:8000", - zone_id=os.getenv("ZONE_ID"), - agent_executor=CrewAIExecutor(create_my_crew), # Framework adapter - capabilities=["assistance"], -) - -serve_agent(config) # Starts server with OAuth middleware -``` - -### Custom Executor - -```python -from keycardai.agents.server import LambdaExecutor - -def my_logic(task, inputs): - return f"Processed: {task}" - -config = AgentServiceConfig( - # ... same config as above - agent_executor=LambdaExecutor(my_logic), # Simple function wrapper -) -``` - -### Advanced: Custom Executor Class - -```python -from keycardai.agents.server import AgentExecutor - -class MyFrameworkExecutor: - """Implement AgentExecutor protocol for any framework.""" - - def execute(self, task, inputs): - # Your framework logic here - result = my_framework.run(task, inputs) - return result - - def set_token_for_delegation(self, access_token): - # Optional: handle delegation token - self.context.set_auth(access_token) - -config = AgentServiceConfig( - # ... - agent_executor=MyFrameworkExecutor(), -) -``` - -## Client Usage - -### User Authentication (PKCE) - -```python -from keycardai.agents.client import AgentClient - -async with AgentClient(config) as client: - # Automatically: OAuth discovery → Browser login → Token exchange - result = await client.invoke("https://service.com", task="Hello") -``` - -### Service-to-Service (Token Exchange) - -```python -from keycardai.agents.server import DelegationClient - -client = DelegationClient(service_config) - -# Get delegation token (RFC 8693) - preserves user context -token = await client.get_delegation_token( - "https://target.com", - subject_token="user_token" -) - -# Invoke with token -result = await client.invoke_service( - "https://target.com", - task="Process data", - token=token -) -# Result includes delegation_chain: ["service_a", "service_b"] -``` - -## Architecture - -### Server - -``` -Your Agent - ↓ -AgentExecutor.execute(task, inputs) - ↓ -AgentServer (keycardai-agents) - ├─ OAuth Middleware (BearerAuthMiddleware) - │ ├─ JWKS validation - │ ├─ Token audience check - │ └─ Delegation chain extraction - ├─ /invoke (protected, REST-like) - ├─ /a2a/jsonrpc (protected, A2A JSONRPC) - │ ├─ message/send - │ ├─ message/stream - │ └─ tasks/* (get, cancel, list) - ├─ /.well-known/agent-card.json (A2A format) - ├─ /.well-known/oauth-protected-resource - └─ /status -``` - -### Dual Protocol Support - -The SDK provides **two ways** to invoke agents: - -1. **A2A JSONRPC** (`/a2a/jsonrpc`) - Standards-compliant - - Use when: Integrating with A2A ecosystem, need standard protocol - - Methods: `message/send`, `message/stream`, `tasks/get`, etc. - - Bridge: `KeycardToA2AExecutorBridge` adapts your executor to A2A protocol - -2. **Custom REST** (`/invoke`) - Simpler API - - Use when: Direct service calls, simpler integration - - Format: `{"task": "...", "inputs": {...}}` - - Direct executor invocation - -**Both endpoints share the same underlying executor** - write once, support both protocols. - -### OAuth Flow - -``` -User → OAuth Login (PKCE) - ↓ -User Token → Service A - ↓ -Service A → Token Exchange (RFC 8693) → Service B Token - ↓ -Service A → Calls Service B with Service B Token - ↓ -Service B validates token (JWKS) -Service B updates delegation_chain -``` - -## A2A Protocol Compliance - -### Agent Card - -Services expose A2A-compliant agent cards at `/.well-known/agent-card.json`: - -```json -{ - "name": "My Service", - "url": "https://my-service.com", - "version": "1.0.0", - "protocolVersion": "0.3.0", - "skills": [ - { - "id": "assistance", - "name": "Assistance", - "description": "assistance capability", - "tags": ["assistance"] - } - ], - "capabilities": { - "streaming": false, - "multiTurn": true - }, - "additionalInterfaces": [ - { - "url": "https://my-service.com/invoke", - "transport": "http+json" - } - ], - "securitySchemes": { - "oauth2": { - "type": "oauth2", - "flows": { - "authorizationCode": { - "authorizationUrl": "https://zone.keycard.cloud/oauth/authorize", - "tokenUrl": "https://zone.keycard.cloud/oauth/token" - } - } - } - } -} -``` - -### Endpoints - -#### A2A JSONRPC Endpoint (Standards-Compliant) - -```bash -POST /a2a/jsonrpc -Authorization: Bearer -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [{"text": "Do something"}] - } - }, - "id": 1 -} -``` - -Response: -```json -{ - "jsonrpc": "2.0", - "result": { - "task": { - "taskId": "task-123", - "state": "completed", - "result": {...} - } - }, - "id": 1 -} -``` - -**Supported methods:** -- `message/send` - Send message to agent -- `message/stream` - Stream agent responses -- `tasks/get` - Get task status -- `tasks/cancel` - Cancel running task -- `tasks/list` - List all tasks - -#### Custom REST Endpoint (Simpler API) - -```bash -POST /invoke -Authorization: Bearer - -{ - "task": "Do something", - "inputs": {"key": "value"} -} -``` - -Response: -```json -{ - "result": "Done", - "delegation_chain": ["service_a", "service_b"] -} -``` - -**Use `/invoke` for:** Direct service calls, easier integration, delegation chain tracking. - -**Use `/a2a/jsonrpc` for:** A2A ecosystem integration, standard protocol compliance, task management. - -## Framework Support - -### CrewAI - -```python -from keycardai.agents.integrations.crewai import CrewAIExecutor - -executor = CrewAIExecutor(lambda: create_my_crew()) -``` - -**Features:** -- Automatic delegation token context -- Supports CrewAI tools -- Handles `crew.kickoff()` execution - -### LangChain, AutoGen, Custom - -Implement the `AgentExecutor` protocol: - -```python -class MyExecutor: - def execute(self, task, inputs): - # Your logic - return result -``` - -## API Reference - -### AgentServiceConfig - -```python -@dataclass -class AgentServiceConfig: - service_name: str # Human-readable name - client_id: str # Keycard Application client ID - client_secret: str # Keycard Application secret - identity_url: str # Public URL - zone_id: str # Keycard zone ID - agent_executor: AgentExecutor # REQUIRED: Executor instance - - # Optional - authorization_server_url: str | None = None - port: int = 8000 - host: str = "0.0.0.0" - description: str = "" - capabilities: list[str] = [] -``` - -### AgentExecutor Protocol - -```python -class AgentExecutor(Protocol): - def execute( - self, - task: dict[str, Any] | str, - inputs: dict[str, Any] | None = None, - ) -> Any: - """Execute agent task.""" - ... - - def set_token_for_delegation(self, access_token: str) -> None: - """Optional: Set token for delegation.""" - ... -``` - -### KeycardToA2AExecutorBridge - -Bridge adapter that makes your executor work with A2A JSONRPC protocol: - -```python -from keycardai.agents.server import KeycardToA2AExecutorBridge, SimpleExecutor - -# Your executor -executor = SimpleExecutor() - -# Wrap for A2A JSONRPC support -a2a_executor = KeycardToA2AExecutorBridge(executor) - -# Now works with A2A DefaultRequestHandler -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore - -handler = DefaultRequestHandler( - agent_executor=a2a_executor, - task_store=InMemoryTaskStore() -) -``` - -**What it does:** -- Converts A2A `RequestContext` → Keycard `task/inputs` format -- Calls your synchronous executor -- Publishes result as A2A Task events -- Handles delegation tokens - -**Note:** This bridge is automatically configured when using `serve_agent()` - you don't need to use it directly unless building custom A2A integrations. - -### serve_agent() - -Start an agent service (blocking): - -```python -serve_agent(config: AgentServiceConfig) -> None -``` - -### AgentClient - -User authentication with PKCE OAuth: - -```python -from keycardai.agents.client import AgentClient - -async with AgentClient(service_config) as client: - result = await client.invoke(service_url, task, inputs) - agent_card = await client.discover_service(service_url) -``` - -### DelegationClient - -Service-to-service with token exchange: - -```python -from keycardai.agents.server import DelegationClient - -client = DelegationClient(service_config) -token = await client.get_delegation_token(target_url, subject_token) -result = await client.invoke_service(url, task, token) -``` - -## Service Delegation - -### Pattern - -```python -# In Service A (orchestrator) -from keycardai.agents.server import DelegationClient - -client = DelegationClient(service_a_config) - -# Discover Service B -card = await client.discover_service("https://service-b.com") - -# Get token with user context -token = await client.get_delegation_token( - "https://service-b.com", - subject_token=user_access_token -) - -# Call Service B -result = await client.invoke_service( - "https://service-b.com", - task="Process data", - token=token -) - -# Result includes delegation chain for audit -print(result["delegation_chain"]) -# ["user_service", "service_a", "service_b"] -``` - -### Delegation Chain Tracking - -1. User authenticates → Token with empty `delegation_chain` -2. User calls Service A → Service A adds itself to chain -3. Service A calls Service B → Token exchange preserves chain -4. Service B adds itself → Full chain in response for audit - -## Production Deployment - -### Environment Variables - -```bash -# Required -export KEYCARD_ZONE_ID="your_zone_id" -export KEYCARD_CLIENT_ID="service_client_id" -export KEYCARD_CLIENT_SECRET="client_secret" -export SERVICE_URL="https://your-service.com" - -# Optional -export PORT="8000" -export HOST="0.0.0.0" -``` - -### Health Checks - -```bash -# Liveness -curl https://your-service.com/status - -# Agent card -curl https://your-service.com/.well-known/agent-card.json -``` - -### Security - -- **Token Validation**: JWKS-based JWT signature verification -- **Audience Check**: Token `aud` must match service URL -- **Issuer Validation**: Token `iss` from Keycard zone -- **Delegation Chain**: Preserved for audit trail - -## Examples - -See `examples/` directory: -- `oauth_client_usage.py` - PKCE user authentication - -## FAQ - -### Q: Why not use the A2A SDK server? -**A**: The A2A SDK has no authentication layer. We'd have to rebuild all OAuth infrastructure. - -### Q: Can I use LangChain/AutoGen? -**A**: Yes! Implement the `AgentExecutor` protocol or use `LambdaExecutor` for simple functions. - -### Q: What's the difference between AgentClient and DelegationClient? -**A**: -- `AgentClient`: User authentication with PKCE (browser-based login) -- `DelegationClient`: Service-to-service with token exchange (RFC 8693) - -### Q: Do I need CrewAI? -**A**: No! Use any framework or write custom logic. Just implement `AgentExecutor`. - -## Support - -- **GitHub**: https://github.com/keycardai/python-sdk -- **Issues**: https://github.com/keycardai/python-sdk/issues -- **Docs**: https://docs.keycard.ai - -## License - -MIT diff --git a/packages/agents/pyproject.toml b/packages/agents/pyproject.toml deleted file mode 100644 index 43046f5..0000000 --- a/packages/agents/pyproject.toml +++ /dev/null @@ -1,97 +0,0 @@ -[project] -name = "keycardai-agents" -dynamic = ["version"] -description = "Legacy CrewAI-over-A2A integration. Decomposing per the Keycard SDK packaging KEP: see keycardai-a2a (delegation), keycardai-oauth (PKCE), and forthcoming keycardai-crewai. This package will be archived." -readme = "README.md" -requires-python = ">=3.10" -license = { text = "MIT" } -authors = [{ name = "Keycard", email = "support@keycard.ai" }] -dependencies = [ - "keycardai-a2a>=0.1.0", - "pydantic>=2.11.7", -] -keywords = ["agents", "ai", "crewai", "authentication", "authorization", "service", "delegation"] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Security", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "License :: OSI Approved :: MIT License", -] - -[project.optional-dependencies] -crewai = [ - "crewai>=0.86.0", -] -test = [ - "pytest>=8.4.1", - "pytest-asyncio>=1.1.0", - "pytest-cov>=6.2.1", - "pytest-timeout>=2.3.1", -] -dev = [ - "ruff>=0.8.6", - "mypy>=1.14.1", -] - -[build-system] -requires = ["hatchling", "uv-dynamic-versioning"] -build-backend = "hatchling.build" - -[tool.hatch.version] -source = "uv-dynamic-versioning" - -[tool.uv-dynamic-versioning] -vcs = "git" -pattern = "(?P\\d+\\.\\d+\\.\\d+)-keycardai-agents" -style = "pep440" - -[tool.hatch.build.targets.wheel] -packages = ["src/keycardai"] - -[tool.hatch.build.targets.sdist] -exclude = [ - "/.github", - "/tests", -] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = ["E501"] - -[tool.mypy] -python_version = "3.10" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true - -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" - -[tool.commitizen] -name = "cz_customize" -version = "0.2.0" -tag_format = "${version}-keycardai-agents" -ignored_tag_formats = ["${version}-*"] -update_changelog_on_bump = true -bump_message = "bump: keycardai-agents $current_version → $new_version" -major_version_zero = true - -[tool.commitizen.customize] -changelog_pattern = "^(feat|fix|refactor|perf|test|build|ci|revert)\\(keycardai-agents\\)(!)?:" diff --git a/packages/agents/src/keycardai/__init__.py b/packages/agents/src/keycardai/__init__.py deleted file mode 100644 index b390758..0000000 --- a/packages/agents/src/keycardai/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Namespace package -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/packages/agents/src/keycardai/agents/__init__.py b/packages/agents/src/keycardai/agents/__init__.py deleted file mode 100644 index bc708d9..0000000 --- a/packages/agents/src/keycardai/agents/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""KeycardAI Agents (legacy package). - -This package previously housed three concerns. Per the KEP "Decompose -keycardai-agents", they have moved to: - -- A2A delegation, agent service hosting, executor primitives, and service - discovery → ``keycardai-a2a`` (``from keycardai.a2a import ...``). -- OAuth 2.0 PKCE user-login flow (``AgentClient``) → ``keycardai-oauth`` - (``from keycardai.oauth.pkce import authenticate``). -- The CrewAI-over-A2A integration is the only remaining piece, accessible - via ``from keycardai.agents.integrations.crewai import ...``. It will - move to a dedicated ``keycardai-crewai`` package; this package will be - archived once that ships. -""" - -# Integrations (optional) -try: - from .integrations import crewai -except ImportError: - crewai = None - -__all__ = [ - "crewai", -] diff --git a/packages/agents/src/keycardai/agents/integrations/__init__.py b/packages/agents/src/keycardai/agents/integrations/__init__.py deleted file mode 100644 index 039cca5..0000000 --- a/packages/agents/src/keycardai/agents/integrations/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Integrations with agent frameworks. - -This package provides integrations with various agent frameworks: -- CrewAI: Tools for agent-to-agent delegation -""" - -try: - from .crewai import create_a2a_tool_for_service, get_a2a_tools, set_delegation_token - - __all__ = [ - "get_a2a_tools", - "set_delegation_token", - "create_a2a_tool_for_service", - ] -except ImportError: - # CrewAI not installed - __all__ = [] diff --git a/packages/agents/src/keycardai/agents/integrations/crewai.py b/packages/agents/src/keycardai/agents/integrations/crewai.py deleted file mode 100644 index 188e54a..0000000 --- a/packages/agents/src/keycardai/agents/integrations/crewai.py +++ /dev/null @@ -1,413 +0,0 @@ -"""CrewAI integration for A2A (agent-to-agent) delegation. - -This module provides: -1. CrewAIExecutor: Adapter for running CrewAI crews in the agent service server -2. Delegation tools: CrewAI tools for calling other agent services - -Usage with executor: - >>> from keycardai.a2a import AgentServiceConfig - >>> from keycardai.agents.integrations.crewai import CrewAIExecutor - >>> from crewai import Agent, Crew, Task - >>> - >>> def create_my_crew(): - ... agent = Agent(role="Assistant", goal="Help users") - ... task = Task(description="{task}", agent=agent) - ... return Crew(agents=[agent], tasks=[task]) - >>> - >>> config = AgentServiceConfig( - ... service_name="My Service", - ... agent_executor=CrewAIExecutor(create_my_crew), - ... # ... other config - ... ) - -Usage with delegation tools: - >>> from keycardai.a2a import AgentServiceConfig - >>> from keycardai.agents.integrations.crewai import get_a2a_tools - >>> from crewai import Agent, Crew - >>> - >>> # Create service config - >>> config = AgentServiceConfig(...) - >>> - >>> # Define services we can delegate to - >>> delegatable_services = [ - >>> { - >>> "name": "echo_service", - >>> "url": "http://localhost:8002", - >>> "description": "Echo service that repeats messages", - >>> } - >>> ] - >>> - >>> # Get A2A delegation tools - >>> a2a_tools = await get_a2a_tools(config, delegatable_services) - >>> - >>> # Use tools in crew - >>> agent = Agent( - >>> role="Orchestrator", - >>> tools=a2a_tools, - >>> allow_delegation=True - >>> ) -""" - -import contextvars -import logging -from typing import Any, Callable - -from pydantic import BaseModel, Field - -from keycardai.a2a import AgentServiceConfig, DelegationClientSync, ServiceDiscovery - -# Context variable to store the current user's access token for delegation -_current_user_token: contextvars.ContextVar[str | None] = contextvars.ContextVar( - "current_user_token", default=None -) - -try: - from crewai import Crew - from crewai.tools import BaseTool -except ImportError: - raise ImportError( - "CrewAI is not installed. Install it with: pip install 'keycardai-agents[crewai]'" - ) from None - -logger = logging.getLogger(__name__) - - -def set_delegation_token(access_token: str) -> None: - """Set the user's access token for delegation context. - - This should be called before crew execution to provide the user's - token for service-to-service delegation. The token will be used - for token exchange when delegating to other services. - - Args: - access_token: The user's access token from the request - - Example: - >>> # In your server's invoke handler - >>> access_token = request.user.access_token - >>> set_delegation_token(access_token) - >>> - >>> # Now crew tools can delegate with the user's context - >>> crew = create_my_crew() - >>> result = crew.kickoff() - """ - _current_user_token.set(access_token) - - -class CrewAIExecutor: - """Executor adapter for CrewAI crews. - - This executor implements the AgentExecutor protocol for CrewAI crews, - allowing them to be used in the generic agent service server. - - The executor: - 1. Takes a crew factory callable - 2. Sets delegation token context before execution - 3. Calls crew.kickoff() with the task/inputs - 4. Returns the result as a string - - Args: - crew_factory: Callable that returns a Crew instance - set_token_context: If True, automatically set delegation token before execution - - Example: - >>> from crewai import Agent, Crew, Task - >>> - >>> def create_my_crew(): - ... agent = Agent(role="Assistant", goal="Help users", backstory="Helpful AI") - ... task = Task(description="{task}", agent=agent, expected_output="A response") - ... return Crew(agents=[agent], tasks=[task]) - >>> - >>> executor = CrewAIExecutor(create_my_crew) - >>> result = executor.execute("Hello world", {"name": "Alice"}) - """ - - def __init__(self, crew_factory: Callable[[], Crew], set_token_context: bool = True): - """Initialize CrewAI executor. - - Args: - crew_factory: Callable that returns a Crew instance - set_token_context: If True, automatically set delegation token before execution - """ - self.crew_factory = crew_factory - self.set_token_context = set_token_context - - def execute( - self, - task: dict[str, Any] | str, - inputs: dict[str, Any] | None = None, - ) -> str: - """Execute crew with the given task and inputs. - - Args: - task: Task description (string) or parameters (dict) - inputs: Optional additional inputs for the crew - - Returns: - Result from crew execution as string - - Raises: - Exception: If crew execution fails - """ - # Create crew instance - crew = self.crew_factory() - - # Prepare inputs for crew - if isinstance(task, dict): - crew_inputs = task - else: - crew_inputs = {"task": task} - - # Merge additional inputs if provided - if inputs: - crew_inputs.update(inputs) - - # Execute crew - # Note: crew.kickoff() is synchronous in CrewAI - logger.info(f"Executing CrewAI crew with inputs: {list(crew_inputs.keys())}") - result = crew.kickoff(inputs=crew_inputs) - - # Return result as string - return str(result) - - def set_token_for_delegation(self, access_token: str) -> None: - """Set access token for delegation context. - - This is called by the server before execution to provide - the user's token for service-to-service delegation. - - Args: - access_token: User's access token - """ - if self.set_token_context: - set_delegation_token(access_token) - - -async def get_a2a_tools( - service_config: AgentServiceConfig, - delegatable_services: list[dict[str, Any]] | None = None, -) -> list[BaseTool]: - """Get A2A delegation tools for CrewAI agents. - - Creates CrewAI tools that allow agents to delegate tasks to other - agent services. Tools are automatically generated based on: - 1. Keycard dependencies (services this service can call) - 2. Agent card capabilities (what each service can do) - - Args: - service_config: Configuration of the calling service - delegatable_services: Optional list of services to create tools for. - If not provided, queries Keycard for dependencies. - Each service dict should have: name, url, description, capabilities - - Returns: - List of CrewAI BaseTool objects for delegation - - Example: - >>> config = AgentServiceConfig(...) - >>> tools = await get_a2a_tools(config) - >>> # Returns tools like: - >>> # - delegate_to_slack_poster - >>> # - delegate_to_deployment_service - >>> agent = Agent(role="Orchestrator", tools=tools) - """ - # Discover delegatable services if not provided - if delegatable_services is None: - discovery = ServiceDiscovery(service_config) - try: - delegatable_services = await discovery.list_delegatable_services() - finally: - await discovery.close() - - if not delegatable_services: - logger.info("No delegatable services found - no A2A tools created") - return [] - - # Create delegation client for delegation (synchronous to avoid event loop issues) - delegation_client = DelegationClientSync(service_config) - - # Create tools for each service - tools = [] - for service_info in delegatable_services: - tool = _create_delegation_tool(service_info, delegation_client) - tools.append(tool) - - logger.info(f"Created {len(tools)} A2A delegation tools") - return tools - - -def _create_delegation_tool( - service_info: dict[str, Any], - delegation_client: DelegationClientSync, -) -> BaseTool: - """Create a CrewAI tool for delegating to a specific service. - - Args: - service_info: Service metadata (name, url, description, capabilities) - delegation_client: Delegation client for service invocation - - Returns: - CrewAI BaseTool for delegation - """ - service_name = service_info["name"] - service_url = service_info["url"] - service_description = service_info.get("description", "") - capabilities = service_info.get("capabilities", []) - - # Generate tool name (e.g., "PR Analysis Service" -> "delegate_to_pr_analysis_service") - tool_name = f"delegate_to_{service_name.lower().replace(' ', '_').replace('-', '_')}" - - # Generate tool description - capabilities_str = ", ".join(capabilities) if capabilities else "various tasks" - tool_description = f"""Delegate a task to {service_name}. - -{service_description} - -This service can handle: {capabilities_str} - -Use this tool when you need {service_name} to perform a task that is within its capabilities. -The service will process the task and return results.""" - - # Define the tool class - class ServiceDelegationTool(BaseTool): - """Tool for delegating to another agent service.""" - - name: str = tool_name - description: str = tool_description - - def __init__( - self, - delegation_client: DelegationClientSync, - service_url: str, - service_name: str, - **kwargs, - ): - super().__init__(**kwargs) - self._delegation_client = delegation_client - self._service_url = service_url - self._service_name = service_name - - def _run(self, task_description: str, task_inputs: dict[str, Any] | None = None) -> str: - """Delegate task to remote service. - - Args: - task_description: Description of the task to delegate - task_inputs: Optional additional inputs for the task - - Returns: - Result from the delegated service - """ - try: - # Prepare task - task = { - "task": task_description, - } - if task_inputs: - task["inputs"] = task_inputs - - # Get user token from context for delegation - user_token = _current_user_token.get() - if not user_token: - logger.warning( - "No user token available for delegation - " - "ensure set_delegation_token() is called before crew execution" - ) - - # Call remote service with user token for delegation - logger.info( - f"Delegating task to {self._service_name}: {task_description[:100]}" - ) - - result = self._delegation_client.invoke_service( - self._service_url, - task, - subject_token=user_token, - ) - - # Format result for agent - result_str = result.get("result", "") - delegation_chain = result.get("delegation_chain", []) - - # Include delegation chain in response for transparency - response = f"Result from {self._service_name}:\n\n{result_str}" - - if delegation_chain: - response += f"\n\n(Delegation chain: {' → '.join(delegation_chain)})" - - return response - - except Exception as e: - logger.error( - f"Delegation to {self._service_name} failed: {e}", - exc_info=True, - ) - return f"Error delegating to {self._service_name}: {str(e)}" - - # Create args schema - class DelegationInput(BaseModel): - """Input for service delegation tool.""" - - task_description: str = Field( - description=f"Description of the task to delegate to {service_name}" - ) - task_inputs: dict[str, Any] | None = Field( - default=None, - description="Optional additional inputs/parameters for the task", - ) - - ServiceDelegationTool.args_schema = DelegationInput - - # Instantiate and return tool - tool = ServiceDelegationTool( - delegation_client=delegation_client, - service_url=service_url, - service_name=service_name, - ) - - return tool - - -# For manual service list specification (useful for testing) -async def create_a2a_tool_for_service( - service_config: AgentServiceConfig, - target_service_url: str, -) -> BaseTool: - """Create a single A2A delegation tool for a specific service. - - Useful for testing or when you want to manually specify delegation targets. - - Args: - service_config: Configuration of the calling service - target_service_url: URL of the target service - - Returns: - CrewAI BaseTool for delegation - - Example: - >>> config = AgentServiceConfig(...) - >>> tool = await create_a2a_tool_for_service( - ... config, - ... "https://slack-poster.example.com" - ... ) - >>> agent = Agent(role="Orchestrator", tools=[tool]) - """ - # Discover the service - discovery = ServiceDiscovery(service_config) - try: - card = await discovery.get_service_card(target_service_url) - finally: - await discovery.close() - - # Create service info dict - service_info = { - "name": card["name"], - "url": target_service_url, - "description": card.get("description", ""), - "capabilities": card.get("capabilities", []), - } - - # Create delegation client (synchronous to avoid event loop issues) - delegation_client = DelegationClientSync(service_config) - - # Create and return tool - return _create_delegation_tool(service_info, delegation_client) diff --git a/packages/agents/tests/__init__.py b/packages/agents/tests/__init__.py deleted file mode 100644 index 243f72d..0000000 --- a/packages/agents/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for keycardai-agents (legacy CrewAI-over-A2A integration).""" diff --git a/packages/agents/tests/integrations/__init__.py b/packages/agents/tests/integrations/__init__.py deleted file mode 100644 index 849610e..0000000 --- a/packages/agents/tests/integrations/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Integration tests for agent frameworks.""" diff --git a/packages/agents/tests/integrations/test_crewai_a2a.py b/packages/agents/tests/integrations/test_crewai_a2a.py deleted file mode 100644 index 7b8d6de..0000000 --- a/packages/agents/tests/integrations/test_crewai_a2a.py +++ /dev/null @@ -1,406 +0,0 @@ -"""Tests for CrewAI A2A delegation integration.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -pytest.importorskip("crewai") - -from keycardai.a2a import AgentServiceConfig - -from keycardai.agents.integrations.crewai import ( - _create_delegation_tool, - create_a2a_tool_for_service, - get_a2a_tools, -) - - -@pytest.fixture -def service_config(): - """Create test service configuration.""" - return AgentServiceConfig( - service_name="Test Service", - client_id="test_client", - client_secret="test_secret", - identity_url="https://test.example.com", - zone_id="test_zone_123", - ) - - -@pytest.fixture -def mock_delegatable_services(): - """Mock list of delegatable services.""" - return [ - { - "name": "PR Analysis Service", - "url": "https://pr-analyzer.example.com", - "description": "Analyzes GitHub pull requests for code quality", - "capabilities": ["pr_analysis", "code_review", "security_scan"], - }, - { - "name": "Slack Notification Service", - "url": "https://slack-notifier.example.com", - "description": "Posts notifications to Slack channels", - "capabilities": ["slack_post", "notification"], - }, - ] - - -@pytest.fixture -def mock_agent_card(): - """Mock agent card for service discovery (a2a-sdk 1.x JSON shape).""" - return { - "name": "Echo Service", - "description": "Simple echo service for testing", - "version": "1.0.0", - "supportedInterfaces": [ - { - "url": "https://echo.example.com/a2a/jsonrpc", - "protocolBinding": "jsonrpc", - "protocolVersion": "1.0", - } - ], - "capabilities": {"streaming": False}, - "skills": [ - {"id": "echo", "name": "Echo"}, - {"id": "testing", "name": "Testing"}, - ], - } - - -class TestGetA2ATools: - """Test A2A tool generation.""" - - @pytest.mark.asyncio - async def test_get_a2a_tools_with_no_services(self, service_config): - """Test get_a2a_tools returns empty list when no services provided.""" - tools = await get_a2a_tools(service_config, delegatable_services=[]) - - assert tools == [] - assert isinstance(tools, list) - - @pytest.mark.asyncio - async def test_get_a2a_tools_with_provided_services( - self, service_config, mock_delegatable_services - ): - """Test get_a2a_tools creates tools for provided services.""" - tools = await get_a2a_tools( - service_config, delegatable_services=mock_delegatable_services - ) - - assert len(tools) == 2 - assert all(hasattr(tool, "name") for tool in tools) - assert all(hasattr(tool, "description") for tool in tools) - assert all(hasattr(tool, "_run") for tool in tools) - - @pytest.mark.asyncio - async def test_get_a2a_tools_discovers_services_when_none_provided( - self, service_config - ): - """Test get_a2a_tools discovers services from Keycard when not provided.""" - # When delegatable_services=None, it should try to discover - # Currently returns empty list (discovery not implemented) - with patch( - "keycardai.agents.integrations.crewai.ServiceDiscovery" - ) as mock_discovery_class: - mock_discovery = AsyncMock() - mock_discovery.list_delegatable_services.return_value = [] - mock_discovery.close = AsyncMock() - mock_discovery_class.return_value = mock_discovery - - tools = await get_a2a_tools(service_config, delegatable_services=None) - - assert isinstance(tools, list) - - @pytest.mark.asyncio - async def test_get_a2a_tools_creates_correct_tool_count( - self, service_config, mock_delegatable_services - ): - """Test one tool is created per service.""" - tools = await get_a2a_tools( - service_config, delegatable_services=mock_delegatable_services - ) - - assert len(tools) == len(mock_delegatable_services) - - -class TestCreateDelegationTool: - """Test delegation tool creation.""" - - def test_tool_name_generation(self, service_config): - """Test tool name is generated correctly from service name.""" - service_info = { - "name": "PR Analysis Service", - "url": "https://pr-analyzer.example.com", - "description": "Test service", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - assert tool.name == "delegate_to_pr_analysis_service" - - def test_tool_name_handles_special_characters(self, service_config): - """Test tool name generation handles special characters.""" - service_info = { - "name": "Slack-Notification Service", - "url": "https://slack.example.com", - "description": "Test service", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - # Hyphens should be converted to underscores - assert tool.name == "delegate_to_slack_notification_service" - - def test_tool_description_includes_capabilities(self, service_config): - """Test tool description includes service capabilities.""" - service_info = { - "name": "Test Service", - "url": "https://test.example.com", - "description": "A test service", - "capabilities": ["capability1", "capability2", "capability3"], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - # Check capabilities are in description - assert "capability1" in tool.description - assert "capability2" in tool.description - assert "capability3" in tool.description - - def test_tool_has_correct_args_schema(self, service_config): - """Test tool has proper args schema for CrewAI.""" - service_info = { - "name": "Test Service", - "url": "https://test.example.com", - "description": "Test", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - # Tool should have args_schema attribute - assert hasattr(tool, "args_schema") - # Args schema should have task_description field - assert "task_description" in tool.args_schema.model_fields - - -class TestDelegationToolExecution: - """Test tool execution behavior.""" - - def test_tool_run_with_task_string(self, service_config): - """Test tool execution with simple task string.""" - service_info = { - "name": "Echo Service", - "url": "https://echo.example.com", - "description": "Test", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - # Mock invoke_service to avoid actual network call - with patch.object(a2a_client, "invoke_service") as mock_invoke: - mock_invoke.return_value = { - "result": "Echo response", - "delegation_chain": ["service1", "echo_service"], - } - - result = tool._run(task_description="Test task") - - assert "Echo response" in result - mock_invoke.assert_called_once() - - def test_tool_run_with_task_and_inputs(self, service_config): - """Test tool execution with task and additional inputs.""" - service_info = { - "name": "PR Analyzer", - "url": "https://pr-analyzer.example.com", - "description": "Test", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - with patch.object(a2a_client, "invoke_service") as mock_invoke: - mock_invoke.return_value = { - "result": "PR analysis complete", - "delegation_chain": [], - } - - tool._run( - task_description="Analyze PR", task_inputs={"pr_number": 123} - ) - - # Check invoke_service was called with correct task structure - call_args = mock_invoke.call_args - task = call_args[0][1] # Second positional argument - assert task["task"] == "Analyze PR" - assert task["inputs"] == {"pr_number": 123} - - def test_tool_run_calls_a2a_client(self, service_config): - """Test tool delegates to A2A client correctly.""" - service_info = { - "name": "Test Service", - "url": "https://test.example.com", - "description": "Test", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - with patch.object(a2a_client, "invoke_service") as mock_invoke: - mock_invoke.return_value = {"result": "success", "delegation_chain": []} - - tool._run(task_description="Test") - - # Verify invoke_service was called with service URL - mock_invoke.assert_called_once() - assert mock_invoke.call_args[0][0] == "https://test.example.com" - - def test_tool_run_formats_result_correctly(self, service_config): - """Test tool formats result with delegation chain.""" - service_info = { - "name": "Test Service", - "url": "https://test.example.com", - "description": "Test", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - with patch.object(a2a_client, "invoke_service") as mock_invoke: - mock_invoke.return_value = { - "result": "Task complete", - "delegation_chain": ["service_a", "service_b"], - } - - result = tool._run(task_description="Test") - - # Result should include delegation chain - assert "Test Service" in result - assert "Task complete" in result - assert "service_a" in result - assert "service_b" in result - - def test_tool_run_includes_delegation_chain(self, service_config): - """Test tool includes delegation chain in response.""" - service_info = { - "name": "Test Service", - "url": "https://test.example.com", - "description": "Test", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - with patch.object(a2a_client, "invoke_service") as mock_invoke: - mock_invoke.return_value = { - "result": "Done", - "delegation_chain": ["chain_element_1", "chain_element_2"], - } - - result = tool._run(task_description="Test") - - assert "Delegation chain" in result or "delegation" in result.lower() - - def test_tool_run_handles_exceptions(self, service_config): - """Test tool handles exceptions gracefully.""" - service_info = { - "name": "Test Service", - "url": "https://test.example.com", - "description": "Test", - "capabilities": [], - } - - from keycardai.a2a.server.delegation import DelegationClientSync - - a2a_client = DelegationClientSync(service_config) - tool = _create_delegation_tool(service_info, a2a_client) - - with patch.object(a2a_client, "invoke_service") as mock_invoke: - mock_invoke.side_effect = RuntimeError("Network error") - - result = tool._run(task_description="Test") - - # Should return error message, not raise exception - assert "Error" in result or "error" in result - assert isinstance(result, str) - - -class TestCreateA2AToolForService: - """Test single service tool creation.""" - - @pytest.mark.asyncio - async def test_create_tool_fetches_agent_card( - self, service_config, mock_agent_card - ): - """Test create_a2a_tool_for_service fetches agent card.""" - with patch( - "keycardai.agents.integrations.crewai.ServiceDiscovery" - ) as mock_discovery_class: - mock_discovery = AsyncMock() - mock_discovery.get_service_card.return_value = mock_agent_card - mock_discovery.close = AsyncMock() - mock_discovery_class.return_value = mock_discovery - - tool = await create_a2a_tool_for_service( - service_config, "https://echo.example.com" - ) - - # Should have fetched agent card - mock_discovery.get_service_card.assert_called_once_with( - "https://echo.example.com" - ) - - # Tool should be created with agent card info - assert hasattr(tool, "name") - assert hasattr(tool, "_run") - - @pytest.mark.asyncio - async def test_create_tool_for_service(self, service_config, mock_agent_card): - """Test tool is created correctly from agent card.""" - with patch( - "keycardai.agents.integrations.crewai.ServiceDiscovery" - ) as mock_discovery_class: - mock_discovery = AsyncMock() - mock_discovery.get_service_card.return_value = mock_agent_card - mock_discovery.close = AsyncMock() - mock_discovery_class.return_value = mock_discovery - - tool = await create_a2a_tool_for_service( - service_config, "https://echo.example.com" - ) - - # Tool name should be based on service name from agent card - assert "echo" in tool.name.lower() - assert "service" in tool.name.lower() diff --git a/pyproject.toml b/pyproject.toml index 9a2d18e..ba75b7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,6 @@ keycardai-fastmcp = { workspace = true } keycardai-mcp-fastmcp = { workspace = true } keycardai-mcp = { workspace = true } keycardai-a2a = { workspace = true } -keycardai-agents = { workspace = true } [build-system] requires = ["hatchling", "uv-dynamic-versioning"] diff --git a/uv.lock b/uv.lock index 432b2b4..05002ba 100644 --- a/uv.lock +++ b/uv.lock @@ -23,7 +23,6 @@ members = [ "keycard-protected-server", "keycardai", "keycardai-a2a", - "keycardai-agents", "keycardai-fastmcp", "keycardai-mcp", "keycardai-mcp-fastmcp", @@ -2437,43 +2436,6 @@ requires-dist = [ ] provides-extras = ["dev", "test"] -[[package]] -name = "keycardai-agents" -source = { editable = "packages/agents" } -dependencies = [ - { name = "keycardai-a2a" }, - { name = "pydantic" }, -] - -[package.optional-dependencies] -crewai = [ - { name = "crewai" }, -] -dev = [ - { name = "mypy" }, - { name = "ruff" }, -] -test = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "pytest-timeout" }, -] - -[package.metadata] -requires-dist = [ - { name = "crewai", marker = "extra == 'crewai'", specifier = ">=0.86.0" }, - { name = "keycardai-a2a", editable = "packages/a2a" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.1" }, - { name = "pydantic", specifier = ">=2.11.7" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.1" }, - { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.1.0" }, - { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.2.1" }, - { name = "pytest-timeout", marker = "extra == 'test'", specifier = ">=2.3.1" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.6" }, -] -provides-extras = ["crewai", "dev", "test"] - [[package]] name = "keycardai-fastmcp" source = { editable = "packages/fastmcp" }