diff --git a/Makefile b/Makefile index 5e4258041..4d22fc05d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ .PHONY: all teal-agents orchestrator services +DOCKER_FLAGS ?= --no-cache + all : | teal-agents orchestrator services teal-agents : diff --git a/src/sk-agents/manual_test/v1_agent/config.yaml b/src/sk-agents/manual_test/v1_agent/config.yaml new file mode 100644 index 000000000..7c6a4055f --- /dev/null +++ b/src/sk-agents/manual_test/v1_agent/config.yaml @@ -0,0 +1,16 @@ +apiVersion: skagents/v1 +kind: Chat +description: > + A simple test chat agent for metadata validation +service_name: TestChatBot +version: "0.1" +input_type: BaseMultiModalInput +spec: + agent: + name: default + role: Default Agent + model: gpt-4o-mini + system_prompt: > + You are a helpful assistant. + plugins: + - WeatherPlugin diff --git a/src/sk-agents/manual_test/v3_agent/config.yaml b/src/sk-agents/manual_test/v3_agent/config.yaml new file mode 100644 index 000000000..a91d237e2 --- /dev/null +++ b/src/sk-agents/manual_test/v3_agent/config.yaml @@ -0,0 +1,15 @@ +apiVersion: tealagents/v1alpha1 +kind: Chat +description: > + A simple test tealagent for metadata validation +name: TestTealAgent +version: "0.1" +spec: + agent: + name: default + role: Default Agent + model: gpt-4o + system_prompt: > + You are a helpful assistant. + plugins: + - MathPlugin diff --git a/src/sk-agents/manual_test/validate_metadata.py b/src/sk-agents/manual_test/validate_metadata.py new file mode 100644 index 000000000..bb6939e33 --- /dev/null +++ b/src/sk-agents/manual_test/validate_metadata.py @@ -0,0 +1,401 @@ +""" +Manual Validation Script for /metadata Endpoint +================================================ + +This script validates the /metadata endpoint works correctly for both +AppV1 (skagents) and AppV3 (tealagents) WITHOUT needing any external +services (no OpenAI, no Redis, no real agent running). + +It directly constructs the FastAPI app with the metadata route and +tests it using FastAPI's TestClient. + +HOW TO RUN: +----------- + cd teal-agents/src/sk-agents + uv run python manual_test/validate_metadata.py + +WHAT IT CHECKS: +--------------- +1. AppV1 (skagents/v1) metadata endpoint returns correct data +2. AppV3 (tealagents/v1alpha1) metadata endpoint returns correct data +3. Response format matches the AgentMetadata schema +4. All four required fields are present: agent_name, description, model, plugins +""" + +import json +import sys + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from sk_agents.ska_types import BaseConfig, ConfigMetadata, ConfigSkill +from sk_agents.skagents.v1.chat.config import Spec as V1Spec +from sk_agents.skagents.v1.config import AgentConfig as V1AgentConfig +from sk_agents.tealagents.v1alpha1.agent.config import Spec as V3Spec +from sk_agents.tealagents.v1alpha1.config import AgentConfig as V3AgentConfig +from sk_agents.utility_routes import UtilityRoutes + +PASS = "\033[92m✅ PASS\033[0m" +FAIL = "\033[91m❌ FAIL\033[0m" + + +def print_header(title: str): + """Print a formatted section header.""" + print(f"\n{'='*60}") + print(f" {title}") + print(f"{'='*60}") + + +def print_response(data: dict): + """Print the JSON response data.""" + print(" Response JSON:") + print(f" {json.dumps(data, indent=4)}") + + +def check(label: str, condition: bool, actual=None, expected=None): + """Check a condition and print PASS/FAIL result.""" + if condition: + print(f" {PASS} {label}") + else: + print(f" {FAIL} {label}") + if actual is not None: + print(f" Expected: {expected}") + print(f" Actual: {actual}") + return condition + + +def test_appv1_metadata(): + """Test /metadata endpoint for AppV1 (skagents/v1) config.""" + print_header("TEST 1: AppV1 (skagents/v1) - WeatherBot with plugins") + + # Build a real V1 config matching 10_chat_plugins/config.yaml + agent = V1AgentConfig( + name="default", + model="gpt-4o", + system_prompt="You are a helpful assistant.", + plugins=["WeatherPlugin"], + ) + spec = V1Spec(agent=agent) + config = BaseConfig( + apiVersion="skagents/v1", + service_name="WeatherBot", + version="0.1", + description="A weather chat agent", + spec=spec, + ) + + # Create app with metadata route + app = FastAPI() + utility_routes = UtilityRoutes() + app.include_router( + utility_routes.get_metadata_routes(config=config), + prefix="/WeatherBot/0.1", + ) + + # Call the endpoint + client = TestClient(app) + response = client.get("/WeatherBot/0.1/metadata") + + print(f" HTTP Status: {response.status_code}") + data = response.json() + print_response(data) + + results = [] + results.append( + check( + "Status code is 200", + response.status_code == 200, + response.status_code, + 200, + ) + ) + results.append( + check( + "agent_name is 'WeatherBot'", + data["agent_name"] == "WeatherBot", + data["agent_name"], + "WeatherBot", + ) + ) + results.append( + check( + "description is 'A weather chat agent'", + data["description"] == "A weather chat agent", + data["description"], + "A weather chat agent", + ) + ) + results.append( + check( + "model is 'gpt-4o'", + data["model"] == "gpt-4o", + data["model"], + "gpt-4o", + ) + ) + results.append( + check( + "plugins contains 'WeatherPlugin'", + data["plugins"] == ["WeatherPlugin"], + data["plugins"], + ["WeatherPlugin"], + ) + ) + return all(results) + + +def test_appv3_metadata(): + """Test /metadata endpoint for AppV3 (tealagents/v1alpha1) config.""" + print_header("TEST 2: AppV3 (tealagents/v1alpha1) - MathAgent with plugins") + + # Build a real V3 config matching 11_hitl/config.yaml + agent = V3AgentConfig( + name="default", + model="gpt-4o-2024-05-13", + system_prompt="Your task is to help with Math problems.", + plugins=["sensitive_plugin"], + ) + spec = V3Spec(agent=agent) + config = BaseConfig( + apiVersion="tealagents/v1alpha1", + name="MathAgent", + version="0.1", + description="A math helper agent", + spec=spec, + ) + + # Create app with metadata route + app = FastAPI() + utility_routes = UtilityRoutes() + app.include_router( + utility_routes.get_metadata_routes(config=config), + prefix="/MathAgent/0.1", + ) + + # Call the endpoint + client = TestClient(app) + response = client.get("/MathAgent/0.1/metadata") + + print(f" HTTP Status: {response.status_code}") + data = response.json() + print_response(data) + + results = [] + results.append( + check( + "Status code is 200", + response.status_code == 200, + response.status_code, + 200, + ) + ) + results.append( + check( + "agent_name is 'MathAgent'", + data["agent_name"] == "MathAgent", + data["agent_name"], + "MathAgent", + ) + ) + results.append( + check( + "description is 'A math helper agent'", + data["description"] == "A math helper agent", + data["description"], + "A math helper agent", + ) + ) + results.append( + check( + "model is 'gpt-4o-2024-05-13'", + data["model"] == "gpt-4o-2024-05-13", + data["model"], + "gpt-4o-2024-05-13", + ) + ) + results.append( + check( + "plugins contains 'sensitive_plugin'", + data["plugins"] == ["sensitive_plugin"], + data["plugins"], + ["sensitive_plugin"], + ) + ) + return all(results) + + +def test_appv1_no_plugins(): + """Test /metadata for an agent with no plugins.""" + print_header("TEST 3: AppV1 - Simple ChatBot (no plugins)") + + agent = V1AgentConfig( + name="default", + model="gpt-4o-mini", + system_prompt="You are a helpful assistant.", + ) + spec = V1Spec(agent=agent) + config = BaseConfig( + apiVersion="skagents/v1", + service_name="ChatBot", + version="0.1", + description="A simple chat agent", + spec=spec, + ) + + app = FastAPI() + utility_routes = UtilityRoutes() + app.include_router( + utility_routes.get_metadata_routes(config=config), + prefix="/ChatBot/0.1", + ) + + client = TestClient(app) + response = client.get("/ChatBot/0.1/metadata") + + print(f" HTTP Status: {response.status_code}") + data = response.json() + print_response(data) + + results = [] + results.append(check("Status code is 200", response.status_code == 200)) + results.append( + check( + "agent_name is 'ChatBot'", + data["agent_name"] == "ChatBot", + ) + ) + results.append( + check( + "model is 'gpt-4o-mini'", + data["model"] == "gpt-4o-mini", + ) + ) + results.append( + check( + "plugins is None (no plugins)", + data["plugins"] is None, + data["plugins"], + None, + ) + ) + return all(results) + + +def test_appv3_with_metadata_description(): + """Test that metadata.description takes precedence over top-level description.""" + print_header("TEST 4: AppV3 - Metadata description takes precedence") + + agent = V3AgentConfig( + name="default", + model="gpt-4", + system_prompt="You are a helpful assistant.", + ) + spec = V3Spec(agent=agent) + config = BaseConfig( + apiVersion="tealagents/v1alpha1", + name="mcp-demo-agent", + version="1.0", + description="Top-level description (should NOT be used)", + metadata=ConfigMetadata( + description="A demonstration agent that showcases MCP integration", + skills=[ + ConfigSkill( + id="mcp-tools", + name="MCP Tools", + description="Tools from MCP servers", + tags=["mcp"], + ) + ], + ), + spec=spec, + ) + + app = FastAPI() + utility_routes = UtilityRoutes() + app.include_router( + utility_routes.get_metadata_routes(config=config), + prefix="/mcp-demo-agent/1.0", + ) + + client = TestClient(app) + response = client.get("/mcp-demo-agent/1.0/metadata") + + print(f" HTTP Status: {response.status_code}") + data = response.json() + print_response(data) + + results = [] + results.append(check("Status code is 200", response.status_code == 200)) + results.append( + check( + "description uses metadata.description (not top-level)", + data["description"] == "A demonstration agent that showcases MCP integration", + data["description"], + "A demonstration agent that showcases MCP integration", + ) + ) + return all(results) + + +def test_response_schema(): + """Test that the response JSON has exactly the expected fields.""" + print_header("TEST 5: Response schema validation") + + config = BaseConfig( + apiVersion="skagents/v1", + name="schema-test", + version="1.0", + ) + + app = FastAPI() + utility_routes = UtilityRoutes() + app.include_router(utility_routes.get_metadata_routes(config=config)) + + client = TestClient(app) + response = client.get("/metadata") + data = response.json() + + expected_fields = {"agent_name", "description", "model", "plugins"} + actual_fields = set(data.keys()) + + print(f" Expected fields: {expected_fields}") + print(f" Actual fields: {actual_fields}") + + results = [] + results.append( + check( + "Response has exactly 4 expected fields", + actual_fields == expected_fields, + actual_fields, + expected_fields, + ) + ) + return all(results) + + +if __name__ == "__main__": + print("\n" + "=" * 60) + print(" METADATA ENDPOINT MANUAL VALIDATION") + print(" " + "=" * 56) + print(" Testing /metadata endpoint for AppV1 and AppV3") + print("=" * 60) + + all_passed = True + all_passed &= test_appv1_metadata() + all_passed &= test_appv3_metadata() + all_passed &= test_appv1_no_plugins() + all_passed &= test_appv3_with_metadata_description() + all_passed &= test_response_schema() + + print_header("FINAL RESULT") + if all_passed: + print(f" {PASS} ALL TESTS PASSED — Ticket is resolved!") + print("\n The /metadata endpoint correctly returns:") + print(" • agent_name — from config name/service_name") + print(" • description — from metadata.description or top-level") + print(" • model — from spec.agent.model") + print(" • plugins — from spec.agent.plugins + remote_plugins + mcp_servers") + sys.exit(0) + else: + print(f" {FAIL} SOME TESTS FAILED — See above for details") + sys.exit(1) diff --git a/src/sk-agents/pyproject.toml b/src/sk-agents/pyproject.toml index 6526a306c..df292a40f 100644 --- a/src/sk-agents/pyproject.toml +++ b/src/sk-agents/pyproject.toml @@ -67,6 +67,8 @@ dev = [ "mkdocs-material>=9.6.0", "mkdocstrings[python]>=0.28.0", "mkdocs-static-i18n>=1.3.0", + "black>=26.3.1", + "pylint>=4.0.5", ] [tool.ruff] @@ -91,6 +93,12 @@ isort = { combine-as-imports = true } [tool.ruff.lint.pydocstyle] convention = "google" +[tool.pylint."messages_control"] +disable = ["duplicate-code"] + +[tool.pylint.format] +max-line-length = 100 + [tool.mypy] strict = false disallow_incomplete_defs = false diff --git a/src/sk-agents/src/sk_agents/app.py b/src/sk-agents/src/sk_agents/app.py index 47a416dba..1ac2a32f0 100644 --- a/src/sk-agents/src/sk_agents/app.py +++ b/src/sk-agents/src/sk_agents/app.py @@ -41,7 +41,7 @@ class AppVersion(Enum): raise try: - (root_handler, api_version) = config.apiVersion.split("/") + root_handler, api_version = config.apiVersion.split("/") except ValueError: logger.exception("Invalid API version format") raise diff --git a/src/sk-agents/src/sk_agents/appv1.py b/src/sk-agents/src/sk_agents/appv1.py index 50b4a9bc4..3407e48b0 100644 --- a/src/sk-agents/src/sk_agents/appv1.py +++ b/src/sk-agents/src/sk_agents/appv1.py @@ -1,3 +1,5 @@ +"""AppV1 application runner for skagents/v1 API version.""" + import os from datetime import datetime from typing import Any @@ -18,9 +20,12 @@ from sk_agents.utils import initialize_plugin_loader -class AppV1: +class AppV1: # pylint: disable=too-few-public-methods + """Application runner for skagents/v1 API version.""" + @staticmethod def run(name: str, version: str, app_config: AppConfig, config: BaseConfig, app: FastAPI): + """Initialize and run the AppV1 application with routes and plugins.""" config_file = app_config.get(TA_SERVICE_CONFIG.env_name) agents_path = str(os.path.dirname(config_file)) @@ -85,3 +90,11 @@ def run(name: str, version: str, app_config: AppConfig, config: BaseConfig, app: ), prefix=f"/{name}/{version}", ) + + # Include metadata route + app.include_router( + utility_routes.get_metadata_routes( + config=config, + ), + prefix=f"/{name}/{version}", + ) diff --git a/src/sk-agents/src/sk_agents/appv3.py b/src/sk-agents/src/sk_agents/appv3.py index 9fcc9eb30..64c2a25c2 100644 --- a/src/sk-agents/src/sk_agents/appv3.py +++ b/src/sk-agents/src/sk_agents/appv3.py @@ -34,8 +34,12 @@ def run(name, version, app_config, config, app): from sk_agents.utils import initialize_plugin_loader -class AppV3: +class AppV3: # pylint: disable=too-few-public-methods + """Application runner for tealagents/v1alpha1 API version.""" + class StateStores(Enum): + """Supported state store backends.""" + IN_MEMORY = "in-memory" REDIS = "redis" @@ -72,13 +76,14 @@ def _get_auth_storage_manager(app_config: AppConfig): @staticmethod def _get_mcp_discovery_manager(app_config: AppConfig): + # pylint: disable=import-outside-toplevel from sk_agents.mcp_discovery import DiscoveryManagerFactory discovery_factory = DiscoveryManagerFactory(app_config) return discovery_factory.get_discovery_manager() @staticmethod - def _get_auth_manager(app_config: AppConfig): + def _get_auth_manager(app_config: AppConfig): # pylint: disable=unused-argument # For initial implementation, use mock authentication # Will be extended in future for Entra ID return MockAuthenticationManager() @@ -103,6 +108,7 @@ def _create_kernel_builder(app_config: AppConfig, authorization: str): @staticmethod def run(name: str, version: str, app_config: AppConfig, config: BaseConfig, app: FastAPI): + """Initialize and run the AppV3 application with routes and plugins.""" if config.apiVersion != "tealagents/v1alpha1": raise ValueError( f"AppV3 only supports 'tealagents/v1alpha1' API version, got: {config.apiVersion}" @@ -173,6 +179,14 @@ def run(name: str, version: str, app_config: AppConfig, config: BaseConfig, app: prefix=f"/{name}/{version}", ) + # Include metadata route + app.include_router( + utility_routes.get_metadata_routes( + config=config, + ), + prefix=f"/{name}/{version}", + ) + # Make config and other essentials available to request handlers app.state.config = config app.state.app_config = app_config diff --git a/src/sk-agents/src/sk_agents/auth/oauth_client.py b/src/sk-agents/src/sk_agents/auth/oauth_client.py index cd12768be..a4eeba83c 100644 --- a/src/sk-agents/src/sk_agents/auth/oauth_client.py +++ b/src/sk-agents/src/sk_agents/auth/oauth_client.py @@ -610,9 +610,9 @@ async def handle_callback( code=code, redirect_uri=server_config.oauth_redirect_uri, code_verifier=flow_state.verifier, - resource=flow_state.resource - if include_resource - else None, # Conditional per protocol version + resource=( + flow_state.resource if include_resource else None + ), # Conditional per protocol version client_id=server_config.oauth_client_id or client_name, client_secret=server_config.oauth_client_secret, requested_scopes=flow_state.scopes, # For scope validation diff --git a/src/sk-agents/src/sk_agents/mcp_client.py b/src/sk-agents/src/sk_agents/mcp_client.py index 93f73d5e2..d43afe359 100644 --- a/src/sk-agents/src/sk_agents/mcp_client.py +++ b/src/sk-agents/src/sk_agents/mcp_client.py @@ -451,9 +451,9 @@ def apply_trust_level_governance( logger.debug("Applying sandboxed server governance: elevated restrictions") return Governance( requires_hitl=True, # Force HITL for sandboxed servers - cost=base_governance.cost - if base_governance.cost != "low" - else "medium", # Elevate cost + cost=( + base_governance.cost if base_governance.cost != "low" else "medium" + ), # Elevate cost data_sensitivity=base_governance.data_sensitivity, ) else: # trusted @@ -521,13 +521,17 @@ def apply_governance_overrides( # Apply selective overrides - only override specified fields return Governance( - requires_hitl=override.requires_hitl - if override.requires_hitl is not None - else base_governance.requires_hitl, + requires_hitl=( + override.requires_hitl + if override.requires_hitl is not None + else base_governance.requires_hitl + ), cost=override.cost if override.cost is not None else base_governance.cost, - data_sensitivity=override.data_sensitivity - if override.data_sensitivity is not None - else base_governance.data_sensitivity, + data_sensitivity=( + override.data_sensitivity + if override.data_sensitivity is not None + else base_governance.data_sensitivity + ), ) @@ -719,9 +723,9 @@ async def resolve_server_auth_headers( refresh_request = RefreshTokenRequest( token_endpoint=token_endpoint, refresh_token=auth_data.refresh_token, - resource=resource_uri - if include_resource - else None, # Conditional per protocol version + resource=( + resource_uri if include_resource else None + ), # Conditional per protocol version client_id=server_config.oauth_client_id or app_config.get("TA_OAUTH_CLIENT_NAME"), client_secret=server_config.oauth_client_secret, diff --git a/src/sk-agents/src/sk_agents/mcp_plugin_registry.py b/src/sk-agents/src/sk_agents/mcp_plugin_registry.py index 9c52732f0..8228ee51d 100644 --- a/src/sk-agents/src/sk_agents/mcp_plugin_registry.py +++ b/src/sk-agents/src/sk_agents/mcp_plugin_registry.py @@ -54,13 +54,17 @@ def _apply_governance_overrides( override = overrides[tool_name] return Governance( - requires_hitl=override.requires_hitl - if override.requires_hitl is not None - else base_governance.requires_hitl, + requires_hitl=( + override.requires_hitl + if override.requires_hitl is not None + else base_governance.requires_hitl + ), cost=override.cost if override.cost is not None else base_governance.cost, - data_sensitivity=override.data_sensitivity - if override.data_sensitivity is not None - else base_governance.data_sensitivity, + data_sensitivity=( + override.data_sensitivity + if override.data_sensitivity is not None + else base_governance.data_sensitivity + ), ) @staticmethod diff --git a/src/sk-agents/src/sk_agents/persistence/in_memory_persistence_manager.py b/src/sk-agents/src/sk_agents/persistence/in_memory_persistence_manager.py index d2c08f2ca..2851a04e0 100644 --- a/src/sk-agents/src/sk_agents/persistence/in_memory_persistence_manager.py +++ b/src/sk-agents/src/sk_agents/persistence/in_memory_persistence_manager.py @@ -17,9 +17,9 @@ class InMemoryPersistenceManager(TaskPersistenceManager): def __init__(self): self.in_memory: dict[str, AgentTask] = {} - self.item_request_id_index: dict[ - str, set[str] - ] = {} # Maps request_id to set of task_ids that contain it + self.item_request_id_index: dict[str, set[str]] = ( + {} + ) # Maps request_id to set of task_ids that contain it logger.info("InMemoryPersistenceManager initialized.") self._lock = asyncio.Lock() diff --git a/src/sk-agents/src/sk_agents/plugin_catalog/local_plugin_catalog.py b/src/sk-agents/src/sk_agents/plugin_catalog/local_plugin_catalog.py index 7ab8b560f..2acf647e8 100644 --- a/src/sk-agents/src/sk_agents/plugin_catalog/local_plugin_catalog.py +++ b/src/sk-agents/src/sk_agents/plugin_catalog/local_plugin_catalog.py @@ -106,9 +106,7 @@ def _load_plugins(self) -> None: # Re-raise our custom exception raise except Exception as e: - raise PluginFileReadException( - message=""" + raise PluginFileReadException(message=""" Catalog encountered an error when attempting to read file - """ - ) from e + """) from e diff --git a/src/sk-agents/src/sk_agents/utility_routes.py b/src/sk-agents/src/sk_agents/utility_routes.py index 9022f5b30..939586d20 100644 --- a/src/sk-agents/src/sk_agents/utility_routes.py +++ b/src/sk-agents/src/sk_agents/utility_routes.py @@ -1,3 +1,5 @@ +"""Utility routes for health checks, liveness, and agent metadata.""" + import logging from datetime import datetime from typing import Any @@ -36,6 +38,15 @@ class LivenessStatus(BaseModel): timestamp: str +class AgentMetadata(BaseModel): + """Agent metadata response model.""" + + agent_name: str | None = None + description: str | None = None + model: str | None = None + plugins: list[str] | None = None + + class UtilityRoutes: """Utility routes for health checks and system monitoring.""" @@ -45,7 +56,7 @@ def __init__(self, start_time: datetime | None = None): def get_health_routes( self, config: BaseConfig, - app_config: AppConfig, + app_config: AppConfig, # pylint: disable=unused-argument ) -> APIRouter: """ Get health check routes for the application. @@ -81,7 +92,7 @@ async def health_check() -> HealthStatus: uptime=uptime, ) except Exception as e: - logger.exception(f"Health check failed: {e}") + logger.exception("Health check failed: %s", e) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Service unhealthy" ) from e @@ -101,9 +112,140 @@ async def liveness_check() -> LivenessStatus: try: return LivenessStatus(alive=True, timestamp=datetime.now().isoformat()) except Exception as e: - logger.exception(f"Liveness check failed: {e}") + logger.exception("Liveness check failed: %s", e) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Service not alive" ) from e return router + + @staticmethod + def _safe_get(obj, key, default=None): + """Safely get a value from an object that may be a dict or an object with attributes.""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + @staticmethod + def _extract_description(config: BaseConfig) -> str | None: + """Extract description from config, preferring metadata.description.""" + if config.metadata is not None and config.metadata.description is not None: + return config.metadata.description + return config.description + + @staticmethod + def _extract_plugins_from_agent(agent, _get) -> list[str]: + """Extract plugin names from a single agent config.""" + plugins: list[str] = [] + agent_plugins = _get(agent, "plugins") + if agent_plugins: + plugins.extend(agent_plugins) + remote_plugins = _get(agent, "remote_plugins") + if remote_plugins: + plugins.extend(remote_plugins) + mcp_servers = _get(agent, "mcp_servers") + if mcp_servers: + for server in mcp_servers: + server_name = _get(server, "name") + if server_name: + plugins.append(f"mcp:{server_name}") + return plugins + + @staticmethod + def _extract_from_multi_agents(agents, plugins, _get) -> str | None: + """Extract model and plugins from a multi-agent spec. Returns combined model string.""" + models = [] + for ag in agents: + ag_model = _get(ag, "model") + if ag_model and ag_model not in models: + models.append(ag_model) + for p in _get(ag, "plugins") or []: + if p not in plugins: + plugins.append(p) + for p in _get(ag, "remote_plugins") or []: + if p not in plugins: + plugins.append(p) + return ", ".join(models) if models else None + + @staticmethod + def _extract_metadata(config: BaseConfig) -> AgentMetadata: + """ + Extract metadata from the agent configuration. + + Supports both skagents (v1) and tealagents (v1alpha1) config formats. + + Args: + config: Base configuration + + Returns: + AgentMetadata: Extracted metadata about the agent + """ + try: + _get = UtilityRoutes._safe_get + agent_name = config.name or config.service_name + description = UtilityRoutes._extract_description(config) + model = None + plugins: list[str] = [] + + if config.spec is not None: + spec = config.spec + agent = _get(spec, "agent") + if agent is not None: + model = _get(agent, "model") + plugins = UtilityRoutes._extract_plugins_from_agent(agent, _get) + + agents = _get(spec, "agents") + if agents is not None: + model = UtilityRoutes._extract_from_multi_agents(agents, plugins, _get) + + logger.info("Extracted metadata for agent: %s", agent_name) + return AgentMetadata( + agent_name=agent_name, + description=description, + model=model, + plugins=plugins if plugins else None, + ) + except Exception as e: # pylint: disable=broad-exception-caught + logger.exception("Failed to extract metadata from config: %s", e) + return AgentMetadata() + + def get_metadata_routes( + self, + config: BaseConfig, + ) -> APIRouter: + """ + Get metadata routes for the application. + + Args: + config: Base configuration + + Returns: + APIRouter: Router with metadata endpoint + """ + metadata = self._extract_metadata(config) + router = APIRouter() + + @router.get( + "/metadata", + response_model=AgentMetadata, + summary="Agent metadata endpoint", + description=( + "Returns metadata about the agent including" + " name, description, model, and available plugins" + ), + tags=["Metadata"], + ) + async def get_metadata() -> AgentMetadata: + """ + Returns metadata about the agent extracted from the agent's configuration. + """ + try: + return metadata + except Exception as e: + logger.exception("Metadata endpoint failed: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve agent metadata", + ) from e + + return router diff --git a/src/sk-agents/tests/tealagents/v1alpha1/agent/test_tealagents_v1alpha1_agent_handler.py b/src/sk-agents/tests/tealagents/v1alpha1/agent/test_tealagents_v1alpha1_agent_handler.py index a2fcc0584..df793cd50 100644 --- a/src/sk-agents/tests/tealagents/v1alpha1/agent/test_tealagents_v1alpha1_agent_handler.py +++ b/src/sk-agents/tests/tealagents/v1alpha1/agent/test_tealagents_v1alpha1_agent_handler.py @@ -94,7 +94,9 @@ def mock_app_config(): mock_config.get.side_effect = lambda key: { TA_PERSISTENCE_MODULE.env_name: TA_PERSISTENCE_MODULE.default_value, TA_PERSISTENCE_CLASS.env_name: TA_PERSISTENCE_CLASS.default_value, - }.get(key, MagicMock()) # Return MagicMock for other keys + }.get( + key, MagicMock() + ) # Return MagicMock for other keys return mock_config @@ -381,9 +383,9 @@ async def test_manage_agent_response_task( Test that _manage_agent_response_task correctly appends a new item and updates the agent task in state. """ - mocker.patch( - "sk_agents.tealagents.v1alpha1.agent.handler.datetime" - ).now.return_value = mock_date_time + mocker.patch("sk_agents.tealagents.v1alpha1.agent.handler.datetime").now.return_value = ( + mock_date_time + ) mocker.patch.object(teal_agents_handler.state, "update", new_callable=mocker.AsyncMock) diff --git a/src/sk-agents/tests/test_appv1.py b/src/sk-agents/tests/test_appv1.py index 4c221bb05..f6a85e2cb 100644 --- a/src/sk-agents/tests/test_appv1.py +++ b/src/sk-agents/tests/test_appv1.py @@ -1,3 +1,5 @@ +"""Tests for AppV1 application runner.""" + import os from typing import Any from unittest.mock import MagicMock, patch @@ -138,8 +140,13 @@ def test_run_with_all_config_properties( app_config=mock_app_config, ) + # Verify metadata routes were registered + mock_utility_routes.get_metadata_routes.assert_called_once_with( + config=mock_base_config, + ) + # Verify routers were added to app with correct prefix - assert mock_fastapi_app.include_router.call_count == 3 + assert mock_fastapi_app.include_router.call_count == 4 for call in mock_fastapi_app.include_router.call_args_list: assert call.kwargs["prefix"] == "/test-service/1.0.0" @@ -675,7 +682,7 @@ def test_run_router_prefix_format( app=mock_fastapi_app, ) - assert mock_fastapi_app.include_router.call_count == 3 + assert mock_fastapi_app.include_router.call_count == 4 call1 = mock_fastapi_app.include_router.call_args_list[0] call2 = mock_fastapi_app.include_router.call_args_list[1] diff --git a/src/sk-agents/tests/test_appv3.py b/src/sk-agents/tests/test_appv3.py index 2681026ba..b5f73b9d9 100644 --- a/src/sk-agents/tests/test_appv3.py +++ b/src/sk-agents/tests/test_appv3.py @@ -1,3 +1,5 @@ +"""Tests for AppV3 application runner.""" + from unittest.mock import MagicMock, patch import pytest @@ -316,8 +318,13 @@ def test_run_success_with_metadata_description( config=mock_base_config, app_config=mock_app_config ) + # Verify metadata routes were registered + mock_utility_routes.get_metadata_routes.assert_called_once_with( + config=mock_base_config, + ) + # Verify router inclusion - assert mock_fastapi_app.include_router.call_count == 3 + assert mock_fastapi_app.include_router.call_count == 4 mock_fastapi_app.include_router.assert_any_call(mock_stateful_router, prefix="/testapp/v1") mock_fastapi_app.include_router.assert_any_call(mock_resume_router, prefix="/testapp/v1") mock_fastapi_app.include_router.assert_any_call(mock_health_router, prefix="/testapp/v1") diff --git a/src/sk-agents/tests/test_utility_routes.py b/src/sk-agents/tests/test_utility_routes.py new file mode 100644 index 000000000..74735bf47 --- /dev/null +++ b/src/sk-agents/tests/test_utility_routes.py @@ -0,0 +1,619 @@ +"""Tests for utility routes including health, liveness, and metadata endpoints.""" + +from unittest.mock import MagicMock, PropertyMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from sk_agents.ska_types import BaseConfig, ConfigMetadata +from sk_agents.utility_routes import AgentMetadata, UtilityRoutes + + +class TestExtractMetadata: + """Test UtilityRoutes._extract_metadata static method.""" + + def test_extract_metadata_with_name_and_description(self): + """Test metadata extraction with top-level name and description.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="my-agent", + version="1.0", + description="A helpful agent", + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.agent_name == "my-agent" + assert metadata.description == "A helpful agent" + assert metadata.model is None + assert metadata.plugins is None + + def test_extract_metadata_with_service_name_fallback(self): + """Test metadata extraction falls back to service_name when name is None.""" + config = BaseConfig( + apiVersion="skagents/v1", + service_name="my-service", + version="1.0", + description="A service", + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.agent_name == "my-service" + + def test_extract_metadata_with_metadata_description(self): + """Test metadata description takes precedence over top-level description.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="my-agent", + version="1.0", + description="Top-level description", + metadata=ConfigMetadata( + description="Metadata description", + skills=[], + ), + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.description == "Metadata description" + + def test_extract_metadata_falls_back_to_top_level_description(self): + """Test fallback to top-level description when metadata description is None.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="my-agent", + version="1.0", + description="Top-level description", + ) + config.metadata = None + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.description == "Top-level description" + + def test_extract_metadata_no_description(self): + """Test metadata extraction when no description is available.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="my-agent", + version="1.0", + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.description is None + + def test_extract_metadata_with_single_agent_spec(self): + """Test metadata extraction with spec.agent (single agent config).""" + mock_agent = MagicMock() + mock_agent.model = "gpt-4o" + mock_agent.plugins = ["PluginA", "PluginB"] + mock_agent.remote_plugins = ["RemotePlugin"] + mock_agent.mcp_servers = None + + mock_spec = MagicMock() + mock_spec.agent = mock_agent + mock_spec.agents = None # Ensure no multi-agent attribute + + config = BaseConfig( + apiVersion="skagents/v1", + name="my-agent", + version="1.0", + description="Test", + spec=mock_spec, + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.model == "gpt-4o" + assert metadata.plugins == ["PluginA", "PluginB", "RemotePlugin"] + + def test_extract_metadata_with_mcp_servers(self): + """Test metadata extraction includes MCP server names as plugins.""" + mock_mcp_server1 = MagicMock() + mock_mcp_server1.name = "filesystem" + mock_mcp_server2 = MagicMock() + mock_mcp_server2.name = "github" + + mock_agent = MagicMock() + mock_agent.model = "gpt-4" + mock_agent.plugins = ["LocalPlugin"] + mock_agent.remote_plugins = None + mock_agent.mcp_servers = [mock_mcp_server1, mock_mcp_server2] + + mock_spec = MagicMock() + mock_spec.agent = mock_agent + mock_spec.agents = None + + config = BaseConfig( + apiVersion="tealagents/v1alpha1", + name="mcp-agent", + version="1.0", + description="MCP agent", + spec=mock_spec, + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.model == "gpt-4" + assert metadata.plugins == ["LocalPlugin", "mcp:filesystem", "mcp:github"] + + def test_extract_metadata_with_multi_agent_spec(self): + """Test metadata extraction with spec.agents (sequential/multi-agent config).""" + mock_agent1 = MagicMock() + mock_agent1.model = "gpt-4o" + mock_agent1.plugins = ["PluginA"] + mock_agent1.remote_plugins = None + + mock_agent2 = MagicMock() + mock_agent2.model = "gpt-4o-mini" + mock_agent2.plugins = ["PluginB"] + mock_agent2.remote_plugins = ["RemoteC"] + + mock_spec = MagicMock() + mock_spec.agent = None # No single agent + mock_spec.agents = [mock_agent1, mock_agent2] + + config = BaseConfig( + apiVersion="skagents/v1", + name="multi-agent", + version="1.0", + description="Multi agent", + spec=mock_spec, + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.model == "gpt-4o, gpt-4o-mini" + assert "PluginA" in metadata.plugins + assert "PluginB" in metadata.plugins + assert "RemoteC" in metadata.plugins + + def test_extract_metadata_multi_agent_deduplicates_models(self): + """Test that duplicate models in multi-agent are deduplicated.""" + mock_agent1 = MagicMock() + mock_agent1.model = "gpt-4o" + mock_agent1.plugins = None + mock_agent1.remote_plugins = None + + mock_agent2 = MagicMock() + mock_agent2.model = "gpt-4o" + mock_agent2.plugins = None + mock_agent2.remote_plugins = None + + mock_spec = MagicMock() + mock_spec.agent = None + mock_spec.agents = [mock_agent1, mock_agent2] + + config = BaseConfig( + apiVersion="skagents/v1", + name="multi-agent", + version="1.0", + spec=mock_spec, + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.model == "gpt-4o" + + def test_extract_metadata_multi_agent_deduplicates_plugins(self): + """Test that duplicate plugins in multi-agent are deduplicated.""" + mock_agent1 = MagicMock() + mock_agent1.model = "gpt-4o" + mock_agent1.plugins = ["SharedPlugin", "PluginA"] + mock_agent1.remote_plugins = None + + mock_agent2 = MagicMock() + mock_agent2.model = "gpt-4o-mini" + mock_agent2.plugins = ["SharedPlugin", "PluginB"] + mock_agent2.remote_plugins = None + + mock_spec = MagicMock() + mock_spec.agent = None + mock_spec.agents = [mock_agent1, mock_agent2] + + config = BaseConfig( + apiVersion="skagents/v1", + name="multi-agent", + version="1.0", + spec=mock_spec, + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.plugins == ["SharedPlugin", "PluginA", "PluginB"] + + def test_extract_metadata_no_spec(self): + """Test metadata extraction when spec is None.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="my-agent", + version="1.0", + description="No spec agent", + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.agent_name == "my-agent" + assert metadata.description == "No spec agent" + assert metadata.model is None + assert metadata.plugins is None + + def test_extract_metadata_with_no_plugins(self): + """Test metadata extraction when agent has no plugins.""" + mock_agent = MagicMock() + mock_agent.model = "gpt-4o" + mock_agent.plugins = None + mock_agent.remote_plugins = None + mock_agent.mcp_servers = None + + mock_spec = MagicMock() + mock_spec.agent = mock_agent + mock_spec.agents = None + + config = BaseConfig( + apiVersion="skagents/v1", + name="my-agent", + version="1.0", + spec=mock_spec, + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.model == "gpt-4o" + assert metadata.plugins is None + + def test_extract_metadata_with_empty_plugins(self): + """Test metadata extraction when agent has empty plugin lists.""" + mock_agent = MagicMock() + mock_agent.model = "gpt-4o" + mock_agent.plugins = [] + mock_agent.remote_plugins = [] + mock_agent.mcp_servers = [] + + mock_spec = MagicMock() + mock_spec.agent = mock_agent + mock_spec.agents = None + + config = BaseConfig( + apiVersion="skagents/v1", + name="my-agent", + version="1.0", + spec=mock_spec, + ) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.plugins is None + + def test_extract_metadata_returns_empty_on_exception(self): + """Test that _extract_metadata returns empty AgentMetadata on unexpected errors.""" + config = MagicMock(spec=BaseConfig) + type(config).name = PropertyMock(side_effect=RuntimeError("unexpected")) + + metadata = UtilityRoutes._extract_metadata(config) + + assert metadata.agent_name is None + assert metadata.description is None + assert metadata.model is None + assert metadata.plugins is None + + def test_extract_metadata_logs_info_on_success(self): + """Test that successful extraction logs an info message.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="log-test-agent", + version="1.0", + ) + + with patch("sk_agents.utility_routes.logger") as mock_logger: + UtilityRoutes._extract_metadata(config) + mock_logger.info.assert_called_once_with( + "Extracted metadata for agent: %s", "log-test-agent" + ) + + def test_extract_metadata_logs_exception_on_error(self): + """Test that extraction failure logs an exception.""" + config = MagicMock(spec=BaseConfig) + type(config).name = PropertyMock(side_effect=RuntimeError("bad config")) + + with patch("sk_agents.utility_routes.logger") as mock_logger: + UtilityRoutes._extract_metadata(config) + mock_logger.exception.assert_called_once() + + +class TestMetadataRoute: + """Test the /metadata endpoint via FastAPI TestClient.""" + + def test_metadata_endpoint_returns_agent_info(self): + """Test that the /metadata endpoint returns correct agent metadata.""" + mock_agent = MagicMock() + mock_agent.model = "gpt-4o" + mock_agent.plugins = ["WeatherPlugin"] + mock_agent.remote_plugins = None + mock_agent.mcp_servers = None + + mock_spec = MagicMock() + mock_spec.agent = mock_agent + mock_spec.agents = None + + config = BaseConfig( + apiVersion="skagents/v1", + name="test-agent", + version="1.0", + description="A test agent", + spec=mock_spec, + ) + + utility_routes = UtilityRoutes() + router = utility_routes.get_metadata_routes(config=config) + + app = FastAPI() + app.include_router(router) + + client = TestClient(app) + response = client.get("/metadata") + + assert response.status_code == 200 + data = response.json() + assert data["agent_name"] == "test-agent" + assert data["description"] == "A test agent" + assert data["model"] == "gpt-4o" + assert data["plugins"] == ["WeatherPlugin"] + + def test_metadata_endpoint_with_minimal_config(self): + """Test metadata endpoint with minimal config (no spec).""" + config = BaseConfig( + apiVersion="skagents/v1", + name="minimal-agent", + version="1.0", + ) + + utility_routes = UtilityRoutes() + router = utility_routes.get_metadata_routes(config=config) + + app = FastAPI() + app.include_router(router) + + client = TestClient(app) + response = client.get("/metadata") + + assert response.status_code == 200 + data = response.json() + assert data["agent_name"] == "minimal-agent" + assert data["description"] is None + assert data["model"] is None + assert data["plugins"] is None + + def test_metadata_endpoint_with_mcp_servers(self): + """Test metadata endpoint includes MCP servers as plugins.""" + mock_mcp = MagicMock() + mock_mcp.name = "filesystem" + + mock_agent = MagicMock() + mock_agent.model = "gpt-4" + mock_agent.plugins = None + mock_agent.remote_plugins = None + mock_agent.mcp_servers = [mock_mcp] + + mock_spec = MagicMock() + mock_spec.agent = mock_agent + mock_spec.agents = None + + config = BaseConfig( + apiVersion="tealagents/v1alpha1", + name="mcp-agent", + version="1.0", + description="MCP agent", + spec=mock_spec, + ) + + utility_routes = UtilityRoutes() + router = utility_routes.get_metadata_routes(config=config) + + app = FastAPI() + app.include_router(router) + + client = TestClient(app) + response = client.get("/metadata") + + assert response.status_code == 200 + data = response.json() + assert data["plugins"] == ["mcp:filesystem"] + + +class TestAgentMetadataModel: + """Test the AgentMetadata Pydantic model.""" + + def test_agent_metadata_all_fields(self): + """Test AgentMetadata with all fields populated.""" + metadata = AgentMetadata( + agent_name="test-agent", + description="A test agent", + model="gpt-4o", + plugins=["PluginA", "PluginB"], + ) + assert metadata.agent_name == "test-agent" + assert metadata.description == "A test agent" + assert metadata.model == "gpt-4o" + assert metadata.plugins == ["PluginA", "PluginB"] + + def test_agent_metadata_defaults_to_none(self): + """Test AgentMetadata defaults all fields to None.""" + metadata = AgentMetadata() + assert metadata.agent_name is None + assert metadata.description is None + assert metadata.model is None + assert metadata.plugins is None + + def test_agent_metadata_serialization(self): + """Test AgentMetadata JSON serialization.""" + metadata = AgentMetadata( + agent_name="my-agent", + description="desc", + model="gpt-4o", + plugins=["P1"], + ) + data = metadata.model_dump() + assert data == { + "agent_name": "my-agent", + "description": "desc", + "model": "gpt-4o", + "plugins": ["P1"], + } + + +class TestSafeGet: + """Test UtilityRoutes._safe_get static method with dicts and objects.""" + + def test_safe_get_from_dict(self): + """Test _safe_get retrieves values from a dict.""" + data = {"name": "agent1", "model": "gpt-4o"} + assert UtilityRoutes._safe_get(data, "name") == "agent1" + assert UtilityRoutes._safe_get(data, "model") == "gpt-4o" + + def test_safe_get_from_dict_missing_key(self): + """Test _safe_get returns default for missing dict keys.""" + data = {"name": "agent1"} + assert UtilityRoutes._safe_get(data, "missing") is None + assert UtilityRoutes._safe_get(data, "missing", "fallback") == "fallback" + + def test_safe_get_from_object(self): + """Test _safe_get retrieves attributes from an object.""" + obj = MagicMock() + obj.name = "agent1" + assert UtilityRoutes._safe_get(obj, "name") == "agent1" + + def test_safe_get_from_object_missing_attr(self): + """Test _safe_get returns None for missing object attributes.""" + obj = MagicMock(spec=[]) + assert UtilityRoutes._safe_get(obj, "missing") is None + + +class TestExtractMetadataWithDictSpec: + """Test _extract_metadata with dict-based specs (real YAML parsing scenario).""" + + def test_single_agent_dict_spec(self): + """Test extraction from a single-agent dict spec.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="ChatBot", + version="1.0", + description="A chat bot", + spec={ + "agent": { + "name": "default", + "model": "gpt-4o", + "plugins": ["PluginA", "PluginB"], + "remote_plugins": ["RemotePlugin"], + } + }, + ) + metadata = UtilityRoutes._extract_metadata(config) + assert metadata.agent_name == "ChatBot" + assert metadata.description == "A chat bot" + assert metadata.model == "gpt-4o" + assert metadata.plugins == ["PluginA", "PluginB", "RemotePlugin"] + + def test_multi_agent_dict_spec(self): + """Test extraction from a multi-agent dict spec.""" + config = BaseConfig( + apiVersion="skagents/v1", + service_name="WeatherAgent", + version="0.1", + description="An agent for retrieving temperature", + spec={ + "agents": [ + { + "name": "default", + "model": "gpt-4o-2024-05-13", + "plugins": ["WeatherPlugin"], + } + ], + "tasks": [{"name": "action_task", "task_no": 1}], + }, + ) + metadata = UtilityRoutes._extract_metadata(config) + assert metadata.agent_name == "WeatherAgent" + assert metadata.description == "An agent for retrieving temperature" + assert metadata.model == "gpt-4o-2024-05-13" + assert metadata.plugins == ["WeatherPlugin"] + + def test_multi_agent_dict_spec_multiple_agents(self): + """Test extraction with multiple agents combines models and deduplicates plugins.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="MultiAgent", + version="1.0", + spec={ + "agents": [ + {"name": "agent1", "model": "gpt-4o", "plugins": ["PluginA"]}, + {"name": "agent2", "model": "gpt-3.5", "plugins": ["PluginA", "PluginB"]}, + ], + }, + ) + metadata = UtilityRoutes._extract_metadata(config) + assert metadata.model == "gpt-4o, gpt-3.5" + assert metadata.plugins == ["PluginA", "PluginB"] + + def test_dict_spec_with_mcp_servers(self): + """Test extraction includes MCP server names prefixed with 'mcp:'.""" + config = BaseConfig( + apiVersion="tealagents/v1alpha1", + name="MCPAgent", + version="1.0", + spec={ + "agent": { + "name": "default", + "model": "gpt-4o", + "plugins": ["LocalPlugin"], + "mcp_servers": [ + {"name": "filesystem", "url": "http://localhost:3000"}, + {"name": "github", "url": "http://localhost:3001"}, + ], + } + }, + ) + metadata = UtilityRoutes._extract_metadata(config) + assert metadata.model == "gpt-4o" + assert metadata.plugins == ["LocalPlugin", "mcp:filesystem", "mcp:github"] + + def test_dict_spec_no_plugins(self): + """Test extraction returns None for plugins when none are configured.""" + config = BaseConfig( + apiVersion="skagents/v1", + name="NoPluginAgent", + version="1.0", + spec={"agent": {"name": "default", "model": "gpt-4o"}}, + ) + metadata = UtilityRoutes._extract_metadata(config) + assert metadata.model == "gpt-4o" + assert metadata.plugins is None + + def test_dict_spec_endpoint_integration(self): + """Test the /metadata endpoint returns correct data for dict-based specs.""" + config = BaseConfig( + apiVersion="skagents/v1", + service_name="WeatherAgent", + version="0.1", + description="An agent for weather", + spec={ + "agents": [ + {"name": "default", "model": "gpt-4o-2024-05-13", "plugins": ["WeatherPlugin"]} + ], + "tasks": [{"name": "action_task", "task_no": 1}], + }, + ) + utility_routes = UtilityRoutes() + router = utility_routes.get_metadata_routes(config=config) + app = FastAPI() + app.include_router(router) + client = TestClient(app) + response = client.get("/metadata") + assert response.status_code == 200 + data = response.json() + assert data["agent_name"] == "WeatherAgent" + assert data["description"] == "An agent for weather" + assert data["model"] == "gpt-4o-2024-05-13" + assert data["plugins"] == ["WeatherPlugin"] diff --git a/src/sk-agents/uv.lock b/src/sk-agents/uv.lock index e7c4cf4e8..dfb625f26 100644 --- a/src/sk-agents/uv.lock +++ b/src/sk-agents/uv.lock @@ -182,6 +182,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -375,6 +384,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, ] +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + [[package]] name = "boto3" version = "1.42.53" @@ -644,6 +680,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -1180,6 +1225,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, ] +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1473,6 +1527,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "mcp" version = "1.26.0" @@ -2362,6 +2425,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" }, ] +[[package]] +name = "pylint" +version = "4.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, +] + [[package]] name = "pymdown-extensions" version = "10.21" @@ -2511,6 +2592,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -2961,6 +3061,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "black" }, { name = "coverage" }, { name = "hatch" }, { name = "hatchling" }, @@ -2969,6 +3070,7 @@ dev = [ { name = "mkdocs-static-i18n" }, { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, + { name = "pylint" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -2998,6 +3100,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "black", specifier = ">=26.3.1" }, { name = "coverage" }, { name = "hatch" }, { name = "hatchling" }, @@ -3006,6 +3109,7 @@ dev = [ { name = "mkdocs-static-i18n", specifier = ">=1.3.0" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.28.0" }, { name = "mypy" }, + { name = "pylint", specifier = ">=4.0.5" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" },