diff --git a/AGENTS.md b/AGENTS.md index b7f5b92..f5f357a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a - Verify every agent hook contract against current official provider documentation before changing an adapter. - Treat `../Elydora-Open-Source/integrations/catalog.json` as the cross-product provider inventory. +- Keep the exported `INTEGRATION_TYPES` tuple aligned with that inventory, and require `integration_type` in every agent registration request. - Keep adapter delivery claims aligned with executable tests in this repository. - Synchronize completed provider behavior into the Node SDK, Go SDK, Open Source distribution, Console, Docs, and landing page through separate reviewed commits. diff --git a/README.md b/README.md index f9b604f..aa87ae0 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ result = client.verify_operation(operation_id) # Register a new agent agent = client.register_agent({ "agent_id": "my-agent", + "integration_type": "codex", "display_name": "My Agent", "responsible_entity": "team@example.com", "keys": [{"kid": "key-v1", "public_key": "", "algorithm": "ed25519"}], diff --git a/elydora/__init__.py b/elydora/__init__.py index 2c3115d..c210404 100644 --- a/elydora/__init__.py +++ b/elydora/__init__.py @@ -32,6 +32,7 @@ from .types import ( AdminAction, AgentStatus, ExportStatus, + INTEGRATION_TYPES, IntegrationType, KeyStatus, RbacRole, @@ -90,6 +91,7 @@ __all__ = [ "AdminAction", "AgentStatus", "ExportStatus", + "INTEGRATION_TYPES", "IntegrationType", "KeyStatus", "RbacRole", diff --git a/elydora/async_client.py b/elydora/async_client.py index cf510b2..4f2ecc7 100644 --- a/elydora/async_client.py +++ b/elydora/async_client.py @@ -11,6 +11,7 @@ import aiohttp from .crypto import compute_chain_hash, compute_payload_hash, sign_eor from .errors import ElydoraError +from .integration_types import require_integration_type from .types import ( AuditQueryResponse, AuthLoginResponse, @@ -27,6 +28,7 @@ from .types import ( GetOperationResponse, HealthResponse, IssueApiTokenResponse, + IntegrationType, ListAdminEventsResponse, ListMembersResponse, ListWebhooksResponse, @@ -248,6 +250,7 @@ class AsyncElydoraClient: async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse: """Register a new agent with the organization.""" + require_integration_type(request.get("integration_type")) return await self._request("POST", "/v1/agents/register", json_body=request) async def get_agent(self, agent_id: str) -> GetAgentResponse: @@ -266,8 +269,9 @@ class AsyncElydoraClient: """Unfreeze an agent.""" return await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason}) - async def update_agent(self, agent_id: str, integration_type: str) -> UpdateAgentResponse: + async def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse: """Update an agent's integration type.""" + require_integration_type(integration_type) return await self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}) async def delete_agent(self, agent_id: str) -> DeleteAgentResponse: diff --git a/elydora/client.py b/elydora/client.py index ef9565b..a6ca713 100644 --- a/elydora/client.py +++ b/elydora/client.py @@ -10,6 +10,7 @@ import requests from .crypto import compute_chain_hash, compute_payload_hash, sign_eor from .errors import ElydoraError +from .integration_types import require_integration_type from .types import ( AuditQueryResponse, AuthLoginResponse, @@ -26,6 +27,7 @@ from .types import ( GetOperationResponse, HealthResponse, IssueApiTokenResponse, + IntegrationType, ListAdminEventsResponse, ListMembersResponse, ListWebhooksResponse, @@ -239,6 +241,7 @@ class ElydoraClient: def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse: """Register a new agent with the organization.""" + require_integration_type(request.get("integration_type")) return self._request("POST", "/v1/agents/register", json_body=request) def get_agent(self, agent_id: str) -> GetAgentResponse: @@ -257,8 +260,9 @@ class ElydoraClient: """Unfreeze an agent.""" return self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason}) - def update_agent(self, agent_id: str, integration_type: str) -> UpdateAgentResponse: + def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse: """Update an agent's integration type.""" + require_integration_type(integration_type) return self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}) def delete_agent(self, agent_id: str) -> DeleteAgentResponse: diff --git a/elydora/integration_types.py b/elydora/integration_types.py new file mode 100644 index 0000000..385a859 --- /dev/null +++ b/elydora/integration_types.py @@ -0,0 +1,12 @@ +"""Runtime validation for agent integration types.""" + +from typing import cast + +from .types import INTEGRATION_TYPES, IntegrationType + + +def require_integration_type(value: object) -> IntegrationType: + if not isinstance(value, str) or value not in INTEGRATION_TYPES: + expected = ", ".join(INTEGRATION_TYPES) + raise ValueError(f"Invalid integration_type {value!r}. Expected one of: {expected}") + return cast(IntegrationType, value) diff --git a/elydora/types.py b/elydora/types.py index 32ade36..9fa5c45 100644 --- a/elydora/types.py +++ b/elydora/types.py @@ -3,15 +3,13 @@ All types use TypedDict for structural typing, compatible with Python 3.9+. """ -from __future__ import annotations - import sys -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple if sys.version_info >= (3, 11): - from typing import Literal, TypedDict + from typing import Literal, NotRequired, TypedDict else: - from typing_extensions import Literal, TypedDict + from typing_extensions import Literal, NotRequired, TypedDict # --------------------------------------------------------------------------- @@ -29,15 +27,31 @@ RbacRole = Literal[ "integration_engineer", ] IntegrationType = Literal[ - "openai", - "anthropic", + "augment", + "claudecode", + "cline", + "codex", + "copilot", + "cursor", + "droid", "gemini", - "langchain", - "llamaindex", - "autogen", - "crewai", - "custom", + "grok", + "kimi", + "kirocli", + "kiroide", + "letta", + "opencode", + "qwen", + "enterprise", + "gui", + "sdk", + "other", ] +INTEGRATION_TYPES: Tuple[IntegrationType, ...] = ( + "augment", "claudecode", "cline", "codex", "copilot", "cursor", "droid", + "gemini", "grok", "kimi", "kirocli", "kiroide", "letta", "opencode", "qwen", + "enterprise", "gui", "sdk", "other", +) AdminAction = Literal[ "agent.freeze", "agent.unfreeze", @@ -78,7 +92,7 @@ class Agent(TypedDict): display_name: str responsible_entity: str status: AgentStatus - integration_type: str + integration_type: IntegrationType created_at: int updated_at: int @@ -212,11 +226,12 @@ class RegisterAgentKeyParam(TypedDict): algorithm: Literal["ed25519"] -class RegisterAgentRequest(TypedDict, total=False): +class RegisterAgentRequest(TypedDict): agent_id: str - display_name: str - responsible_entity: str + integration_type: IntegrationType keys: List[RegisterAgentKeyParam] + display_name: NotRequired[str] + responsible_entity: NotRequired[str] class AuditQueryParams(TypedDict, total=False): @@ -410,8 +425,8 @@ class MemberEntry(TypedDict): # --------------------------------------------------------------------------- -class UpdateAgentRequest(TypedDict, total=False): - integration_type: str +class UpdateAgentRequest(TypedDict): + integration_type: IntegrationType # --------------------------------------------------------------------------- diff --git a/tests/test_agent_registration_contract.py b/tests/test_agent_registration_contract.py new file mode 100644 index 0000000..0e71e9a --- /dev/null +++ b/tests/test_agent_registration_contract.py @@ -0,0 +1,141 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import get_args + +import pytest + +from elydora import ( + AsyncElydoraClient, + ElydoraClient, + INTEGRATION_TYPES, + IntegrationType, + RegisterAgentRequest, +) + +EXPECTED_INTEGRATION_TYPES = ( + "augment", "claudecode", "cline", "codex", "copilot", "cursor", "droid", + "gemini", "grok", "kimi", "kirocli", "kiroide", "letta", "opencode", "qwen", + "enterprise", "gui", "sdk", "other", +) + + +class RegistrationHandler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + content_length = int(self.headers["Content-Length"]) + body = json.loads(self.rfile.read(content_length)) + self.server.requests.append({ + "method": self.command, + "path": self.path, + "authorization": self.headers.get("Authorization"), + "body": body, + }) + response = json.dumps({ + "agent": { + "agent_id": body["agent_id"], + "org_id": "org-1", + "display_name": body.get("display_name", ""), + "responsible_entity": body.get("responsible_entity", ""), + "integration_type": body["integration_type"], + "status": "active", + "created_at": 1, + "updated_at": 1, + }, + "keys": [], + }).encode() + self.send_response(201) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + + def log_message(self, _format: str, *args: object) -> None: + pass + + +@pytest.fixture +def registration_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), RegistrationHandler) + server.requests = [] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join() + + +def registration_request(): + return { + "agent_id": "agent-1", + "integration_type": "grok", + "display_name": "Grok Agent", + "responsible_entity": "platform@example.com", + "keys": [{"kid": "key-v1", "public_key": "public-key", "algorithm": "ed25519"}], + } + + +def expected_http_request(): + return { + "method": "POST", + "path": "/v1/agents/register", + "authorization": "Bearer api-token", + "body": registration_request(), + } + + +def test_integration_types_match_public_api_contract() -> None: + assert INTEGRATION_TYPES == EXPECTED_INTEGRATION_TYPES + assert get_args(IntegrationType) == EXPECTED_INTEGRATION_TYPES + assert RegisterAgentRequest.__required_keys__ == frozenset({ + "agent_id", "integration_type", "keys", + }) + assert RegisterAgentRequest.__optional_keys__ == frozenset({ + "display_name", "responsible_entity", + }) + + +def test_sync_registration_requires_and_sends_integration_type(registration_server) -> None: + host, port = registration_server.server_address + client = ElydoraClient( + "org-1", "admin-agent", "unused", + base_url=f"http://{host}:{port}", max_retries=0, token="api-token", + ) + with pytest.raises(ValueError, match="Invalid integration_type None"): + client.register_agent({"agent_id": "agent-1", "keys": []}) + with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"): + client.register_agent({ + "agent_id": "agent-1", "integration_type": "future-cli", "keys": [], + }) + with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"): + client.update_agent("agent-1", "future-cli") + assert registration_server.requests == [] + + response = client.register_agent(registration_request()) + + assert response["agent"]["integration_type"] == "grok" + assert registration_server.requests == [expected_http_request()] + + +@pytest.mark.asyncio +async def test_async_registration_requires_and_sends_integration_type(registration_server) -> None: + host, port = registration_server.server_address + client = AsyncElydoraClient( + "org-1", "admin-agent", "unused", + base_url=f"http://{host}:{port}", max_retries=0, token="api-token", + ) + try: + with pytest.raises(ValueError, match="Invalid integration_type None"): + await client.register_agent({"agent_id": "agent-1", "keys": []}) + with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"): + await client.update_agent("agent-1", "future-cli") + assert registration_server.requests == [] + + response = await client.register_agent(registration_request()) + + assert response["agent"]["integration_type"] == "grok" + assert registration_server.requests == [expected_http_request()] + finally: + await client.close()