diff --git a/.gitignore b/.gitignore index b7faf40..f2682f1 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,7 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Local virtualenvs +.venv/ +venv/ diff --git a/SKILL.md b/SKILL.md index b9b33ec..d0d530e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,7 +2,7 @@ name: symbiont-sdk-python title: Symbiont SDK for Python description: Python SDK for the Symbiont agent runtime — agent lifecycle, webhook verification, AgentPin identity, memory systems, skill scanning, metrics, scheduling, and vector search -version: 1.11.0 +version: 1.14.3 --- # Symbiont SDK for Python — Skills Guide diff --git a/setup.py b/setup.py index f236d98..27e2a9f 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ def read_requirements(): setup( name='symbiont-sdk', - version='1.10.0', + version='1.14.3', author='Jascha Wanger / ThirdKey.ai', author_email='oss@symbiont.dev', description='Python SDK for Symbiont platform with Tool Review and Runtime APIs', diff --git a/symbiont/__init__.py b/symbiont/__init__.py index e31a57d..5f2439e 100644 --- a/symbiont/__init__.py +++ b/symbiont/__init__.py @@ -111,37 +111,6 @@ WorkflowExecutionRequest, WorkflowExecutionResponse, ) -from .reasoning import ( - CedarPolicy, - CircuitBreakerConfig, - CircuitBreakerStatus, - CircuitState, - FinishReason, - InferenceOptions, - InferenceResponse, - JournalEntry, - KnowledgeConfig, - LoopConfig, - LoopDecision, - LoopDecisionType, - LoopEvent, - LoopEventType, - LoopResult, - LoopState, - Observation, - ProposedAction, - ProposedActionType, - RecoveryStrategy, - RecoveryStrategyType, - RunReasoningLoopRequest, - RunReasoningLoopResponse, - TerminationReason, - TerminationReasonType, - ToolCallRequest, - ToolDefinition, - Usage, -) -from .reasoning_client import ReasoningClient from .schedules import ( CreateScheduleRequest, CreateScheduleResponse, @@ -167,114 +136,150 @@ SkillMetadata, SkillScanner, ) -from .toolclad import ToolCladClient from .webhooks import HmacVerifier, JwtVerifier, SignatureVerifier, WebhookProvider # Load environment variables from .env file load_dotenv() -__version__ = "1.11.0" +__version__ = "1.14.3" __all__ = [ # Client - 'Client', - + "Client", # Core Agent Models - 'Agent', 'AgentState', 'ResourceUsage', 'AgentStatusResponse', 'AgentMetrics', - + "Agent", + "AgentState", + "ResourceUsage", + "AgentStatusResponse", + "AgentMetrics", # Workflow Models - 'WorkflowExecutionRequest', 'WorkflowExecutionResponse', - + "WorkflowExecutionRequest", + "WorkflowExecutionResponse", # Tool Review Models - 'Tool', 'ToolProvider', 'ToolSchema', - 'ReviewStatus', 'ReviewSession', 'ReviewSessionCreate', 'ReviewSessionResponse', 'ReviewSessionList', - 'SecurityFinding', 'FindingSeverity', 'FindingCategory', 'AnalysisResults', - 'ReviewSessionState', 'HumanReviewDecision', - 'SigningRequest', 'SigningResponse', 'SignedTool', - + "Tool", + "ToolProvider", + "ToolSchema", + "ReviewStatus", + "ReviewSession", + "ReviewSessionCreate", + "ReviewSessionResponse", + "ReviewSessionList", + "SecurityFinding", + "FindingSeverity", + "FindingCategory", + "AnalysisResults", + "ReviewSessionState", + "HumanReviewDecision", + "SigningRequest", + "SigningResponse", + "SignedTool", # System Models - 'HealthResponse', 'ErrorResponse', 'PaginationInfo', 'SystemMetrics', - + "HealthResponse", + "ErrorResponse", + "PaginationInfo", + "SystemMetrics", # Secrets Management Models - 'SecretBackendType', 'SecretBackendConfig', 'SecretRequest', 'SecretResponse', 'SecretListResponse', - 'VaultAuthMethod', 'VaultConfig', - + "SecretBackendType", + "SecretBackendConfig", + "SecretRequest", + "SecretResponse", + "SecretListResponse", + "VaultAuthMethod", + "VaultConfig", # MCP Management Models - 'McpConnectionStatus', 'McpServerConfig', 'McpConnectionInfo', 'McpToolInfo', 'McpResourceInfo', - + "McpConnectionStatus", + "McpServerConfig", + "McpConnectionInfo", + "McpToolInfo", + "McpResourceInfo", # Vector Database & RAG Models - 'KnowledgeSourceType', 'VectorMetadata', 'KnowledgeItem', - 'VectorSearchRequest', 'VectorSearchResult', 'VectorSearchResponse', - 'ContextQuery', 'ContextResponse', - + "KnowledgeSourceType", + "VectorMetadata", + "KnowledgeItem", + "VectorSearchRequest", + "VectorSearchResult", + "VectorSearchResponse", + "ContextQuery", + "ContextResponse", # Agent DSL Models - 'DslCompileRequest', 'DslCompileResponse', 'AgentDeployRequest', 'AgentDeployResponse', - + "DslCompileRequest", + "DslCompileResponse", + "AgentDeployRequest", + "AgentDeployResponse", # HTTP Input Models - 'RouteMatchType', 'AgentRoutingRule', 'HttpResponseControlConfig', - 'HttpInputConfig', 'HttpInputServerInfo', 'HttpInputCreateRequest', 'HttpInputUpdateRequest', - 'WebhookTriggerRequest', 'WebhookTriggerResponse', - + "RouteMatchType", + "AgentRoutingRule", + "HttpResponseControlConfig", + "HttpInputConfig", + "HttpInputServerInfo", + "HttpInputCreateRequest", + "HttpInputUpdateRequest", + "WebhookTriggerRequest", + "WebhookTriggerResponse", # HTTP Input Invocation Models (Symbiont v1.10.0) - 'WebhookInvocationStatus', 'WebhookInvocationRequest', 'WebhookToolRun', - 'WebhookExecutionStartedResponse', 'WebhookCompletedResponse', 'WebhookInvocationResponse', - + "WebhookInvocationStatus", + "WebhookInvocationRequest", + "WebhookToolRun", + "WebhookExecutionStartedResponse", + "WebhookCompletedResponse", + "WebhookInvocationResponse", # AgentPin - 'AgentPinClient', - + "AgentPinClient", # Schedule Models - 'ScheduleClient', - 'CreateScheduleRequest', 'CreateScheduleResponse', - 'UpdateScheduleRequest', - 'ScheduleSummary', 'ScheduleDetail', 'ScheduleRunEntry', - 'ScheduleHistoryResponse', 'NextRunsResponse', - 'ScheduleActionResponse', 'DeleteScheduleResponse', - 'SchedulerHealthResponse', - + "ScheduleClient", + "CreateScheduleRequest", + "CreateScheduleResponse", + "UpdateScheduleRequest", + "ScheduleSummary", + "ScheduleDetail", + "ScheduleRunEntry", + "ScheduleHistoryResponse", + "NextRunsResponse", + "ScheduleActionResponse", + "DeleteScheduleResponse", + "SchedulerHealthResponse", # Webhook Verification - 'WebhookProvider', 'HmacVerifier', 'JwtVerifier', 'SignatureVerifier', - + "WebhookProvider", + "HmacVerifier", + "JwtVerifier", + "SignatureVerifier", # Markdown Memory - 'MarkdownMemoryStore', 'AgentMemoryContext', 'StorageStats', - + "MarkdownMemoryStore", + "AgentMemoryContext", + "StorageStats", # Skills - 'SkillLoader', 'SkillScanner', 'LoadedSkill', 'ScanResult', 'ScanFinding', - 'ScanSeverity', 'SignatureStatus', 'SkillLoaderConfig', 'SkillMetadata', - + "SkillLoader", + "SkillScanner", + "LoadedSkill", + "ScanResult", + "ScanFinding", + "ScanSeverity", + "SignatureStatus", + "SkillLoaderConfig", + "SkillMetadata", # Metrics - 'MetricsClient', 'MetricsCollector', 'MetricsSnapshot', - 'FileMetricsExporter', 'CompositeExporter', - - # ToolClad - 'ToolCladClient', - 'ToolManifestInfo', 'ToolValidationResult', 'ToolTestResult', 'ToolExecutionResult', - + "MetricsClient", + "MetricsCollector", + "MetricsSnapshot", + "FileMetricsExporter", + "CompositeExporter", + # ToolClad Models + "ToolManifestInfo", + "ToolValidationResult", + "ToolTestResult", + "ToolExecutionResult", # Communication Policy - 'CommunicationRule', 'CommunicationEvaluation', - - # Reasoning Loop - 'ReasoningClient', - 'Usage', 'ToolDefinition', 'ToolCallRequest', - 'FinishReason', 'InferenceOptions', 'InferenceResponse', - 'Observation', 'ProposedAction', 'ProposedActionType', - 'LoopDecision', 'LoopDecisionType', - 'RecoveryStrategy', 'RecoveryStrategyType', - 'TerminationReason', 'TerminationReasonType', - 'LoopConfig', 'LoopState', 'LoopResult', - 'LoopEvent', 'LoopEventType', 'JournalEntry', - 'CedarPolicy', 'KnowledgeConfig', - 'CircuitState', 'CircuitBreakerConfig', 'CircuitBreakerStatus', - 'RunReasoningLoopRequest', 'RunReasoningLoopResponse', - + "CommunicationRule", + "CommunicationEvaluation", # Exceptions - 'SymbiontError', - 'APIError', - 'AuthenticationError', - 'NotFoundError', - 'RateLimitError', - 'WebhookVerificationError', - 'SkillLoadError', - 'SkillScanError', - 'MetricsExportError', - 'MetricsConfigError', + "SymbiontError", + "APIError", + "AuthenticationError", + "NotFoundError", + "RateLimitError", + "WebhookVerificationError", + "SkillLoadError", + "SkillScanError", + "MetricsExportError", + "MetricsConfigError", ] diff --git a/symbiont/agentpin.py b/symbiont/agentpin.py index 6efc3a0..1e3283a 100644 --- a/symbiont/agentpin.py +++ b/symbiont/agentpin.py @@ -207,9 +207,7 @@ def verify_credential_with_bundle( VerificationResult with validation details """ store = pin_store if pin_store is not None else self._pin_store - return verify_credential_with_bundle( - jwt, bundle, store, audience, config - ) + return verify_credential_with_bundle(jwt, bundle, store, audience, config) # ========================================================================= # Discovery diff --git a/symbiont/auth.py b/symbiont/auth.py index 4da0671..e12e1f4 100644 --- a/symbiont/auth.py +++ b/symbiont/auth.py @@ -14,6 +14,7 @@ class AuthMethod(str, Enum): """Authentication method enumeration.""" + API_KEY = "api_key" JWT = "jwt" OAUTH2 = "oauth2" @@ -22,6 +23,7 @@ class AuthMethod(str, Enum): class TokenType(str, Enum): """Token type enumeration.""" + ACCESS = "access" REFRESH = "refresh" API_KEY = "api_key" @@ -29,6 +31,7 @@ class TokenType(str, Enum): class Permission(str, Enum): """Permission enumeration.""" + READ = "read" WRITE = "write" DELETE = "delete" @@ -38,6 +41,7 @@ class Permission(str, Enum): class Role(BaseModel): """Role definition with permissions.""" + name: str permissions: Set[Permission] description: Optional[str] = None @@ -46,6 +50,7 @@ class Role(BaseModel): class AuthToken(BaseModel): """Authentication token model.""" + token: str token_type: TokenType expires_at: datetime @@ -58,6 +63,7 @@ class AuthToken(BaseModel): class AuthUser(BaseModel): """Authenticated user model.""" + user_id: str username: Optional[str] = None email: Optional[str] = None @@ -115,12 +121,14 @@ def _validate_config(self) -> None: if not self.config.jwt_algorithm: raise ValueError("JWT algorithm is required") - def generate_token(self, - user_id: str, - roles: List[str] = None, - permissions: Set[Permission] = None, - token_type: TokenType = TokenType.ACCESS, - expires_in: Optional[int] = None) -> AuthToken: + def generate_token( + self, + user_id: str, + roles: List[str] = None, + permissions: Set[Permission] = None, + token_type: TokenType = TokenType.ACCESS, + expires_in: Optional[int] = None, + ) -> AuthToken: """Generate a JWT token. Args: @@ -148,21 +156,19 @@ def generate_token(self, # Create payload payload = { - 'sub': user_id, - 'iat': int(now.timestamp()), - 'exp': int(expires_at.timestamp()), - 'iss': self.config.token_issuer, - 'aud': self.config.token_audience, - 'type': token_type.value, - 'roles': roles, - 'permissions': [p.value for p in permissions] if permissions else [] + "sub": user_id, + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + "iss": self.config.token_issuer, + "aud": self.config.token_audience, + "type": token_type.value, + "roles": roles, + "permissions": [p.value for p in permissions] if permissions else [], } # Generate token token = jwt.encode( - payload, - self.config.jwt_secret_key, - algorithm=self.config.jwt_algorithm + payload, self.config.jwt_secret_key, algorithm=self.config.jwt_algorithm ) return AuthToken( @@ -172,7 +178,7 @@ def generate_token(self, issued_at=now, user_id=user_id, roles=roles, - permissions=permissions + permissions=permissions, ) def decode_token(self, token: str) -> Optional[Dict[str, Any]]: @@ -190,7 +196,7 @@ def decode_token(self, token: str) -> Optional[Dict[str, Any]]: self.config.jwt_secret_key, algorithms=[self.config.jwt_algorithm], issuer=self.config.token_issuer, - audience=self.config.token_audience + audience=self.config.token_audience, ) return payload except jwt.InvalidTokenError: @@ -209,16 +215,16 @@ def refresh_token(self, refresh_token: str) -> Optional[AuthToken]: return None payload = self.decode_token(refresh_token) - if not payload or payload.get('type') != TokenType.REFRESH.value: + if not payload or payload.get("type") != TokenType.REFRESH.value: return None # Generate new access token - permissions = {Permission(p) for p in payload.get('permissions', [])} + permissions = {Permission(p) for p in payload.get("permissions", [])} return self.generate_token( - user_id=payload['sub'], - roles=payload.get('roles', []), + user_id=payload["sub"], + roles=payload.get("roles", []), permissions=permissions, - token_type=TokenType.ACCESS + token_type=TokenType.ACCESS, ) @@ -251,13 +257,13 @@ def validate_token(self, token: str) -> Optional[AuthUser]: return None # Convert permissions back to enum - permissions = {Permission(p) for p in payload.get('permissions', [])} + permissions = {Permission(p) for p in payload.get("permissions", [])} return AuthUser( - user_id=payload['sub'], - roles=payload.get('roles', []), + user_id=payload["sub"], + roles=payload.get("roles", []), permissions=permissions, - last_login=datetime.now(timezone.utc) + last_login=datetime.now(timezone.utc), ) def blacklist_token(self, token: str) -> None: @@ -293,19 +299,25 @@ def _setup_default_roles(self) -> None: default_roles = [ Role( name="admin", - permissions={Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN, Permission.EXECUTE}, - description="Full system access" + permissions={ + Permission.READ, + Permission.WRITE, + Permission.DELETE, + Permission.ADMIN, + Permission.EXECUTE, + }, + description="Full system access", ), Role( name="user", permissions={Permission.READ, Permission.WRITE, Permission.EXECUTE}, - description="Standard user access" + description="Standard user access", ), Role( name="readonly", permissions={Permission.READ}, - description="Read-only access" - ) + description="Read-only access", + ), ] for role in default_roles: @@ -346,7 +358,9 @@ def get_permissions_for_roles(self, role_names: List[str]) -> Set[Permission]: permissions.update(role.permissions) return permissions - def has_permission(self, user_roles: List[str], required_permission: Permission) -> bool: + def has_permission( + self, user_roles: List[str], required_permission: Permission + ) -> bool: """Check if user has required permission. Args: @@ -357,7 +371,10 @@ def has_permission(self, user_roles: List[str], required_permission: Permission) True if user has permission, False otherwise """ user_permissions = self.get_permissions_for_roles(user_roles) - return required_permission in user_permissions or Permission.ADMIN in user_permissions + return ( + required_permission in user_permissions + or Permission.ADMIN in user_permissions + ) class AuthManager: @@ -401,7 +418,7 @@ def authenticate_with_api_key(self, api_key: str) -> Optional[AuthUser]: user_id=user_id, roles=["user"], permissions=self.role_manager.get_permissions_for_roles(["user"]), - last_login=datetime.now(timezone.utc) + last_login=datetime.now(timezone.utc), ) return None @@ -416,7 +433,9 @@ def authenticate_with_jwt(self, token: str) -> Optional[AuthUser]: """ return self.token_validator.validate_token(token) - def authenticate(self, method: AuthMethod, credentials: Dict[str, Any]) -> Optional[AuthUser]: + def authenticate( + self, method: AuthMethod, credentials: Dict[str, Any] + ) -> Optional[AuthUser]: """Authenticate user with specified method and credentials. Args: @@ -427,10 +446,10 @@ def authenticate(self, method: AuthMethod, credentials: Dict[str, Any]) -> Optio AuthUser if authentication successful, None otherwise """ if method == AuthMethod.API_KEY: - api_key = credentials.get('api_key') + api_key = credentials.get("api_key") return self.authenticate_with_api_key(api_key) elif method == AuthMethod.JWT: - token = credentials.get('token') + token = credentials.get("token") return self.authenticate_with_jwt(token) else: # Try registered providers @@ -457,9 +476,9 @@ def generate_tokens(self, user: AuthUser) -> Dict[str, AuthToken]: user_id=user.user_id, roles=user.roles, permissions=user.permissions, - token_type=TokenType.ACCESS + token_type=TokenType.ACCESS, ) - tokens['access'] = access_token + tokens["access"] = access_token # Generate refresh token if enabled if self.config.enable_refresh_tokens: @@ -467,9 +486,9 @@ def generate_tokens(self, user: AuthUser) -> Dict[str, AuthToken]: user_id=user.user_id, roles=user.roles, permissions=user.permissions, - token_type=TokenType.REFRESH + token_type=TokenType.REFRESH, ) - tokens['refresh'] = refresh_token + tokens["refresh"] = refresh_token return tokens @@ -484,7 +503,9 @@ def refresh_access_token(self, refresh_token: str) -> Optional[AuthToken]: """ return self.jwt_handler.refresh_token(refresh_token) - def validate_permissions(self, user: AuthUser, action: str, resource: str = None) -> bool: + def validate_permissions( + self, user: AuthUser, action: str, resource: str = None + ) -> bool: """Validate if user has permission for a specific action. Args: diff --git a/symbiont/channels.py b/symbiont/channels.py index 18662d4..640c9be 100644 --- a/symbiont/channels.py +++ b/symbiont/channels.py @@ -232,25 +232,17 @@ def add_mapping( } if request.email is not None: payload["email"] = request.email - data = self._request( - "POST", f"/channels/{channel_id}/mappings", json=payload - ) + data = self._request("POST", f"/channels/{channel_id}/mappings", json=payload) return IdentityMappingEntry(**data) def remove_mapping(self, channel_id: str, user_id: str) -> None: """Remove an identity mapping. ``DELETE /channels/{id}/mappings/{user_id}``""" - self._client._request( - "DELETE", f"/channels/{channel_id}/mappings/{user_id}" - ) + self._client._request("DELETE", f"/channels/{channel_id}/mappings/{user_id}") - def query_audit( - self, channel_id: str, limit: int = 50 - ) -> ChannelAuditResponse: + def query_audit(self, channel_id: str, limit: int = 50) -> ChannelAuditResponse: """Get audit log entries. ``GET /channels/{id}/audit``""" data = self._request( "GET", f"/channels/{channel_id}/audit", params={"limit": limit} ) entries = [ChannelAuditEntry(**entry) for entry in data.get("entries", [])] - return ChannelAuditResponse( - channel_id=data["channel_id"], entries=entries - ) + return ChannelAuditResponse(channel_id=data["channel_id"], entries=entries) diff --git a/symbiont/client.py b/symbiont/client.py index 56e11b1..2a5be16 100644 --- a/symbiont/client.py +++ b/symbiont/client.py @@ -22,67 +22,11 @@ from .models import ( # Agent models Agent, - AgentDeployRequest, - AgentDeployResponse, - AgentMetrics, AgentStatusResponse, - AnalysisResults, - # Phase 3 Qdrant Integration models - CollectionCreateRequest, - CollectionInfo, - CollectionResponse, - ConsolidationResponse, - # Configuration models (Phase 1) - ContextQuery, - ContextResponse, - ConversationContext, - # Agent DSL models - DslCompileRequest, - DslCompileResponse, - EndpointMetrics, # System models HealthResponse, - # Phase 4 HTTP Endpoint Management models - HttpEndpointCreateRequest, - HttpEndpointInfo, - HttpEndpointResponse, - HttpEndpointUpdateRequest, - HttpInputCreateRequest, - HttpInputServerInfo, - HttpInputUpdateRequest, - HumanReviewDecision, - # Vector Database & RAG models - KnowledgeItem, - McpConnectionInfo, - McpResourceInfo, - # MCP Management models - McpServerConfig, - McpToolInfo, - MemoryQuery, - MemoryResponse, - MemorySearchRequest, - MemorySearchResponse, - # Phase 2 Memory System models - MemoryStoreRequest, - ReviewSession, - ReviewSessionCreate, - ReviewSessionList, - ReviewSessionResponse, - # Secrets Management models - SecretBackendConfig, - SecretListResponse, - SecretRequest, - SecretResponse, - SignedTool, - SigningRequest, - SigningResponse, SystemMetrics, - UpsertResponse, - VectorSearchRequest, - VectorSearchResponse, - VectorUpsertRequest, - WebhookTriggerRequest, - WebhookTriggerResponse, + # Workflow models WorkflowExecutionRequest, ) from .schedules import ScheduleClient @@ -91,10 +35,12 @@ class Client: """Main API client for the Symbiont Agent Runtime System.""" - def __init__(self, - config: Optional[Union[ClientConfig, Dict[str, Any], str, Path]] = None, - api_key: Optional[str] = None, - base_url: Optional[str] = None): + def __init__( + self, + config: Optional[Union[ClientConfig, Dict[str, Any], str, Path]] = None, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + ): """Initialize the Symbiont API client. Args: @@ -125,7 +71,7 @@ def __init__(self, if api_key: self.config.api_key = api_key if base_url: - self.config.base_url = base_url.rstrip('/') + self.config.base_url = base_url.rstrip("/") # Validate configuration config_errors = self._config_manager.validate_required_settings() @@ -154,8 +100,6 @@ def __init__(self, self._channels: Optional[ChannelClient] = None self._agentpin: Optional[Any] = None self._metrics_client: Optional[Any] = None - self._reasoning: Optional[Any] = None - self._toolclad: Optional[Any] = None @property def schedules(self) -> ScheduleClient: @@ -176,33 +120,19 @@ def agentpin(self): """Lazy-loaded AgentPin client for credential verification and discovery.""" if self._agentpin is None: from .agentpin import AgentPinClient + self._agentpin = AgentPinClient(self) return self._agentpin - @property - def reasoning(self): - """Lazy-loaded reasoning client for loop, journal, Cedar, circuit breaker, and knowledge operations.""" - if self._reasoning is None: - from .reasoning_client import ReasoningClient - self._reasoning = ReasoningClient(self) - return self._reasoning - @property def metrics_client(self): """Lazy-loaded metrics client for runtime metrics queries.""" if self._metrics_client is None: from .metrics import MetricsClient + self._metrics_client = MetricsClient(self) return self._metrics_client - @property - def toolclad(self): - """Lazy-loaded ToolClad manifest management client.""" - if self._toolclad is None: - from .toolclad import ToolCladClient - self._toolclad = ToolCladClient(self) - return self._toolclad - def _request(self, method: str, endpoint: str, **kwargs): """Make an HTTP request to the API. @@ -222,17 +152,32 @@ def _request(self, method: str, endpoint: str, **kwargs): RateLimitError: For 429 Too Many Requests responses APIError: For other 4xx and 5xx responses """ - url = f"{self.config.base_url}/{endpoint.lstrip('/')}" + # De-duplicate the API version prefix in exactly one place. The + # configured ``base_url`` is expected to include the version segment + # (the default is ``http://localhost:8080/api/v1``). Some call sites + # historically passed endpoints that ALSO carried an ``api/v1/`` + # prefix, producing a doubled ``/api/v1/api/v1/`` path that 404s + # against the runtime. When ``base_url`` already ends with + # ``/api/v1``, strip a leading ``api/v1/`` from the endpoint so the + # version segment appears exactly once. Base URLs with a different + # prefix (or none) are left untouched, preserving the behavior of + # custom deployments. + endpoint_clean = endpoint.lstrip("/") + if self.config.base_url.rstrip("/").endswith( + "/api/v1" + ) and endpoint_clean.startswith("api/v1/"): + endpoint_clean = endpoint_clean[len("api/v1/") :] + url = f"{self.config.base_url}/{endpoint_clean}" # Set default headers - headers = kwargs.pop('headers', {}) + headers = kwargs.pop("headers", {}) # Add authentication headers self._add_auth_headers(headers) # Add timeout if not specified - if 'timeout' not in kwargs: - kwargs['timeout'] = self.config.timeout + if "timeout" not in kwargs: + kwargs["timeout"] = self.config.timeout # Make the request with retry logic max_retries = self.config.max_retries @@ -252,15 +197,15 @@ def _request(self, method: str, endpoint: str, **kwargs): continue else: # No refresh possible or refresh failed - if 'expired' in response.text.lower(): + if "expired" in response.text.lower(): raise AuthenticationExpiredError( "Authentication token has expired", - response_text=response.text + response_text=response.text, ) else: raise AuthenticationError( "Authentication failed - check your credentials", - response_text=response.text + response_text=response.text, ) # Handle other error responses @@ -269,30 +214,31 @@ def _request(self, method: str, endpoint: str, **kwargs): if response.status_code == 403: raise PermissionDeniedError( "Insufficient permissions for this operation", - response_text=response_text + response_text=response_text, ) elif response.status_code == 404: raise NotFoundError( - "Resource not found", - response_text=response_text + "Resource not found", response_text=response_text ) elif response.status_code == 429: raise RateLimitError( "Rate limit exceeded - too many requests", - response_text=response_text + response_text=response_text, ) else: # Handle other 4xx and 5xx errors raise APIError( f"API request failed with status {response.status_code}", status_code=response.status_code, - response_text=response_text + response_text=response_text, ) except requests.RequestException as e: if attempt == max_retries: - raise APIError(f"Request failed after {max_retries + 1} attempts: {e}") from e - time.sleep(2 ** attempt) # Exponential backoff + raise APIError( + f"Request failed after {max_retries + 1} attempts: {e}" + ) from e + time.sleep(2**attempt) # Exponential backoff # This should never be reached raise APIError("Unexpected error in request handling") @@ -303,10 +249,10 @@ def _add_auth_headers(self, headers: Dict[str, str]) -> None: Args: headers: Headers dictionary to modify """ - if self._current_tokens.get('access'): - headers['Authorization'] = f'Bearer {self._current_tokens["access"]}' + if self._current_tokens.get("access"): + headers["Authorization"] = f'Bearer {self._current_tokens["access"]}' elif self.config.api_key: - headers['Authorization'] = f'Bearer {self.config.api_key}' + headers["Authorization"] = f"Bearer {self.config.api_key}" def _try_refresh_token(self) -> bool: """Try to refresh the access token using refresh token. @@ -317,14 +263,14 @@ def _try_refresh_token(self) -> bool: if not self.config.auth.enable_refresh_tokens: return False - refresh_token = self._current_tokens.get('refresh') + refresh_token = self._current_tokens.get("refresh") if not refresh_token: return False try: new_token = self.auth_manager.refresh_access_token(refresh_token) if new_token: - self._current_tokens['access'] = new_token.token + self._current_tokens["access"] = new_token.token self._last_token_refresh = time.time() return True except Exception: @@ -392,13 +338,13 @@ def authenticate_jwt(self, token: str) -> Dict[str, Any]: user = self.auth_manager.authenticate_with_jwt(token) if user: self._current_user = user - self._current_tokens['access'] = token + self._current_tokens["access"] = token return { "user_id": user.user_id, "roles": user.roles, "permissions": [p.value for p in user.permissions], - "authenticated": True + "authenticated": True, } else: raise AuthenticationError("Invalid JWT token") @@ -409,19 +355,19 @@ def refresh_token(self) -> Dict[str, Any]: Returns: Token refresh response """ - refresh_token = self._current_tokens.get('refresh') + refresh_token = self._current_tokens.get("refresh") if not refresh_token: raise TokenRefreshError("No refresh token available") new_token = self.auth_manager.refresh_access_token(refresh_token) if new_token: - self._current_tokens['access'] = new_token.token + self._current_tokens["access"] = new_token.token self._last_token_refresh = time.time() return { "access_token": new_token.token, "token_type": "Bearer", # nosec B105 - "expires_in": self.config.auth.jwt_expiration_seconds + "expires_in": self.config.auth.jwt_expiration_seconds, } else: raise TokenRefreshError("Failed to refresh token") @@ -475,18 +421,6 @@ def get_metrics(self) -> SystemMetrics: response = self._request("GET", "metrics") return SystemMetrics(**response.json()) - def get_agent_metrics(self, agent_id: str) -> AgentMetrics: - """Get metrics for a specific agent. - - Args: - agent_id: The agent identifier - - Returns: - AgentMetrics: Agent-specific metrics - """ - response = self._request("GET", f"agents/{agent_id}/metrics") - return AgentMetrics(**response.json()) - # ============================================================================= # Agent Management Methods # ============================================================================= @@ -509,14 +443,16 @@ def get_agent_status(self, agent_id: str) -> AgentStatusResponse: Returns: AgentStatusResponse: Agent status information """ - response = self._request("GET", f"agents/{agent_id}") + response = self._request("GET", f"agents/{agent_id}/status") return AgentStatusResponse(**response.json()) # ============================================================================= # Workflow Execution Methods # ============================================================================= - def execute_workflow(self, workflow_request: Union[WorkflowExecutionRequest, Dict[str, Any]]) -> Dict[str, Any]: + def execute_workflow( + self, workflow_request: Union[WorkflowExecutionRequest, Dict[str, Any]] + ) -> Dict[str, Any]: """Execute a workflow. Args: @@ -528,120 +464,11 @@ def execute_workflow(self, workflow_request: Union[WorkflowExecutionRequest, Dic if isinstance(workflow_request, dict): workflow_request = WorkflowExecutionRequest(**workflow_request) - response = self._request("POST", "workflows", json=workflow_request.model_dump()) - return response.json() - - # ============================================================================= - # Tool Review API Methods - # ============================================================================= - - def submit_tool_for_review(self, review_request: Union[ReviewSessionCreate, Dict[str, Any]]) -> ReviewSessionResponse: - """Submit a tool for security review. - - Args: - review_request: Tool review request - - Returns: - ReviewSessionResponse: Review session information - """ - if isinstance(review_request, dict): - review_request = ReviewSessionCreate(**review_request) - - response = self._request("POST", "tool-review/sessions", json=review_request.model_dump()) - return ReviewSessionResponse(**response.json()) - - def get_review_session(self, review_id: str) -> ReviewSession: - """Get details of a specific review session. - - Args: - review_id: The review session identifier - - Returns: - ReviewSession: Review session details - """ - response = self._request("GET", f"tool-review/sessions/{review_id}") - return ReviewSession(**response.json()) - - def list_review_sessions(self, - page: int = 1, - limit: int = 20, - status: Optional[str] = None, - author: Optional[str] = None) -> ReviewSessionList: - """List review sessions with optional filtering. - - Args: - page: Page number for pagination - limit: Number of items per page - status: Filter by review status - author: Filter by tool author - - Returns: - ReviewSessionList: List of review sessions with pagination - """ - params = {"page": page, "limit": limit} - if status: - params["status"] = status - if author: - params["author"] = author - - response = self._request("GET", "tool-review/sessions", params=params) - return ReviewSessionList(**response.json()) - - def get_analysis_results(self, analysis_id: str) -> AnalysisResults: - """Get detailed security analysis results. - - Args: - analysis_id: The analysis identifier - - Returns: - AnalysisResults: Security analysis results - """ - response = self._request("GET", f"tool-review/analysis/{analysis_id}") - return AnalysisResults(**response.json()) - - def submit_human_review_decision(self, review_id: str, decision: Union[HumanReviewDecision, Dict[str, Any]]) -> Dict[str, Any]: - """Submit a human review decision. - - Args: - review_id: The review session identifier - decision: Human review decision - - Returns: - Dict[str, Any]: Decision submission result - """ - if isinstance(decision, dict): - decision = HumanReviewDecision(**decision) - - response = self._request("POST", f"tool-review/sessions/{review_id}/decisions", json=decision.model_dump()) + response = self._request( + "POST", "workflows/execute", json=workflow_request.model_dump() + ) return response.json() - def sign_approved_tool(self, signing_request: Union[SigningRequest, Dict[str, Any]]) -> SigningResponse: - """Sign an approved tool. - - Args: - signing_request: Tool signing request - - Returns: - SigningResponse: Signing operation result - """ - if isinstance(signing_request, dict): - signing_request = SigningRequest(**signing_request) - - response = self._request("POST", "tool-review/sign", json=signing_request.model_dump()) - return SigningResponse(**response.json()) - - def get_signed_tool(self, review_id: str) -> SignedTool: - """Get signed tool information. - - Args: - review_id: The review session identifier - - Returns: - SignedTool: Signed tool information - """ - response = self._request("GET", f"tool-review/signed/{review_id}") - return SignedTool(**response.json()) - # ============================================================================= # Convenience Methods # ============================================================================= @@ -661,878 +488,175 @@ def create_agent(self, agent_data: Union[Agent, Dict[str, Any]]) -> Dict[str, An response = self._request("POST", "agents", json=agent_data.model_dump()) return response.json() - def wait_for_review_completion(self, review_id: str, timeout: int = 300) -> ReviewSession: - """Wait for a review session to complete. - - Args: - review_id: The review session identifier - timeout: Maximum wait time in seconds - - Returns: - ReviewSession: Final review session state - - Raises: - TimeoutError: If review doesn't complete within timeout - """ - import time - start_time = time.time() - - while time.time() - start_time < timeout: - session = self.get_review_session(review_id) - if session.status in ["approved", "rejected", "signed"]: - return session - time.sleep(5) # Check every 5 seconds - - raise TimeoutError(f"Review {review_id} did not complete within {timeout} seconds") - # ============================================================================= - # Secrets Management Methods + # Agent Lifecycle Methods # ============================================================================= - def configure_secret_backend(self, config: Union[SecretBackendConfig, Dict[str, Any]]) -> Dict[str, Any]: - """Configure the secrets backend. - - Args: - config: Secret backend configuration - - Returns: - Dict[str, Any]: Configuration confirmation - """ - if isinstance(config, dict): - config = SecretBackendConfig(**config) - - response = self._request("POST", "secrets/config", json=config.model_dump()) - return response.json() - - def store_secret(self, secret_request: Union[SecretRequest, Dict[str, Any]]) -> SecretResponse: - """Store a secret for an agent. - - Args: - secret_request: Secret storage request - - Returns: - SecretResponse: Secret storage confirmation - """ - if isinstance(secret_request, dict): - secret_request = SecretRequest(**secret_request) - - response = self._request("POST", "secrets", json=secret_request.model_dump()) - return SecretResponse(**response.json()) - - def get_secret(self, agent_id: str, secret_name: str) -> str: - """Retrieve a secret value. - - Args: - agent_id: The agent identifier - secret_name: Name of the secret - - Returns: - str: The secret value - """ - response = self._request("GET", f"secrets/{agent_id}/{secret_name}") - return response.json()["value"] - - def list_secrets(self, agent_id: str) -> SecretListResponse: - """List all secrets for an agent. - - Args: - agent_id: The agent identifier - - Returns: - SecretListResponse: List of secret names - """ - response = self._request("GET", f"secrets/{agent_id}") - return SecretListResponse(**response.json()) - - def delete_secret(self, agent_id: str, secret_name: str) -> Dict[str, Any]: - """Delete a secret. + def delete_agent(self, agent_id: str) -> Dict: + """Delete an agent and its metadata. Args: agent_id: The agent identifier - secret_name: Name of the secret to delete - - Returns: - Dict[str, Any]: Deletion confirmation - """ - response = self._request("DELETE", f"secrets/{agent_id}/{secret_name}") - return response.json() - - # ============================================================================= - # MCP Management Methods - # ============================================================================= - - def add_mcp_server(self, config: Union[McpServerConfig, Dict[str, Any]]) -> Dict[str, Any]: - """Add a new MCP server configuration. - - Args: - config: MCP server configuration - - Returns: - Dict[str, Any]: Addition confirmation - """ - if isinstance(config, dict): - config = McpServerConfig(**config) - - response = self._request("POST", "mcp/servers", json=config.model_dump()) - return response.json() - - def list_mcp_servers(self) -> List[McpConnectionInfo]: - """List all configured MCP servers. - - Returns: - List[McpConnectionInfo]: MCP server information - """ - response = self._request("GET", "mcp/servers") - return [McpConnectionInfo(**server) for server in response.json()] - - def get_mcp_server(self, server_name: str) -> McpConnectionInfo: - """Get information about a specific MCP server. - - Args: - server_name: Name of the MCP server - - Returns: - McpConnectionInfo: MCP server information - """ - response = self._request("GET", f"mcp/servers/{server_name}") - return McpConnectionInfo(**response.json()) - - def connect_mcp_server(self, server_name: str) -> Dict[str, Any]: - """Connect to an MCP server. - - Args: - server_name: Name of the MCP server - - Returns: - Dict[str, Any]: Connection result - """ - response = self._request("POST", f"mcp/servers/{server_name}/connect") - return response.json() - - def disconnect_mcp_server(self, server_name: str) -> Dict[str, Any]: - """Disconnect from an MCP server. - - Args: - server_name: Name of the MCP server - - Returns: - Dict[str, Any]: Disconnection result - """ - response = self._request("POST", f"mcp/servers/{server_name}/disconnect") - return response.json() - - def list_mcp_tools(self, server_name: Optional[str] = None) -> List[McpToolInfo]: - """List available MCP tools. - - Args: - server_name: Optional server name to filter by - - Returns: - List[McpToolInfo]: Available MCP tools - """ - endpoint = "mcp/tools" - params = {} - if server_name: - params["server"] = server_name - - response = self._request("GET", endpoint, params=params) - return [McpToolInfo(**tool) for tool in response.json()] - - def list_mcp_resources(self, server_name: Optional[str] = None) -> List[McpResourceInfo]: - """List available MCP resources. - - Args: - server_name: Optional server name to filter by - - Returns: - List[McpResourceInfo]: Available MCP resources - """ - endpoint = "mcp/resources" - params = {} - if server_name: - params["server"] = server_name - - response = self._request("GET", endpoint, params=params) - return [McpResourceInfo(**resource) for resource in response.json()] - - # ============================================================================= - # Vector Database & RAG Methods - # ============================================================================= - - def add_knowledge_item(self, item: Union[KnowledgeItem, Dict[str, Any]]) -> Dict[str, Any]: - """Add a knowledge item to the vector database. - - Args: - item: Knowledge item to add - - Returns: - Dict[str, Any]: Addition confirmation - """ - if isinstance(item, dict): - item = KnowledgeItem(**item) - - response = self._request("POST", "knowledge", json=item.model_dump()) - return response.json() - - def search_knowledge(self, search_request: Union[VectorSearchRequest, Dict[str, Any]]) -> VectorSearchResponse: - """Search the knowledge base using vector similarity. - - Args: - search_request: Vector search request - - Returns: - VectorSearchResponse: Search results - """ - if isinstance(search_request, dict): - search_request = VectorSearchRequest(**search_request) - - response = self._request("POST", "knowledge/search", json=search_request.model_dump()) - return VectorSearchResponse(**response.json()) - - def get_context(self, context_query: Union[ContextQuery, Dict[str, Any]]) -> ContextResponse: - """Get relevant context for RAG operations. - - Args: - context_query: Context query request - - Returns: - ContextResponse: Relevant context information - """ - if isinstance(context_query, dict): - context_query = ContextQuery(**context_query) - - response = self._request("POST", "rag/context", json=context_query.model_dump()) - return ContextResponse(**response.json()) - - def delete_knowledge_item(self, item_id: str) -> Dict[str, Any]: - """Delete a knowledge item from the vector database. - - Args: - item_id: ID of the knowledge item to delete - - Returns: - Dict[str, Any]: Deletion confirmation - """ - response = self._request("DELETE", f"knowledge/{item_id}") - return response.json() - - # ============================================================================= - # Agent DSL Methods - # ============================================================================= - - def compile_dsl(self, compile_request: Union[DslCompileRequest, Dict[str, Any]]) -> DslCompileResponse: - """Compile DSL code into an agent. - - Args: - compile_request: DSL compilation request - - Returns: - DslCompileResponse: Compilation result - """ - if isinstance(compile_request, dict): - compile_request = DslCompileRequest(**compile_request) - - response = self._request("POST", "dsl/compile", json=compile_request.model_dump()) - return DslCompileResponse(**response.json()) - - def deploy_agent(self, deploy_request: Union[AgentDeployRequest, Dict[str, Any]]) -> AgentDeployResponse: - """Deploy a compiled agent. - - Args: - deploy_request: Agent deployment request - - Returns: - AgentDeployResponse: Deployment result - """ - if isinstance(deploy_request, dict): - deploy_request = AgentDeployRequest(**deploy_request) - - response = self._request("POST", "agents/deploy", json=deploy_request.model_dump()) - return AgentDeployResponse(**response.json()) - - def get_agent_deployment(self, deployment_id: str) -> AgentDeployResponse: - """Get information about an agent deployment. - - Args: - deployment_id: The deployment identifier - - Returns: - AgentDeployResponse: Deployment information - """ - response = self._request("GET", f"agents/deployments/{deployment_id}") - return AgentDeployResponse(**response.json()) - - def list_agent_deployments(self, agent_id: Optional[str] = None) -> List[AgentDeployResponse]: - """List agent deployments. - - Args: - agent_id: Optional agent ID to filter by - - Returns: - List[AgentDeployResponse]: Agent deployments - """ - endpoint = "agents/deployments" - params = {} - if agent_id: - params["agent_id"] = agent_id - - response = self._request("GET", endpoint, params=params) - return [AgentDeployResponse(**deployment) for deployment in response.json()] - - def stop_agent_deployment(self, deployment_id: str) -> Dict[str, Any]: - """Stop an agent deployment. - - Args: - deployment_id: The deployment identifier - - Returns: - Dict[str, Any]: Stop confirmation - """ - response = self._request("POST", f"agents/deployments/{deployment_id}/stop") - return response.json() - - # ============================================================================= - # HTTP Input Methods - # ============================================================================= - - def create_http_input_server(self, request: Union[HttpInputCreateRequest, Dict[str, Any]]) -> HttpInputServerInfo: - """Create and start an HTTP input server. - - Args: - request: HTTP input server creation request - - Returns: - HttpInputServerInfo: Server information - """ - if isinstance(request, dict): - request = HttpInputCreateRequest(**request) - - response = self._request("POST", "http-input/servers", json=request.model_dump()) - return HttpInputServerInfo(**response.json()) - - def list_http_input_servers(self) -> List[HttpInputServerInfo]: - """List all HTTP input servers. - - Returns: - List[HttpInputServerInfo]: List of server information - """ - response = self._request("GET", "http-input/servers") - return [HttpInputServerInfo(**server) for server in response.json()] - - def get_http_input_server(self, server_id: str) -> HttpInputServerInfo: - """Get information about a specific HTTP input server. - - Args: - server_id: The server identifier - - Returns: - HttpInputServerInfo: Server information - """ - response = self._request("GET", f"http-input/servers/{server_id}") - return HttpInputServerInfo(**response.json()) - - def update_http_input_server(self, request: Union[HttpInputUpdateRequest, Dict[str, Any]]) -> HttpInputServerInfo: - """Update an HTTP input server configuration. - - Args: - request: HTTP input server update request - - Returns: - HttpInputServerInfo: Updated server information - """ - if isinstance(request, dict): - request = HttpInputUpdateRequest(**request) - - response = self._request("PUT", f"http-input/servers/{request.server_id}", json=request.model_dump()) - return HttpInputServerInfo(**response.json()) - - def start_http_input_server(self, server_id: str) -> Dict[str, Any]: - """Start an HTTP input server. - - Args: - server_id: The server identifier - - Returns: - Dict[str, Any]: Start confirmation - """ - response = self._request("POST", f"http-input/servers/{server_id}/start") - return response.json() - - def stop_http_input_server(self, server_id: str) -> Dict[str, Any]: - """Stop an HTTP input server. - - Args: - server_id: The server identifier Returns: - Dict[str, Any]: Stop confirmation - """ - response = self._request("POST", f"http-input/servers/{server_id}/stop") - return response.json() - - def delete_http_input_server(self, server_id: str) -> Dict[str, Any]: - """Delete an HTTP input server. - - Args: - server_id: The server identifier - - Returns: - Dict[str, Any]: Deletion confirmation - """ - response = self._request("DELETE", f"http-input/servers/{server_id}") - return response.json() - - def trigger_webhook(self, request: Union[WebhookTriggerRequest, Dict[str, Any]]) -> WebhookTriggerResponse: - """Manually trigger a webhook for testing purposes. - - Args: - request: Webhook trigger request - - Returns: - WebhookTriggerResponse: Trigger response - """ - if isinstance(request, dict): - request = WebhookTriggerRequest(**request) - - response = self._request("POST", f"http-input/servers/{request.server_id}/trigger", json=request.model_dump()) - return WebhookTriggerResponse(**response.json()) - - def get_http_input_metrics(self, server_id: str) -> Dict[str, Any]: - """Get metrics for an HTTP input server. - - Args: - server_id: The server identifier - - Returns: - Dict[str, Any]: Server metrics + Dict: Deletion confirmation """ - response = self._request("GET", f"http-input/servers/{server_id}/metrics") + response = self._request("DELETE", f"agents/{agent_id}") return response.json() - # ============================================================================= - # Phase 2 Memory System Methods - # ============================================================================= - - def add_memory(self, memory_request: Union[MemoryStoreRequest, Dict[str, Any]]) -> MemoryResponse: - """Store a new memory in the system. - - Args: - memory_request: Memory storage request - - Returns: - MemoryResponse: Memory storage response - """ - if isinstance(memory_request, dict): - memory_request = MemoryStoreRequest(**memory_request) - - response = self._request("POST", "memory", json=memory_request.model_dump()) - return MemoryResponse(**response.json()) - - def get_memory(self, memory_query: Union[MemoryQuery, Dict[str, Any]]) -> MemoryResponse: - """Retrieve a specific memory. - - Args: - memory_query: Memory query parameters - - Returns: - MemoryResponse: Memory retrieval response - """ - if isinstance(memory_query, dict): - memory_query = MemoryQuery(**memory_query) - - response = self._request("GET", "memory", params=memory_query.model_dump(exclude_none=True)) - return MemoryResponse(**response.json()) - - def search_memory(self, search_request: Union[MemorySearchRequest, Dict[str, Any]]) -> MemorySearchResponse: - """Search for memories matching criteria. - - Args: - search_request: Memory search request - - Returns: - MemorySearchResponse: Search results - """ - if isinstance(search_request, dict): - search_request = MemorySearchRequest(**search_request) - - response = self._request("POST", "memory/search", json=search_request.model_dump()) - return MemorySearchResponse(**response.json()) - - def consolidate_memory(self, agent_id: str) -> ConsolidationResponse: - """Consolidate memories for an agent. - - Args: - agent_id: The agent identifier - - Returns: - ConsolidationResponse: Consolidation process results - """ - response = self._request("POST", f"memory/consolidate/{agent_id}") - return ConsolidationResponse(**response.json()) + def execute_agent(self, agent_id: str) -> Dict: + """Execute an agent immediately. - def get_conversation_context(self, conversation_id: str, agent_id: str) -> ConversationContext: - """Get conversation context with associated memories. + Triggers a fresh execution of an existing agent. Maps to + ``POST /api/v1/agents/{id}/execute`` on the runtime. Args: - conversation_id: The conversation identifier agent_id: The agent identifier Returns: - ConversationContext: Conversation context with memories - """ - params = { - "conversation_id": conversation_id, - "agent_id": agent_id - } - response = self._request("GET", "memory/conversation", params=params) - return ConversationContext(**response.json()) - - def delete_memory(self, memory_id: str) -> Dict[str, Any]: - """Delete a memory by ID. - - Args: - memory_id: The memory identifier - - Returns: - Dict[str, Any]: Deletion confirmation - """ - response = self._request("DELETE", f"memory/{memory_id}") - return response.json() - - def list_agent_memories(self, agent_id: str, limit: int = 100) -> MemorySearchResponse: - """List all memories for an agent. - - Args: - agent_id: The agent identifier - limit: Maximum number of memories to return - - Returns: - MemorySearchResponse: List of agent memories - """ - params = { - "agent_id": agent_id, - "limit": limit - } - response = self._request("GET", "memory/agent", params=params) - return MemorySearchResponse(**response.json()) - - # ============================================================================= - # Phase 3 Qdrant Vector Database Methods - # ============================================================================= - - def create_vector_collection(self, collection_request: Union[CollectionCreateRequest, Dict[str, Any]]) -> CollectionResponse: - """Create a new vector collection. - - Args: - collection_request: Collection creation request - - Returns: - CollectionResponse: Collection creation result - """ - if isinstance(collection_request, dict): - collection_request = CollectionCreateRequest(**collection_request) - - response = self._request("POST", "vectors/collections", json=collection_request.model_dump()) - return CollectionResponse(**response.json()) - - def delete_vector_collection(self, collection_name: str) -> Dict[str, Any]: - """Delete a vector collection. - - Args: - collection_name: Name of the collection to delete - - Returns: - Dict[str, Any]: Deletion confirmation - """ - response = self._request("DELETE", f"vectors/collections/{collection_name}") - return response.json() - - def get_collection_info(self, collection_name: str) -> CollectionInfo: - """Get information about a vector collection. - - Args: - collection_name: Name of the collection - - Returns: - CollectionInfo: Collection information - """ - response = self._request("GET", f"vectors/collections/{collection_name}") - return CollectionInfo(**response.json()) - - def list_vector_collections(self) -> List[str]: - """List all vector collections. - - Returns: - List[str]: List of collection names - """ - response = self._request("GET", "vectors/collections") - return response.json() - - def add_vectors(self, upsert_request: Union[VectorUpsertRequest, Dict[str, Any]]) -> UpsertResponse: - """Add vectors to a collection. - - Args: - upsert_request: Vector upsert request - - Returns: - UpsertResponse: Upsert operation result - """ - if isinstance(upsert_request, dict): - upsert_request = VectorUpsertRequest(**upsert_request) - - response = self._request("POST", "vectors/upsert", json=upsert_request.model_dump()) - return UpsertResponse(**response.json()) - - def get_vectors(self, collection_name: str, vector_ids: List[Union[str, int]]) -> List[Dict[str, Any]]: - """Get vectors by IDs from a collection. - - Args: - collection_name: Name of the collection - vector_ids: List of vector IDs to retrieve - - Returns: - List[Dict[str, Any]]: Retrieved vectors + Dict: ``{"execution_id": str, "status": str}`` """ - params = { - "collection_name": collection_name, - "ids": vector_ids - } - response = self._request("GET", "vectors/retrieve", params=params) + response = self._request("POST", f"api/v1/agents/{agent_id}/execute", json={}) return response.json() - def search_vectors(self, search_request: Union[VectorSearchRequest, Dict[str, Any]]) -> VectorSearchResponse: - """Search vectors using similarity search. - - Args: - search_request: Vector search request - - Returns: - VectorSearchResponse: Search results - """ - if isinstance(search_request, dict): - search_request = VectorSearchRequest(**search_request) - - response = self._request("POST", "vectors/search", json=search_request.model_dump()) - return VectorSearchResponse(**response.json()) - - def delete_vectors(self, collection_name: str, vector_ids: List[Union[str, int]]) -> Dict[str, Any]: - """Delete vectors from a collection. - - Args: - collection_name: Name of the collection - vector_ids: List of vector IDs to delete - - Returns: - Dict[str, Any]: Deletion confirmation - """ - data = { - "collection_name": collection_name, - "ids": vector_ids - } - response = self._request("DELETE", "vectors/delete", json=data) - return response.json() - - def count_vectors(self, collection_name: str) -> int: - """Count vectors in a collection. - - Args: - collection_name: Name of the collection - - Returns: - int: Number of vectors in the collection - """ - response = self._request("GET", f"vectors/collections/{collection_name}/count") - return response.json()["count"] - # ============================================================================= - # Phase 4 HTTP Endpoint Management Methods + # Inter-Agent Messaging Methods # ============================================================================= - def create_http_endpoint(self, endpoint_request: Union[HttpEndpointCreateRequest, Dict[str, Any]]) -> HttpEndpointResponse: - """Create a new HTTP endpoint. - - Args: - endpoint_request: HTTP endpoint creation request - - Returns: - HttpEndpointResponse: Endpoint creation result - """ - if isinstance(endpoint_request, dict): - endpoint_request = HttpEndpointCreateRequest(**endpoint_request) - - response = self._request("POST", "endpoints", json=endpoint_request.model_dump()) - return HttpEndpointResponse(**response.json()) - - def list_http_endpoints(self) -> List[HttpEndpointInfo]: - """List all HTTP endpoints. - - Returns: - List[HttpEndpointInfo]: List of endpoint information - """ - response = self._request("GET", "endpoints") - return [HttpEndpointInfo(**endpoint) for endpoint in response.json()] - - def update_http_endpoint(self, endpoint_request: Union[HttpEndpointUpdateRequest, Dict[str, Any]]) -> HttpEndpointResponse: - """Update an existing HTTP endpoint. - - Args: - endpoint_request: HTTP endpoint update request - - Returns: - HttpEndpointResponse: Endpoint update result - """ - if isinstance(endpoint_request, dict): - endpoint_request = HttpEndpointUpdateRequest(**endpoint_request) - - response = self._request("PUT", f"endpoints/{endpoint_request.endpoint_id}", json=endpoint_request.model_dump()) - return HttpEndpointResponse(**response.json()) - - def delete_http_endpoint(self, endpoint_id: str) -> Dict[str, Any]: - """Delete an HTTP endpoint. - - Args: - endpoint_id: The endpoint identifier - - Returns: - Dict[str, Any]: Deletion confirmation - """ - response = self._request("DELETE", f"endpoints/{endpoint_id}") - return response.json() - - def get_http_endpoint(self, endpoint_id: str) -> HttpEndpointInfo: - """Get information about a specific HTTP endpoint. - - Args: - endpoint_id: The endpoint identifier - - Returns: - HttpEndpointInfo: Endpoint information - """ - response = self._request("GET", f"endpoints/{endpoint_id}") - return HttpEndpointInfo(**response.json()) - - def get_endpoint_metrics(self, endpoint_id: str) -> EndpointMetrics: - """Get metrics for a specific HTTP endpoint. - - Args: - endpoint_id: The endpoint identifier - - Returns: - EndpointMetrics: Endpoint metrics information - """ - response = self._request("GET", f"endpoints/{endpoint_id}/metrics") - return EndpointMetrics(**response.json()) - - def enable_http_endpoint(self, endpoint_id: str) -> Dict[str, Any]: - """Enable/activate an HTTP endpoint. - - Args: - endpoint_id: The endpoint identifier - - Returns: - Dict[str, Any]: Operation confirmation - """ - response = self._request("POST", f"endpoints/{endpoint_id}/enable") - return response.json() + def send_message( + self, + agent_id: str, + sender: str, + payload: str, + ttl_seconds: Optional[int] = None, + topic: Optional[str] = None, + agentpin_jwt: Optional[str] = None, + ) -> Dict: + """Send a message to an agent via the runtime message bus. - def disable_http_endpoint(self, endpoint_id: str) -> Dict[str, Any]: - """Disable/deactivate an HTTP endpoint. + Maps to ``POST /api/v1/agents/{id}/messages``. The payload is + plaintext; the receiving runtime handles bus-level encryption + internally. If ``topic`` is set the message is published to the + topic instead of delivered directly. Args: - endpoint_id: The endpoint identifier + agent_id: The recipient agent identifier (URL path). + sender: The sender agent identifier. + payload: Plaintext message body. + ttl_seconds: Optional TTL in seconds (runtime default 300). + topic: Optional pub/sub topic. + agentpin_jwt: Optional AgentPin JWT. Required when the + receiving runtime enforces AgentPin verification. Returns: - Dict[str, Any]: Operation confirmation + Dict: ``{"message_id": str, "status": str}`` """ - response = self._request("POST", f"endpoints/{endpoint_id}/disable") + body: Dict[str, Any] = {"sender": sender, "payload": payload} + if ttl_seconds is not None: + body["ttl_seconds"] = ttl_seconds + if topic is not None: + body["topic"] = topic + if agentpin_jwt is not None: + body["agentpin_jwt"] = agentpin_jwt + response = self._request( + "POST", f"api/v1/agents/{agent_id}/messages", json=body + ) return response.json() - # ============================================================================= - # Inter-Agent Communication Policy Methods - # ============================================================================= - - def list_communication_rules(self) -> List[Dict]: - """List all communication policy rules. + def receive_messages(self, agent_id: str) -> Dict: + """Receive and consume an agent's pending messages. - Returns: - List[Dict]: List of communication policy rules - """ - response = self._request("GET", "api/v1/communication/rules") - return response.json() - - def add_communication_rule(self, rule: Dict) -> Dict: - """Add a communication policy rule. + Maps to ``GET /api/v1/agents/{id}/messages``. Returns the + plaintext message envelopes queued for the agent and removes + them from the queue. Args: - rule: Dict with keys: from_agent, to_agent, action (allow/deny), - effect, reason, priority, max_depth + agent_id: The agent identifier. Returns: - Dict: Created rule confirmation + Dict: ``{"messages": [MessageEnvelope, ...]}`` where each + envelope carries ``message_id``, ``sender``, ``recipient``, + ``topic``, ``payload``, ``message_type``, ``timestamp_secs``, + and ``ttl_seconds``. """ - response = self._request("POST", "api/v1/communication/rules", json=rule) + response = self._request("GET", f"api/v1/agents/{agent_id}/messages") return response.json() - def remove_communication_rule(self, rule_id: str) -> Dict: - """Remove a communication policy rule by ID. - - Args: - rule_id: The rule identifier - - Returns: - Dict: Removal confirmation - """ - response = self._request("DELETE", f"api/v1/communication/rules/{rule_id}") - return response.json() + def get_message_status(self, message_id: str) -> Dict: + """Get the delivery status of a previously sent message. - def evaluate_communication(self, sender: str, recipient: str, action: str) -> Dict: - """Evaluate whether a communication is allowed by policy. + Maps to ``GET /api/v1/messages/{id}/status``. Args: - sender: Sending agent identifier - recipient: Receiving agent identifier - action: The action to evaluate + message_id: The message identifier returned by + :meth:`send_message`. Returns: - Dict with 'allowed' (bool), 'rule' (matching rule), 'reason'. + Dict: ``{"message_id": str, "status": str}`` where status is + one of ``"pending"``, ``"delivered"``, ``"failed"``, + ``"expired"``. """ - response = self._request("POST", "api/v1/communication/evaluate", json={ - "sender": sender, - "recipient": recipient, - "action": action, - }) + response = self._request("GET", f"api/v1/messages/{message_id}/status") return response.json() # ============================================================================= - # Agent Lifecycle Methods - # ============================================================================= - - def delete_agent(self, agent_id: str) -> Dict: - """Delete an agent and its metadata. - - Args: - agent_id: The agent identifier - - Returns: - Dict: Deletion confirmation - """ - response = self._request("DELETE", f"api/v1/agents/{agent_id}") - return response.json() - - def re_execute_agent(self, agent_id: str, input_data: Any = None) -> Dict: - """Re-execute an agent with optional new input. - - Resets the agent's ORGA loop state and starts a new execution. - - Args: - agent_id: The agent identifier - input_data: Optional new input data for the agent - - Returns: - Dict: Re-execution result - """ - payload = {} - if input_data is not None: - payload["input"] = input_data - response = self._request("POST", f"api/v1/agents/{agent_id}/re-execute", json=payload) - return response.json() + # External Agent Lifecycle Methods (Heartbeat & Events) + # ============================================================================= + + def send_heartbeat( + self, + agent_id: str, + state: str, + metadata: Optional[Dict[str, str]] = None, + last_result: Optional[str] = None, + agentpin_jwt: Optional[str] = None, + ) -> None: + """Report a heartbeat for an externally-running agent. + + Maps to ``POST /api/v1/agents/{id}/heartbeat``. Used by agents + running outside the runtime (remote, containerized, cloud) to + report liveness and current state. + + Args: + agent_id: The agent identifier. + state: Current agent state, e.g. ``"Running"``, + ``"Completed"``, ``"Failed"`` (matches the runtime + ``AgentState`` variants, PascalCase). + metadata: Optional key/value metadata update. + last_result: Optional summary of the last execution. + agentpin_jwt: Optional AgentPin JWT. Required when the + runtime enforces AgentPin verification. + """ + body: Dict[str, Any] = {"state": state} + if metadata is not None: + body["metadata"] = metadata + if last_result is not None: + body["last_result"] = last_result + if agentpin_jwt is not None: + body["agentpin_jwt"] = agentpin_jwt + self._request("POST", f"api/v1/agents/{agent_id}/heartbeat", json=body) + + def push_agent_event( + self, + agent_id: str, + event_type: str, + payload: Any, + agentpin_jwt: Optional[str] = None, + ) -> None: + """Push a lifecycle event from an externally-running agent. + + Maps to ``POST /api/v1/agents/{id}/events``. + + Args: + agent_id: The agent identifier. + event_type: One of ``"RunStarted"``, ``"RunCompleted"``, + ``"RunFailed"`` (matches the runtime ``AgentEventType`` + variants, PascalCase). + payload: Event-specific JSON-serializable data. + agentpin_jwt: Optional AgentPin JWT. Required when the + runtime enforces AgentPin verification. + """ + body: Dict[str, Any] = {"event_type": event_type, "payload": payload} + if agentpin_jwt is not None: + body["agentpin_jwt"] = agentpin_jwt + self._request("POST", f"api/v1/agents/{agent_id}/events", json=body) diff --git a/symbiont/config.py b/symbiont/config.py index cd561b5..d1088ef 100644 --- a/symbiont/config.py +++ b/symbiont/config.py @@ -13,6 +13,7 @@ class ConfigSource(str, Enum): """Configuration source enumeration.""" + ENVIRONMENT = "environment" FILE = "file" VAULT = "vault" @@ -21,6 +22,7 @@ class ConfigSource(str, Enum): class DatabaseConfig(BaseSettings): """Database connection configuration.""" + model_config = SettingsConfigDict(env_prefix="SYMBIONT_DB_") host: str = "localhost" port: int = 5432 @@ -34,6 +36,7 @@ class DatabaseConfig(BaseSettings): class AuthConfig(BaseSettings): """Authentication configuration.""" + model_config = SettingsConfigDict(env_prefix="SYMBIONT_AUTH_", case_sensitive=False) jwt_secret_key: Optional[str] = None jwt_algorithm: str = "HS256" @@ -47,6 +50,7 @@ class AuthConfig(BaseSettings): class VectorConfig(BaseSettings): """Vector database configuration.""" + model_config = SettingsConfigDict(env_prefix="SYMBIONT_VECTOR_") provider: str = "qdrant" host: str = "localhost" @@ -60,6 +64,7 @@ class VectorConfig(BaseSettings): class LoggingConfig(BaseSettings): """Logging configuration.""" + model_config = SettingsConfigDict(env_prefix="SYMBIONT_LOGGING_") level: str = "INFO" format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" @@ -94,15 +99,15 @@ class ClientConfig(BaseSettings): env_file=".env", env_file_encoding="utf-8", case_sensitive=False, - env_prefix="SYMBIONT_" + env_prefix="SYMBIONT_", ) - @field_validator('base_url') + @field_validator("base_url") @classmethod def validate_base_url(cls, v): """Validate and normalize base URL.""" if v: - return v.rstrip('/') + return v.rstrip("/") return v @@ -119,9 +124,9 @@ def __init__(self, config_path: Optional[Union[str, Path]] = None): self._config_path = Path(config_path) if config_path else None self._sources: Dict[str, ConfigSource] = {} - def load(self, - config_path: Optional[Union[str, Path]] = None, - force_reload: bool = False) -> ClientConfig: + def load( + self, config_path: Optional[Union[str, Path]] = None, force_reload: bool = False + ) -> ClientConfig: """Load configuration from various sources. Args: @@ -149,9 +154,13 @@ def load(self, config_file_path = Path(config_file_path) if config_file_path.exists(): config_dict.update(self._load_from_file(config_file_path)) - self._sources.update(dict.fromkeys(config_dict.keys(), ConfigSource.FILE)) + self._sources.update( + dict.fromkeys(config_dict.keys(), ConfigSource.FILE) + ) elif config_path: # Only raise if explicitly provided - raise FileNotFoundError(f"Configuration file not found: {config_file_path}") + raise FileNotFoundError( + f"Configuration file not found: {config_file_path}" + ) # Create configuration with file data + environment overrides self._config = ClientConfig(**config_dict) @@ -179,13 +188,15 @@ def _load_from_file(self, file_path: Path) -> Dict[str, Any]: ValueError: If file format is unsupported or invalid """ try: - with open(file_path, encoding='utf-8') as f: - if file_path.suffix.lower() in ['.yml', '.yaml']: + with open(file_path, encoding="utf-8") as f: + if file_path.suffix.lower() in [".yml", ".yaml"]: return yaml.safe_load(f) or {} - elif file_path.suffix.lower() == '.json': + elif file_path.suffix.lower() == ".json": return json.load(f) or {} else: - raise ValueError(f"Unsupported config file format: {file_path.suffix}") + raise ValueError( + f"Unsupported config file format: {file_path.suffix}" + ) except yaml.YAMLError as e: raise ValueError(f"Invalid YAML in config file: {e}") from e except json.JSONDecodeError as e: @@ -238,7 +249,9 @@ def validate_required_settings(self) -> Dict[str, str]: # errors['api_key'] = "API key is required for authentication" if config.auth.jwt_secret_key is None and config.auth.enable_refresh_tokens: - errors['auth.jwt_secret_key'] = "JWT secret key required when refresh tokens enabled" # nosec B105 - This is an error message, not a password + errors["auth.jwt_secret_key"] = ( + "JWT secret key required when refresh tokens enabled" # nosec B105 - This is an error message, not a password + ) return errors @@ -252,9 +265,12 @@ def to_dict(self) -> Dict[str, Any]: raise RuntimeError("Configuration not loaded. Call load() first.") return self._config.model_dump() - def save_to_file(self, file_path: Union[str, Path], - format: str = "yaml", - exclude_secrets: bool = True) -> None: + def save_to_file( + self, + file_path: Union[str, Path], + format: str = "yaml", + exclude_secrets: bool = True, + ) -> None: """Save current configuration to file. Args: @@ -270,9 +286,9 @@ def save_to_file(self, file_path: Union[str, Path], if exclude_secrets: # Remove sensitive fields sensitive_paths = [ - ['api_key'], - ['auth', 'jwt_secret_key'], - ['database', 'password'] + ["api_key"], + ["auth", "jwt_secret_key"], + ["database", "password"], ] for path in sensitive_paths: @@ -288,10 +304,10 @@ def save_to_file(self, file_path: Union[str, Path], file_path = Path(file_path) - with open(file_path, 'w', encoding='utf-8') as f: - if format.lower() == 'yaml': + with open(file_path, "w", encoding="utf-8") as f: + if format.lower() == "yaml": yaml.safe_dump(config_dict, f, default_flow_style=False, indent=2) - elif format.lower() == 'json': + elif format.lower() == "json": json.dump(config_dict, f, indent=2, default=str) else: raise ValueError(f"Unsupported format: {format}") diff --git a/symbiont/exceptions.py b/symbiont/exceptions.py index 207b4ec..386cf8e 100644 --- a/symbiont/exceptions.py +++ b/symbiont/exceptions.py @@ -33,7 +33,9 @@ def __init__(self, message: str, status_code: int, response_text: str = None): class AuthenticationError(SymbiontError): """Authentication error for 401 Unauthorized responses.""" - def __init__(self, message: str = "Authentication failed", response_text: str = None): + def __init__( + self, message: str = "Authentication failed", response_text: str = None + ): """Initialize the AuthenticationError. Args: @@ -76,6 +78,7 @@ def __init__(self, message: str = "Rate limit exceeded", response_text: str = No # Phase 1 New Exception Classes # ============================================================================= + class ConfigurationError(SymbiontError): """Configuration-related errors.""" @@ -93,7 +96,11 @@ def __init__(self, message: str, config_key: str = None): class AuthenticationExpiredError(AuthenticationError): """Authentication expired error for expired tokens.""" - def __init__(self, message: str = "Authentication token has expired", response_text: str = None): + def __init__( + self, + message: str = "Authentication token has expired", + response_text: str = None, + ): """Initialize the AuthenticationExpiredError. Args: @@ -106,7 +113,11 @@ def __init__(self, message: str = "Authentication token has expired", response_t class TokenRefreshError(AuthenticationError): """Token refresh error for failed token refresh attempts.""" - def __init__(self, message: str = "Failed to refresh authentication token", response_text: str = None): + def __init__( + self, + message: str = "Failed to refresh authentication token", + response_text: str = None, + ): """Initialize the TokenRefreshError. Args: @@ -119,7 +130,11 @@ def __init__(self, message: str = "Failed to refresh authentication token", resp class PermissionDeniedError(SymbiontError): """Permission denied error for insufficient privileges.""" - def __init__(self, message: str = "Insufficient permissions for this operation", required_permission: str = None): + def __init__( + self, + message: str = "Insufficient permissions for this operation", + required_permission: str = None, + ): """Initialize the PermissionDeniedError. Args: @@ -134,8 +149,10 @@ def __init__(self, message: str = "Insufficient permissions for this operation", # Phase 2 Memory System Exception Classes # ============================================================================= + class MemoryError(SymbiontError): """Base exception for memory system errors.""" + pass @@ -171,8 +188,10 @@ def __init__(self, message: str = "Memory retrieval error", memory_id: str = Non # Phase 3 Vector Database Exception Classes # ============================================================================= + class VectorDatabaseError(SymbiontError): """Base exception for vector database errors.""" + pass @@ -193,7 +212,9 @@ def __init__(self, message: str = "Qdrant connection error", host: str = None): class CollectionNotFoundError(VectorDatabaseError): """Raised when a vector collection is not found.""" - def __init__(self, message: str = "Vector collection not found", collection_name: str = None): + def __init__( + self, message: str = "Vector collection not found", collection_name: str = None + ): """Initialize the CollectionNotFoundError. Args: @@ -222,15 +243,19 @@ def __init__(self, message: str = "Embedding generation error", model: str = Non # Phase 4 HTTP Endpoint Management Exception Classes # ============================================================================= + class EndpointError(SymbiontError): """Base exception for HTTP endpoint management errors.""" + pass class EndpointNotFoundError(EndpointError): """Raised when an HTTP endpoint is not found.""" - def __init__(self, message: str = "HTTP endpoint not found", endpoint_id: str = None): + def __init__( + self, message: str = "HTTP endpoint not found", endpoint_id: str = None + ): """Initialize the EndpointNotFoundError. Args: @@ -244,7 +269,12 @@ def __init__(self, message: str = "HTTP endpoint not found", endpoint_id: str = class EndpointConflictError(EndpointError): """Raised when an HTTP endpoint creation conflicts with existing endpoints.""" - def __init__(self, message: str = "HTTP endpoint conflict", path: str = None, method: str = None): + def __init__( + self, + message: str = "HTTP endpoint conflict", + path: str = None, + method: str = None, + ): """Initialize the EndpointConflictError. Args: @@ -260,7 +290,9 @@ def __init__(self, message: str = "HTTP endpoint conflict", path: str = None, me class EndpointConfigurationError(EndpointError): """Raised when HTTP endpoint configuration is invalid.""" - def __init__(self, message: str = "Invalid endpoint configuration", config_field: str = None): + def __init__( + self, message: str = "Invalid endpoint configuration", config_field: str = None + ): """Initialize the EndpointConfigurationError. Args: @@ -274,7 +306,9 @@ def __init__(self, message: str = "Invalid endpoint configuration", config_field class EndpointRateLimitError(EndpointError): """Raised when an HTTP endpoint rate limit is exceeded.""" - def __init__(self, message: str = "Endpoint rate limit exceeded", endpoint_id: str = None): + def __init__( + self, message: str = "Endpoint rate limit exceeded", endpoint_id: str = None + ): """Initialize the EndpointRateLimitError. Args: @@ -289,10 +323,13 @@ def __init__(self, message: str = "Endpoint rate limit exceeded", endpoint_id: s # Phase 5 Webhook, Skill & Metrics Exception Classes # ============================================================================= + class WebhookVerificationError(SymbiontError): """Raised when webhook signature verification fails.""" - def __init__(self, message: str = "Webhook verification failed", header_name: str = None): + def __init__( + self, message: str = "Webhook verification failed", header_name: str = None + ): """Initialize the WebhookVerificationError. Args: @@ -348,7 +385,9 @@ def __init__(self, message: str = "Metrics export failed", backend: str = None): class MetricsConfigError(SymbiontError): """Raised when metrics configuration is invalid.""" - def __init__(self, message: str = "Metrics configuration error", config_field: str = None): + def __init__( + self, message: str = "Metrics configuration error", config_field: str = None + ): """Initialize the MetricsConfigError. Args: diff --git a/symbiont/memory.py b/symbiont/memory.py deleted file mode 100644 index b911a98..0000000 --- a/symbiont/memory.py +++ /dev/null @@ -1,554 +0,0 @@ -""" -Memory system implementation for hierarchical agent memory management. - -This module implements a hierarchical memory system that supports multiple -storage backends, conversation context management, and memory consolidation. -""" - -import json -import uuid -from abc import ABC, abstractmethod -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from typing import Any, Dict, List, Optional - -try: - import redis - REDIS_AVAILABLE = True -except ImportError: - REDIS_AVAILABLE = False - -from .exceptions import MemoryError, MemoryRetrievalError, MemoryStorageError - - -class MemoryLevel(Enum): - """Memory hierarchy levels for different types of memory storage.""" - SHORT_TERM = "short_term" # Recent interactions, limited capacity - LONG_TERM = "long_term" # Persistent important information - EPISODIC = "episodic" # Event-based contextual memory - SEMANTIC = "semantic" # Fact-based knowledge memory - - -class MemoryType(Enum): - """Types of memory content.""" - CONVERSATION = "conversation" - FACT = "fact" - EXPERIENCE = "experience" - CONTEXT = "context" - METADATA = "metadata" - - -@dataclass -class MemoryNode: - """Individual memory item with metadata and content.""" - id: str - content: Dict[str, Any] - memory_type: MemoryType - memory_level: MemoryLevel - timestamp: datetime - agent_id: str - conversation_id: Optional[str] = None - parent_id: Optional[str] = None - children_ids: List[str] = None - importance_score: float = 0.0 - access_count: int = 0 - last_accessed: Optional[datetime] = None - metadata: Dict[str, Any] = None - - def __post_init__(self): - """Initialize default values after dataclass creation.""" - if self.children_ids is None: - self.children_ids = [] - if self.metadata is None: - self.metadata = {} - - def to_dict(self) -> Dict[str, Any]: - """Convert memory node to dictionary for storage.""" - return { - 'id': self.id, - 'content': self.content, - 'memory_type': self.memory_type.value, - 'memory_level': self.memory_level.value, - 'timestamp': self.timestamp.isoformat(), - 'agent_id': self.agent_id, - 'conversation_id': self.conversation_id, - 'parent_id': self.parent_id, - 'children_ids': self.children_ids, - 'importance_score': self.importance_score, - 'access_count': self.access_count, - 'last_accessed': self.last_accessed.isoformat() if self.last_accessed else None, - 'metadata': self.metadata - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'MemoryNode': - """Create memory node from dictionary.""" - return cls( - id=data['id'], - content=data['content'], - memory_type=MemoryType(data['memory_type']), - memory_level=MemoryLevel(data['memory_level']), - timestamp=datetime.fromisoformat(data['timestamp']), - agent_id=data['agent_id'], - conversation_id=data.get('conversation_id'), - parent_id=data.get('parent_id'), - children_ids=data.get('children_ids', []), - importance_score=data.get('importance_score', 0.0), - access_count=data.get('access_count', 0), - last_accessed=datetime.fromisoformat(data['last_accessed']) if data.get('last_accessed') else None, - metadata=data.get('metadata', {}) - ) - - -class MemoryStore(ABC): - """Abstract base class for memory storage backends.""" - - @abstractmethod - def store(self, memory_node: MemoryNode) -> bool: - """Store a memory node.""" - pass - - @abstractmethod - def retrieve(self, memory_id: str) -> Optional[MemoryNode]: - """Retrieve a memory node by ID.""" - pass - - @abstractmethod - def search(self, query: Dict[str, Any]) -> List[MemoryNode]: - """Search for memory nodes matching criteria.""" - pass - - @abstractmethod - def delete(self, memory_id: str) -> bool: - """Delete a memory node.""" - pass - - @abstractmethod - def list_by_agent(self, agent_id: str, limit: int = 100) -> List[MemoryNode]: - """List memories for a specific agent.""" - pass - - -class InMemoryStore(MemoryStore): - """In-memory storage backend for testing and development.""" - - def __init__(self): - self._memories: Dict[str, MemoryNode] = {} - - def store(self, memory_node: MemoryNode) -> bool: - """Store a memory node in memory.""" - try: - self._memories[memory_node.id] = memory_node - return True - except Exception as e: - raise MemoryStorageError(f"Failed to store memory: {e}") from e - - def retrieve(self, memory_id: str) -> Optional[MemoryNode]: - """Retrieve a memory node by ID.""" - try: - memory = self._memories.get(memory_id) - if memory: - memory.access_count += 1 - memory.last_accessed = datetime.now() - return memory - except Exception as e: - raise MemoryRetrievalError(f"Failed to retrieve memory: {e}") from e - - def search(self, query: Dict[str, Any]) -> List[MemoryNode]: - """Search for memory nodes matching criteria.""" - try: - results = [] - for memory in self._memories.values(): - if self._matches_query(memory, query): - results.append(memory) - - # Sort by importance score and timestamp - results.sort(key=lambda m: (m.importance_score, m.timestamp), reverse=True) - return results - except Exception as e: - raise MemoryRetrievalError(f"Failed to search memories: {e}") from e - - def delete(self, memory_id: str) -> bool: - """Delete a memory node.""" - try: - if memory_id in self._memories: - del self._memories[memory_id] - return True - return False - except Exception as e: - raise MemoryStorageError(f"Failed to delete memory: {e}") from e - - def list_by_agent(self, agent_id: str, limit: int = 100) -> List[MemoryNode]: - """List memories for a specific agent.""" - try: - agent_memories = [ - memory for memory in self._memories.values() - if memory.agent_id == agent_id - ] - agent_memories.sort(key=lambda m: m.timestamp, reverse=True) - return agent_memories[:limit] - except Exception as e: - raise MemoryRetrievalError(f"Failed to list agent memories: {e}") from e - - def _matches_query(self, memory: MemoryNode, query: Dict[str, Any]) -> bool: - """Check if memory matches query criteria.""" - for key, value in query.items(): - if key == 'agent_id' and memory.agent_id != value: - return False - elif key == 'conversation_id' and memory.conversation_id != value: - return False - elif key == 'memory_type' and memory.memory_type != MemoryType(value): - return False - elif key == 'memory_level' and memory.memory_level != MemoryLevel(value): - return False - elif key == 'content_contains': - content_str = json.dumps(memory.content).lower() - if value.lower() not in content_str: - return False - return True - - -class RedisMemoryStore(MemoryStore): - """Redis-based memory storage backend.""" - - def __init__(self, redis_url: str = "redis://localhost:6379/0", key_prefix: str = "symbiont:memory"): - if not REDIS_AVAILABLE: - raise MemoryError("Redis not available. Install redis package.") - - try: - self.redis_client = redis.from_url(redis_url) - self.key_prefix = key_prefix - # Test connection - self.redis_client.ping() - except Exception as e: - raise MemoryStorageError(f"Failed to connect to Redis: {e}") from e - - def _get_key(self, memory_id: str) -> str: - """Get Redis key for memory ID.""" - return f"{self.key_prefix}:{memory_id}" - - def _get_agent_key(self, agent_id: str) -> str: - """Get Redis key for agent memory list.""" - return f"{self.key_prefix}:agent:{agent_id}" - - def store(self, memory_node: MemoryNode) -> bool: - """Store a memory node in Redis.""" - try: - key = self._get_key(memory_node.id) - agent_key = self._get_agent_key(memory_node.agent_id) - - # Store memory data - memory_data = json.dumps(memory_node.to_dict()) - self.redis_client.set(key, memory_data) - - # Add to agent's memory list - self.redis_client.lpush(agent_key, memory_node.id) - - # Set TTL for short-term memories - if memory_node.memory_level == MemoryLevel.SHORT_TERM: - self.redis_client.expire(key, 3600) # 1 hour TTL - - return True - except Exception as e: - raise MemoryStorageError(f"Failed to store memory in Redis: {e}") from e - - def retrieve(self, memory_id: str) -> Optional[MemoryNode]: - """Retrieve a memory node by ID from Redis.""" - try: - key = self._get_key(memory_id) - memory_data = self.redis_client.get(key) - - if memory_data: - data = json.loads(memory_data) - memory = MemoryNode.from_dict(data) - - # Update access statistics - memory.access_count += 1 - memory.last_accessed = datetime.now() - - # Update in Redis - updated_data = json.dumps(memory.to_dict()) - self.redis_client.set(key, updated_data) - - return memory - return None - except Exception as e: - raise MemoryRetrievalError(f"Failed to retrieve memory from Redis: {e}") from e - - def search(self, query: Dict[str, Any]) -> List[MemoryNode]: - """Search for memory nodes matching criteria in Redis.""" - try: - results = [] - - # If agent_id specified, search within agent's memories - if 'agent_id' in query: - agent_key = self._get_agent_key(query['agent_id']) - memory_ids = self.redis_client.lrange(agent_key, 0, -1) - - for memory_id in memory_ids: - memory = self.retrieve(memory_id.decode('utf-8')) - if memory and self._matches_query(memory, query): - results.append(memory) - else: - # Search all memories (less efficient) - pattern = f"{self.key_prefix}:*" - for key in self.redis_client.scan_iter(match=pattern): - if not key.decode('utf-8').startswith(f"{self.key_prefix}:agent:"): - memory_id = key.decode('utf-8').split(':')[-1] - memory = self.retrieve(memory_id) - if memory and self._matches_query(memory, query): - results.append(memory) - - # Sort by importance score and timestamp - results.sort(key=lambda m: (m.importance_score, m.timestamp), reverse=True) - return results - except Exception as e: - raise MemoryRetrievalError(f"Failed to search memories in Redis: {e}") from e - - def delete(self, memory_id: str) -> bool: - """Delete a memory node from Redis.""" - try: - key = self._get_key(memory_id) - result = self.redis_client.delete(key) - return result > 0 - except Exception as e: - raise MemoryStorageError(f"Failed to delete memory from Redis: {e}") from e - - def list_by_agent(self, agent_id: str, limit: int = 100) -> List[MemoryNode]: - """List memories for a specific agent from Redis.""" - try: - agent_key = self._get_agent_key(agent_id) - memory_ids = self.redis_client.lrange(agent_key, 0, limit - 1) - - memories = [] - for memory_id in memory_ids: - memory = self.retrieve(memory_id.decode('utf-8')) - if memory: - memories.append(memory) - - return memories - except Exception as e: - raise MemoryRetrievalError(f"Failed to list agent memories from Redis: {e}") from e - - def _matches_query(self, memory: MemoryNode, query: Dict[str, Any]) -> bool: - """Check if memory matches query criteria.""" - for key, value in query.items(): - if key == 'agent_id' and memory.agent_id != value: - return False - elif key == 'conversation_id' and memory.conversation_id != value: - return False - elif key == 'memory_type' and memory.memory_type != MemoryType(value): - return False - elif key == 'memory_level' and memory.memory_level != MemoryLevel(value): - return False - elif key == 'content_contains': - content_str = json.dumps(memory.content).lower() - if value.lower() not in content_str: - return False - return True - - -class HierarchicalMemory: - """Manages hierarchical memory structure with different levels.""" - - def __init__(self, memory_store: MemoryStore): - self.memory_store = memory_store - self.consolidation_thresholds = { - MemoryLevel.SHORT_TERM: 50, # Max short-term memories - MemoryLevel.LONG_TERM: 1000, # Max long-term memories - MemoryLevel.EPISODIC: 500, # Max episodic memories - MemoryLevel.SEMANTIC: 2000 # Max semantic memories - } - - def add_memory( - self, - content: Dict[str, Any], - memory_type: MemoryType, - memory_level: MemoryLevel, - agent_id: str, - conversation_id: Optional[str] = None, - importance_score: float = 0.0, - parent_id: Optional[str] = None - ) -> MemoryNode: - """Add a new memory to the hierarchy.""" - memory_node = MemoryNode( - id=str(uuid.uuid4()), - content=content, - memory_type=memory_type, - memory_level=memory_level, - timestamp=datetime.now(), - agent_id=agent_id, - conversation_id=conversation_id, - parent_id=parent_id, - importance_score=importance_score - ) - - if not self.memory_store.store(memory_node): - raise MemoryStorageError("Failed to store memory node") - - # Check if consolidation is needed - self._check_consolidation(agent_id, memory_level) - - return memory_node - - def get_memory(self, memory_id: str) -> Optional[MemoryNode]: - """Retrieve a memory by ID.""" - return self.memory_store.retrieve(memory_id) - - def search_memories( - self, - agent_id: str, - query: Optional[Dict[str, Any]] = None, - memory_levels: Optional[List[MemoryLevel]] = None, - limit: int = 50 - ) -> List[MemoryNode]: - """Search memories with optional filtering.""" - search_query = {'agent_id': agent_id} - - if query: - search_query.update(query) - - results = self.memory_store.search(search_query) - - # Filter by memory levels if specified - if memory_levels: - results = [m for m in results if m.memory_level in memory_levels] - - return results[:limit] - - def get_conversation_context(self, conversation_id: str, agent_id: str) -> List[MemoryNode]: - """Get all memories related to a conversation.""" - query = { - 'agent_id': agent_id, - 'conversation_id': conversation_id - } - return self.memory_store.search(query) - - def consolidate_memories(self, agent_id: str) -> Dict[str, int]: - """Consolidate memories by promoting important ones and pruning old ones.""" - consolidated = { - 'promoted': 0, - 'pruned': 0, - 'consolidated': 0 - } - - for level in MemoryLevel: - level_memories = self.search_memories( - agent_id=agent_id, - query={'memory_level': level.value} - ) - - threshold = self.consolidation_thresholds[level] - - if len(level_memories) > threshold: - # Sort by importance and recency - level_memories.sort( - key=lambda m: (m.importance_score, m.timestamp), - reverse=True - ) - - # Keep important memories, prune others - to_keep = level_memories[:threshold] - to_prune = level_memories[threshold:] - - # Promote highly important short-term memories to long-term - if level == MemoryLevel.SHORT_TERM: - for memory in to_keep: - if memory.importance_score > 0.7: - memory.memory_level = MemoryLevel.LONG_TERM - self.memory_store.store(memory) - consolidated['promoted'] += 1 - - # Prune less important memories - for memory in to_prune: - self.memory_store.delete(memory.id) - consolidated['pruned'] += 1 - - return consolidated - - def _check_consolidation(self, agent_id: str, memory_level: MemoryLevel): - """Check if consolidation is needed for a memory level.""" - level_memories = self.search_memories( - agent_id=agent_id, - query={'memory_level': memory_level.value} - ) - - threshold = self.consolidation_thresholds[memory_level] - - if len(level_memories) > threshold * 1.2: # 20% buffer - self.consolidate_memories(agent_id) - - -class MemoryManager: - """Main memory management system.""" - - def __init__(self, config: Optional[Dict[str, Any]] = None): - self.config = config or {} - - # Initialize memory store based on configuration - storage_type = self.config.get('storage_type', 'memory') - - if storage_type == 'redis': - redis_url = self.config.get('redis_url', 'redis://localhost:6379/0') - self.memory_store = RedisMemoryStore(redis_url) - else: - self.memory_store = InMemoryStore() - - self.hierarchical_memory = HierarchicalMemory(self.memory_store) - - def add_memory( - self, - content: Dict[str, Any], - memory_type: MemoryType, - memory_level: MemoryLevel, - agent_id: str, - conversation_id: Optional[str] = None, - importance_score: float = 0.0, - parent_id: Optional[str] = None - ) -> MemoryNode: - """Add a new memory to the system.""" - return self.hierarchical_memory.add_memory( - content=content, - memory_type=memory_type, - memory_level=memory_level, - agent_id=agent_id, - conversation_id=conversation_id, - importance_score=importance_score, - parent_id=parent_id - ) - - def get_memory(self, memory_id: str) -> Optional[MemoryNode]: - """Retrieve a memory by ID.""" - return self.hierarchical_memory.get_memory(memory_id) - - def search_memory( - self, - agent_id: str, - query: Optional[Dict[str, Any]] = None, - memory_levels: Optional[List[MemoryLevel]] = None, - limit: int = 50 - ) -> List[MemoryNode]: - """Search memories with filtering options.""" - return self.hierarchical_memory.search_memories( - agent_id=agent_id, - query=query, - memory_levels=memory_levels, - limit=limit - ) - - def get_conversation_context(self, conversation_id: str, agent_id: str) -> List[MemoryNode]: - """Get conversation context.""" - return self.hierarchical_memory.get_conversation_context(conversation_id, agent_id) - - def consolidate_memory(self, agent_id: str) -> Dict[str, int]: - """Consolidate memories for an agent.""" - return self.hierarchical_memory.consolidate_memories(agent_id) - - def delete_memory(self, memory_id: str) -> bool: - """Delete a memory.""" - return self.memory_store.delete(memory_id) - - def list_agent_memories(self, agent_id: str, limit: int = 100) -> List[MemoryNode]: - """List all memories for an agent.""" - return self.memory_store.list_by_agent(agent_id, limit) diff --git a/symbiont/metrics.py b/symbiont/metrics.py index 6408384..553a2b3 100644 --- a/symbiont/metrics.py +++ b/symbiont/metrics.py @@ -329,18 +329,14 @@ def _request( response = self._client._request(method, path, json=json, params=params) return response.json() - def get_metrics_snapshot(self) -> Dict[str, Any]: - """Get the current metrics snapshot. ``GET /metrics/snapshot``""" - return self._request("GET", "/metrics/snapshot") - - def get_scheduler_metrics(self) -> Dict[str, Any]: - """Get scheduler-specific metrics. ``GET /metrics/scheduler``""" - return self._request("GET", "/metrics/scheduler") - - def get_system_metrics(self) -> Dict[str, Any]: - """Get system resource metrics. ``GET /metrics/system``""" - return self._request("GET", "/metrics/system") - - def export_metrics(self) -> Dict[str, Any]: - """Trigger a metrics export. ``POST /metrics/export``""" - return self._request("POST", "/metrics/export") + def get_metrics(self) -> Dict[str, Any]: + """Get runtime metrics. ``GET /api/v1/metrics``. + + This is the only metrics endpoint the OSS runtime exposes. The + previous per-facet methods (snapshot/scheduler/system/export) + targeted endpoints that the OSS runtime does not serve and have + been removed; use the client-side exporters in this module + (``FileMetricsExporter``/``OtlpExporter``) to persist or ship the + returned data. + """ + return self._request("GET", "metrics") diff --git a/symbiont/models.py b/symbiont/models.py index 34e6a4d..b719d99 100644 --- a/symbiont/models.py +++ b/symbiont/models.py @@ -9,6 +9,7 @@ class AgentState(str, Enum): """Agent state enumeration.""" + IDLE = "idle" ACTIVE = "active" BUSY = "busy" @@ -18,6 +19,7 @@ class AgentState(str, Enum): class SecretBackendType(str, Enum): """Secret backend type enumeration.""" + VAULT = "vault" ENCRYPTED_FILE = "encrypted_file" OS_KEYCHAIN = "os_keychain" @@ -25,6 +27,7 @@ class SecretBackendType(str, Enum): class VaultAuthMethod(str, Enum): """Vault authentication method enumeration.""" + TOKEN = "token" # nosec B105 - This is an enum value, not a password KUBERNETES = "kubernetes" AWS_IAM = "aws_iam" @@ -33,6 +36,7 @@ class VaultAuthMethod(str, Enum): class McpConnectionStatus(str, Enum): """MCP connection status enumeration.""" + CONNECTED = "connected" DISCONNECTED = "disconnected" CONNECTING = "connecting" @@ -41,6 +45,7 @@ class McpConnectionStatus(str, Enum): class KnowledgeSourceType(str, Enum): """Knowledge source type enumeration.""" + DOCUMENT = "document" CODE = "code" API_REFERENCE = "api_reference" @@ -49,6 +54,7 @@ class KnowledgeSourceType(str, Enum): class ReviewStatus(str, Enum): """Review session status enumeration.""" + SUBMITTED = "submitted" PENDING_ANALYSIS = "pending_analysis" ANALYZING = "analyzing" @@ -61,6 +67,7 @@ class ReviewStatus(str, Enum): class FindingSeverity(str, Enum): """Security finding severity levels.""" + LOW = "low" MEDIUM = "medium" HIGH = "high" @@ -69,6 +76,7 @@ class FindingSeverity(str, Enum): class FindingCategory(str, Enum): """Security finding categories.""" + SCHEMA_INJECTION = "schema_injection" PRIVILEGE_ESCALATION = "privilege_escalation" DATA_EXPOSURE = "data_exposure" @@ -80,6 +88,7 @@ class FindingCategory(str, Enum): # Core Agent Models # ============================================================================= + class Agent(BaseModel): """Agent model for the Symbiont platform.""" @@ -96,6 +105,7 @@ class Agent(BaseModel): class ResourceUsage(BaseModel): """Resource usage information for agents.""" + memory_bytes: int = Field(..., description="Memory usage in bytes") cpu_percent: float = Field(..., description="CPU usage percentage") active_tasks: int = Field(..., description="Number of active tasks") @@ -103,6 +113,7 @@ class ResourceUsage(BaseModel): class AgentStatusResponse(BaseModel): """Response structure for agent status queries.""" + agent_id: str state: AgentState last_activity: datetime @@ -113,15 +124,22 @@ class AgentStatusResponse(BaseModel): # Workflow Models # ============================================================================= + class WorkflowExecutionRequest(BaseModel): """Request structure for workflow execution.""" + workflow_id: str = Field(..., description="The workflow definition or identifier") - parameters: Dict[str, Any] = Field(..., description="Parameters to pass to the workflow") - agent_id: Optional[str] = Field(None, description="Optional agent ID to execute the workflow") + parameters: Dict[str, Any] = Field( + ..., description="Parameters to pass to the workflow" + ) + agent_id: Optional[str] = Field( + None, description="Optional agent ID to execute the workflow" + ) class WorkflowExecutionResponse(BaseModel): """Response structure for workflow execution.""" + execution_id: str status: str started_at: datetime @@ -132,14 +150,17 @@ class WorkflowExecutionResponse(BaseModel): # Tool Review API Models # ============================================================================= + class ToolProvider(BaseModel): """Tool provider information.""" + name: str public_key_url: Optional[str] = None class ToolSchema(BaseModel): """Tool schema definition.""" + type: str = "object" properties: Dict[str, Any] required: List[str] = [] @@ -147,6 +168,7 @@ class ToolSchema(BaseModel): class Tool(BaseModel): """Tool definition for review.""" + name: str description: str tool_schema: ToolSchema = Field(..., alias="schema") @@ -155,6 +177,7 @@ class Tool(BaseModel): class SecurityFinding(BaseModel): """Security analysis finding.""" + finding_id: str severity: FindingSeverity category: FindingCategory @@ -166,6 +189,7 @@ class SecurityFinding(BaseModel): class AnalysisResults(BaseModel): """Security analysis results.""" + analysis_id: str risk_score: int = Field(..., ge=0, le=100) findings: List[SecurityFinding] @@ -175,6 +199,7 @@ class AnalysisResults(BaseModel): class ReviewSessionState(BaseModel): """Review session state information.""" + type: str analysis_id: Optional[str] = None analysis_completed_at: Optional[datetime] = None @@ -185,6 +210,7 @@ class ReviewSessionState(BaseModel): class ReviewSession(BaseModel): """Tool review session.""" + review_id: str tool: Tool status: ReviewStatus @@ -197,6 +223,7 @@ class ReviewSession(BaseModel): class ReviewSessionCreate(BaseModel): """Request to create a new review session.""" + tool: Tool submitted_by: str priority: str = "normal" @@ -204,6 +231,7 @@ class ReviewSessionCreate(BaseModel): class ReviewSessionResponse(BaseModel): """Response when creating a review session.""" + review_id: str status: ReviewStatus submitted_at: datetime @@ -212,12 +240,14 @@ class ReviewSessionResponse(BaseModel): class ReviewSessionList(BaseModel): """List of review sessions with pagination.""" + sessions: List[ReviewSession] pagination: Dict[str, Any] class HumanReviewDecision(BaseModel): """Human reviewer decision.""" + decision: str # "approve" or "reject" comments: Optional[str] = None reviewer_id: str @@ -227,8 +257,10 @@ class HumanReviewDecision(BaseModel): # System Models # ============================================================================= + class HealthResponse(BaseModel): """Health check response.""" + status: str uptime_seconds: int timestamp: datetime @@ -237,6 +269,7 @@ class HealthResponse(BaseModel): class ErrorResponse(BaseModel): """Error response structure.""" + error: str code: str details: Optional[Dict[str, Any]] = None @@ -244,6 +277,7 @@ class ErrorResponse(BaseModel): class PaginationInfo(BaseModel): """Pagination information.""" + page: int limit: int total: int @@ -254,14 +288,17 @@ class PaginationInfo(BaseModel): # Signing Models # ============================================================================= + class SigningRequest(BaseModel): """Request to sign an approved tool.""" + review_id: str signing_key_id: str class SigningResponse(BaseModel): """Response from signing operation.""" + signature: str signed_at: datetime signer_id: str @@ -270,6 +307,7 @@ class SigningResponse(BaseModel): class SignedTool(BaseModel): """Signed tool information.""" + tool: Tool signature: str signed_at: datetime @@ -282,8 +320,10 @@ class SignedTool(BaseModel): # Secrets Management Models # ============================================================================= + class VaultConfig(BaseModel): """HashiCorp Vault configuration.""" + url: str auth_method: VaultAuthMethod token: Optional[str] = None @@ -295,6 +335,7 @@ class VaultConfig(BaseModel): class SecretBackendConfig(BaseModel): """Secret backend configuration.""" + backend_type: SecretBackendType vault_config: Optional[VaultConfig] = None file_path: Optional[str] = None @@ -303,6 +344,7 @@ class SecretBackendConfig(BaseModel): class SecretRequest(BaseModel): """Secret operation request.""" + agent_id: str secret_name: str secret_value: Optional[str] = None @@ -311,6 +353,7 @@ class SecretRequest(BaseModel): class SecretResponse(BaseModel): """Secret operation response.""" + secret_name: str agent_id: str created_at: datetime @@ -319,6 +362,7 @@ class SecretResponse(BaseModel): class SecretListResponse(BaseModel): """List secrets response.""" + secrets: List[str] agent_id: str @@ -327,8 +371,10 @@ class SecretListResponse(BaseModel): # MCP Management Models # ============================================================================= + class McpServerConfig(BaseModel): """MCP server configuration.""" + name: str command: List[str] env: Dict[str, str] = {} @@ -338,6 +384,7 @@ class McpServerConfig(BaseModel): class McpConnectionInfo(BaseModel): """MCP connection information.""" + server_name: str status: McpConnectionStatus pid: Optional[int] = None @@ -349,6 +396,7 @@ class McpConnectionInfo(BaseModel): class McpToolInfo(BaseModel): """MCP tool information.""" + name: str description: str server_name: str @@ -359,6 +407,7 @@ class McpToolInfo(BaseModel): class McpResourceInfo(BaseModel): """MCP resource information.""" + uri: str name: Optional[str] = None description: Optional[str] = None @@ -370,8 +419,10 @@ class McpResourceInfo(BaseModel): # Vector Database & RAG Models # ============================================================================= + class VectorMetadata(BaseModel): """Vector metadata for knowledge items.""" + source: str source_type: KnowledgeSourceType chunk_index: Optional[int] = None @@ -381,6 +432,7 @@ class VectorMetadata(BaseModel): class KnowledgeItem(BaseModel): """Knowledge item for vector database.""" + id: str content: str embedding: Optional[List[float]] = None @@ -389,6 +441,7 @@ class KnowledgeItem(BaseModel): class VectorSearchRequest(BaseModel): """Vector similarity search request.""" + query: str agent_id: Optional[str] = None source_types: List[KnowledgeSourceType] = [] @@ -398,12 +451,14 @@ class VectorSearchRequest(BaseModel): class VectorSearchResult(BaseModel): """Vector search result.""" + item: KnowledgeItem similarity_score: float class VectorSearchResponse(BaseModel): """Vector search response.""" + results: List[VectorSearchResult] query: str total_results: int @@ -411,6 +466,7 @@ class VectorSearchResponse(BaseModel): class ContextQuery(BaseModel): """Context query for RAG operations.""" + query: str agent_id: Optional[str] = None max_context_items: int = 5 @@ -419,6 +475,7 @@ class ContextQuery(BaseModel): class ContextResponse(BaseModel): """Context response from RAG system.""" + context_items: List[str] sources: List[str] query: str @@ -429,8 +486,10 @@ class ContextResponse(BaseModel): # Agent DSL Models # ============================================================================= + class DslCompileRequest(BaseModel): """DSL compilation request.""" + dsl_content: str agent_name: str validate_only: bool = False @@ -438,6 +497,7 @@ class DslCompileRequest(BaseModel): class DslCompileResponse(BaseModel): """DSL compilation response.""" + success: bool agent_id: Optional[str] = None errors: List[str] = [] @@ -447,6 +507,7 @@ class DslCompileResponse(BaseModel): class AgentDeployRequest(BaseModel): """Agent deployment request.""" + agent_id: str environment: str = "development" config_overrides: Dict[str, Any] = {} @@ -454,6 +515,7 @@ class AgentDeployRequest(BaseModel): class AgentDeployResponse(BaseModel): """Agent deployment response.""" + deployment_id: str agent_id: str status: str @@ -465,8 +527,10 @@ class AgentDeployResponse(BaseModel): # Enhanced Monitoring Models # ============================================================================= + class SystemMetrics(BaseModel): """Enhanced system metrics.""" + uptime_seconds: int memory_usage_bytes: int memory_usage_percent: float @@ -482,6 +546,7 @@ class SystemMetrics(BaseModel): class AgentMetrics(BaseModel): """Agent-specific metrics.""" + agent_id: str tasks_completed: int tasks_failed: int @@ -496,8 +561,10 @@ class AgentMetrics(BaseModel): # Configuration Models (Phase 1) # ============================================================================= + class ClientConfig(BaseModel): """Client configuration model.""" + api_key: Optional[str] = None base_url: str = "http://localhost:8080/api/v1" timeout: int = 30 @@ -509,6 +576,7 @@ class ClientConfig(BaseModel): class DatabaseConfig(BaseModel): """Database connection configuration.""" + host: str = "localhost" port: int = 5432 database: str = "symbiont" @@ -521,6 +589,7 @@ class DatabaseConfig(BaseModel): class AuthConfig(BaseModel): """Authentication configuration.""" + jwt_secret_key: Optional[str] = None jwt_algorithm: str = "HS256" jwt_expiration_seconds: int = 3600 @@ -533,6 +602,7 @@ class AuthConfig(BaseModel): class VectorConfig(BaseModel): """Vector database configuration.""" + provider: str = "qdrant" host: str = "localhost" port: int = 6333 @@ -545,6 +615,7 @@ class VectorConfig(BaseModel): class LoggingConfig(BaseModel): """Logging configuration.""" + level: str = "INFO" format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" file_path: Optional[str] = None @@ -558,8 +629,10 @@ class LoggingConfig(BaseModel): # Authentication Models (Phase 1) # ============================================================================= + class JWTToken(BaseModel): """JWT token model.""" + token: str token_type: str expires_at: datetime @@ -570,6 +643,7 @@ class JWTToken(BaseModel): class AuthResponse(BaseModel): """Authentication response.""" + user_id: str access_token: str refresh_token: Optional[str] = None @@ -581,11 +655,13 @@ class AuthResponse(BaseModel): class TokenRefreshRequest(BaseModel): """Token refresh request.""" + refresh_token: str class TokenRefreshResponse(BaseModel): """Token refresh response.""" + access_token: str token_type: str = "Bearer" expires_in: int @@ -593,6 +669,7 @@ class TokenRefreshResponse(BaseModel): class UserPermissions(BaseModel): """User permissions model.""" + user_id: str roles: List[str] permissions: List[str] @@ -601,6 +678,7 @@ class UserPermissions(BaseModel): class RoleDefinition(BaseModel): """Role definition model.""" + name: str permissions: List[str] description: Optional[str] = None @@ -611,8 +689,10 @@ class RoleDefinition(BaseModel): # HTTP Input Models # ============================================================================= + class RouteMatchType(str, Enum): """Route matching condition types.""" + PATH_PREFIX = "path_prefix" HEADER_EQUALS = "header_equals" JSON_FIELD_EQUALS = "json_field_equals" @@ -620,6 +700,7 @@ class RouteMatchType(str, Enum): class AgentRoutingRule(BaseModel): """Rule to route HTTP requests to specific agents.""" + condition_type: RouteMatchType condition_value: str condition_target: Optional[str] = None # For header/field name @@ -628,6 +709,7 @@ class AgentRoutingRule(BaseModel): class HttpResponseControlConfig(BaseModel): """HTTP response control configuration.""" + default_status: int = 200 agent_output_to_json: bool = True error_status: int = 500 @@ -636,7 +718,10 @@ class HttpResponseControlConfig(BaseModel): class HttpInputConfig(BaseModel): """HTTP input server configuration.""" - bind_address: str = "0.0.0.0" # nosec B104 - This is a configuration default, not actual binding + + bind_address: str = ( + "0.0.0.0" # nosec B104 - This is a configuration default, not actual binding + ) port: int = 8081 path: str = "/webhook" agent_id: str @@ -653,6 +738,7 @@ class HttpInputConfig(BaseModel): class HttpInputServerInfo(BaseModel): """HTTP input server status information.""" + server_id: str config: HttpInputConfig status: str # "running", "stopped", "error" @@ -664,17 +750,20 @@ class HttpInputServerInfo(BaseModel): class HttpInputCreateRequest(BaseModel): """Request to create/start HTTP input server.""" + config: HttpInputConfig class HttpInputUpdateRequest(BaseModel): """Request to update HTTP input server configuration.""" + server_id: str config: HttpInputConfig class WebhookTriggerRequest(BaseModel): """Request to manually trigger webhook for testing.""" + server_id: str payload: Dict[str, Any] headers: Dict[str, str] = {} @@ -682,6 +771,7 @@ class WebhookTriggerRequest(BaseModel): class WebhookTriggerResponse(BaseModel): """Response from webhook trigger.""" + status: str response_code: int response_body: Dict[str, Any] @@ -693,25 +783,42 @@ class WebhookTriggerResponse(BaseModel): # Phase 2 Memory System Models # ============================================================================= + class MemoryNode(BaseModel): """Individual memory item with metadata and content.""" + id: str = Field(..., description="Unique memory identifier") content: Dict[str, Any] = Field(..., description="Memory content") - memory_type: str = Field(..., description="Type of memory (conversation, fact, experience, context, metadata)") - memory_level: str = Field(..., description="Memory hierarchy level (short_term, long_term, episodic, semantic)") + memory_type: str = Field( + ..., + description="Type of memory (conversation, fact, experience, context, metadata)", + ) + memory_level: str = Field( + ..., + description="Memory hierarchy level (short_term, long_term, episodic, semantic)", + ) timestamp: datetime = Field(..., description="Memory creation timestamp") agent_id: str = Field(..., description="Agent that owns this memory") - conversation_id: Optional[str] = Field(None, description="Associated conversation ID") + conversation_id: Optional[str] = Field( + None, description="Associated conversation ID" + ) parent_id: Optional[str] = Field(None, description="Parent memory ID") - children_ids: List[str] = Field(default_factory=list, description="Child memory IDs") - importance_score: float = Field(0.0, description="Memory importance score (0.0-1.0)") + children_ids: List[str] = Field( + default_factory=list, description="Child memory IDs" + ) + importance_score: float = Field( + 0.0, description="Memory importance score (0.0-1.0)" + ) access_count: int = Field(0, description="Number of times memory was accessed") last_accessed: Optional[datetime] = Field(None, description="Last access timestamp") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata") + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional metadata" + ) class MemoryStoreRequest(BaseModel): """Request to store a memory.""" + content: Dict[str, Any] = Field(..., description="Memory content") memory_type: str = Field(..., description="Type of memory") memory_level: str = Field(..., description="Memory hierarchy level") @@ -719,11 +826,14 @@ class MemoryStoreRequest(BaseModel): conversation_id: Optional[str] = Field(None, description="Conversation ID") importance_score: float = Field(0.0, description="Memory importance score") parent_id: Optional[str] = Field(None, description="Parent memory ID") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata") + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional metadata" + ) class MemoryResponse(BaseModel): """Response containing memory information.""" + memory: MemoryNode = Field(..., description="Memory node") success: bool = Field(True, description="Operation success status") message: Optional[str] = Field(None, description="Response message") @@ -731,6 +841,7 @@ class MemoryResponse(BaseModel): class MemoryQuery(BaseModel): """Query for retrieving specific memories.""" + memory_id: Optional[str] = Field(None, description="Specific memory ID") agent_id: str = Field(..., description="Agent ID") conversation_id: Optional[str] = Field(None, description="Conversation ID") @@ -742,37 +853,55 @@ class MemoryQuery(BaseModel): class MemorySearchRequest(BaseModel): """Request for searching memories.""" + agent_id: str = Field(..., description="Agent ID") query: Optional[Dict[str, Any]] = Field(None, description="Search query parameters") - memory_levels: Optional[List[str]] = Field(None, description="Memory levels to search") + memory_levels: Optional[List[str]] = Field( + None, description="Memory levels to search" + ) content_contains: Optional[str] = Field(None, description="Content search string") - importance_threshold: Optional[float] = Field(None, description="Minimum importance score") - time_range: Optional[Dict[str, datetime]] = Field(None, description="Time range filter") + importance_threshold: Optional[float] = Field( + None, description="Minimum importance score" + ) + time_range: Optional[Dict[str, datetime]] = Field( + None, description="Time range filter" + ) limit: int = Field(50, description="Maximum number of results") class MemorySearchResponse(BaseModel): """Response containing search results.""" + memories: List[MemoryNode] = Field(..., description="Found memories") total_count: int = Field(..., description="Total number of matches") - search_time_ms: float = Field(..., description="Search execution time in milliseconds") + search_time_ms: float = Field( + ..., description="Search execution time in milliseconds" + ) success: bool = Field(True, description="Search success status") message: Optional[str] = Field(None, description="Response message") class ConversationContext(BaseModel): """Conversation-specific memory context.""" + conversation_id: str = Field(..., description="Conversation identifier") agent_id: str = Field(..., description="Agent identifier") memories: List[MemoryNode] = Field(..., description="Conversation memories") context_summary: Optional[str] = Field(None, description="Context summary") - created_at: datetime = Field(default_factory=datetime.now, description="Context creation time") - updated_at: datetime = Field(default_factory=datetime.now, description="Last update time") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional context metadata") + created_at: datetime = Field( + default_factory=datetime.now, description="Context creation time" + ) + updated_at: datetime = Field( + default_factory=datetime.now, description="Last update time" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional context metadata" + ) class ConsolidationResponse(BaseModel): """Response from memory consolidation process.""" + agent_id: str = Field(..., description="Agent ID") promoted_count: int = Field(0, description="Number of memories promoted") pruned_count: int = Field(0, description="Number of memories pruned") @@ -784,59 +913,89 @@ class ConsolidationResponse(BaseModel): class MemorySearchResult(BaseModel): """Individual memory search result with relevance scoring.""" + memory: MemoryNode = Field(..., description="Memory node") - relevance_score: float = Field(..., description="Relevance score for the search query") + relevance_score: float = Field( + ..., description="Relevance score for the search query" + ) match_reason: str = Field(..., description="Reason for the match") - highlighted_content: Optional[Dict[str, Any]] = Field(None, description="Content with search highlights") + highlighted_content: Optional[Dict[str, Any]] = Field( + None, description="Content with search highlights" + ) # ============================================================================= # Phase 3 Qdrant Integration Models # ============================================================================= + class Vector(BaseModel): """Vector representation for Qdrant.""" + id: Union[str, int] = Field(..., description="Vector identifier") values: List[float] = Field(..., description="Vector values/embeddings") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Vector metadata") + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Vector metadata" + ) class Point(BaseModel): """Point representation for Qdrant.""" + id: Union[str, int] = Field(..., description="Point identifier") - vector: Union[List[float], Dict[str, List[float]]] = Field(..., description="Vector data") - payload: Dict[str, Any] = Field(default_factory=dict, description="Point payload/metadata") + vector: Union[List[float], Dict[str, List[float]]] = Field( + ..., description="Vector data" + ) + payload: Dict[str, Any] = Field( + default_factory=dict, description="Point payload/metadata" + ) class SearchQuery(BaseModel): """Search query for vector similarity search.""" + vector: List[float] = Field(..., description="Query vector") limit: int = Field(10, description="Maximum number of results") - score_threshold: Optional[float] = Field(None, description="Minimum similarity score") - filter: Optional[Dict[str, Any]] = Field(None, description="Payload filter conditions") + score_threshold: Optional[float] = Field( + None, description="Minimum similarity score" + ) + filter: Optional[Dict[str, Any]] = Field( + None, description="Payload filter conditions" + ) with_payload: bool = Field(True, description="Include payload in results") with_vector: bool = Field(False, description="Include vectors in results") class CollectionCreateRequest(BaseModel): """Request to create a new vector collection.""" + name: str = Field(..., description="Collection name") vector_size: int = Field(..., description="Vector dimension size") - distance: str = Field("Cosine", description="Distance metric (Cosine, Euclidean, Dot)") + distance: str = Field( + "Cosine", description="Distance metric (Cosine, Euclidean, Dot)" + ) on_disk_payload: bool = Field(False, description="Store payload on disk") - hnsw_config: Optional[Dict[str, Any]] = Field(None, description="HNSW configuration") - optimizers_config: Optional[Dict[str, Any]] = Field(None, description="Optimizer configuration") + hnsw_config: Optional[Dict[str, Any]] = Field( + None, description="HNSW configuration" + ) + optimizers_config: Optional[Dict[str, Any]] = Field( + None, description="Optimizer configuration" + ) class CollectionResponse(BaseModel): """Response from collection operations.""" + collection_name: str = Field(..., description="Collection name") status: str = Field(..., description="Operation status") - result: Optional[Dict[str, Any]] = Field(None, description="Operation result details") + result: Optional[Dict[str, Any]] = Field( + None, description="Operation result details" + ) class CollectionInfo(BaseModel): """Information about a vector collection.""" + collection_name: str = Field(..., description="Collection name") config: Dict[str, Any] = Field(..., description="Collection configuration") status: str = Field(..., description="Collection status") @@ -847,6 +1006,7 @@ class CollectionInfo(BaseModel): class VectorUpsertRequest(BaseModel): """Request to upsert vectors into a collection.""" + collection_name: str = Field(..., description="Target collection name") points: List[Point] = Field(..., description="Points to upsert") wait: bool = Field(True, description="Wait for operation completion") @@ -854,6 +1014,7 @@ class VectorUpsertRequest(BaseModel): class UpsertResponse(BaseModel): """Response from vector upsert operation.""" + collection_name: str = Field(..., description="Collection name") operation_id: Optional[str] = Field(None, description="Operation identifier") status: str = Field(..., description="Operation status") @@ -862,23 +1023,34 @@ class UpsertResponse(BaseModel): class VectorPoint(BaseModel): """Vector point with metadata.""" + id: Union[str, int] = Field(..., description="Point identifier") vector: List[float] = Field(..., description="Vector values") payload: Dict[str, Any] = Field(default_factory=dict, description="Point metadata") - score: Optional[float] = Field(None, description="Similarity score (for search results)") + score: Optional[float] = Field( + None, description="Similarity score (for search results)" + ) class EmbeddingRequest(BaseModel): """Request to generate embeddings.""" + texts: List[str] = Field(..., description="Texts to embed") model: str = Field("default", description="Embedding model to use") collection_name: Optional[str] = Field(None, description="Target collection") - metadata: Optional[List[Dict[str, Any]]] = Field(None, description="Metadata for each text") + metadata: Optional[List[Dict[str, Any]]] = Field( + None, description="Metadata for each text" + ) + + class EmbeddingResponse(BaseModel): """Response from embedding generation.""" + embeddings: List[List[float]] = Field(..., description="Generated embeddings") model: str = Field(..., description="Model used for embedding") - processing_time_ms: float = Field(..., description="Processing time in milliseconds") + processing_time_ms: float = Field( + ..., description="Processing time in milliseconds" + ) token_count: Optional[int] = Field(None, description="Total token count processed") @@ -886,8 +1058,10 @@ class EmbeddingResponse(BaseModel): # Phase 4 HTTP Endpoint Management Models # ============================================================================= + class HttpMethod(str, Enum): """HTTP method enumeration.""" + GET = "GET" POST = "POST" PUT = "PUT" @@ -899,6 +1073,7 @@ class HttpMethod(str, Enum): class EndpointStatus(str, Enum): """HTTP endpoint status enumeration.""" + ACTIVE = "active" INACTIVE = "inactive" MAINTENANCE = "maintenance" @@ -907,6 +1082,7 @@ class EndpointStatus(str, Enum): class HttpEndpointCreateRequest(BaseModel): """Request to create a new HTTP endpoint.""" + path: str = Field(..., description="Endpoint path") method: HttpMethod = Field(..., description="HTTP method") agent_id: str = Field(..., description="Agent to handle requests") @@ -914,42 +1090,65 @@ class HttpEndpointCreateRequest(BaseModel): auth_required: bool = Field(True, description="Whether authentication is required") rate_limit: Optional[int] = Field(None, description="Rate limit per minute") timeout_seconds: int = Field(30, description="Request timeout in seconds") - middleware: List[str] = Field(default_factory=list, description="Middleware to apply") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional endpoint metadata") + middleware: List[str] = Field( + default_factory=list, description="Middleware to apply" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional endpoint metadata" + ) class HttpEndpointUpdateRequest(BaseModel): """Request to update an existing HTTP endpoint.""" + endpoint_id: str = Field(..., description="Endpoint identifier") path: Optional[str] = Field(None, description="Endpoint path") method: Optional[HttpMethod] = Field(None, description="HTTP method") agent_id: Optional[str] = Field(None, description="Agent to handle requests") description: Optional[str] = Field(None, description="Endpoint description") - auth_required: Optional[bool] = Field(None, description="Whether authentication is required") + auth_required: Optional[bool] = Field( + None, description="Whether authentication is required" + ) rate_limit: Optional[int] = Field(None, description="Rate limit per minute") - timeout_seconds: Optional[int] = Field(None, description="Request timeout in seconds") + timeout_seconds: Optional[int] = Field( + None, description="Request timeout in seconds" + ) status: Optional[EndpointStatus] = Field(None, description="Endpoint status") middleware: Optional[List[str]] = Field(None, description="Middleware to apply") - metadata: Optional[Dict[str, Any]] = Field(None, description="Additional endpoint metadata") + metadata: Optional[Dict[str, Any]] = Field( + None, description="Additional endpoint metadata" + ) class EndpointMetrics(BaseModel): """HTTP endpoint metrics.""" + endpoint_id: str = Field(..., description="Endpoint identifier") total_requests: int = Field(..., description="Total number of requests") successful_requests: int = Field(..., description="Number of successful requests") failed_requests: int = Field(..., description="Number of failed requests") - average_response_time_ms: float = Field(..., description="Average response time in milliseconds") - max_response_time_ms: float = Field(..., description="Maximum response time in milliseconds") - min_response_time_ms: float = Field(..., description="Minimum response time in milliseconds") - requests_per_minute: float = Field(..., description="Current requests per minute rate") + average_response_time_ms: float = Field( + ..., description="Average response time in milliseconds" + ) + max_response_time_ms: float = Field( + ..., description="Maximum response time in milliseconds" + ) + min_response_time_ms: float = Field( + ..., description="Minimum response time in milliseconds" + ) + requests_per_minute: float = Field( + ..., description="Current requests per minute rate" + ) error_rate_percent: float = Field(..., description="Error rate percentage") - last_request_at: Optional[datetime] = Field(None, description="Timestamp of last request") + last_request_at: Optional[datetime] = Field( + None, description="Timestamp of last request" + ) uptime_seconds: int = Field(..., description="Endpoint uptime in seconds") class HttpEndpointInfo(BaseModel): """HTTP endpoint information.""" + endpoint_id: str = Field(..., description="Endpoint identifier") path: str = Field(..., description="Endpoint path") method: HttpMethod = Field(..., description="HTTP method") @@ -959,20 +1158,27 @@ class HttpEndpointInfo(BaseModel): auth_required: bool = Field(..., description="Whether authentication is required") rate_limit: Optional[int] = Field(None, description="Rate limit per minute") timeout_seconds: int = Field(..., description="Request timeout in seconds") - middleware: List[str] = Field(default_factory=list, description="Applied middleware") + middleware: List[str] = Field( + default_factory=list, description="Applied middleware" + ) created_at: datetime = Field(..., description="Endpoint creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") created_by: str = Field(..., description="User who created the endpoint") metrics: Optional[EndpointMetrics] = Field(None, description="Endpoint metrics") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional endpoint metadata") + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional endpoint metadata" + ) class HttpEndpointResponse(BaseModel): """Response from HTTP endpoint operations.""" + endpoint_id: str = Field(..., description="Endpoint identifier") status: str = Field(..., description="Operation status") message: Optional[str] = Field(None, description="Operation message") - endpoint_info: Optional[HttpEndpointInfo] = Field(None, description="Endpoint information") + endpoint_info: Optional[HttpEndpointInfo] = Field( + None, description="Endpoint information" + ) created_at: Optional[datetime] = Field(None, description="Creation timestamp") @@ -980,8 +1186,10 @@ class HttpEndpointResponse(BaseModel): # Webhook Verification Models # ============================================================================= + class WebhookProviderType(str, Enum): """Webhook provider type enumeration.""" + GITHUB = "github" STRIPE = "stripe" SLACK = "slack" @@ -990,6 +1198,7 @@ class WebhookProviderType(str, Enum): class WebhookVerificationConfig(BaseModel): """Webhook verification configuration.""" + provider: WebhookProviderType = Field(..., description="Webhook provider type") secret: str = Field(..., description="Shared secret for verification") header_name: Optional[str] = Field(None, description="Custom header name override") @@ -1008,6 +1217,7 @@ class WebhookVerificationConfig(BaseModel): class WebhookInvocationStatus(str, Enum): """Status of an HTTP Input invocation.""" + EXECUTION_STARTED = "execution_started" COMPLETED = "completed" @@ -1021,6 +1231,7 @@ class WebhookInvocationRequest(BaseModel): 4096 bytes by the runtime (truncated on a UTF-8 character boundary). Additional fields are permitted and passed through. """ + prompt: Optional[str] = Field(None, description="User prompt (preferred)") message: Optional[str] = Field(None, description="Alternative user message field") system_prompt: Optional[str] = Field( @@ -1038,18 +1249,26 @@ class WebhookToolRun(BaseModel): ``output_preview`` is truncated on a UTF-8 character boundary to at most 500 bytes by the runtime. """ + tool: str = Field(..., description="ToolClad manifest name") - input: Dict[str, Any] = Field(default_factory=dict, description="Tool call input arguments") - output_preview: str = Field(..., description="UTF-8 safe preview of tool output (<= 500 bytes)") + input: Dict[str, Any] = Field( + default_factory=dict, description="Tool call input arguments" + ) + output_preview: str = Field( + ..., description="UTF-8 safe preview of tool output (<= 500 bytes)" + ) class WebhookExecutionStartedResponse(BaseModel): """Response when the target agent was running and the request was dispatched on the runtime communication bus. """ + status: str = Field("execution_started", description="Always 'execution_started'") agent_id: str = Field(..., description="Target agent identifier") - message_id: str = Field(..., description="Runtime message identifier for the dispatch") + message_id: str = Field( + ..., description="Runtime message identifier for the dispatch" + ) latency_ms: int = Field(..., description="Dispatch latency in milliseconds") timestamp: str = Field(..., description="RFC 3339 timestamp") @@ -1058,6 +1277,7 @@ class WebhookCompletedResponse(BaseModel): """Response when the request was served by the on-demand LLM ORGA tool-calling loop. """ + status: str = Field("completed", description="Always 'completed'") agent_id: str = Field(..., description="Target agent identifier") response: str = Field(..., description="Final assistant text from the LLM") @@ -1066,21 +1286,27 @@ class WebhookCompletedResponse(BaseModel): description="Per-tool execution previews from the ORGA loop", ) model: str = Field(..., description="LLM model identifier used") - provider: str = Field(..., description="LLM provider name (e.g. 'anthropic', 'openai', 'openrouter')") + provider: str = Field( + ..., description="LLM provider name (e.g. 'anthropic', 'openai', 'openrouter')" + ) latency_ms: int = Field(..., description="End-to-end loop latency in milliseconds") timestamp: str = Field(..., description="RFC 3339 timestamp") #: Discriminated union of all HTTP Input invocation responses. -WebhookInvocationResponse = Union[WebhookExecutionStartedResponse, WebhookCompletedResponse] +WebhookInvocationResponse = Union[ + WebhookExecutionStartedResponse, WebhookCompletedResponse +] # ============================================================================= # Skills Models # ============================================================================= + class SignatureStatusType(str, Enum): """Skill signature verification status.""" + VERIFIED = "verified" PINNED = "pinned" UNSIGNED = "unsigned" @@ -1090,6 +1316,7 @@ class SignatureStatusType(str, Enum): class ScanSeverityType(str, Enum): """Skill scan finding severity levels.""" + CRITICAL = "critical" WARNING = "warning" INFO = "info" @@ -1097,6 +1324,7 @@ class ScanSeverityType(str, Enum): class ScanFindingModel(BaseModel): """Individual scan finding from skill scanning.""" + rule: str = Field(..., description="Rule that triggered the finding") severity: ScanSeverityType = Field(..., description="Finding severity") message: str = Field(..., description="Finding description") @@ -1106,22 +1334,31 @@ class ScanFindingModel(BaseModel): class ScanResultModel(BaseModel): """Result of scanning a skill.""" + passed: bool = Field(..., description="Whether the scan passed") - findings: List[ScanFindingModel] = Field(default_factory=list, description="Scan findings") + findings: List[ScanFindingModel] = Field( + default_factory=list, description="Scan findings" + ) class SkillMetadataModel(BaseModel): """Skill metadata from frontmatter.""" + name: str = Field(..., description="Skill name") description: Optional[str] = Field(None, description="Skill description") - raw_frontmatter: Dict[str, Any] = Field(default_factory=dict, description="Raw YAML frontmatter") + raw_frontmatter: Dict[str, Any] = Field( + default_factory=dict, description="Raw YAML frontmatter" + ) class LoadedSkillModel(BaseModel): """A loaded skill with metadata and scan results.""" + name: str = Field(..., description="Skill name") path: str = Field(..., description="Skill directory path") - signature_status: SignatureStatusType = Field(..., description="Signature verification status") + signature_status: SignatureStatusType = Field( + ..., description="Signature verification status" + ) content: str = Field(..., description="Skill content") metadata: Optional[SkillMetadataModel] = Field(None, description="Skill metadata") scan_result: Optional[ScanResultModel] = Field(None, description="Scan result") @@ -1129,48 +1366,65 @@ class LoadedSkillModel(BaseModel): class SkillsConfig(BaseModel): """Skills configuration.""" - load_paths: List[str] = Field(default_factory=list, description="Paths to search for skills") + + load_paths: List[str] = Field( + default_factory=list, description="Paths to search for skills" + ) require_signed: bool = Field(False, description="Require all skills to be signed") - allow_unsigned_from: List[str] = Field(default_factory=list, description="Paths that allow unsigned skills") + allow_unsigned_from: List[str] = Field( + default_factory=list, description="Paths that allow unsigned skills" + ) auto_pin: bool = Field(False, description="Automatically pin new skill signatures") scan_enabled: bool = Field(True, description="Enable skill scanning") - custom_deny_patterns: List[str] = Field(default_factory=list, description="Custom deny regex patterns") + custom_deny_patterns: List[str] = Field( + default_factory=list, description="Custom deny regex patterns" + ) # ============================================================================= # Metrics Models # ============================================================================= + class OtlpProtocol(str, Enum): """OTLP exporter protocol.""" + GRPC = "grpc" HTTP = "http" class OtlpConfig(BaseModel): """OTLP exporter configuration.""" + endpoint: str = Field(..., description="OTLP endpoint URL") protocol: OtlpProtocol = Field(OtlpProtocol.GRPC, description="Transport protocol") - headers: Dict[str, str] = Field(default_factory=dict, description="Additional headers") + headers: Dict[str, str] = Field( + default_factory=dict, description="Additional headers" + ) timeout_seconds: int = Field(10, description="Export timeout in seconds") class FileMetricsConfig(BaseModel): """File-based metrics exporter configuration.""" + path: str = Field(..., description="Output file path") compact: bool = Field(True, description="Use compact JSON format") class MetricsConfig(BaseModel): """Metrics collection and export configuration.""" + enabled: bool = Field(True, description="Enable metrics collection") export_interval_seconds: int = Field(60, description="Export interval in seconds") otlp: Optional[OtlpConfig] = Field(None, description="OTLP exporter configuration") - file: Optional[FileMetricsConfig] = Field(None, description="File exporter configuration") + file: Optional[FileMetricsConfig] = Field( + None, description="File exporter configuration" + ) class SchedulerMetricsSnapshot(BaseModel): """Scheduler metrics snapshot.""" + jobs_total: int = Field(0, description="Total scheduled jobs") jobs_active: int = Field(0, description="Active jobs") jobs_paused: int = Field(0, description="Paused jobs") @@ -1181,6 +1435,7 @@ class SchedulerMetricsSnapshot(BaseModel): class TaskManagerMetricsSnapshot(BaseModel): """Task manager metrics snapshot.""" + tasks_active: int = Field(0, description="Active tasks") tasks_queued: int = Field(0, description="Queued tasks") tasks_completed: int = Field(0, description="Completed tasks") @@ -1189,6 +1444,7 @@ class TaskManagerMetricsSnapshot(BaseModel): class LoadBalancerMetricsSnapshot(BaseModel): """Load balancer metrics snapshot.""" + total_requests: int = Field(0, description="Total requests processed") active_connections: int = Field(0, description="Active connections") backends_healthy: int = Field(0, description="Healthy backends") @@ -1197,6 +1453,7 @@ class LoadBalancerMetricsSnapshot(BaseModel): class SystemResourceMetricsSnapshot(BaseModel): """System resource metrics snapshot.""" + cpu_usage_percent: float = Field(0.0, description="CPU usage percentage") memory_usage_bytes: int = Field(0, description="Memory usage in bytes") memory_usage_percent: float = Field(0.0, description="Memory usage percentage") @@ -1206,19 +1463,30 @@ class SystemResourceMetricsSnapshot(BaseModel): class MetricsSnapshot(BaseModel): """Complete metrics snapshot at a point in time.""" + timestamp: datetime = Field(..., description="Snapshot timestamp") - scheduler: Optional[SchedulerMetricsSnapshot] = Field(None, description="Scheduler metrics") - task_manager: Optional[TaskManagerMetricsSnapshot] = Field(None, description="Task manager metrics") - load_balancer: Optional[LoadBalancerMetricsSnapshot] = Field(None, description="Load balancer metrics") - system: Optional[SystemResourceMetricsSnapshot] = Field(None, description="System resource metrics") + scheduler: Optional[SchedulerMetricsSnapshot] = Field( + None, description="Scheduler metrics" + ) + task_manager: Optional[TaskManagerMetricsSnapshot] = Field( + None, description="Task manager metrics" + ) + load_balancer: Optional[LoadBalancerMetricsSnapshot] = Field( + None, description="Load balancer metrics" + ) + system: Optional[SystemResourceMetricsSnapshot] = Field( + None, description="System resource metrics" + ) # ============================================================================= # ToolClad Models # ============================================================================= + class ToolManifestInfo(BaseModel): """Summary of a ToolClad manifest.""" + name: str version: str description: str @@ -1231,6 +1499,7 @@ class ToolManifestInfo(BaseModel): class ToolValidationResult(BaseModel): """Result of manifest validation.""" + valid: bool errors: List[str] = [] warnings: List[str] = [] @@ -1238,6 +1507,7 @@ class ToolValidationResult(BaseModel): class ToolTestResult(BaseModel): """Result of a tool dry-run.""" + command: str validations: List[str] cedar: Optional[str] = None @@ -1246,6 +1516,7 @@ class ToolTestResult(BaseModel): class ToolExecutionResult(BaseModel): """Evidence envelope from tool execution.""" + status: str scan_id: str tool: str @@ -1262,8 +1533,10 @@ class ToolExecutionResult(BaseModel): # Communication Policy Models # ============================================================================= + class CommunicationRule(BaseModel): """Inter-agent communication policy rule.""" + id: Optional[str] = None from_agent: str to_agent: str @@ -1276,7 +1549,7 @@ class CommunicationRule(BaseModel): class CommunicationEvaluation(BaseModel): """Result of a communication policy evaluation.""" + allowed: bool rule: Optional[CommunicationRule] = None reason: str = "" - diff --git a/symbiont/qdrant.py b/symbiont/qdrant.py deleted file mode 100644 index ef0212c..0000000 --- a/symbiont/qdrant.py +++ /dev/null @@ -1,672 +0,0 @@ -"""Qdrant vector database integration for Symbiont SDK.""" - -import logging -from typing import Any, Dict, List, Optional, Union -from uuid import uuid4 - -from .exceptions import ( - CollectionNotFoundError, - QdrantConnectionError, - VectorDatabaseError, -) - -logger = logging.getLogger(__name__) - - -class QdrantManager: - """Manages interactions with Qdrant vector database.""" - - def __init__(self, - host: str = "localhost", - port: int = 6333, - grpc_port: int = 6334, - prefer_grpc: bool = False, - timeout: float = 60.0, - api_key: Optional[str] = None, - **kwargs): - """Initialize Qdrant manager. - - Args: - host: Qdrant server host - port: Qdrant HTTP API port - grpc_port: Qdrant gRPC port - prefer_grpc: Whether to use gRPC instead of HTTP - timeout: Request timeout in seconds - api_key: Optional API key for authentication - **kwargs: Additional client configuration - """ - self.host = host - self.port = port - self.grpc_port = grpc_port - self.prefer_grpc = prefer_grpc - self.timeout = timeout - self.api_key = api_key - self._client = None - self._config = kwargs - - def _get_client(self): - """Get or create Qdrant client instance.""" - if self._client is None: - try: - from qdrant_client import QdrantClient - self._client = QdrantClient( - host=self.host, - port=self.port, - grpc_port=self.grpc_port, - prefer_grpc=self.prefer_grpc, - timeout=self.timeout, - api_key=self.api_key, - **self._config - ) - except ImportError as e: - raise VectorDatabaseError( - "qdrant-client is required but not installed. " - "Install it with: pip install qdrant-client" - ) from e - except Exception as e: - raise QdrantConnectionError( - f"Failed to connect to Qdrant at {self.host}:{self.port}: {e}" - ) from e - return self._client - - def health_check(self) -> bool: - """Check if Qdrant server is healthy. - - Returns: - True if server is healthy, False otherwise - """ - try: - client = self._get_client() - return client.get_collections() is not None - except Exception as e: - logger.error(f"Qdrant health check failed: {e}") - return False - - def create_collection(self, - collection_name: str, - vector_size: int, - distance: str = "Cosine", - on_disk_payload: bool = False, - hnsw_config: Optional[Dict[str, Any]] = None, - optimizers_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Create a new collection. - - Args: - collection_name: Name of the collection - vector_size: Size of the vectors - distance: Distance metric (Cosine, Euclidean, Dot) - on_disk_payload: Whether to store payload on disk - hnsw_config: HNSW configuration parameters - optimizers_config: Optimizer configuration parameters - - Returns: - Collection creation result - """ - try: - from qdrant_client.models import ( - Distance, - HnswConfigDiff, - OptimizersConfigDiff, - VectorParams, - ) - - client = self._get_client() - - # Map distance string to enum - distance_map = { - "Cosine": Distance.COSINE, - "Euclidean": Distance.EUCLID, - "Dot": Distance.DOT - } - distance_metric = distance_map.get(distance, Distance.COSINE) - - # Configure HNSW parameters - hnsw_config_obj = None - if hnsw_config: - hnsw_config_obj = HnswConfigDiff(**hnsw_config) - - # Configure optimizer parameters - optimizers_config_obj = None - if optimizers_config: - optimizers_config_obj = OptimizersConfigDiff(**optimizers_config) - - result = client.create_collection( - collection_name=collection_name, - vectors_config=VectorParams( - size=vector_size, - distance=distance_metric, - on_disk=on_disk_payload - ), - hnsw_config=hnsw_config_obj, - optimizers_config=optimizers_config_obj - ) - - return { - "collection_name": collection_name, - "status": "created", - "result": result - } - - except Exception as e: - raise VectorDatabaseError(f"Failed to create collection {collection_name}: {e}") from e - - def delete_collection(self, collection_name: str) -> Dict[str, Any]: - """Delete a collection. - - Args: - collection_name: Name of the collection to delete - - Returns: - Deletion result - """ - try: - client = self._get_client() - result = client.delete_collection(collection_name) - - return { - "collection_name": collection_name, - "status": "deleted", - "result": result - } - - except Exception as e: - raise VectorDatabaseError(f"Failed to delete collection {collection_name}: {e}") from e - - def get_collection_info(self, collection_name: str) -> Dict[str, Any]: - """Get information about a collection. - - Args: - collection_name: Name of the collection - - Returns: - Collection information - """ - try: - client = self._get_client() - info = client.get_collection(collection_name) - - return { - "collection_name": collection_name, - "config": info.config.dict() if hasattr(info.config, 'dict') else str(info.config), - "status": info.status, - "vectors_count": info.vectors_count, - "indexed_vectors_count": info.indexed_vectors_count, - "points_count": info.points_count - } - - except Exception as e: - if "not found" in str(e).lower(): - raise CollectionNotFoundError(f"Collection {collection_name} not found") from e - raise VectorDatabaseError(f"Failed to get collection info for {collection_name}: {e}") from e - - def list_collections(self) -> List[str]: - """List all collections. - - Returns: - List of collection names - """ - try: - client = self._get_client() - collections = client.get_collections() - return [collection.name for collection in collections.collections] - - except Exception as e: - raise VectorDatabaseError(f"Failed to list collections: {e}") from e - - def upsert_points(self, - collection_name: str, - points: List[Dict[str, Any]], - wait: bool = True) -> Dict[str, Any]: - """Upsert points into a collection. - - Args: - collection_name: Name of the collection - points: List of points to upsert - wait: Whether to wait for the operation to complete - - Returns: - Upsert operation result - """ - try: - from qdrant_client.models import PointStruct - - client = self._get_client() - - # Convert points to PointStruct objects - point_structs = [] - for point in points: - point_id = point.get("id", str(uuid4())) - vector = point.get("vector", point.get("embedding")) - payload = point.get("payload", {}) - - if vector is None: - raise VectorDatabaseError(f"Point {point_id} missing vector/embedding") - - point_structs.append(PointStruct( - id=point_id, - vector=vector, - payload=payload - )) - - result = client.upsert( - collection_name=collection_name, - points=point_structs, - wait=wait - ) - - return { - "collection_name": collection_name, - "operation_id": result.operation_id, - "status": result.status.value, - "points_count": len(point_structs) - } - - except Exception as e: - raise VectorDatabaseError(f"Failed to upsert points to {collection_name}: {e}") from e - - def search_points(self, - collection_name: str, - query_vector: List[float], - limit: int = 10, - score_threshold: Optional[float] = None, - payload_filter: Optional[Dict[str, Any]] = None, - with_payload: bool = True, - with_vectors: bool = False) -> List[Dict[str, Any]]: - """Search for similar points. - - Args: - collection_name: Name of the collection - query_vector: Query vector - limit: Maximum number of results - score_threshold: Minimum similarity score - payload_filter: Optional payload filter - with_payload: Whether to include payload in results - with_vectors: Whether to include vectors in results - - Returns: - List of search results - """ - try: - from qdrant_client.models import Filter - - client = self._get_client() - - # Convert payload filter if provided - filter_obj = None - if payload_filter: - filter_obj = Filter(**payload_filter) - - results = client.search( - collection_name=collection_name, - query_vector=query_vector, - limit=limit, - score_threshold=score_threshold, - query_filter=filter_obj, - with_payload=with_payload, - with_vectors=with_vectors - ) - - # Convert results to dict format - search_results = [] - for result in results: - result_dict = { - "id": result.id, - "score": result.score - } - - if with_payload and result.payload: - result_dict["payload"] = result.payload - - if with_vectors and result.vector: - result_dict["vector"] = result.vector - - search_results.append(result_dict) - - return search_results - - except Exception as e: - if "not found" in str(e).lower(): - raise CollectionNotFoundError(f"Collection {collection_name} not found") from e - raise VectorDatabaseError(f"Failed to search in collection {collection_name}: {e}") from e - - def get_points(self, - collection_name: str, - point_ids: List[Union[str, int]], - with_payload: bool = True, - with_vectors: bool = False) -> List[Dict[str, Any]]: - """Retrieve points by IDs. - - Args: - collection_name: Name of the collection - point_ids: List of point IDs - with_payload: Whether to include payload - with_vectors: Whether to include vectors - - Returns: - List of retrieved points - """ - try: - client = self._get_client() - - results = client.retrieve( - collection_name=collection_name, - ids=point_ids, - with_payload=with_payload, - with_vectors=with_vectors - ) - - # Convert results to dict format - points = [] - for result in results: - point_dict = { - "id": result.id - } - - if with_payload and result.payload: - point_dict["payload"] = result.payload - - if with_vectors and result.vector: - point_dict["vector"] = result.vector - - points.append(point_dict) - - return points - - except Exception as e: - if "not found" in str(e).lower(): - raise CollectionNotFoundError(f"Collection {collection_name} not found") from e - raise VectorDatabaseError(f"Failed to retrieve points from {collection_name}: {e}") from e - - def delete_points(self, - collection_name: str, - point_ids: List[Union[str, int]], - wait: bool = True) -> Dict[str, Any]: - """Delete points by IDs. - - Args: - collection_name: Name of the collection - point_ids: List of point IDs to delete - wait: Whether to wait for the operation to complete - - Returns: - Deletion result - """ - try: - client = self._get_client() - - result = client.delete( - collection_name=collection_name, - points_selector=point_ids, - wait=wait - ) - - return { - "collection_name": collection_name, - "operation_id": result.operation_id, - "status": result.status.value, - "deleted_count": len(point_ids) - } - - except Exception as e: - if "not found" in str(e).lower(): - raise CollectionNotFoundError(f"Collection {collection_name} not found") from e - raise VectorDatabaseError(f"Failed to delete points from {collection_name}: {e}") from e - - def count_points(self, - collection_name: str, - exact: bool = True) -> int: - """Count points in a collection. - - Args: - collection_name: Name of the collection - exact: Whether to return exact count - - Returns: - Number of points in the collection - """ - try: - client = self._get_client() - result = client.count(collection_name=collection_name, exact=exact) - return result.count - - except Exception as e: - if "not found" in str(e).lower(): - raise CollectionNotFoundError(f"Collection {collection_name} not found") from e - raise VectorDatabaseError(f"Failed to count points in {collection_name}: {e}") from e - - def close(self): - """Close the Qdrant client connection.""" - if self._client: - try: - self._client.close() - except Exception as e: - logger.warning(f"Error closing Qdrant client: {e}") - finally: - self._client = None - - -class CollectionManager: - """Manages Qdrant collection lifecycle operations.""" - - def __init__(self, qdrant_manager: QdrantManager): - """Initialize collection manager. - - Args: - qdrant_manager: QdrantManager instance - """ - self.qdrant = qdrant_manager - - def ensure_collection_exists(self, - collection_name: str, - vector_size: int, - distance: str = "Cosine", - **kwargs) -> bool: - """Ensure a collection exists, creating it if necessary. - - Args: - collection_name: Name of the collection - vector_size: Size of the vectors - distance: Distance metric - **kwargs: Additional collection parameters - - Returns: - True if collection was created, False if it already existed - """ - try: - self.qdrant.get_collection_info(collection_name) - return False # Collection already exists - except CollectionNotFoundError: - self.qdrant.create_collection( - collection_name=collection_name, - vector_size=vector_size, - distance=distance, - **kwargs - ) - return True # Collection was created - - -class VectorOperations: - """Handles vector CRUD operations.""" - - def __init__(self, qdrant_manager: QdrantManager): - """Initialize vector operations. - - Args: - qdrant_manager: QdrantManager instance - """ - self.qdrant = qdrant_manager - - def add_vectors(self, - collection_name: str, - vectors: List[Dict[str, Any]]) -> Dict[str, Any]: - """Add vectors to a collection. - - Args: - collection_name: Name of the collection - vectors: List of vector objects - - Returns: - Operation result - """ - return self.qdrant.upsert_points(collection_name, vectors) - - def get_vectors(self, - collection_name: str, - vector_ids: List[Union[str, int]]) -> List[Dict[str, Any]]: - """Get vectors by IDs. - - Args: - collection_name: Name of the collection - vector_ids: List of vector IDs - - Returns: - List of vectors - """ - return self.qdrant.get_points( - collection_name=collection_name, - point_ids=vector_ids, - with_vectors=True, - with_payload=True - ) - - def search_vectors(self, - collection_name: str, - query_vector: List[float], - limit: int = 10, - **kwargs) -> List[Dict[str, Any]]: - """Search for similar vectors. - - Args: - collection_name: Name of the collection - query_vector: Query vector - limit: Maximum number of results - **kwargs: Additional search parameters - - Returns: - List of search results - """ - return self.qdrant.search_points( - collection_name=collection_name, - query_vector=query_vector, - limit=limit, - **kwargs - ) - - -class SearchEngine: - """Semantic search implementation using Qdrant.""" - - def __init__(self, qdrant_manager: QdrantManager): - """Initialize search engine. - - Args: - qdrant_manager: QdrantManager instance - """ - self.qdrant = qdrant_manager - - def semantic_search(self, - collection_name: str, - query_text: str, - limit: int = 10, - score_threshold: Optional[float] = None, - embedding_function: Optional[callable] = None) -> List[Dict[str, Any]]: - """Perform semantic search using text query. - - Args: - collection_name: Name of the collection - query_text: Text query to search for - limit: Maximum number of results - score_threshold: Minimum similarity score - embedding_function: Function to convert text to embeddings - - Returns: - List of search results - """ - if embedding_function is None: - raise VectorDatabaseError("embedding_function is required for semantic search") - - # Convert text to vector - query_vector = embedding_function(query_text) - - # Perform vector search - return self.qdrant.search_points( - collection_name=collection_name, - query_vector=query_vector, - limit=limit, - score_threshold=score_threshold, - with_payload=True - ) - - -class EmbeddingManager: - """Manages embedding generation and storage.""" - - def __init__(self, qdrant_manager: QdrantManager): - """Initialize embedding manager. - - Args: - qdrant_manager: QdrantManager instance - """ - self.qdrant = qdrant_manager - self._embedding_functions = {} - - def register_embedding_function(self, - name: str, - function: callable): - """Register an embedding function. - - Args: - name: Name of the embedding function - function: Function that converts text to embeddings - """ - self._embedding_functions[name] = function - - def get_embedding_function(self, name: str) -> callable: - """Get a registered embedding function. - - Args: - name: Name of the embedding function - - Returns: - The embedding function - """ - if name not in self._embedding_functions: - raise VectorDatabaseError(f"Embedding function '{name}' not registered") - return self._embedding_functions[name] - - def embed_and_store(self, - collection_name: str, - texts: List[str], - embedding_function_name: str, - metadata: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: - """Embed texts and store in collection. - - Args: - collection_name: Name of the collection - texts: List of texts to embed - embedding_function_name: Name of registered embedding function - metadata: Optional metadata for each text - - Returns: - Storage operation result - """ - embedding_function = self.get_embedding_function(embedding_function_name) - - # Generate embeddings - embeddings = [embedding_function(text) for text in texts] - - # Prepare points for storage - points = [] - for i, (text, embedding) in enumerate(zip(texts, embeddings)): - point_metadata = metadata[i] if metadata and i < len(metadata) else {} - point_metadata["text"] = text - point_metadata["embedding_function"] = embedding_function_name - - points.append({ - "id": str(uuid4()), - "vector": embedding, - "payload": point_metadata - }) - - # Store in Qdrant - return self.qdrant.upsert_points(collection_name, points) diff --git a/symbiont/reasoning.py b/symbiont/reasoning.py deleted file mode 100644 index ef167fd..0000000 --- a/symbiont/reasoning.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Reasoning loop models for the Symbiont SDK. - -Maps Rust runtime types from crates/runtime/src/reasoning/. -""" - -from enum import Enum -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel, Field - -# ============================================================================= -# Enums -# ============================================================================= - -class FinishReason(str, Enum): - """Reason the model stopped generating.""" - STOP = "stop" - TOOL_CALLS = "tool_calls" - MAX_TOKENS = "max_tokens" - CONTENT_FILTER = "content_filter" - - -class ProposedActionType(str, Enum): - """Type of proposed action from the reasoning loop.""" - TOOL_CALL = "tool_call" - DELEGATE = "delegate" - RESPOND = "respond" - TERMINATE = "terminate" - - -class LoopDecisionType(str, Enum): - """Policy gate decision for a proposed action.""" - ALLOW = "allow" - DENY = "deny" - MODIFY = "modify" - - -class TerminationReasonType(str, Enum): - """Reason the reasoning loop terminated.""" - COMPLETED = "completed" - MAX_ITERATIONS = "max_iterations" - MAX_TOKENS = "max_tokens" - TIMEOUT = "timeout" - POLICY_DENIAL = "policy_denial" - ERROR = "error" - - -class RecoveryStrategyType(str, Enum): - """Recovery strategy when a tool call fails.""" - RETRY = "retry" - FALLBACK = "fallback" - CACHED_RESULT = "cached_result" - LLM_RECOVERY = "llm_recovery" - ESCALATE = "escalate" - DEAD_LETTER = "dead_letter" - - -class LoopEventType(str, Enum): - """Type of event in the reasoning loop journal.""" - STARTED = "started" - REASONING_COMPLETE = "reasoning_complete" - POLICY_EVALUATED = "policy_evaluated" - TOOLS_DISPATCHED = "tools_dispatched" - OBSERVATIONS_COLLECTED = "observations_collected" - TERMINATED = "terminated" - RECOVERY_TRIGGERED = "recovery_triggered" - - -class CircuitState(str, Enum): - """Circuit breaker state.""" - CLOSED = "closed" - OPEN = "open" - HALF_OPEN = "half_open" - - -# ============================================================================= -# Inference Models -# ============================================================================= - -class Usage(BaseModel): - """Token usage statistics.""" - prompt_tokens: int = Field(..., description="Tokens in prompt/input") - completion_tokens: int = Field(..., description="Tokens in completion/output") - total_tokens: int = Field(..., description="Total tokens used") - - -class ToolDefinition(BaseModel): - """Tool available to the reasoning loop.""" - name: str = Field(..., description="Tool name") - description: str = Field(..., description="Human-readable description") - parameters: Any = Field(..., description="JSON Schema for parameters") - - -class ToolCallRequest(BaseModel): - """Request to invoke a tool.""" - id: str = Field(..., description="Unique call identifier") - name: str = Field(..., description="Name of tool to invoke") - arguments: str = Field(..., description="JSON-encoded arguments") - - -class InferenceOptions(BaseModel): - """Options for an LLM inference call.""" - max_tokens: int = Field(..., description="Max tokens to generate") - temperature: float = Field(0.7, description="Sampling temperature") - tool_definitions: List[ToolDefinition] = Field(default_factory=list, description="Available tools") - response_format: Dict[str, Any] = Field(default_factory=lambda: {"type": "text"}, description="Response format") - model: Optional[str] = Field(None, description="Optional model override") - extra: Dict[str, Any] = Field(default_factory=dict, description="Provider-specific params") - - -class InferenceResponse(BaseModel): - """Response from an LLM inference call.""" - content: str = Field(..., description="Text content of response") - tool_calls: List[ToolCallRequest] = Field(default_factory=list, description="Tool calls if any") - finish_reason: FinishReason = Field(..., description="Why generation stopped") - usage: Usage = Field(..., description="Token usage stats") - model: str = Field(..., description="Model that served the request") - - -# ============================================================================= -# Loop Models -# ============================================================================= - -class Observation(BaseModel): - """Observation from a tool execution or external source.""" - source: str = Field(..., description="Source of the observation") - content: str = Field(..., description="The observation content") - is_error: bool = Field(False, description="Whether this is an error") - metadata: Dict[str, str] = Field(default_factory=dict, description="Metadata for logging/auditing") - - -class ProposedAction(BaseModel): - """Action proposed by the reasoning loop.""" - type: ProposedActionType = Field(..., description="Action type") - # ToolCall fields - call_id: Optional[str] = Field(None, description="Unique call identifier (tool_call)") - name: Optional[str] = Field(None, description="Tool name (tool_call)") - arguments: Optional[str] = Field(None, description="JSON-encoded arguments (tool_call)") - # Delegate fields - target: Optional[str] = Field(None, description="Target agent identifier (delegate)") - message: Optional[str] = Field(None, description="Message to send (delegate)") - # Respond fields - content: Optional[str] = Field(None, description="Response content (respond)") - # Terminate fields - reason: Optional[str] = Field(None, description="Reason for termination (terminate)") - output: Optional[str] = Field(None, description="Final output (terminate)") - - -class LoopDecision(BaseModel): - """Decision from the policy gate.""" - decision: LoopDecisionType = Field(..., description="Decision type") - reason: Optional[str] = Field(None, description="Reason for deny/modify") - modified_action: Optional[ProposedAction] = Field(None, description="Modified action (modify only)") - - -class RecoveryStrategy(BaseModel): - """Strategy for recovering from tool failures.""" - type: RecoveryStrategyType = Field(..., description="Strategy type") - # Retry fields - max_attempts: Optional[int] = Field(None, description="Max retry attempts (retry)") - base_delay_ms: Optional[int] = Field(None, description="Base delay in ms (retry)") - # Fallback fields - alternatives: Optional[List[str]] = Field(None, description="Alternative tools (fallback)") - # CachedResult fields - max_staleness_ms: Optional[int] = Field(None, description="Max cache staleness in ms (cached_result)") - # LlmRecovery fields - max_recovery_attempts: Optional[int] = Field(None, description="Max LLM recovery attempts (llm_recovery)") - # Escalate fields - queue: Optional[str] = Field(None, description="Escalation queue (escalate)") - context_snapshot: Optional[bool] = Field(None, description="Include context snapshot (escalate)") - - -class TerminationReason(BaseModel): - """Reason the reasoning loop terminated.""" - type: TerminationReasonType = Field(..., description="Termination type") - reason: Optional[str] = Field(None, description="Reason for policy_denial") - message: Optional[str] = Field(None, description="Error message (error)") - - -class LoopConfig(BaseModel): - """Configuration for a reasoning loop run.""" - max_iterations: int = Field(10, description="Maximum iterations before forced termination") - max_total_tokens: int = Field(100000, description="Maximum tokens before forced termination") - timeout_ms: int = Field(300000, description="Maximum wall-clock time in ms") - default_recovery: RecoveryStrategy = Field( - default_factory=lambda: RecoveryStrategy(type=RecoveryStrategyType.DEAD_LETTER), - description="Default recovery strategy", - ) - tool_timeout_ms: int = Field(30000, description="Per-tool timeout in ms") - max_concurrent_tools: int = Field(4, description="Max concurrent tool calls") - context_token_budget: int = Field(4096, description="Token budget for context window") - tool_definitions: List[ToolDefinition] = Field(default_factory=list, description="Available tools") - - -class LoopState(BaseModel): - """Current state of a reasoning loop.""" - agent_id: str = Field(..., description="Agent identity") - iteration: int = Field(..., description="Current iteration (0-indexed)") - total_usage: Usage = Field(..., description="Cumulative token usage") - pending_observations: List[Observation] = Field(default_factory=list, description="Pending observations") - started_at: str = Field(..., description="Timestamp when loop started") - current_phase: str = Field(..., description="Current loop phase") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Arbitrary metadata") - - -class LoopResult(BaseModel): - """Result of a completed reasoning loop.""" - output: str = Field(..., description="Final response content") - iterations: int = Field(..., description="Total iterations") - total_usage: Usage = Field(..., description="Total token usage") - termination_reason: TerminationReason = Field(..., description="Why loop terminated") - duration_ms: int = Field(..., description="Wall-clock duration in ms") - - -# ============================================================================= -# Journal Models -# ============================================================================= - -class LoopEvent(BaseModel): - """Event recorded in the reasoning loop journal.""" - type: LoopEventType = Field(..., description="Event type") - # Started fields - agent_id: Optional[str] = Field(None, description="Agent ID (started)") - config: Optional[LoopConfig] = Field(None, description="Loop config (started)") - # ReasoningComplete / PolicyEvaluated / ToolsDispatched / ObservationsCollected fields - iteration: Optional[int] = Field(None, description="Iteration number") - actions: Optional[List[ProposedAction]] = Field(None, description="Proposed actions (reasoning_complete)") - usage: Optional[Usage] = Field(None, description="Token usage (reasoning_complete)") - action_count: Optional[int] = Field(None, description="Action count (policy_evaluated)") - denied_count: Optional[int] = Field(None, description="Denied count (policy_evaluated)") - tool_count: Optional[int] = Field(None, description="Tool count (tools_dispatched)") - duration_ms: Optional[int] = Field(None, description="Duration in ms (tools_dispatched, terminated)") - observation_count: Optional[int] = Field(None, description="Observation count (observations_collected)") - # Terminated fields - reason: Optional[TerminationReason] = Field(None, description="Termination reason (terminated)") - iterations: Optional[int] = Field(None, description="Total iterations (terminated)") - total_usage: Optional[Usage] = Field(None, description="Total usage (terminated)") - # RecoveryTriggered fields - tool_name: Optional[str] = Field(None, description="Tool name (recovery_triggered)") - strategy: Optional[RecoveryStrategy] = Field(None, description="Recovery strategy (recovery_triggered)") - error: Optional[str] = Field(None, description="Error message (recovery_triggered)") - - -class JournalEntry(BaseModel): - """Entry in the reasoning loop journal.""" - sequence: int = Field(..., description="Monotonically increasing sequence") - timestamp: str = Field(..., description="When entry was created") - agent_id: str = Field(..., description="Agent this entry belongs to") - iteration: int = Field(..., description="Iteration it was created in") - event: LoopEvent = Field(..., description="The event recorded") - - -# ============================================================================= -# Cedar Policy Models -# ============================================================================= - -class CedarPolicy(BaseModel): - """Cedar policy source definition.""" - name: str = Field(..., description="Unique policy name") - source: str = Field(..., description="Cedar policy source text") - active: bool = Field(True, description="Whether policy is active") - - -# ============================================================================= -# Knowledge Bridge Models -# ============================================================================= - -class KnowledgeConfig(BaseModel): - """Configuration for the knowledge bridge.""" - max_context_items: int = Field(5, description="Max items to inject per iteration") - relevance_threshold: float = Field(0.7, ge=0.0, le=1.0, description="Relevance threshold") - auto_persist: bool = Field(False, description="Auto-store learnings after loop") - - -# ============================================================================= -# Circuit Breaker Models -# ============================================================================= - -class CircuitBreakerConfig(BaseModel): - """Circuit breaker configuration.""" - failure_threshold: int = Field(5, ge=1, description="Failures before opening") - recovery_timeout_ms: int = Field(30000, ge=0, description="Open to HalfOpen delay in ms") - half_open_max_calls: int = Field(3, ge=1, description="Max calls in HalfOpen") - - -class CircuitBreakerStatus(BaseModel): - """Current status of a circuit breaker.""" - state: CircuitState = Field(..., description="Current circuit state") - failure_count: int = Field(..., ge=0, description="Consecutive failure count") - success_count: int = Field(..., ge=0, description="Success count") - config: CircuitBreakerConfig = Field(..., description="Breaker configuration") - - -# ============================================================================= -# API Request / Response Models -# ============================================================================= - -class RunReasoningLoopRequest(BaseModel): - """Request to start a reasoning loop.""" - config: LoopConfig = Field(..., description="Loop configuration") - initial_message: str = Field(..., description="Initial message to start the loop") - inference_options: Optional[InferenceOptions] = Field(None, description="Inference options") - cedar_policies: Optional[List[CedarPolicy]] = Field(None, description="Cedar policies to apply") - knowledge_config: Optional[KnowledgeConfig] = Field(None, description="Knowledge bridge config") - - -class RunReasoningLoopResponse(BaseModel): - """Response from a reasoning loop run.""" - loop_id: str = Field(..., description="Unique loop execution ID") - result: LoopResult = Field(..., description="Loop result") - journal_entries: List[JournalEntry] = Field(default_factory=list, description="Journal entries") diff --git a/symbiont/reasoning_client.py b/symbiont/reasoning_client.py deleted file mode 100644 index 924823f..0000000 --- a/symbiont/reasoning_client.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Reasoning loop client for the Symbiont SDK. - -Provides methods for reasoning loop, journal, Cedar policy, circuit breaker, -and knowledge bridge operations via the Symbiont Runtime API. -""" - -from typing import Any, Dict, List, Optional - -from .reasoning import ( - CedarPolicy, - CircuitBreakerStatus, - JournalEntry, - LoopDecision, - LoopState, - ProposedAction, - RunReasoningLoopRequest, - RunReasoningLoopResponse, -) - - -class ReasoningClient: - """Client for reasoning loop operations via the Symbiont Runtime API. - - This class is typically accessed through the main ``Client`` instance:: - - from symbiont import Client - client = Client() - response = client.reasoning.run_loop("agent-1", request) - """ - - def __init__(self, parent_client: Any) -> None: - self._client = parent_client - - def _request( - self, - method: str, - path: str, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - ) -> Any: - """Make an authenticated request through the parent client.""" - response = self._client._request(method, path, json=json, params=params) - return response.json() - - # ------------------------------------------------------------------------- - # Reasoning Loop - # ------------------------------------------------------------------------- - - def run_loop( - self, agent_id: str, request: RunReasoningLoopRequest - ) -> RunReasoningLoopResponse: - """Start a reasoning loop. ``POST /api/v1/agents/{id}/reasoning/loop``""" - data = self._request( - "POST", - f"/api/v1/agents/{agent_id}/reasoning/loop", - json=request.model_dump(), - ) - return RunReasoningLoopResponse(**data) - - def get_loop_status(self, agent_id: str, loop_id: str) -> LoopState: - """Get loop status. ``GET /api/v1/agents/{id}/reasoning/loop/{loop_id}``""" - data = self._request( - "GET", f"/api/v1/agents/{agent_id}/reasoning/loop/{loop_id}" - ) - return LoopState(**data) - - def cancel_loop(self, agent_id: str, loop_id: str) -> None: - """Cancel a running loop. ``DELETE /api/v1/agents/{id}/reasoning/loop/{loop_id}``""" - self._request( - "DELETE", f"/api/v1/agents/{agent_id}/reasoning/loop/{loop_id}" - ) - - # ------------------------------------------------------------------------- - # Journal - # ------------------------------------------------------------------------- - - def get_journal_entries( - self, - agent_id: str, - from_sequence: int = 0, - limit: int = 100, - ) -> List[JournalEntry]: - """Get journal entries. ``GET /api/v1/agents/{id}/reasoning/journal``""" - data = self._request( - "GET", - f"/api/v1/agents/{agent_id}/reasoning/journal", - params={"from_sequence": from_sequence, "limit": limit}, - ) - return [JournalEntry(**entry) for entry in data] - - def compact_journal(self, agent_id: str) -> Dict[str, int]: - """Compact journal entries. ``POST /api/v1/agents/{id}/reasoning/journal/compact``""" - return self._request( - "POST", f"/api/v1/agents/{agent_id}/reasoning/journal/compact" - ) - - # ------------------------------------------------------------------------- - # Cedar Policies - # ------------------------------------------------------------------------- - - def list_cedar_policies(self, agent_id: str) -> List[CedarPolicy]: - """List Cedar policies. ``GET /api/v1/agents/{id}/reasoning/cedar``""" - data = self._request( - "GET", f"/api/v1/agents/{agent_id}/reasoning/cedar" - ) - return [CedarPolicy(**item) for item in data] - - def add_cedar_policy(self, agent_id: str, policy: CedarPolicy) -> None: - """Add a Cedar policy. ``POST /api/v1/agents/{id}/reasoning/cedar``""" - self._request( - "POST", - f"/api/v1/agents/{agent_id}/reasoning/cedar", - json=policy.model_dump(), - ) - - def remove_cedar_policy(self, agent_id: str, policy_name: str) -> bool: - """Remove a Cedar policy. ``DELETE /api/v1/agents/{id}/reasoning/cedar/{name}``""" - data = self._request( - "DELETE", f"/api/v1/agents/{agent_id}/reasoning/cedar/{policy_name}" - ) - return data.get("removed", False) - - def evaluate_cedar_policy( - self, agent_id: str, action: ProposedAction - ) -> LoopDecision: - """Evaluate a Cedar policy. ``POST /api/v1/agents/{id}/reasoning/cedar/evaluate``""" - data = self._request( - "POST", - f"/api/v1/agents/{agent_id}/reasoning/cedar/evaluate", - json=action.model_dump(), - ) - return LoopDecision(**data) - - # ------------------------------------------------------------------------- - # Circuit Breakers - # ------------------------------------------------------------------------- - - def get_circuit_breaker_status( - self, agent_id: str - ) -> Dict[str, CircuitBreakerStatus]: - """Get circuit breaker status. ``GET /api/v1/agents/{id}/reasoning/circuit-breakers``""" - data = self._request( - "GET", f"/api/v1/agents/{agent_id}/reasoning/circuit-breakers" - ) - return {k: CircuitBreakerStatus(**v) for k, v in data.items()} - - def reset_circuit_breaker(self, agent_id: str, tool_name: str) -> None: - """Reset a circuit breaker. ``POST /api/v1/agents/{id}/reasoning/circuit-breakers/{tool}/reset``""" - self._request( - "POST", - f"/api/v1/agents/{agent_id}/reasoning/circuit-breakers/{tool_name}/reset", - ) - - # ------------------------------------------------------------------------- - # Knowledge Bridge - # ------------------------------------------------------------------------- - - def recall_knowledge( - self, agent_id: str, query: str, limit: int = 5 - ) -> List[str]: - """Recall knowledge. ``POST /api/v1/agents/{id}/reasoning/knowledge/recall``""" - return self._request( - "POST", - f"/api/v1/agents/{agent_id}/reasoning/knowledge/recall", - json={"query": query, "limit": limit}, - ) - - def store_knowledge( - self, - agent_id: str, - subject: str, - predicate: str, - object: str, - confidence: float = 0.8, - ) -> Dict[str, str]: - """Store knowledge. ``POST /api/v1/agents/{id}/reasoning/knowledge/store``""" - return self._request( - "POST", - f"/api/v1/agents/{agent_id}/reasoning/knowledge/store", - json={ - "subject": subject, - "predicate": predicate, - "object": object, - "confidence": confidence, - }, - ) - - # ------------------------------------------------------------------------- - # ORGA-Adaptive Features - # ------------------------------------------------------------------------- - - def get_tool_profiles(self, agent_id: str) -> List[Dict]: - """Get tool execution profiles for an agent. - - Returns timing statistics and success rates per tool. - - ``GET /api/v1/agents/{id}/reasoning/tool-profiles`` - """ - return self._request( - "GET", f"/api/v1/agents/{agent_id}/reasoning/tool-profiles" - ) - - def get_loop_diagnostics(self, agent_id: str, loop_id: str) -> Dict: - """Get diagnostics for a reasoning loop. - - Includes stuck-loop detection status, iteration history, - and adaptive parameters. - - ``GET /api/v1/agents/{id}/reasoning/{loop_id}/diagnostics`` - """ - return self._request( - "GET", - f"/api/v1/agents/{agent_id}/reasoning/{loop_id}/diagnostics", - ) diff --git a/symbiont/schedules.py b/symbiont/schedules.py index 6fe4d19..79d5375 100644 --- a/symbiont/schedules.py +++ b/symbiont/schedules.py @@ -229,9 +229,7 @@ def get_schedule_history( history = [ScheduleRunEntry(**entry) for entry in data.get("history", [])] return ScheduleHistoryResponse(job_id=data["job_id"], history=history) - def get_schedule_next_runs( - self, job_id: str, count: int = 10 - ) -> NextRunsResponse: + def get_schedule_next_runs(self, job_id: str, count: int = 10) -> NextRunsResponse: """Get next N run times. ``GET /schedules/{id}/next-runs``""" data = self._request( "GET", f"/schedules/{job_id}/next-runs", params={"count": count} diff --git a/symbiont/skills.py b/symbiont/skills.py index bfe88f3..8bad9e9 100644 --- a/symbiont/skills.py +++ b/symbiont/skills.py @@ -212,8 +212,16 @@ def scan_skill(self, skill_dir: str) -> ScanResult: all_findings: List[ScanFinding] = [] text_extensions = { - ".md", ".txt", ".py", ".js", ".ts", ".sh", - ".yaml", ".yml", ".json", ".toml", + ".md", + ".txt", + ".py", + ".js", + ".ts", + ".sh", + ".yaml", + ".yml", + ".json", + ".toml", } for root, _dirs, files in os.walk(skill_dir): @@ -231,9 +239,7 @@ def scan_skill(self, skill_dir: str) -> ScanResult: except OSError: pass - has_critical = any( - f.severity == ScanSeverity.CRITICAL for f in all_findings - ) + has_critical = any(f.severity == ScanSeverity.CRITICAL for f in all_findings) return ScanResult(passed=not has_critical, findings=all_findings) @@ -269,17 +275,19 @@ class SkillLoader: def __init__(self, config: SkillLoaderConfig) -> None: self._config = config self._scanner = SkillScanner( - custom_rules=[ - ScanRule( - name=f"custom-deny-{i}", - pattern=p, - severity=ScanSeverity.CRITICAL, - message=f"Custom deny pattern matched: {p}", - ) - for i, p in enumerate(config.custom_deny_patterns) - ] - if config.custom_deny_patterns - else None + custom_rules=( + [ + ScanRule( + name=f"custom-deny-{i}", + pattern=p, + severity=ScanSeverity.CRITICAL, + message=f"Custom deny pattern matched: {p}", + ) + for i, p in enumerate(config.custom_deny_patterns) + ] + if config.custom_deny_patterns + else None + ) ) self._schemapin_available = False try: diff --git a/symbiont/toolclad.py b/symbiont/toolclad.py deleted file mode 100644 index 7418311..0000000 --- a/symbiont/toolclad.py +++ /dev/null @@ -1,68 +0,0 @@ -"""ToolClad manifest management client for Symbiont SDK.""" - -from typing import Any, Dict, List - - -class ToolCladClient: - """Client for ToolClad manifest operations. - - This class is typically accessed through the main ``Client`` instance:: - - from symbiont import Client - client = Client() - tools = client.toolclad.list_tools() - """ - - def __init__(self, client): - self._client = client - - def list_tools(self) -> List[Dict[str, Any]]: - """List all discovered ToolClad manifests.""" - response = self._client._request("GET", "api/v1/tools") - return response.json() - - def validate_manifest(self, path: str) -> Dict[str, Any]: - """Validate a .clad.toml manifest file. - - Args: - path: Path to the manifest file (relative to tools_dir) - """ - response = self._client._request( - "POST", "api/v1/tools/validate", json={"path": path} - ) - return response.json() - - def test_tool(self, tool_name: str, args: Dict[str, str]) -> Dict[str, Any]: - """Dry-run a tool with given arguments (no execution). - - Returns the command that would be executed and validation results. - """ - response = self._client._request( - "POST", f"api/v1/tools/{tool_name}/test", json={"args": args} - ) - return response.json() - - def get_schema(self, tool_name: str) -> Dict[str, Any]: - """Get the MCP-compatible JSON schema for a tool.""" - response = self._client._request("GET", f"api/v1/tools/{tool_name}/schema") - return response.json() - - def execute_tool(self, tool_name: str, args: Dict[str, str]) -> Dict[str, Any]: - """Execute a tool with validated arguments. - - Returns an evidence envelope with results. - """ - response = self._client._request( - "POST", f"api/v1/tools/{tool_name}/execute", json={"args": args} - ) - return response.json() - - def get_tool_info(self, tool_name: str) -> Dict[str, Any]: - """Get detailed information about a tool manifest.""" - response = self._client._request("GET", f"api/v1/tools/{tool_name}") - return response.json() - - def reload_tools(self) -> Dict[str, Any]: - """Trigger a hot-reload of tool manifests from the tools directory.""" - response = self._client._request("POST", "api/v1/tools/reload") - return response.json() diff --git a/symbiont/webhooks.py b/symbiont/webhooks.py index 774d50c..32c106f 100644 --- a/symbiont/webhooks.py +++ b/symbiont/webhooks.py @@ -59,7 +59,7 @@ def verify(self, headers: Dict[str, str], body: bytes) -> None: # Strip prefix if configured if self._prefix and sig_value.startswith(self._prefix): - sig_value = sig_value[len(self._prefix):] + sig_value = sig_value[len(self._prefix) :] # Compute expected HMAC expected = hmac.new(self._secret, body, hashlib.sha256).hexdigest() diff --git a/tests/test_client.py b/tests/test_client.py index 865fce4..0e4f21a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,7 +18,7 @@ def _create_test_config(api_key=None, base_url=None): """Helper function to create a valid test configuration.""" config = ClientConfig() - config.auth.jwt_secret_key = 'test-secret-key-for-validation' + config.auth.jwt_secret_key = "test-secret-key-for-validation" config.auth.enable_refresh_tokens = False # Disable to avoid JWT validation if api_key: config.api_key = api_key @@ -41,7 +41,13 @@ def test_initialization_with_parameters(self): assert client.api_key == api_key assert client.base_url == base_url - @patch.dict(os.environ, {'SYMBIONT_API_KEY': 'env_api_key', 'SYMBIONT_BASE_URL': 'https://env.example.com/api/v1'}) + @patch.dict( + os.environ, + { + "SYMBIONT_API_KEY": "env_api_key", + "SYMBIONT_BASE_URL": "https://env.example.com/api/v1", + }, + ) def test_initialization_from_environment_variables(self): """Test Client loads configuration from environment variables.""" config = _create_test_config() @@ -63,7 +69,9 @@ def test_initialization_with_defaults(self): def test_initialization_parameter_priority(self): """Test that parameters take priority over environment variables.""" - config = _create_test_config(api_key="param_api_key", base_url="https://param.example.com/api/v1") + config = _create_test_config( + api_key="param_api_key", base_url="https://param.example.com/api/v1" + ) client = Client(config=config) assert client.api_key == "param_api_key" @@ -81,7 +89,7 @@ def test_base_url_trailing_slash_removal(self): class TestClientRequestHandling: """Test Client HTTP request handling.""" - @patch('requests.request') + @patch("requests.request") def test_successful_request_returns_response(self, mock_request): """Test that a successful request returns the expected response.""" # Mock successful response @@ -92,17 +100,17 @@ def test_successful_request_returns_response(self, mock_request): config = _create_test_config(api_key="test_key") client = Client(config=config) - response = client._request('GET', 'test-endpoint') + response = client._request("GET", "test-endpoint") assert response == mock_response mock_request.assert_called_once_with( - 'GET', - 'http://localhost:8080/api/v1/test-endpoint', - headers={'Authorization': 'Bearer test_key'}, - timeout=30 + "GET", + "http://localhost:8080/api/v1/test-endpoint", + headers={"Authorization": "Bearer test_key"}, + timeout=30, ) - @patch('requests.request') + @patch("requests.request") def test_request_with_api_key_includes_authorization_header(self, mock_request): """Test that Authorization header is correctly set when api_key is present.""" mock_response = Mock() @@ -111,16 +119,16 @@ def test_request_with_api_key_includes_authorization_header(self, mock_request): config = _create_test_config(api_key="test_api_key") client = Client(config=config) - client._request('GET', 'test-endpoint') + client._request("GET", "test-endpoint") mock_request.assert_called_once_with( - 'GET', - 'http://localhost:8080/api/v1/test-endpoint', - headers={'Authorization': 'Bearer test_api_key'}, - timeout=30 + "GET", + "http://localhost:8080/api/v1/test-endpoint", + headers={"Authorization": "Bearer test_api_key"}, + timeout=30, ) - @patch('requests.request') + @patch("requests.request") def test_request_without_api_key_omits_authorization_header(self, mock_request): """Test that Authorization header is omitted when api_key is not present.""" mock_response = Mock() @@ -129,16 +137,13 @@ def test_request_without_api_key_omits_authorization_header(self, mock_request): config = _create_test_config() # No API key client = Client(config=config) - client._request('GET', 'test-endpoint') + client._request("GET", "test-endpoint") mock_request.assert_called_once_with( - 'GET', - 'http://localhost:8080/api/v1/test-endpoint', - headers={}, - timeout=30 + "GET", "http://localhost:8080/api/v1/test-endpoint", headers={}, timeout=30 ) - @patch('requests.request') + @patch("requests.request") def test_request_with_custom_headers(self, mock_request): """Test that custom headers are merged correctly.""" mock_response = Mock() @@ -147,20 +152,20 @@ def test_request_with_custom_headers(self, mock_request): config = _create_test_config(api_key="test_key") client = Client(config=config) - custom_headers = {'Content-Type': 'application/json', 'X-Custom': 'value'} - client._request('POST', 'test-endpoint', headers=custom_headers) + custom_headers = {"Content-Type": "application/json", "X-Custom": "value"} + client._request("POST", "test-endpoint", headers=custom_headers) # Check that request was called with correct parameters (headers may be in different order) mock_request.assert_called_once() call_args = mock_request.call_args - assert call_args[0] == ('POST', 'http://localhost:8080/api/v1/test-endpoint') - assert call_args[1]['timeout'] == 30 - headers = call_args[1]['headers'] - assert headers['Authorization'] == 'Bearer test_key' - assert headers['Content-Type'] == 'application/json' - assert headers['X-Custom'] == 'value' - - @patch('requests.request') + assert call_args[0] == ("POST", "http://localhost:8080/api/v1/test-endpoint") + assert call_args[1]["timeout"] == 30 + headers = call_args[1]["headers"] + assert headers["Authorization"] == "Bearer test_key" + assert headers["Content-Type"] == "application/json" + assert headers["X-Custom"] == "value" + + @patch("requests.request") def test_request_url_construction(self, mock_request): """Test that URLs are constructed correctly with different endpoint formats.""" mock_response = Mock() @@ -171,29 +176,23 @@ def test_request_url_construction(self, mock_request): client = Client(config=config) # Test endpoint without leading slash - client._request('GET', 'agents') + client._request("GET", "agents") mock_request.assert_called_with( - 'GET', - 'https://api.example.com/v1/agents', - headers={}, - timeout=30 + "GET", "https://api.example.com/v1/agents", headers={}, timeout=30 ) # Test endpoint with leading slash mock_request.reset_mock() - client._request('GET', '/agents') + client._request("GET", "/agents") mock_request.assert_called_with( - 'GET', - 'https://api.example.com/v1/agents', - headers={}, - timeout=30 + "GET", "https://api.example.com/v1/agents", headers={}, timeout=30 ) class TestClientErrorHandling: """Test Client HTTP error handling.""" - @patch('requests.request') + @patch("requests.request") def test_401_raises_authentication_error(self, mock_request): """Test that 401 status code raises AuthenticationError.""" mock_response = Mock() @@ -205,13 +204,13 @@ def test_401_raises_authentication_error(self, mock_request): client = Client(config=config) with pytest.raises(AuthenticationError) as exc_info: - client._request('GET', 'test-endpoint') + client._request("GET", "test-endpoint") assert exc_info.value.status_code == 401 assert exc_info.value.response_text == "Unauthorized" assert "Authentication failed - check your credentials" in str(exc_info.value) - @patch('requests.request') + @patch("requests.request") def test_404_raises_not_found_error(self, mock_request): """Test that 404 status code raises NotFoundError.""" mock_response = Mock() @@ -223,13 +222,13 @@ def test_404_raises_not_found_error(self, mock_request): client = Client(config=config) with pytest.raises(NotFoundError) as exc_info: - client._request('GET', 'test-endpoint') + client._request("GET", "test-endpoint") assert exc_info.value.status_code == 404 assert exc_info.value.response_text == "Not Found" assert "Resource not found" in str(exc_info.value) - @patch('requests.request') + @patch("requests.request") def test_429_raises_rate_limit_error(self, mock_request): """Test that 429 status code raises RateLimitError.""" mock_response = Mock() @@ -241,13 +240,13 @@ def test_429_raises_rate_limit_error(self, mock_request): client = Client(config=config) with pytest.raises(RateLimitError) as exc_info: - client._request('GET', 'test-endpoint') + client._request("GET", "test-endpoint") assert exc_info.value.status_code == 429 assert exc_info.value.response_text == "Too Many Requests" assert "Rate limit exceeded - too many requests" in str(exc_info.value) - @patch('requests.request') + @patch("requests.request") def test_500_raises_api_error(self, mock_request): """Test that 500 status code raises APIError.""" mock_response = Mock() @@ -259,13 +258,13 @@ def test_500_raises_api_error(self, mock_request): client = Client(config=config) with pytest.raises(APIError) as exc_info: - client._request('GET', 'test-endpoint') + client._request("GET", "test-endpoint") assert exc_info.value.status_code == 500 assert exc_info.value.response_text == "Internal Server Error" assert "API request failed with status 500" in str(exc_info.value) - @patch('requests.request') + @patch("requests.request") def test_400_raises_api_error(self, mock_request): """Test that 400 status code raises APIError.""" mock_response = Mock() @@ -277,7 +276,7 @@ def test_400_raises_api_error(self, mock_request): client = Client(config=config) with pytest.raises(APIError) as exc_info: - client._request('GET', 'test-endpoint') + client._request("GET", "test-endpoint") assert exc_info.value.status_code == 400 assert exc_info.value.response_text == "Bad Request" diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 2a26217..d433bb5 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -91,7 +91,9 @@ def test_compact_json(self, tmp_path): def test_shutdown(self, tmp_path): """shutdown() is a no-op but doesn't raise.""" - exporter = FileMetricsExporter(FileExporterConfig(path=str(tmp_path / "x.json"))) + exporter = FileMetricsExporter( + FileExporterConfig(path=str(tmp_path / "x.json")) + ) exporter.shutdown() def test_overwrite(self, tmp_path): @@ -180,8 +182,9 @@ def test_client_mock(self): mock_parent._request.return_value = mock_response client = MetricsClient(mock_parent) - result = client.get_metrics_snapshot() + result = client.get_metrics() assert result["timestamp"] == "2026-02-15T12:00:00Z" + # The only metrics endpoint the OSS runtime serves is GET /api/v1/metrics. mock_parent._request.assert_called_once_with( - "GET", "/metrics/snapshot", json=None, params=None + "GET", "metrics", json=None, params=None ) diff --git a/tests/test_models.py b/tests/test_models.py index b10abbf..c9064d6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -20,7 +20,7 @@ def test_agent_creation_with_valid_data(self): "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } agent = Agent(**agent_data) @@ -46,7 +46,7 @@ def test_agent_creation_with_minimal_valid_data(self): "model": "gpt-3.5-turbo", "temperature": 0.0, "top_p": 0.1, - "max_tokens": 100 + "max_tokens": 100, } agent = Agent(**agent_data) @@ -68,7 +68,7 @@ def test_agent_missing_required_id_raises_validation_error(self): "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -89,7 +89,7 @@ def test_agent_missing_required_name_raises_validation_error(self): "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -110,7 +110,7 @@ def test_agent_missing_required_description_raises_validation_error(self): "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -131,7 +131,7 @@ def test_agent_missing_required_system_prompt_raises_validation_error(self): "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -152,7 +152,7 @@ def test_agent_missing_required_tools_raises_validation_error(self): "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -173,7 +173,7 @@ def test_agent_missing_required_model_raises_validation_error(self): "tools": ["tool1"], "temperature": 0.7, "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -194,7 +194,7 @@ def test_agent_missing_required_temperature_raises_validation_error(self): "tools": ["tool1"], "model": "gpt-4", "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -215,7 +215,7 @@ def test_agent_missing_required_top_p_raises_validation_error(self): "tools": ["tool1"], "model": "gpt-4", "temperature": 0.7, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -236,7 +236,7 @@ def test_agent_missing_required_max_tokens_raises_validation_error(self): "tools": ["tool1"], "model": "gpt-4", "temperature": 0.7, - "top_p": 0.9 + "top_p": 0.9, } with pytest.raises(ValidationError) as exc_info: @@ -261,7 +261,16 @@ def test_agent_multiple_missing_fields_raises_validation_error(self): assert len(errors) == 8 # All fields except 'id' are missing missing_fields = {error["loc"][0] for error in errors} - expected_missing = {"name", "description", "system_prompt", "tools", "model", "temperature", "top_p", "max_tokens"} + expected_missing = { + "name", + "description", + "system_prompt", + "tools", + "model", + "temperature", + "top_p", + "max_tokens", + } assert missing_fields == expected_missing def test_agent_invalid_temperature_type_raises_validation_error(self): @@ -275,7 +284,7 @@ def test_agent_invalid_temperature_type_raises_validation_error(self): "model": "gpt-4", "temperature": "invalid", # Should be float "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -297,7 +306,7 @@ def test_agent_invalid_top_p_type_raises_validation_error(self): "model": "gpt-4", "temperature": 0.7, "top_p": "invalid", # Should be float - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: @@ -319,7 +328,7 @@ def test_agent_invalid_max_tokens_type_raises_validation_error(self): "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, - "max_tokens": "invalid" # Should be int + "max_tokens": "invalid", # Should be int } with pytest.raises(ValidationError) as exc_info: @@ -341,7 +350,7 @@ def test_agent_invalid_tools_type_raises_validation_error(self): "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, - "max_tokens": 2000 + "max_tokens": 2000, } with pytest.raises(ValidationError) as exc_info: diff --git a/tests/test_new_features.py b/tests/test_new_features.py index ade7618..81dfd47 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -12,12 +12,6 @@ AuthConfig, ClientConfig, ) -from symbiont.memory import MemoryLevel, MemoryManager, MemoryType -from symbiont.models import ( - HttpEndpointCreateRequest, - HttpMethod, -) -from symbiont.qdrant import QdrantManager # Mock data and constants TEST_SECRET_KEY = "your-test-secret-key" @@ -37,36 +31,10 @@ def auth_manager(mock_config): return AuthManager(mock_config.auth) -@pytest.fixture -def memory_manager(): - """Fixture for MemoryManager with in-memory storage.""" - return MemoryManager(config={'storage_type': 'in-memory'}) - - -@pytest.fixture -def redis_memory_manager(): - """Fixture for MemoryManager with a mocked Redis backend.""" - with patch('redis.from_url') as mock_from_url: - mock_redis_instance = MagicMock() - mock_from_url.return_value = mock_redis_instance - manager = MemoryManager(config={'storage_type': 'redis'}) - yield manager - - -@pytest.fixture -@patch('qdrant_client.QdrantClient') -def qdrant_manager(mock_qdrant_client): - """Fixture for QdrantManager with a mocked Qdrant client.""" - mock_qdrant_instance = mock_qdrant_client.return_value - manager = QdrantManager(api_key="test-key", host="localhost", port=6333) - manager._client = mock_qdrant_instance - return manager - - @pytest.fixture def mock_client(mock_config): """Fixture for the API client with mocked requests.""" - with patch('requests.request'): + with patch("requests.request"): client = Client(config=mock_config) # We are mocking the internal _request method, not the requests library directly client._request = MagicMock() @@ -77,6 +45,7 @@ def mock_client(mock_config): # 1. Configuration and Authentication Tests # =================================== + def test_config_manager_env_override(monkeypatch): """Test that environment variables override default config values.""" monkeypatch.setenv("SYMBIONT_BASE_URL", "https://new-api.symbiont.com") @@ -84,6 +53,7 @@ def test_config_manager_env_override(monkeypatch): # The model needs to be reloaded to pick up the new env vars from symbiont.config import ClientConfig + ClientConfig.model_rebuild(force=True) config = ClientConfig() @@ -91,22 +61,24 @@ def test_config_manager_env_override(monkeypatch): assert config.base_url == "https://new-api.symbiont.com" assert config.auth.jwt_secret_key == "env-secret-key" + def test_jwt_creation_and_validation(auth_manager): """Test creating a JWT and then validating it.""" user = AuthUser(user_id=TEST_USER_ID, roles=[TEST_ROLE]) tokens = auth_manager.generate_tokens(user) - access_token = tokens['access'].token + access_token = tokens["access"].token validated_user = auth_manager.authenticate_with_jwt(access_token) assert validated_user is not None assert validated_user.user_id == TEST_USER_ID assert TEST_ROLE in validated_user.roles + def test_jwt_refresh(auth_manager): """Test the token refresh logic.""" user = AuthUser(user_id=TEST_USER_ID, roles=[TEST_ROLE]) tokens = auth_manager.generate_tokens(user) - refresh_token = tokens['refresh'].token + refresh_token = tokens["refresh"].token new_access_token = auth_manager.refresh_access_token(refresh_token) assert new_access_token is not None @@ -114,6 +86,7 @@ def test_jwt_refresh(auth_manager): validated_user = auth_manager.authenticate_with_jwt(new_access_token.token) assert validated_user.user_id == TEST_USER_ID + def test_expired_token_validation(auth_manager): """Test that an expired token raises AuthenticationExpiredError.""" auth_manager.config.jwt_expiration_seconds = -1 @@ -121,78 +94,21 @@ def test_expired_token_validation(auth_manager): tokens = auth_manager.generate_tokens(user) # The authenticate_with_jwt method should return None for an invalid token - assert auth_manager.authenticate_with_jwt(tokens['access'].token) is None - -# =================================== -# 2. Memory System Tests -# =================================== + assert auth_manager.authenticate_with_jwt(tokens["access"].token) is None -def test_hierarchical_memory_in_memory(memory_manager): - """Test HierarchicalMemory with the in-memory backend.""" - memory_manager.add_memory( - content={"message": "hello"}, - memory_type=MemoryType.CONVERSATION, - memory_level=MemoryLevel.SHORT_TERM, - agent_id="test-agent" - ) - memories = memory_manager.list_agent_memories("test-agent") - assert len(memories) == 1 - assert memories[0].content["message"] == "hello" - -def test_memory_manager_redis(redis_memory_manager): - """Test MemoryManager with the mocked Redis backend.""" - redis_memory_manager.add_memory( - content={"message": "hello redis"}, - memory_type=MemoryType.CONVERSATION, - memory_level=MemoryLevel.SHORT_TERM, - agent_id="test-agent-redis" - ) - redis_memory_manager.memory_store.redis_client.set.assert_called() - redis_memory_manager.memory_store.redis_client.lpush.assert_called() # =================================== -# 3. Qdrant Integration Tests +# 2. Client Convenience Method Tests # =================================== -def test_qdrant_manager_create_collection(qdrant_manager): - """Test creating a Qdrant collection.""" - qdrant_manager.create_collection("test-collection", vector_size=4) - qdrant_manager._client.create_collection.assert_called_once() - args, kwargs = qdrant_manager._client.create_collection.call_args - assert kwargs["collection_name"] == "test-collection" - -def test_qdrant_manager_upsert_points(qdrant_manager): - """Test upserting points to a Qdrant collection.""" - points = [{"id": 1, "vector": [0.1, 0.2, 0.3, 0.4], "payload": {"meta": "data"}}] - qdrant_manager.upsert_points("test-collection", points) - qdrant_manager._client.upsert.assert_called_once() - args, kwargs = qdrant_manager._client.upsert.call_args - assert kwargs["collection_name"] == "test-collection" - assert len(kwargs["points"]) == 1 - -# =================================== -# 4. API Endpoint Tests -# =================================== def test_client_get_configuration(mock_client): """Test the client's get_configuration method.""" assert mock_client.get_configuration() == mock_client.config + def test_client_get_user_roles(mock_client): """Test the client's get_user_roles method.""" mock_client._current_user = AuthUser(user_id=TEST_USER_ID, roles=[TEST_ROLE]) roles = mock_client.get_user_roles() assert TEST_ROLE in roles - -def test_client_create_http_endpoint(mock_client): - """Test the client's create_http_endpoint method.""" - mock_response_data = {"endpoint_id": "ep-123", "status": "active"} - mock_client._request.return_value.json.return_value = mock_response_data - - create_req = HttpEndpointCreateRequest( - path="/test", method=HttpMethod.POST, agent_id="agent-1" - ) - - response = mock_client.create_http_endpoint(create_req) - assert response.endpoint_id == "ep-123" - mock_client._request.assert_called_with("POST", "endpoints", json=create_req.model_dump()) diff --git a/tests/test_oss_endpoints.py b/tests/test_oss_endpoints.py new file mode 100644 index 0000000..6eb20ee --- /dev/null +++ b/tests/test_oss_endpoints.py @@ -0,0 +1,185 @@ +"""Tests for OSS runtime endpoint coverage and /api/v1 prefix de-duplication. + +These tests pin two things against Symbiont OSS runtime v1.14.x: + +1. The ``/api/v1`` version prefix is de-duplicated in exactly one place + (``Client._request``). The configured ``base_url`` already carries the + version segment (default ``http://localhost:8080/api/v1``); endpoints that + historically also hard-coded an ``api/v1/`` prefix previously produced a + doubled ``/api/v1/api/v1/`` path that 404s. The de-dup makes the segment + appear exactly once, while a ``base_url`` with a different prefix is left + untouched. + +2. The agent-execute, inter-agent messaging, and external-agent + heartbeat/event endpoints the runtime serves are reachable from the client + at their correct paths. +""" + +from unittest.mock import Mock, patch + +from symbiont import Client +from symbiont.config import ClientConfig + + +def _client(base_url=None, api_key="test-key"): + """Build a Client with refresh tokens disabled (mirrors test_client.py).""" + config = ClientConfig() + config.auth.jwt_secret_key = "test-secret-key-for-validation" + config.auth.enable_refresh_tokens = False + config.api_key = api_key + if base_url: + config.base_url = base_url + return Client(config=config) + + +def _mock_ok(payload=None): + response = Mock() + response.status_code = 200 + response.json.return_value = payload if payload is not None else {} + return response + + +# --------------------------------------------------------------------------- +# /api/v1 prefix de-duplication +# --------------------------------------------------------------------------- + + +@patch("requests.request") +def test_bare_endpoint_resolves_under_api_v1(mock_request): + """A bare endpoint resolves under the base_url's /api/v1 segment.""" + mock_request.return_value = _mock_ok({"status": "healthy"}) + client = _client() # default base_url = http://localhost:8080/api/v1 + client._request("GET", "agents/agent-1") + assert mock_request.call_args[0] == ( + "GET", + "http://localhost:8080/api/v1/agents/agent-1", + ) + + +@patch("requests.request") +def test_prefixed_endpoint_is_not_doubled(mock_request): + """An endpoint that already carries api/v1/ must not be doubled.""" + mock_request.return_value = _mock_ok({}) + client = _client() # default base_url ends with /api/v1 + client._request("GET", "api/v1/agents/agent-1") + assert mock_request.call_args[0] == ( + "GET", + "http://localhost:8080/api/v1/agents/agent-1", + ) + + +@patch("requests.request") +def test_custom_prefix_base_url_is_preserved(mock_request): + """A base_url with a non-/api/v1 prefix is left untouched.""" + mock_request.return_value = _mock_ok({}) + client = _client(base_url="https://api.example.com/v1") + client._request("GET", "test-endpoint") + assert mock_request.call_args[0] == ( + "GET", + "https://api.example.com/v1/test-endpoint", + ) + + +# --------------------------------------------------------------------------- +# Agent execute +# --------------------------------------------------------------------------- + + +@patch("requests.request") +def test_execute_agent(mock_request): + mock_request.return_value = _mock_ok( + {"execution_id": "exec-1", "status": "started"} + ) + client = _client() + result = client.execute_agent("agent-123") + assert result == {"execution_id": "exec-1", "status": "started"} + assert mock_request.call_args[0] == ( + "POST", + "http://localhost:8080/api/v1/agents/agent-123/execute", + ) + + +# --------------------------------------------------------------------------- +# Inter-agent messaging +# --------------------------------------------------------------------------- + + +@patch("requests.request") +def test_send_message(mock_request): + mock_request.return_value = _mock_ok({"message_id": "msg-1", "status": "pending"}) + client = _client() + result = client.send_message( + "agent-123", sender="agent-system", payload="hello", ttl_seconds=120 + ) + assert result["message_id"] == "msg-1" + assert mock_request.call_args[0] == ( + "POST", + "http://localhost:8080/api/v1/agents/agent-123/messages", + ) + body = mock_request.call_args[1]["json"] + assert body == { + "sender": "agent-system", + "payload": "hello", + "ttl_seconds": 120, + } + + +@patch("requests.request") +def test_receive_messages(mock_request): + mock_request.return_value = _mock_ok( + {"messages": [{"message_id": "msg-1", "payload": "hi"}]} + ) + client = _client() + result = client.receive_messages("agent-123") + assert result["messages"][0]["message_id"] == "msg-1" + assert mock_request.call_args[0] == ( + "GET", + "http://localhost:8080/api/v1/agents/agent-123/messages", + ) + + +@patch("requests.request") +def test_get_message_status(mock_request): + mock_request.return_value = _mock_ok({"message_id": "msg-1", "status": "delivered"}) + client = _client() + result = client.get_message_status("msg-1") + assert result["status"] == "delivered" + assert mock_request.call_args[0] == ( + "GET", + "http://localhost:8080/api/v1/messages/msg-1/status", + ) + + +# --------------------------------------------------------------------------- +# External agent lifecycle: heartbeat + events +# --------------------------------------------------------------------------- + + +@patch("requests.request") +def test_send_heartbeat(mock_request): + mock_request.return_value = _mock_ok({}) + client = _client() + assert client.send_heartbeat("agent-123", state="Running") is None + assert mock_request.call_args[0] == ( + "POST", + "http://localhost:8080/api/v1/agents/agent-123/heartbeat", + ) + assert mock_request.call_args[1]["json"] == {"state": "Running"} + + +@patch("requests.request") +def test_push_agent_event(mock_request): + mock_request.return_value = _mock_ok({}) + client = _client() + assert ( + client.push_agent_event( + "agent-123", event_type="RunCompleted", payload={"ok": True} + ) + is None + ) + assert mock_request.call_args[0] == ( + "POST", + "http://localhost:8080/api/v1/agents/agent-123/events", + ) + body = mock_request.call_args[1]["json"] + assert body == {"event_type": "RunCompleted", "payload": {"ok": True}} diff --git a/tests/test_reasoning.py b/tests/test_reasoning.py deleted file mode 100644 index 7ee9262..0000000 --- a/tests/test_reasoning.py +++ /dev/null @@ -1,366 +0,0 @@ -"""Unit tests for reasoning models.""" - -from symbiont.reasoning import ( - CedarPolicy, - CircuitBreakerConfig, - CircuitBreakerStatus, - CircuitState, - FinishReason, - InferenceOptions, - InferenceResponse, - JournalEntry, - KnowledgeConfig, - LoopConfig, - LoopDecision, - LoopDecisionType, - LoopEvent, - LoopEventType, - LoopResult, - LoopState, - Observation, - ProposedAction, - ProposedActionType, - RecoveryStrategy, - RecoveryStrategyType, - RunReasoningLoopRequest, - RunReasoningLoopResponse, - TerminationReason, - TerminationReasonType, - ToolCallRequest, - ToolDefinition, - Usage, -) - -# ============================================================================= -# Enum Tests -# ============================================================================= - -class TestEnums: - def test_finish_reason_values(self): - assert FinishReason.STOP == "stop" - assert FinishReason.TOOL_CALLS == "tool_calls" - assert FinishReason.MAX_TOKENS == "max_tokens" - assert FinishReason.CONTENT_FILTER == "content_filter" - - def test_proposed_action_type_values(self): - assert ProposedActionType.TOOL_CALL == "tool_call" - assert ProposedActionType.DELEGATE == "delegate" - assert ProposedActionType.RESPOND == "respond" - assert ProposedActionType.TERMINATE == "terminate" - - def test_loop_decision_type_values(self): - assert LoopDecisionType.ALLOW == "allow" - assert LoopDecisionType.DENY == "deny" - assert LoopDecisionType.MODIFY == "modify" - - def test_circuit_state_values(self): - assert CircuitState.CLOSED == "closed" - assert CircuitState.OPEN == "open" - assert CircuitState.HALF_OPEN == "half_open" - - def test_termination_reason_type_values(self): - assert TerminationReasonType.COMPLETED == "completed" - assert TerminationReasonType.POLICY_DENIAL == "policy_denial" - assert TerminationReasonType.ERROR == "error" - - def test_recovery_strategy_type_values(self): - assert RecoveryStrategyType.RETRY == "retry" - assert RecoveryStrategyType.DEAD_LETTER == "dead_letter" - assert RecoveryStrategyType.ESCALATE == "escalate" - - def test_loop_event_type_values(self): - assert LoopEventType.STARTED == "started" - assert LoopEventType.TERMINATED == "terminated" - assert LoopEventType.RECOVERY_TRIGGERED == "recovery_triggered" - - -# ============================================================================= -# Inference Model Tests -# ============================================================================= - -class TestUsage: - def test_construction(self): - u = Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30) - assert u.prompt_tokens == 10 - assert u.total_tokens == 30 - - def test_roundtrip(self): - u = Usage(prompt_tokens=5, completion_tokens=10, total_tokens=15) - data = u.model_dump() - u2 = Usage.model_validate(data) - assert u2 == u - - -class TestToolDefinition: - def test_construction(self): - td = ToolDefinition(name="search", description="Search the web", parameters={"type": "object"}) - assert td.name == "search" - - -class TestToolCallRequest: - def test_construction(self): - tc = ToolCallRequest(id="c-1", name="search", arguments='{"q":"test"}') - assert tc.name == "search" - - -class TestInferenceOptions: - def test_defaults(self): - opts = InferenceOptions(max_tokens=1000) - assert opts.temperature == 0.7 - assert opts.tool_definitions == [] - assert opts.model is None - - def test_with_model_override(self): - opts = InferenceOptions(max_tokens=500, model="gpt-4o") - assert opts.model == "gpt-4o" - - -class TestInferenceResponse: - def test_construction(self): - resp = InferenceResponse( - content="Hello", - finish_reason=FinishReason.STOP, - usage=Usage(prompt_tokens=5, completion_tokens=3, total_tokens=8), - model="gpt-4", - ) - assert resp.content == "Hello" - assert resp.tool_calls == [] - - -# ============================================================================= -# Loop Model Tests -# ============================================================================= - -class TestObservation: - def test_defaults(self): - obs = Observation(source="tool:search", content="result data") - assert obs.is_error is False - assert obs.metadata == {} - - -class TestProposedAction: - def test_tool_call(self): - a = ProposedAction( - type=ProposedActionType.TOOL_CALL, - call_id="c-1", - name="search", - arguments='{"q":"hi"}', - ) - assert a.type == "tool_call" - assert a.name == "search" - - def test_respond(self): - a = ProposedAction(type=ProposedActionType.RESPOND, content="The answer is 42") - assert a.content == "The answer is 42" - - def test_terminate(self): - a = ProposedAction(type=ProposedActionType.TERMINATE, reason="done", output="result") - assert a.reason == "done" - - def test_roundtrip(self): - a = ProposedAction(type=ProposedActionType.DELEGATE, target="agent-2", message="help") - data = a.model_dump() - a2 = ProposedAction.model_validate(data) - assert a2.type == ProposedActionType.DELEGATE - assert a2.target == "agent-2" - - -class TestLoopDecision: - def test_allow(self): - d = LoopDecision(decision=LoopDecisionType.ALLOW) - assert d.reason is None - - def test_deny(self): - d = LoopDecision(decision=LoopDecisionType.DENY, reason="not allowed") - assert d.reason == "not allowed" - - def test_modify(self): - action = ProposedAction(type=ProposedActionType.RESPOND, content="sanitized") - d = LoopDecision(decision=LoopDecisionType.MODIFY, modified_action=action, reason="redacted") - assert d.modified_action is not None - - -class TestRecoveryStrategy: - def test_retry(self): - s = RecoveryStrategy(type=RecoveryStrategyType.RETRY, max_attempts=3, base_delay_ms=1000) - assert s.max_attempts == 3 - - def test_dead_letter(self): - s = RecoveryStrategy(type=RecoveryStrategyType.DEAD_LETTER) - assert s.type == "dead_letter" - - def test_escalate(self): - s = RecoveryStrategy(type=RecoveryStrategyType.ESCALATE, queue="ops", context_snapshot=True) - assert s.queue == "ops" - - -class TestTerminationReason: - def test_completed(self): - t = TerminationReason(type=TerminationReasonType.COMPLETED) - assert t.reason is None - - def test_policy_denial(self): - t = TerminationReason(type=TerminationReasonType.POLICY_DENIAL, reason="blocked") - assert t.reason == "blocked" - - def test_error(self): - t = TerminationReason(type=TerminationReasonType.ERROR, message="crash") - assert t.message == "crash" - - -class TestLoopConfig: - def test_defaults(self): - c = LoopConfig() - assert c.max_iterations == 10 - assert c.max_total_tokens == 100000 - assert c.timeout_ms == 300000 - assert c.default_recovery.type == RecoveryStrategyType.DEAD_LETTER - - def test_override(self): - c = LoopConfig(max_iterations=5) - assert c.max_iterations == 5 - - -class TestLoopState: - def test_construction(self): - s = LoopState( - agent_id="agent-1", - iteration=2, - total_usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), - started_at="2026-01-01T00:00:00Z", - current_phase="reasoning", - ) - assert s.iteration == 2 - assert s.pending_observations == [] - - -class TestLoopResult: - def test_construction(self): - r = LoopResult( - output="Done", - iterations=3, - total_usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), - termination_reason=TerminationReason(type=TerminationReasonType.COMPLETED), - duration_ms=5000, - ) - assert r.output == "Done" - assert r.termination_reason.type == "completed" - - -# ============================================================================= -# Journal Model Tests -# ============================================================================= - -class TestLoopEvent: - def test_started(self): - e = LoopEvent(type=LoopEventType.STARTED, agent_id="a-1", config=LoopConfig()) - assert e.type == "started" - - def test_terminated(self): - e = LoopEvent( - type=LoopEventType.TERMINATED, - reason=TerminationReason(type=TerminationReasonType.COMPLETED), - iterations=5, - total_usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), - duration_ms=10000, - ) - assert e.iterations == 5 - - -class TestJournalEntry: - def test_construction(self): - entry = JournalEntry( - sequence=0, - timestamp="2026-01-01T00:00:00Z", - agent_id="agent-1", - iteration=0, - event=LoopEvent(type=LoopEventType.STARTED, agent_id="agent-1", config=LoopConfig()), - ) - assert entry.sequence == 0 - - def test_roundtrip(self): - entry = JournalEntry( - sequence=1, - timestamp="2026-01-01T00:00:01Z", - agent_id="agent-1", - iteration=1, - event=LoopEvent( - type=LoopEventType.REASONING_COMPLETE, - iteration=1, - actions=[ProposedAction(type=ProposedActionType.RESPOND, content="hi")], - usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), - ), - ) - data = entry.model_dump() - entry2 = JournalEntry.model_validate(data) - assert entry2.sequence == 1 - assert entry2.event.type == LoopEventType.REASONING_COMPLETE - - -# ============================================================================= -# Cedar / Knowledge / Circuit Breaker Tests -# ============================================================================= - -class TestCedarPolicy: - def test_default_active(self): - p = CedarPolicy(name="deny-all", source="forbid(principal,action,resource);") - assert p.active is True - - def test_inactive(self): - p = CedarPolicy(name="p1", source="src", active=False) - assert p.active is False - - -class TestKnowledgeConfig: - def test_defaults(self): - c = KnowledgeConfig() - assert c.max_context_items == 5 - assert c.relevance_threshold == 0.7 - assert c.auto_persist is False - - -class TestCircuitBreakerConfig: - def test_defaults(self): - c = CircuitBreakerConfig() - assert c.failure_threshold == 5 - assert c.recovery_timeout_ms == 30000 - assert c.half_open_max_calls == 3 - - -class TestCircuitBreakerStatus: - def test_construction(self): - s = CircuitBreakerStatus( - state=CircuitState.CLOSED, - failure_count=0, - success_count=10, - config=CircuitBreakerConfig(), - ) - assert s.state == "closed" - assert s.success_count == 10 - - -# ============================================================================= -# API Request / Response Tests -# ============================================================================= - -class TestRunReasoningLoopRequest: - def test_minimal(self): - r = RunReasoningLoopRequest(config=LoopConfig(), initial_message="Hello") - assert r.initial_message == "Hello" - assert r.cedar_policies is None - - -class TestRunReasoningLoopResponse: - def test_construction(self): - r = RunReasoningLoopResponse( - loop_id="loop-1", - result=LoopResult( - output="Done", - iterations=2, - total_usage=Usage(prompt_tokens=50, completion_tokens=25, total_tokens=75), - termination_reason=TerminationReason(type=TerminationReasonType.COMPLETED), - duration_ms=3000, - ), - ) - assert r.loop_id == "loop-1" - assert r.journal_entries == [] diff --git a/tests/test_reasoning_client.py b/tests/test_reasoning_client.py deleted file mode 100644 index 1aed426..0000000 --- a/tests/test_reasoning_client.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Unit tests for the Symbiont SDK ReasoningClient.""" - -from unittest.mock import Mock, patch - -from symbiont import Client -from symbiont.config import ClientConfig -from symbiont.reasoning import ( - CedarPolicy, - CircuitBreakerStatus, - JournalEntry, - LoopConfig, - LoopDecision, - LoopState, - ProposedAction, - ProposedActionType, - RunReasoningLoopRequest, - RunReasoningLoopResponse, -) -from symbiont.reasoning_client import ReasoningClient - - -def _create_test_config(): - """Helper to create a valid test configuration.""" - config = ClientConfig() - config.auth.jwt_secret_key = "test-secret-key-for-validation" - config.auth.enable_refresh_tokens = False - config.api_key = "test-api-key" - return config - - -def _make_client(): - """Create a Client with mocked config.""" - return Client(config=_create_test_config()) - - -class TestReasoningClientAccess: - """Test that ReasoningClient is accessible from Client.""" - - def test_reasoning_property_returns_reasoning_client(self): - client = _make_client() - assert client.reasoning is not None - assert isinstance(client.reasoning, ReasoningClient) - - def test_reasoning_property_is_cached(self): - client = _make_client() - first = client.reasoning - second = client.reasoning - assert first is second - - -class TestRunLoop: - """Test ReasoningClient.run_loop().""" - - @patch("requests.request") - def test_run_loop_success(self, mock_request): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "loop_id": "loop-1", - "result": { - "output": "The answer is 42", - "iterations": 3, - "total_usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, - "termination_reason": {"type": "completed"}, - "duration_ms": 5000, - }, - "journal_entries": [], - } - mock_request.return_value = mock_response - - client = _make_client() - request = RunReasoningLoopRequest(config=LoopConfig(), initial_message="What is 6*7?") - result = client.reasoning.run_loop("agent-1", request) - - assert isinstance(result, RunReasoningLoopResponse) - assert result.loop_id == "loop-1" - assert result.result.output == "The answer is 42" - - -class TestGetLoopStatus: - """Test ReasoningClient.get_loop_status().""" - - @patch("requests.request") - def test_get_loop_status_success(self, mock_request): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "agent_id": "agent-1", - "iteration": 2, - "total_usage": {"prompt_tokens": 50, "completion_tokens": 25, "total_tokens": 75}, - "pending_observations": [], - "started_at": "2026-01-01T00:00:00Z", - "current_phase": "tools", - "metadata": {}, - } - mock_request.return_value = mock_response - - client = _make_client() - result = client.reasoning.get_loop_status("agent-1", "loop-1") - - assert isinstance(result, LoopState) - assert result.iteration == 2 - assert result.current_phase == "tools" - - -class TestGetJournalEntries: - """Test ReasoningClient.get_journal_entries().""" - - @patch("requests.request") - def test_get_journal_entries_success(self, mock_request): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - { - "sequence": 0, - "timestamp": "2026-01-01T00:00:00Z", - "agent_id": "agent-1", - "iteration": 0, - "event": {"type": "started", "agent_id": "agent-1"}, - }, - ] - mock_request.return_value = mock_response - - client = _make_client() - result = client.reasoning.get_journal_entries("agent-1") - - assert len(result) == 1 - assert isinstance(result[0], JournalEntry) - assert result[0].sequence == 0 - - -class TestListCedarPolicies: - """Test ReasoningClient.list_cedar_policies().""" - - @patch("requests.request") - def test_list_cedar_policies_success(self, mock_request): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - {"name": "deny-all", "source": "forbid(principal,action,resource);", "active": True}, - ] - mock_request.return_value = mock_response - - client = _make_client() - result = client.reasoning.list_cedar_policies("agent-1") - - assert len(result) == 1 - assert isinstance(result[0], CedarPolicy) - assert result[0].name == "deny-all" - - -class TestEvaluateCedarPolicy: - """Test ReasoningClient.evaluate_cedar_policy().""" - - @patch("requests.request") - def test_evaluate_cedar_policy_allow(self, mock_request): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"decision": "allow"} - mock_request.return_value = mock_response - - client = _make_client() - action = ProposedAction(type=ProposedActionType.RESPOND, content="hello") - result = client.reasoning.evaluate_cedar_policy("agent-1", action) - - assert isinstance(result, LoopDecision) - assert result.decision == "allow" - - -class TestGetCircuitBreakerStatus: - """Test ReasoningClient.get_circuit_breaker_status().""" - - @patch("requests.request") - def test_get_circuit_breaker_status_success(self, mock_request): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "search": { - "state": "closed", - "failure_count": 0, - "success_count": 10, - "config": {"failure_threshold": 5, "recovery_timeout_ms": 30000, "half_open_max_calls": 3}, - }, - } - mock_request.return_value = mock_response - - client = _make_client() - result = client.reasoning.get_circuit_breaker_status("agent-1") - - assert "search" in result - assert isinstance(result["search"], CircuitBreakerStatus) - assert result["search"].state == "closed" - - -class TestRecallKnowledge: - """Test ReasoningClient.recall_knowledge().""" - - @patch("requests.request") - def test_recall_knowledge_success(self, mock_request): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = ["The sky is blue", "Water boils at 100C"] - mock_request.return_value = mock_response - - client = _make_client() - result = client.reasoning.recall_knowledge("agent-1", "what color is the sky?") - - assert len(result) == 2 - assert result[0] == "The sky is blue" - - -class TestStoreKnowledge: - """Test ReasoningClient.store_knowledge().""" - - @patch("requests.request") - def test_store_knowledge_success(self, mock_request): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"id": "k-1"} - mock_request.return_value = mock_response - - client = _make_client() - result = client.reasoning.store_knowledge("agent-1", "sky", "color_is", "blue") - - assert result["id"] == "k-1" diff --git a/tests/test_skills.py b/tests/test_skills.py index 07b6d89..44a35aa 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -62,7 +62,9 @@ def test_chmod_777(self, scanner): assert any(f.rule == "chmod-777" for f in findings) def test_clean_content(self, scanner): - findings = scanner.scan_content("This is perfectly safe content.\nNothing bad here.") + findings = scanner.scan_content( + "This is perfectly safe content.\nNothing bad here." + ) assert findings == [] def test_custom_rule(self):