diff --git a/sdk/agentserver/azure-ai-agentserver/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver/CHANGELOG.md new file mode 100644 index 000000000000..1ba538d24413 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/CHANGELOG.md @@ -0,0 +1,12 @@ +# Release History + +## 1.0.0b1 (Unreleased) + +### Features Added + +- Initial release of `azure-ai-agentserver`. +- Generic `AgentServer` base class with pluggable protocol heads. +- `/invoke` protocol head with all 4 operations: create, get, cancel, and OpenAPI spec. +- OpenAPI spec-based request/response validation via `jsonschema`. +- Health check endpoints (`/liveness`, `/readiness`). +- Streaming and non-streaming invocation support. diff --git a/sdk/agentserver/azure-ai-agentserver/LICENSE b/sdk/agentserver/azure-ai-agentserver/LICENSE new file mode 100644 index 000000000000..b2f52a2bad4e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk/agentserver/azure-ai-agentserver/MANIFEST.in b/sdk/agentserver/azure-ai-agentserver/MANIFEST.in new file mode 100644 index 000000000000..468601f6166b --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/MANIFEST.in @@ -0,0 +1,7 @@ +include *.md +include LICENSE +recursive-include tests *.py +recursive-include samples *.py *.md +include azure/__init__.py +include azure/ai/__init__.py +include azure/ai/agentserver/py.typed diff --git a/sdk/agentserver/azure-ai-agentserver/README.md b/sdk/agentserver/azure-ai-agentserver/README.md new file mode 100644 index 000000000000..4068c4c6c59d --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/README.md @@ -0,0 +1,197 @@ +# azure-ai-agentserver + +A standalone, **protocol-agnostic agent server** package for Azure AI. Provides a +Starlette-based `AgentServer` base class with pluggable protocol heads, OpenAPI-based +request/response validation, tracing, logging, and health endpoints — with **zero +framework coupling**. + +## Overview + +`azure-ai-agentserver` is the canonical agent-server package going forward. It supports +multiple protocol heads (`/invoke`, `/responses`, and future protocols) through a pluggable +handler architecture. Phase 1 ships with `/invoke` support; `/responses` and other +protocols will be added in subsequent phases. + +**Key properties:** + +- **Standalone** — no dependency on `azure-ai-agentserver-core`, `openai`, or any AI + framework library. +- **Starlette + uvicorn** — lightweight ASGI server, same technology used by other Azure + AI packages. +- **Abstract base class** — subclass `AgentServer` and implement handler methods for the + protocol heads you need. +- **Customer-managed adapters** — integration with LangGraph, Agent Framework, Semantic + Kernel, etc. is done in your own code. We provide samples, not separate adapter packages. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Layer 1: Agent Service (Cloud Infrastructure) │ +│ Supports: /invoke, /responses, /mcp, /a2a, /activity │ +└────────────────────────────┬────────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────┐ + │ azure-ai-agentserver │ + │ AgentServer │ + │ │ + │ Protocol heads: │ + │ • /invoke (Phase 1) │ + │ • /responses (Phase 2) │ + │ │ + │ OpenAPI spec validation (all) │ + │ Tracing, logging, health │ + └───────────────────────────────┘ + │ + Customer owns adapters: + ┌─────────┼─────────┐ + ▼ ▼ ▼ + LangGraph Agent Semantic + adapter Framework Kernel + (sample) adapter adapter + (sample) (sample) +``` + +**Single package, multiple protocol heads, no framework coupling.** + +## Installation + +```bash +pip install azure-ai-agentserver +``` + +**Requires Python >= 3.10.** + +## Quick Start + +```python +import json +from azure.ai.agentserver import AgentServer, InvokeRequest + + +class GreetingAgent(AgentServer): + async def invoke(self, request: InvokeRequest) -> bytes: + data = json.loads(request.body) + greeting = f"Hello, {data['name']}!" + return json.dumps({"greeting": greeting}).encode() + + +if __name__ == "__main__": + GreetingAgent().run() +``` + +```bash +# Start the agent +python my_agent.py + +# Call it +curl -X POST http://localhost:8088/invocations \ + -H "Content-Type: application/json" \ + -d '{"name": "World"}' +# → {"greeting": "Hello, World!"} +``` + +## Subclassing `AgentServer` + +| Method | Required | Description | +|--------|----------|-------------| +| `invoke(request)` | **Yes** | Process an invocation. Return `bytes` or `AsyncGenerator[bytes, None]`. | +| `get_invocation(invocation_id)` | No | Retrieve a stored invocation result. Default returns 404. | +| `cancel_invocation(invocation_id, ...)` | No | Cancel a running invocation. Default returns 404. | + +### Routes (Phase 1) + +| Route | Method | Description | +|-------|--------|-------------| +| `/invocations` | POST | Create and process an invocation | +| `/invocations/{id}` | GET | Retrieve a previous invocation result | +| `/invocations/{id}/cancel` | POST | Cancel a running invocation | +| `/invocations/docs/openapi.json` | GET | Return the registered OpenAPI spec | +| `/liveness` | GET | Health check | +| `/readiness` | GET | Readiness check | + +## OpenAPI Validation + +Register a spec to validate request and response bodies at runtime: + +```python +spec = { + "openapi": "3.0.0", + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"greeting": {"type": "string"}}, + } + } + } + } + }, + } + } + }, +} + +agent = GreetingAgent(openapi_spec=spec) +agent.run() +``` + +- Non-conforming **requests** return 400 with details. +- Non-conforming **responses** log warnings but are not blocked. +- `GET /invocations/docs/openapi.json` serves the registered spec (or 404 if none). + +## Samples + +| Sample | Description | +|--------|-------------| +| `samples/simple_invoke_agent/` | Minimal from-scratch agent | +| `samples/openapi_validated_agent/` | OpenAPI spec with request/response validation | +| `samples/async_invoke_agent/` | Long-running tasks with get & cancel support | +| `samples/human_in_the_loop_agent/` | Synchronous human-in-the-loop interaction | +| `samples/langgraph_invoke_agent/` | Customer-managed LangGraph adapter | +| `samples/agentframework_invoke_agent/` | Customer-managed Agent Framework adapter | + +## Vision & Migration Path + +### Phase 1 (Current): `/invoke` only + +- Ship `azure-ai-agentserver` with the `/invoke` protocol head. +- Existing `agentserver-core` + Layer 3 adapter packages remain as-is for `/responses` + customers. +- Samples show customer-managed framework integration (LangGraph, Agent Framework, Semantic + Kernel, etc.). + +### Phase 2 (Future): Add `/responses` + +- Add a `/responses` protocol head to `azure-ai-agentserver` as a built-in handler. +- Validation for `/responses` uses the same OpenAPI-based approach (not + `openai.types.responses.*` imports). +- Sample adapters show how to port existing LangGraph / Agent Framework patterns. + +### Phase 3 (Future): Deprecate old packages + +- Deprecate `azure-ai-agentserver-core` (replaced by this package's `/responses` handler). +- Deprecate `azure-ai-agentserver-agentframework` and `azure-ai-agentserver-langgraph` + (replaced by customer-managed adapter code provided as samples). +- Customers who depend on Layer 3 adapters copy the adapter code into their own projects. + +## License + +MIT diff --git a/sdk/agentserver/azure-ai-agentserver/azure/__init__.py b/sdk/agentserver/azure-ai-agentserver/azure/__init__.py new file mode 100644 index 000000000000..d55ccad1f573 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/__init__.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/__init__.py new file mode 100644 index 000000000000..d55ccad1f573 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/__init__.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/__init__.py new file mode 100644 index 000000000000..5b06f96a5156 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/__init__.py @@ -0,0 +1,10 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + +from ._types import InvokeRequest +from ._version import VERSION +from .server._base import AgentServer + +__all__ = ["AgentServer", "InvokeRequest"] +__version__ = VERSION diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_constants.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_constants.py new file mode 100644 index 000000000000..f0c7b605b558 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_constants.py @@ -0,0 +1,16 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + + +class Constants: + """Well-known environment variables and defaults for AgentServer.""" + + AGENT_LOG_LEVEL = "AGENT_LOG_LEVEL" + AGENT_DEBUG_ERRORS = "AGENT_DEBUG_ERRORS" + DEFAULT_AD_PORT = "DEFAULT_AD_PORT" + OTEL_EXPORTER_ENDPOINT = "OTEL_EXPORTER_ENDPOINT" + OTEL_EXPORTER_OTLP_PROTOCOL = "OTEL_EXPORTER_OTLP_PROTOCOL" + ENABLE_APPLICATION_INSIGHTS_LOGGER = "AGENT_APP_INSIGHTS_ENABLED" + APPLICATIONINSIGHTS_CONNECTION_STRING = "APPLICATIONINSIGHTS_CONNECTION_STRING" + DEFAULT_PORT = 8088 diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_logger.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_logger.py new file mode 100644 index 000000000000..73e73bb659e1 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_logger.py @@ -0,0 +1,78 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +import logging +import logging.config +import os +from typing import Any, Optional + +from ._constants import Constants + + +def _get_default_log_config() -> dict[str, Any]: + """Build default logging configuration with level from environment. + + :return: A dictionary containing logging configuration. + :rtype: dict[str, Any] + """ + log_level = _get_log_level() + return { + "version": 1, + "disable_existing_loggers": False, + "loggers": { + "azure.ai.agentserver": { + "handlers": ["console"], + "level": log_level, + "propagate": False, + }, + }, + "handlers": { + "console": { + "formatter": "std_out", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "level": log_level, + }, + }, + "formatters": { + "std_out": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + }, + }, + } + + +def _get_log_level() -> str: + """Read log level from environment, defaulting to INFO. + + :return: A valid Python log level string. + :rtype: str + """ + log_level = os.getenv(Constants.AGENT_LOG_LEVEL, "INFO").upper() + valid_levels = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + if log_level not in valid_levels: + log_level = "INFO" + return log_level + + +def configure_logging(log_config: Optional[dict[str, Any]] = None) -> None: + """Configure logging for the azure.ai.agentserver namespace. + + :param log_config: Optional logging configuration dict compatible with logging.config.dictConfig. + :type log_config: Optional[dict[str, Any]] + """ + try: + if log_config is None: + log_config = _get_default_log_config() + logging.config.dictConfig(log_config) + except Exception as exc: # noqa: BLE001 + logging.getLogger(__name__).warning("Failed to configure logging: %s", exc) + + +def get_logger() -> logging.Logger: + """Return the library-scoped logger. + + :return: Configured logger instance for azure.ai.agentserver. + :rtype: logging.Logger + """ + return logging.getLogger("azure.ai.agentserver") diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_types.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_types.py new file mode 100644 index 000000000000..6595ee7e2771 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_types.py @@ -0,0 +1,21 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +from dataclasses import dataclass + + +@dataclass +class InvokeRequest: + """Incoming invoke request. + + :param body: Raw request body bytes. + :type body: bytes + :param headers: All HTTP request headers. + :type headers: dict[str, str] + :param invocation_id: Server-generated UUID for this invocation. + :type invocation_id: str + """ + + body: bytes + headers: dict[str, str] + invocation_id: str diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_version.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_version.py new file mode 100644 index 000000000000..67d209a8cafd --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/_version.py @@ -0,0 +1,5 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + +VERSION = "1.0.0b1" diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/py.typed b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/py.typed new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/server/__init__.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/server/__init__.py new file mode 100644 index 000000000000..d540fd20468c --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/server/__init__.py @@ -0,0 +1,3 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/server/_base.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/server/_base.py new file mode 100644 index 000000000000..ae67a2c41dd9 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/server/_base.py @@ -0,0 +1,368 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +import contextlib +import inspect +import os +import uuid +from abc import abstractmethod +from typing import Any, AsyncGenerator, Optional, Union + +import uvicorn +from opentelemetry import trace +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from .._constants import Constants +from .._logger import configure_logging, get_logger +from .._types import InvokeRequest +from ..validation._openapi_validator import OpenApiValidator + +logger = get_logger() + + +class AgentServer: + """Generic agent server base with pluggable protocol heads. + + Subclass and implement :meth:`invoke` to handle ``/invocations`` requests. + Optionally override :meth:`get_invocation` and :meth:`cancel_invocation` + for long-running invocation support. + + :param openapi_spec: Optional OpenAPI spec dict for request/response validation. + :type openapi_spec: Optional[dict[str, Any]] + """ + + def __init__(self, openapi_spec: Optional[dict[str, Any]] = None) -> None: + configure_logging() + self._openapi_spec = openapi_spec + self._validator: Optional[OpenApiValidator] = ( + OpenApiValidator(openapi_spec) if openapi_spec else None + ) + self._tracer = trace.get_tracer("azure.ai.agentserver") + self._build_app() + + # ------------------------------------------------------------------ + # Abstract / overridable protocol methods + # ------------------------------------------------------------------ + + @abstractmethod + async def invoke( + self, + request: InvokeRequest, + ) -> Union[bytes, AsyncGenerator[bytes, None]]: + """Process an invocation. + + Return either: + - ``bytes`` for a non-streaming response + - ``AsyncGenerator[bytes, None]`` for a streaming response that yields chunks + + :param request: The invoke request containing body, headers, and invocation_id. + :type request: InvokeRequest + :return: Response bytes or an async generator of byte chunks. + :rtype: Union[bytes, AsyncGenerator[bytes, None]] + """ + + async def get_invocation(self, invocation_id: str) -> bytes: + """Retrieve a previous invocation result. + + Default implementation raises :class:`NotImplementedError` (returns 404). + Override to support retrieval. + + :param invocation_id: The invocation ID to look up. + :type invocation_id: str + :return: The stored invocation result as bytes. + :rtype: bytes + :raises NotImplementedError: When not overridden. + """ + raise NotImplementedError + + async def cancel_invocation( + self, + invocation_id: str, + body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + ) -> bytes: + """Cancel an invocation. + + Default implementation raises :class:`NotImplementedError` (returns 404). + Override to support cancellation. + + :param invocation_id: The invocation ID to cancel. + :type invocation_id: str + :param body: Optional request body bytes. + :type body: Optional[bytes] + :param headers: Optional request headers. + :type headers: Optional[dict[str, str]] + :return: Cancellation result as bytes. + :rtype: bytes + :raises NotImplementedError: When not overridden. + """ + raise NotImplementedError + + def get_openapi_spec(self) -> Optional[dict[str, Any]]: + """Return the OpenAPI spec dict for this agent, or None. + + :return: The registered OpenAPI spec or None. + :rtype: Optional[dict[str, Any]] + """ + return self._openapi_spec + + def set_openapi_spec(self, spec: dict[str, Any]) -> None: + """Register or replace the OpenAPI spec at runtime. + + :param spec: An OpenAPI spec dictionary. + :type spec: dict[str, Any] + """ + self._openapi_spec = spec + self._validator = OpenApiValidator(spec) + + # ------------------------------------------------------------------ + # Run helpers + # ------------------------------------------------------------------ + + def run(self, port: Optional[int] = None) -> None: + """Start the server synchronously via uvicorn. + + :param port: Port to bind. Defaults to ``DEFAULT_AD_PORT`` env var or 8088. + :type port: Optional[int] + """ + resolved_port = self._resolve_port(port) + logger.info("AgentServer starting on port %s", resolved_port) + uvicorn.run(self.app, host="0.0.0.0", port=resolved_port) + + async def run_async(self, port: Optional[int] = None) -> None: + """Start the server asynchronously (awaitable). + + :param port: Port to bind. Defaults to ``DEFAULT_AD_PORT`` env var or 8088. + :type port: Optional[int] + """ + resolved_port = self._resolve_port(port) + logger.info("AgentServer starting on port %s (async)", resolved_port) + config = uvicorn.Config(self.app, host="0.0.0.0", port=resolved_port) + server = uvicorn.Server(config) + await server.serve() + + # ------------------------------------------------------------------ + # Private: app construction + # ------------------------------------------------------------------ + + def _build_app(self) -> None: + """Construct the Starlette ASGI application with all routes.""" + + @contextlib.asynccontextmanager + async def _lifespan(app: Starlette): # type: ignore[no-untyped-def] + logger.info("AgentServer started") + yield + + routes = [ + Route( + "/invocations/docs/openapi.json", + self._get_openapi_spec_endpoint, + methods=["GET"], + name="get_openapi_spec", + ), + Route( + "/invocations", + self._create_invocation_endpoint, + methods=["POST"], + name="create_invocation", + ), + Route( + "/invocations/{invocation_id}", + self._get_invocation_endpoint, + methods=["GET"], + name="get_invocation", + ), + Route( + "/invocations/{invocation_id}/cancel", + self._cancel_invocation_endpoint, + methods=["POST"], + name="cancel_invocation", + ), + Route("/liveness", self._liveness_endpoint, methods=["GET"], name="liveness"), + Route("/readiness", self._readiness_endpoint, methods=["GET"], name="readiness"), + ] + + self.app = Starlette(routes=routes, lifespan=_lifespan) + self.app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # ------------------------------------------------------------------ + # Private: endpoint handlers + # ------------------------------------------------------------------ + + async def _get_openapi_spec_endpoint(self, request: Request) -> Response: + """GET /invocations/docs/openapi.json — return registered spec or 404. + + :param request: The incoming Starlette request. + :type request: Request + :return: JSON response with the spec or 404. + :rtype: Response + """ + spec = self.get_openapi_spec() + if spec is None: + return JSONResponse({"error": "No OpenAPI spec registered"}, status_code=404) + return JSONResponse(spec) + + async def _create_invocation_endpoint(self, request: Request) -> Response: + """POST /invocations — create and process an invocation. + + :param request: The incoming Starlette request. + :type request: Request + :return: The invocation result or error response. + :rtype: Response + """ + body = await request.body() + headers = dict(request.headers) + invocation_id = str(uuid.uuid4()) + invoke_request = InvokeRequest(body=body, headers=headers, invocation_id=invocation_id) + + # Validate request body against OpenAPI spec + if self._validator is not None: + content_type = headers.get("content-type", "application/json") + errors = self._validator.validate_request(body, content_type) + if errors: + return JSONResponse( + {"error": "Request validation failed", "details": errors}, + status_code=400, + ) + + with self._tracer.start_as_current_span( + name="agentserver-invoke", + kind=trace.SpanKind.SERVER, + ): + try: + result = self.invoke(invoke_request) + # If invoke() is a coroutine (non-streaming), await it. + # If it's an async generator (streaming), use it directly. + if not inspect.isasyncgen(result) and inspect.isawaitable(result): + result = await result + except Exception as exc: + logger.error("Error processing invocation %s: %s", invocation_id, exc, exc_info=True) + error_message = str(exc) if os.environ.get(Constants.AGENT_DEBUG_ERRORS) else "Internal server error" + return JSONResponse( + {"error": error_message}, + status_code=500, + headers={"x-agent-invocation-id": invocation_id}, + ) + + response_headers = {"x-agent-invocation-id": invocation_id} + + if isinstance(result, bytes): + # Non-streaming: optionally validate response + if self._validator is not None: + content_type = "application/json" + resp_errors = self._validator.validate_response(result, content_type) + if resp_errors: + logger.warning( + "Response validation warnings for invocation %s: %s", + invocation_id, + resp_errors, + ) + return Response(content=result, media_type="application/json", headers=response_headers) + + # Streaming: result is an AsyncGenerator + return StreamingResponse(result, headers=response_headers) + + async def _get_invocation_endpoint(self, request: Request) -> Response: + """GET /invocations/{invocation_id} — retrieve an invocation result. + + :param request: The incoming Starlette request. + :type request: Request + :return: The stored result or 404. + :rtype: Response + """ + invocation_id = request.path_params["invocation_id"] + try: + result = await self.get_invocation(invocation_id) + return Response(content=result) + except NotImplementedError: + return JSONResponse( + {"error": "get_invocation not supported"}, + status_code=404, + ) + except Exception as exc: + logger.error("Error in get_invocation %s: %s", invocation_id, exc, exc_info=True) + error_message = str(exc) if os.environ.get(Constants.AGENT_DEBUG_ERRORS) else "Internal server error" + return JSONResponse( + {"error": error_message}, + status_code=500, + ) + + async def _cancel_invocation_endpoint(self, request: Request) -> Response: + """POST /invocations/{invocation_id}/cancel — cancel an invocation. + + :param request: The incoming Starlette request. + :type request: Request + :return: The cancellation result or 404. + :rtype: Response + """ + invocation_id = request.path_params["invocation_id"] + body = await request.body() + headers = dict(request.headers) + try: + result = await self.cancel_invocation(invocation_id, body=body, headers=headers) + return Response(content=result) + except NotImplementedError: + return JSONResponse( + {"error": "cancel_invocation not supported"}, + status_code=404, + ) + except Exception as exc: + logger.error("Error in cancel_invocation %s: %s", invocation_id, exc, exc_info=True) + error_message = str(exc) if os.environ.get(Constants.AGENT_DEBUG_ERRORS) else "Internal server error" + return JSONResponse( + {"error": error_message}, + status_code=500, + ) + + async def _liveness_endpoint(self, request: Request) -> Response: + """GET /liveness — health check. + + :param request: The incoming Starlette request. + :type request: Request + :return: 200 OK response. + :rtype: Response + """ + return JSONResponse({"status": "alive"}) + + async def _readiness_endpoint(self, request: Request) -> Response: + """GET /readiness — readiness check. + + :param request: The incoming Starlette request. + :type request: Request + :return: 200 OK response. + :rtype: Response + """ + return JSONResponse({"status": "ready"}) + + # ------------------------------------------------------------------ + # Private: helpers + # ------------------------------------------------------------------ + + @staticmethod + def _resolve_port(port: Optional[int]) -> int: + """Resolve the server port from argument, env var, or default. + + :param port: Explicitly requested port or None. + :type port: Optional[int] + :return: The resolved port number. + :rtype: int + """ + if port is not None: + return port + env_port = os.environ.get(Constants.DEFAULT_AD_PORT) + if env_port: + try: + return int(env_port) + except ValueError: + pass + return Constants.DEFAULT_PORT diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/validation/__init__.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/validation/__init__.py new file mode 100644 index 000000000000..d540fd20468c --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/validation/__init__.py @@ -0,0 +1,3 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- diff --git a/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/validation/_openapi_validator.py b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/validation/_openapi_validator.py new file mode 100644 index 000000000000..74ab93ab4ba6 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/azure/ai/agentserver/validation/_openapi_validator.py @@ -0,0 +1,184 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +import json +import logging +from typing import Any, Optional + +import jsonschema + +logger = logging.getLogger("azure.ai.agentserver") + + +class OpenApiValidator: + """Validates request/response bodies against an OpenAPI spec. + + Extracts the request and response JSON schemas from the provided OpenAPI spec dict + and uses ``jsonschema`` to validate bodies at runtime. + + :param spec: An OpenAPI spec dictionary. + :type spec: dict[str, Any] + """ + + def __init__(self, spec: dict[str, Any]) -> None: + self._spec = spec + self._request_schema = self._extract_request_schema(spec) + self._response_schema = self._extract_response_schema(spec) + + def validate_request(self, body: bytes, content_type: str) -> list[str]: + """Validate a request body against the spec's request schema. + + :param body: Raw request body bytes. + :type body: bytes + :param content_type: The Content-Type header value. + :type content_type: str + :return: List of validation error messages. Empty when valid. + :rtype: list[str] + """ + if self._request_schema is None: + return [] + return self._validate_body(body, content_type, self._request_schema) + + def validate_response(self, body: bytes, content_type: str) -> list[str]: + """Validate a response body against the spec's response schema. + + :param body: Raw response body bytes. + :type body: bytes + :param content_type: The Content-Type header value. + :type content_type: str + :return: List of validation error messages. Empty when valid. + :rtype: list[str] + """ + if self._response_schema is None: + return [] + return self._validate_body(body, content_type, self._response_schema) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + @staticmethod + def _validate_body(body: bytes, content_type: str, schema: dict[str, Any]) -> list[str]: + """Parse body as JSON and validate against *schema*. + + :param body: Raw bytes to validate. + :type body: bytes + :param content_type: The Content-Type header value. + :type content_type: str + :param schema: JSON Schema dict to validate against. + :type schema: dict[str, Any] + :return: List of validation error strings. + :rtype: list[str] + """ + if "json" not in content_type.lower(): + return [] # skip validation for non-JSON payloads + + try: + data = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + return [f"Invalid JSON body: {exc}"] + + errors: list[str] = [] + validator = jsonschema.Draft7Validator(schema) + for error in validator.iter_errors(data): + errors.append(error.message) + return errors + + @staticmethod + def _extract_request_schema(spec: dict[str, Any]) -> Optional[dict[str, Any]]: + """Extract the request body JSON schema from the POST /invocations operation. + + :param spec: OpenAPI spec dictionary. + :type spec: dict[str, Any] + :return: JSON Schema dict or None. + :rtype: Optional[dict[str, Any]] + """ + return OpenApiValidator._find_schema_in_paths( + spec, "/invocations", "post", "requestBody" + ) + + @staticmethod + def _extract_response_schema(spec: dict[str, Any]) -> Optional[dict[str, Any]]: + """Extract the response body JSON schema from the POST /invocations operation. + + :param spec: OpenAPI spec dictionary. + :type spec: dict[str, Any] + :return: JSON Schema dict or None. + :rtype: Optional[dict[str, Any]] + """ + return OpenApiValidator._find_schema_in_paths( + spec, "/invocations", "post", "responses" + ) + + @staticmethod + def _find_schema_in_paths( + spec: dict[str, Any], + path: str, + method: str, + section: str, + ) -> Optional[dict[str, Any]]: + """Walk the spec to find a JSON schema for the given path/method/section. + + :param spec: OpenAPI spec dictionary. + :type spec: dict[str, Any] + :param path: The API path (e.g. ``/invocations``). + :type path: str + :param method: HTTP method (e.g. ``post``). + :type method: str + :param section: Either ``requestBody`` or ``responses``. + :type section: str + :return: Resolved JSON Schema dict or None. + :rtype: Optional[dict[str, Any]] + """ + paths = spec.get("paths", {}) + operation = paths.get(path, {}).get(method, {}) + + if section == "requestBody": + request_body = operation.get("requestBody", {}) + content = request_body.get("content", {}) + json_content = content.get("application/json", {}) + schema = json_content.get("schema") + return _resolve_ref(spec, schema) if schema else None + + if section == "responses": + responses = operation.get("responses", {}) + # Try 200, then 201, then first available + for code in ("200", "201"): + resp = responses.get(code, {}) + content = resp.get("content", {}) + json_content = content.get("application/json", {}) + schema = json_content.get("schema") + if schema: + return _resolve_ref(spec, schema) + # Fallback: first response with JSON content + for resp in responses.values(): + if isinstance(resp, dict): + content = resp.get("content", {}) + json_content = content.get("application/json", {}) + schema = json_content.get("schema") + if schema: + return _resolve_ref(spec, schema) + return None + + +def _resolve_ref(spec: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]: + """Resolve a ``$ref`` pointer within the spec. + + :param spec: The full OpenAPI spec dictionary. + :type spec: dict[str, Any] + :param schema: A schema dict that may contain a ``$ref`` key. + :type schema: dict[str, Any] + :return: The resolved schema. + :rtype: dict[str, Any] + """ + if "$ref" not in schema: + return schema + ref_path = schema["$ref"] # e.g. "#/components/schemas/MyModel" + parts = ref_path.lstrip("#/").split("/") + current: Any = spec + for part in parts: + if isinstance(current, dict): + current = current.get(part) + else: + return schema # can't resolve, return as-is + return current if isinstance(current, dict) else schema diff --git a/sdk/agentserver/azure-ai-agentserver/cspell.json b/sdk/agentserver/azure-ai-agentserver/cspell.json new file mode 100644 index 000000000000..c26e19a317bf --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/cspell.json @@ -0,0 +1,15 @@ +{ + "ignoreWords": [ + "agentserver", + "azureai", + "ainvoke", + "invocations", + "openapi" + ], + "ignorePaths": [ + "*.csv", + "*.json", + "*.rst", + "samples/**" + ] +} \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver/dev_requirements.txt b/sdk/agentserver/azure-ai-agentserver/dev_requirements.txt new file mode 100644 index 000000000000..d3a44be1dd0f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/dev_requirements.txt @@ -0,0 +1,4 @@ +-e ../../../eng/tools/azure-sdk-tools +pytest +httpx +pytest-asyncio diff --git a/sdk/agentserver/azure-ai-agentserver/pyproject.toml b/sdk/agentserver/azure-ai-agentserver/pyproject.toml new file mode 100644 index 000000000000..3a767f0c014c --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/pyproject.toml @@ -0,0 +1,70 @@ +[project] +name = "azure-ai-agentserver" +dynamic = ["version", "readme"] +description = "Generic agent server for Azure AI with pluggable protocol heads" +requires-python = ">=3.10" +authors = [ + { name = "Microsoft Corporation", email = "azpysdkhelp@microsoft.com" }, +] +license = "MIT" +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +keywords = ["azure", "azure sdk", "agent", "agentserver"] + +dependencies = [ + "azure-core>=1.35.0", + "opentelemetry-api>=1.35", + "opentelemetry-exporter-otlp-proto-http", + "starlette>=0.45.0", + "uvicorn>=0.31.0", + "jsonschema>=4.0.0", +] + +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project.urls] +repository = "https://github.com/Azure/azure-sdk-for-python" + +[tool.setuptools.packages.find] +exclude = [ + "tests*", + "samples*", + "doc*", + "azure", + "azure.ai", +] + +[tool.setuptools.dynamic] +version = { attr = "azure.ai.agentserver._version.VERSION" } +readme = { file = ["README.md"], content-type = "text/markdown" } + +[tool.setuptools.package-data] +pytyped = ["py.typed"] + +[tool.ruff] +line-length = 120 +target-version = "py310" +lint.select = ["E", "F", "B", "I"] +lint.ignore = [] +fix = false + +[tool.ruff.lint.isort] +known-first-party = ["azure.ai.agentserver"] +combine-as-imports = true + +[tool.azure-sdk-build] +breaking = false +pyright = false +verifytypes = false +latestdependency = false +dependencies = false diff --git a/sdk/agentserver/azure-ai-agentserver/pyrightconfig.json b/sdk/agentserver/azure-ai-agentserver/pyrightconfig.json new file mode 100644 index 000000000000..5f81af3c9da7 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "reportOptionalMemberAccess": "warning", + "reportArgumentType": "warning", + "reportAttributeAccessIssue": "warning", + "reportMissingImports": "warning", + "reportGeneralTypeIssues": "warning", + "reportReturnType": "warning", + "exclude": [ + "**/samples/**" + ] +} \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/.env.sample b/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/.env.sample new file mode 100644 index 000000000000..b2381c6f1b1b --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/.env.sample @@ -0,0 +1,4 @@ +# Azure AI credentials (used by DefaultAzureCredential + AzureOpenAIChatClient) +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +AZURE_OPENAI_API_VERSION=2024-12-01-preview +AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4.1 \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/agentframework_invoke_agent.py b/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/agentframework_invoke_agent.py new file mode 100644 index 000000000000..f09c5f8b543b --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/agentframework_invoke_agent.py @@ -0,0 +1,82 @@ +"""Agent Framework agent served via /invoke. + +Customer owns the AgentFramework <-> invoke conversion logic. +This replaces the need for azure-ai-agentserver-agentframework. + +Usage:: + + # Start the agent + python agentframework_invoke_agent.py + + # Send a request + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"input": "What is the weather in Seattle?"}' + # -> {"result": "The weather in Seattle is sunny with a high of 25°C."} +""" +import asyncio +import json +import os +from random import randint +from typing import Annotated + +from dotenv import load_dotenv + +load_dotenv() + +from agent_framework import Agent +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import DefaultAzureCredential + +from azure.ai.agentserver import AgentServer, InvokeRequest + + +# -- Customer defines their tools -- + +def get_weather( + location: Annotated[str, "The location to get the weather for."], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +# -- Customer builds their Agent Framework agent -- + +def build_agent() -> Agent: + """Create an Agent Framework Agent with tools.""" + client = AzureOpenAIChatClient(credential=DefaultAzureCredential()) + return client.as_agent( + instructions="You are a helpful weather assistant.", + tools=get_weather, + ) + + +# -- Customer-managed adapter: Agent Framework <-> /invoke -- + +class AgentFrameworkInvokeAgent(AgentServer): + """Customer-managed adapter: Agent Framework <-> /invoke protocol.""" + + def __init__(self, agent: Agent): + super().__init__() + self.agent = agent + + async def invoke(self, request: InvokeRequest) -> bytes: + """Process an invocation via Agent Framework. + + :param request: The invocation request. + :type request: InvokeRequest + :return: JSON-encoded response bytes. + :rtype: bytes + """ + data = json.loads(request.body) + user_input = data.get("input", "") + + # Run the agent + response = await self.agent.run(user_input) + result = response.content if hasattr(response, "content") else str(response) + + return json.dumps({"result": result}).encode() + + +if __name__ == "__main__": + agent = build_agent() + AgentFrameworkInvokeAgent(agent).run() diff --git a/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/requirements.txt new file mode 100644 index 000000000000..bd3b80baf653 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/agentframework_invoke_agent/requirements.txt @@ -0,0 +1,4 @@ +azure-ai-agentserver +agent-framework>=1.0.0rc2 +azure-identity>=1.25.0 +python-dotenv>=1.0.0 diff --git a/sdk/agentserver/azure-ai-agentserver/samples/async_invoke_agent/async_invoke_agent.py b/sdk/agentserver/azure-ai-agentserver/samples/async_invoke_agent/async_invoke_agent.py new file mode 100644 index 000000000000..71f70512db74 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/async_invoke_agent/async_invoke_agent.py @@ -0,0 +1,149 @@ +"""Async invoke agent example. + +Demonstrates get_invocation and cancel_invocation for long-running work. +Invocations run in background tasks; callers poll or cancel by ID. + +Usage:: + + # Start the agent + python async_invoke_agent.py + + # Start a long-running invocation + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"query": "analyze dataset"}' + # -> x-agent-invocation-id: abc-123 + # -> {"invocation_id": "abc-123", "status": "running"} + + # Poll for result + curl http://localhost:8088/invocations/abc-123 + # -> {"invocation_id": "abc-123", "status": "running"} (still working) + # -> {"invocation_id": "abc-123", "status": "completed"} (done) + + # Or cancel + curl -X POST http://localhost:8088/invocations/abc-123/cancel + # -> {"invocation_id": "abc-123", "status": "cancelled"} +""" +import asyncio +import json +from typing import Optional + +from azure.ai.agentserver import AgentServer, InvokeRequest + + +class AsyncAgent(AgentServer): + """Agent that supports long-running invocations with get and cancel.""" + + def __init__(self): + super().__init__() + self._tasks: dict[str, asyncio.Task] = {} + self._results: dict[str, bytes] = {} + + async def invoke(self, request: InvokeRequest) -> bytes: + """Start a long-running invocation in a background task. + + :param request: The invocation request. + :type request: InvokeRequest + :return: JSON status indicating the task is running. + :rtype: bytes + """ + data = json.loads(request.body) + + task = asyncio.create_task(self._do_work(request.invocation_id, data)) + self._tasks[request.invocation_id] = task + + return json.dumps({ + "invocation_id": request.invocation_id, + "status": "running", + }).encode() + + async def get_invocation(self, invocation_id: str) -> bytes: + """Retrieve a previous invocation result. + + :param invocation_id: The invocation ID to look up. + :type invocation_id: str + :return: JSON status or result bytes. + :rtype: bytes + """ + if invocation_id in self._results: + return self._results[invocation_id] + + if invocation_id in self._tasks: + task = self._tasks[invocation_id] + if not task.done(): + return json.dumps({ + "invocation_id": invocation_id, + "status": "running", + }).encode() + result = task.result() + self._results[invocation_id] = result + del self._tasks[invocation_id] + return result + + return json.dumps({"error": "not found"}).encode() + + async def cancel_invocation( + self, + invocation_id: str, + body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + ) -> bytes: + """Cancel a running invocation. + + :param invocation_id: The invocation ID to cancel. + :type invocation_id: str + :param body: Optional request body bytes. + :type body: Optional[bytes] + :param headers: Optional request headers. + :type headers: Optional[dict[str, str]] + :return: JSON cancellation status. + :rtype: bytes + """ + # Already completed — cannot cancel + if invocation_id in self._results: + return json.dumps({ + "invocation_id": invocation_id, + "status": "completed", + "error": "invocation already completed", + }).encode() + + if invocation_id in self._tasks: + task = self._tasks[invocation_id] + if task.done(): + # Task finished between check — treat as completed + self._results[invocation_id] = task.result() + del self._tasks[invocation_id] + return json.dumps({ + "invocation_id": invocation_id, + "status": "completed", + "error": "invocation already completed", + }).encode() + task.cancel() + del self._tasks[invocation_id] + return json.dumps({ + "invocation_id": invocation_id, + "status": "cancelled", + }).encode() + + return json.dumps({"error": "not found"}).encode() + + async def _do_work(self, invocation_id: str, data: dict) -> bytes: + """Simulate long-running work. + + :param invocation_id: The invocation ID for this task. + :type invocation_id: str + :param data: The parsed request data. + :type data: dict + :return: JSON result bytes. + :rtype: bytes + """ + await asyncio.sleep(10) + result = json.dumps({ + "invocation_id": invocation_id, + "status": "completed", + "output": f"Processed: {data}", + }).encode() + self._results[invocation_id] = result + return result + + +if __name__ == "__main__": + AsyncAgent().run() diff --git a/sdk/agentserver/azure-ai-agentserver/samples/async_invoke_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver/samples/async_invoke_agent/requirements.txt new file mode 100644 index 000000000000..10ccd9f42648 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/async_invoke_agent/requirements.txt @@ -0,0 +1 @@ +azure-ai-agentserver diff --git a/sdk/agentserver/azure-ai-agentserver/samples/human_in_the_loop_agent/human_in_the_loop_agent.py b/sdk/agentserver/azure-ai-agentserver/samples/human_in_the_loop_agent/human_in_the_loop_agent.py new file mode 100644 index 000000000000..8a7357203ee3 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/human_in_the_loop_agent/human_in_the_loop_agent.py @@ -0,0 +1,72 @@ +"""Human-in-the-loop invoke agent example. + +Demonstrates a synchronous human-in-the-loop pattern using only +POST /invocations. The agent asks a clarifying question, and the client +replies in a second request. + +Flow: + 1. Client sends a message -> agent returns a question + invocation_id + 2. Client sends a reply -> agent returns the final result + +Usage:: + + # Start the agent + python human_in_the_loop_agent.py + + # Step 1: Send a request — agent asks a clarifying question + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"message": "Book me a flight"}' + # -> {"invocation_id": "", "status": "needs_input", "question": "Where would you like to fly to?"} + + # Step 2: Reply with the answer — agent completes + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"reply_to": "", "message": "Seattle"}' + # -> {"invocation_id": "", "status": "completed", "response": "Flight to Seattle booked."} +""" +import json +from typing import Any + +from azure.ai.agentserver import AgentServer, InvokeRequest + + +class HumanInTheLoopAgent(AgentServer): + """Agent that asks one clarifying question before completing a request.""" + + def __init__(self) -> None: + super().__init__() + # Holds questions waiting for a human reply, keyed by invocation_id + self._waiting: dict[str, dict[str, Any]] = {} + + async def invoke(self, request: InvokeRequest) -> bytes: + """Handle messages and replies. + + :param request: The invocation request. + :type request: InvokeRequest + :return: JSON response bytes. + :rtype: bytes + """ + data = json.loads(request.body) + + # --- Reply to a previous question --- + reply_to = data.get("reply_to") + if reply_to: + if reply_to not in self._waiting: + return json.dumps({"error": f"No pending question for {reply_to}"}).encode() + + return json.dumps({ + "invocation_id": reply_to, + "status": "completed", + "response": f"Flight to {data.get('message', '?')} booked.", + }).encode() + + # --- New request: ask a clarifying question --- + self._waiting[request.invocation_id] = { + "message": data.get("message", ""), + } + return json.dumps({ + "invocation_id": request.invocation_id, + "status": "needs_input", + "question": "Where would you like to fly to?", + }).encode() + + +if __name__ == "__main__": + HumanInTheLoopAgent().run() diff --git a/sdk/agentserver/azure-ai-agentserver/samples/human_in_the_loop_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver/samples/human_in_the_loop_agent/requirements.txt new file mode 100644 index 000000000000..10ccd9f42648 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/human_in_the_loop_agent/requirements.txt @@ -0,0 +1 @@ +azure-ai-agentserver diff --git a/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/.env.sample b/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/.env.sample new file mode 100644 index 000000000000..a75e7aec8869 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/.env.sample @@ -0,0 +1,5 @@ +# Azure OpenAI credentials +AZURE_OPENAI_API_KEY=your-api-key-here +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +AZURE_OPENAI_API_VERSION=2024-12-01-preview +AZURE_OPENAI_MODEL=gpt-4o diff --git a/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/langgraph_invoke_agent.py b/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/langgraph_invoke_agent.py new file mode 100644 index 000000000000..02597dd0249e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/langgraph_invoke_agent.py @@ -0,0 +1,98 @@ +"""LangGraph agent served via /invoke. + +Customer owns the LangGraph <-> invoke conversion logic. +This replaces the need for azure-ai-agentserver-langgraph. + +Usage:: + + # Start the agent + python langgraph_invoke_agent.py + + # Non-streaming request + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"message": "What is the capital of France?"}' + # -> {"reply": "The capital of France is Paris."} + + # Streaming request + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"message": "Tell me a joke", "stream": true}' + # -> {"delta": "Why did..."} + # {"delta": " the chicken..."} +""" +import json +import os +from typing import AsyncGenerator + +from dotenv import load_dotenv + +load_dotenv() + +from langgraph.graph import END, START, MessagesState, StateGraph +from langchain_openai import AzureChatOpenAI + +from azure.ai.agentserver import AgentServer, InvokeRequest + + +def build_graph() -> StateGraph: + """Customer builds their LangGraph agent as usual.""" + llm = AzureChatOpenAI( + model=os.environ["AZURE_OPENAI_MODEL"], + api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview"), + ) + + def chatbot(state: MessagesState): + return {"messages": [llm.invoke(state["messages"])]} + + graph = StateGraph(MessagesState) + graph.add_node("chatbot", chatbot) + graph.add_edge(START, "chatbot") + graph.add_edge("chatbot", END) + return graph.compile() + + +class LangGraphInvokeAgent(AgentServer): + """Customer-managed adapter: LangGraph <-> /invoke protocol.""" + + def __init__(self): + super().__init__() + self.graph = build_graph() + + async def invoke(self, request: InvokeRequest): + """Process the invocation via LangGraph. + + :param request: The invocation request. + :type request: InvokeRequest + :return: JSON bytes or an async generator of byte chunks. + :rtype: Union[bytes, AsyncGenerator[bytes, None]] + """ + data = json.loads(request.body) + user_message = data["message"] + stream = data.get("stream", False) + + if stream: + return self._stream_response(user_message) + + result = await self.graph.ainvoke( + {"messages": [{"role": "user", "content": user_message}]} + ) + last_message = result["messages"][-1] + return json.dumps({"reply": last_message.content}).encode() + + async def _stream_response(self, user_message: str) -> AsyncGenerator[bytes, None]: + """Async generator that yields response chunks. + + :param user_message: The user message to process. + :type user_message: str + :return: An async generator yielding JSON-encoded byte chunks. + :rtype: AsyncGenerator[bytes, None] + """ + async for event in self.graph.astream_events( + {"messages": [{"role": "user", "content": user_message}]}, + version="v2", + ): + if event["event"] == "on_chat_model_stream": + chunk = event["data"]["chunk"].content + if chunk: + yield json.dumps({"delta": chunk}).encode() + b"\n" + + +if __name__ == "__main__": + LangGraphInvokeAgent().run() diff --git a/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/requirements.txt new file mode 100644 index 000000000000..980438cbf628 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/langgraph_invoke_agent/requirements.txt @@ -0,0 +1,4 @@ +azure-ai-agentserver +langgraph>=1.0.0 +langchain-openai>=1.0.0 +python-dotenv>=1.0.0 diff --git a/sdk/agentserver/azure-ai-agentserver/samples/openapi_validated_agent/openapi_validated_agent.py b/sdk/agentserver/azure-ai-agentserver/samples/openapi_validated_agent/openapi_validated_agent.py new file mode 100644 index 000000000000..4402d7009f32 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/openapi_validated_agent/openapi_validated_agent.py @@ -0,0 +1,120 @@ +"""OpenAPI-validated agent example. + +Demonstrates how to supply an OpenAPI spec to AgentServer so that +incoming requests and outgoing responses are validated automatically. +Invalid requests receive a 400 response before ``invoke`` is called, +and response validation warnings are logged. + +The spec is also served at ``GET /invocations/docs/openapi.json`` so +that callers can discover the agent's contract at runtime. + +Usage:: + + # Start the agent + python openapi_validated_agent.py + + # Fetch the OpenAPI spec + curl http://localhost:8088/invocations/docs/openapi.json + + # Valid request (200) + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"name": "Alice", "language": "fr"}' + # -> {"greeting": "Bonjour, Alice!"} + + # Invalid request — missing required "name" field (400) + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"language": "en"}' + # -> {"error": ["'name' is a required property"]} +""" +import json +from typing import Any + +from azure.ai.agentserver import AgentServer, InvokeRequest + +# Define a simple OpenAPI 3.0 spec inline. In production this could be +# loaded from a YAML/JSON file. +OPENAPI_SPEC: dict[str, Any] = { + "openapi": "3.0.0", + "info": {"title": "Greeting Agent", "version": "1.0.0"}, + "paths": { + "/invocations": { + "post": { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "Name of the person to greet.", + }, + "language": { + "type": "string", + "enum": ["en", "es", "fr"], + "description": "Language for the greeting.", + }, + }, + "additionalProperties": False, + } + } + }, + }, + "responses": { + "200": { + "description": "Greeting response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["greeting"], + "properties": { + "greeting": {"type": "string"}, + }, + } + } + }, + } + }, + } + } + }, +} + +GREETINGS = { + "en": "Hello", + "es": "Hola", + "fr": "Bonjour", +} + + +class GreetingAgent(AgentServer): + """Agent that greets a user in the requested language. + + The OpenAPI spec enforces that "name" is required, "language" must be + one of ``en``, ``es``, or ``fr``, and no extra fields are allowed. + Requests that violate the schema are rejected with 400 before reaching + ``invoke``. + """ + + def __init__(self) -> None: + super().__init__(openapi_spec=OPENAPI_SPEC) + + async def invoke(self, request: InvokeRequest) -> bytes: + """Return a localised greeting. + + :param request: The invocation request. + :type request: InvokeRequest + :return: JSON-encoded greeting response. + :rtype: bytes + """ + data = json.loads(request.body) + language = data.get("language", "en") + prefix = GREETINGS.get(language, "Hello") + greeting = f"{prefix}, {data['name']}!" + return json.dumps({"greeting": greeting}).encode() + + +if __name__ == "__main__": + agent = GreetingAgent() + agent.run() diff --git a/sdk/agentserver/azure-ai-agentserver/samples/openapi_validated_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver/samples/openapi_validated_agent/requirements.txt new file mode 100644 index 000000000000..10ccd9f42648 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/openapi_validated_agent/requirements.txt @@ -0,0 +1 @@ +azure-ai-agentserver diff --git a/sdk/agentserver/azure-ai-agentserver/samples/simple_invoke_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver/samples/simple_invoke_agent/requirements.txt new file mode 100644 index 000000000000..10ccd9f42648 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/simple_invoke_agent/requirements.txt @@ -0,0 +1 @@ +azure-ai-agentserver diff --git a/sdk/agentserver/azure-ai-agentserver/samples/simple_invoke_agent/simple_invoke_agent.py b/sdk/agentserver/azure-ai-agentserver/samples/simple_invoke_agent/simple_invoke_agent.py new file mode 100644 index 000000000000..730157ba8871 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/samples/simple_invoke_agent/simple_invoke_agent.py @@ -0,0 +1,36 @@ +"""Simple invoke agent example. + +Accepts JSON requests, echoes back with a greeting. + +Usage:: + + # Start the agent + python simple_invoke_agent.py + + # Send a greeting request + curl -X POST http://localhost:8088/invocations -H "Content-Type: application/json" -d '{"name": "Alice"}' + # -> {"greeting": "Hello, Alice!"} +""" +import json + +from azure.ai.agentserver import AgentServer, InvokeRequest + + +class GreetingAgent(AgentServer): + """Minimal agent that echoes a greeting.""" + + async def invoke(self, request: InvokeRequest) -> bytes: + """Process the invocation by echoing a greeting. + + :param request: The invocation request. + :type request: InvokeRequest + :return: JSON-encoded greeting response. + :rtype: bytes + """ + data = json.loads(request.body) + greeting = f"Hello, {data['name']}!" + return json.dumps({"greeting": greeting}).encode() + + +if __name__ == "__main__": + GreetingAgent().run() diff --git a/sdk/agentserver/azure-ai-agentserver/tests/conftest.py b/sdk/agentserver/azure-ai-agentserver/tests/conftest.py new file mode 100644 index 000000000000..373f8472f685 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/tests/conftest.py @@ -0,0 +1,202 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Shared fixtures for azure-ai-agentserver tests.""" +import json +from typing import AsyncGenerator, Optional + +import pytest +import httpx + +from azure.ai.agentserver import AgentServer, InvokeRequest + + +# --------------------------------------------------------------------------- +# Test agent implementations +# --------------------------------------------------------------------------- + + +class EchoAgent(AgentServer): + """Echoes the request body back as-is.""" + + async def invoke(self, request: InvokeRequest) -> bytes: + return request.body + + +class StreamingAgent(AgentServer): + """Returns a multi-chunk streaming response.""" + + async def invoke(self, request: InvokeRequest) -> AsyncGenerator[bytes, None]: + for i in range(3): + yield json.dumps({"chunk": i}).encode() + b"\n" + + +class AsyncStorageAgent(AgentServer): + """Supports get_invocation and cancel_invocation via in-memory storage.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._store: dict[str, bytes] = {} + + async def invoke(self, request: InvokeRequest) -> bytes: + result = json.dumps({"echo": request.body.decode()}).encode() + self._store[request.invocation_id] = result + return result + + async def get_invocation(self, invocation_id: str) -> bytes: + if invocation_id in self._store: + return self._store[invocation_id] + raise NotImplementedError + + async def cancel_invocation( + self, + invocation_id: str, + body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + ) -> bytes: + if invocation_id in self._store: + del self._store[invocation_id] + return json.dumps({"status": "cancelled"}).encode() + raise NotImplementedError + + +SAMPLE_OPENAPI_SPEC: dict = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0"}, + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + "required": ["name"], + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "greeting": {"type": "string"}, + }, + "required": ["greeting"], + } + } + } + } + }, + } + } + }, +} + + +class ValidatedAgent(AgentServer): + """Agent with OpenAPI validation that returns a greeting.""" + + def __init__(self): + super().__init__(openapi_spec=SAMPLE_OPENAPI_SPEC) + + async def invoke(self, request: InvokeRequest) -> bytes: + data = json.loads(request.body) + return json.dumps({"greeting": f"Hello, {data['name']}!"}).encode() + + +class BadResponseAgent(AgentServer): + """Agent with OpenAPI validation that returns a non-conforming response.""" + + def __init__(self): + super().__init__(openapi_spec=SAMPLE_OPENAPI_SPEC) + + async def invoke(self, request: InvokeRequest) -> bytes: + # Returns a response missing the required 'greeting' field + return json.dumps({"wrong_field": "oops"}).encode() + + +class FailingAgent(AgentServer): + """Agent whose invoke() always raises an exception.""" + + async def invoke(self, request: InvokeRequest) -> bytes: + raise ValueError("something went wrong") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def echo_client(): + """httpx.AsyncClient wired to EchoAgent's ASGI app.""" + agent = EchoAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.fixture +async def streaming_client(): + """httpx.AsyncClient wired to StreamingAgent's ASGI app.""" + agent = StreamingAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.fixture +def async_storage_agent(): + """An AsyncStorageAgent instance.""" + return AsyncStorageAgent() + + +@pytest.fixture +async def async_storage_client(async_storage_agent): + """httpx.AsyncClient wired to AsyncStorageAgent's ASGI app.""" + transport = httpx.ASGITransport(app=async_storage_agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.fixture +async def validated_client(): + """httpx.AsyncClient wired to ValidatedAgent's ASGI app.""" + agent = ValidatedAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.fixture +async def bad_response_client(): + """httpx.AsyncClient wired to BadResponseAgent's ASGI app.""" + agent = BadResponseAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.fixture +async def no_spec_client(): + """httpx.AsyncClient wired to EchoAgent (no OpenAPI spec).""" + agent = EchoAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.fixture +async def failing_client(): + """httpx.AsyncClient wired to FailingAgent's ASGI app.""" + agent = FailingAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client diff --git a/sdk/agentserver/azure-ai-agentserver/tests/test_get_cancel.py b/sdk/agentserver/azure-ai-agentserver/tests/test_get_cancel.py new file mode 100644 index 000000000000..5b4714d2f088 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/tests/test_get_cancel.py @@ -0,0 +1,102 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for get_invocation and cancel_invocation.""" +import json +import uuid + +import pytest + + +@pytest.mark.asyncio +async def test_get_invocation_after_invoke(async_storage_client): + """Invoke, then GET /invocations/{id} returns stored result.""" + resp = await async_storage_client.post("/invocations", content=b'{"key":"value"}') + invocation_id = resp.headers["x-agent-invocation-id"] + + get_resp = await async_storage_client.get(f"/invocations/{invocation_id}") + assert get_resp.status_code == 200 + data = json.loads(get_resp.content) + assert "echo" in data + + +@pytest.mark.asyncio +async def test_get_invocation_unknown_id_returns_404(async_storage_client): + """GET /invocations/{unknown} returns 404.""" + resp = await async_storage_client.get(f"/invocations/{uuid.uuid4()}") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_cancel_invocation_after_invoke(async_storage_client): + """Invoke, then POST /invocations/{id}/cancel returns cancelled status.""" + resp = await async_storage_client.post("/invocations", content=b'{"key":"value"}') + invocation_id = resp.headers["x-agent-invocation-id"] + + cancel_resp = await async_storage_client.post(f"/invocations/{invocation_id}/cancel") + assert cancel_resp.status_code == 200 + data = json.loads(cancel_resp.content) + assert data["status"] == "cancelled" + + +@pytest.mark.asyncio +async def test_cancel_invocation_unknown_id_returns_404(async_storage_client): + """POST /invocations/{unknown}/cancel returns 404.""" + resp = await async_storage_client.post(f"/invocations/{uuid.uuid4()}/cancel") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_after_cancel_returns_404(async_storage_client): + """Cancel, then get same ID returns 404.""" + resp = await async_storage_client.post("/invocations", content=b'{"key":"value"}') + invocation_id = resp.headers["x-agent-invocation-id"] + + await async_storage_client.post(f"/invocations/{invocation_id}/cancel") + get_resp = await async_storage_client.get(f"/invocations/{invocation_id}") + assert get_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_invocation_error_returns_500(): + """GET /invocations/{id} returns 500 when customer code raises an error.""" + import httpx + from azure.ai.agentserver import AgentServer, InvokeRequest + + class BuggyGetAgent(AgentServer): + async def invoke(self, request: InvokeRequest) -> bytes: + return b"{}" + + async def get_invocation(self, invocation_id: str) -> bytes: + raise RuntimeError("storage unavailable") + + agent = BuggyGetAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.get("/invocations/some-id") + assert resp.status_code == 500 + + +@pytest.mark.asyncio +async def test_cancel_invocation_error_returns_500(): + """POST /invocations/{id}/cancel returns 500 when customer code raises an error.""" + import httpx + from azure.ai.agentserver import AgentServer, InvokeRequest + from typing import Optional + + class BuggyCancelAgent(AgentServer): + async def invoke(self, request: InvokeRequest) -> bytes: + return b"{}" + + async def cancel_invocation( + self, invocation_id: str, + body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + ) -> bytes: + raise RuntimeError("cancel failed") + + agent = BuggyCancelAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.post("/invocations/some-id/cancel") + assert resp.status_code == 500 diff --git a/sdk/agentserver/azure-ai-agentserver/tests/test_health.py b/sdk/agentserver/azure-ai-agentserver/tests/test_health.py new file mode 100644 index 000000000000..3c0786f8eeac --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/tests/test_health.py @@ -0,0 +1,23 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for health check endpoints.""" +import pytest + + +@pytest.mark.asyncio +async def test_liveness_returns_200(echo_client): + """GET /liveness returns 200.""" + resp = await echo_client.get("/liveness") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "alive" + + +@pytest.mark.asyncio +async def test_readiness_returns_200(echo_client): + """GET /readiness returns 200.""" + resp = await echo_client.get("/readiness") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ready" diff --git a/sdk/agentserver/azure-ai-agentserver/tests/test_invoke.py b/sdk/agentserver/azure-ai-agentserver/tests/test_invoke.py new file mode 100644 index 000000000000..7d10f1336872 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/tests/test_invoke.py @@ -0,0 +1,96 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for invoke dispatch (streaming and non-streaming).""" +import json +import uuid + +import pytest + + +@pytest.mark.asyncio +async def test_invoke_echoes_body(echo_client): + """POST /invocations body is passed to invoke() and echoed back.""" + payload = b'{"message":"ping"}' + resp = await echo_client.post("/invocations", content=payload) + assert resp.status_code == 200 + assert resp.content == payload + + +@pytest.mark.asyncio +async def test_invoke_receives_headers(echo_client): + """request.headers contains sent HTTP headers.""" + # EchoAgent echoes body; we just confirm the request succeeds with custom headers. + resp = await echo_client.post( + "/invocations", + content=b"{}", + headers={"X-Custom-Header": "test-value", "Content-Type": "application/json"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_invoke_receives_invocation_id(echo_client): + """Response x-agent-invocation-id is a non-empty UUID string.""" + resp = await echo_client.post("/invocations", content=b"{}") + invocation_id = resp.headers["x-agent-invocation-id"] + assert invocation_id + uuid.UUID(invocation_id) # raises if not valid UUID + + +@pytest.mark.asyncio +async def test_invoke_invocation_id_unique(echo_client): + """Two consecutive POST /invocations return different x-agent-invocation-id values.""" + resp1 = await echo_client.post("/invocations", content=b"{}") + resp2 = await echo_client.post("/invocations", content=b"{}") + id1 = resp1.headers["x-agent-invocation-id"] + id2 = resp2.headers["x-agent-invocation-id"] + assert id1 != id2 + + +@pytest.mark.asyncio +async def test_invoke_streaming_returns_chunked(streaming_client): + """Streaming agent returns a StreamingResponse.""" + resp = await streaming_client.post("/invocations", content=b"{}") + assert resp.status_code == 200 + # The response should contain all chunks + assert len(resp.content) > 0 + + +@pytest.mark.asyncio +async def test_invoke_streaming_yields_all_chunks(streaming_client): + """All chunks from the async generator are received by the client.""" + resp = await streaming_client.post("/invocations", content=b"{}") + lines = resp.content.decode().strip().split("\n") + chunks = [json.loads(line) for line in lines] + assert len(chunks) == 3 + assert chunks[0]["chunk"] == 0 + assert chunks[1]["chunk"] == 1 + assert chunks[2]["chunk"] == 2 + + +@pytest.mark.asyncio +async def test_invoke_streaming_has_invocation_id_header(streaming_client): + """Streaming response also includes x-agent-invocation-id header.""" + resp = await streaming_client.post("/invocations", content=b"{}") + invocation_id = resp.headers.get("x-agent-invocation-id") + assert invocation_id is not None + uuid.UUID(invocation_id) + + +@pytest.mark.asyncio +async def test_invoke_empty_body(echo_client): + """POST /invocations with empty body doesn't crash.""" + resp = await echo_client.post("/invocations", content=b"") + assert resp.status_code == 200 + assert resp.content == b"" + + +@pytest.mark.asyncio +async def test_invoke_error_returns_500(failing_client): + """When invoke() raises, server returns 500 with error message and invocation id.""" + resp = await failing_client.post("/invocations", content=b'{"key":"value"}') + assert resp.status_code == 500 + data = resp.json() + assert "error" in data + assert resp.headers.get("x-agent-invocation-id") is not None diff --git a/sdk/agentserver/azure-ai-agentserver/tests/test_openapi_validation.py b/sdk/agentserver/azure-ai-agentserver/tests/test_openapi_validation.py new file mode 100644 index 000000000000..ba3772488f54 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/tests/test_openapi_validation.py @@ -0,0 +1,126 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for OpenAPI spec validation.""" +import json + +import pytest + + +@pytest.mark.asyncio +async def test_valid_request_passes(validated_client): + """Request matching schema returns 200.""" + resp = await validated_client.post( + "/invocations", + content=json.dumps({"name": "World"}).encode(), + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 200 + data = json.loads(resp.content) + assert data["greeting"] == "Hello, World!" + + +@pytest.mark.asyncio +async def test_invalid_request_returns_400(validated_client): + """Request missing required field returns 400 with error details.""" + resp = await validated_client.post( + "/invocations", + content=json.dumps({"wrong_field": "oops"}).encode(), + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 400 + data = resp.json() + assert "error" in data + assert "details" in data + + +@pytest.mark.asyncio +async def test_invalid_request_wrong_type_returns_400(validated_client): + """Request with wrong field type returns 400.""" + resp = await validated_client.post( + "/invocations", + content=json.dumps({"name": 12345}).encode(), + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 400 + data = resp.json() + assert "details" in data + + +@pytest.mark.asyncio +async def test_response_validation_logs_warning(bad_response_client, caplog): + """Invalid response body logs warning but still returns 200 (non-blocking).""" + import logging + with caplog.at_level(logging.WARNING, logger="azure.ai.agentserver"): + resp = await bad_response_client.post( + "/invocations", + content=json.dumps({"name": "World"}).encode(), + headers={"Content-Type": "application/json"}, + ) + # Response should still be returned (validation is non-blocking) + assert resp.status_code == 200 + assert any("Response validation warnings" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_no_spec_skips_validation(no_spec_client): + """Agent with no spec accepts any request body.""" + resp = await no_spec_client.post( + "/invocations", + content=b"this is not json at all", + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_spec_endpoint_returns_spec(validated_client): + """GET /invocations/docs/openapi.json returns the registered spec.""" + resp = await validated_client.get("/invocations/docs/openapi.json") + assert resp.status_code == 200 + data = resp.json() + assert "paths" in data + + +@pytest.mark.asyncio +async def test_non_json_body_skips_validation(no_spec_client): + """Non-JSON content type bypasses JSON schema validation.""" + # Use no_spec_client (EchoAgent) which handles any body without crashing. + # With a spec, non-JSON content-type still passes validation (validation is skipped). + from azure.ai.agentserver import AgentServer, InvokeRequest + import httpx + + class PlainTextEchoAgent(AgentServer): + def __init__(self): + super().__init__(openapi_spec={ + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0"}, + "paths": { + "/invocations": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"type": "object", "required": ["name"]} + } + } + }, + "responses": {"200": {"description": "OK"}} + } + } + } + }) + + async def invoke(self, request: InvokeRequest) -> bytes: + return request.body + + agent = PlainTextEchoAgent() + transport = httpx.ASGITransport(app=agent.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.post( + "/invocations", + content=b"plain text body", + headers={"Content-Type": "text/plain"}, + ) + # Should not fail validation since content-type is not JSON + assert resp.status_code == 200 diff --git a/sdk/agentserver/azure-ai-agentserver/tests/test_server_routes.py b/sdk/agentserver/azure-ai-agentserver/tests/test_server_routes.py new file mode 100644 index 000000000000..24e7e78985e3 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/tests/test_server_routes.py @@ -0,0 +1,62 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for route registration and basic endpoint behavior.""" +import uuid + +import pytest + + +@pytest.mark.asyncio +async def test_post_invocations_returns_200(echo_client): + """POST /invocations with valid body returns 200.""" + resp = await echo_client.post("/invocations", content=b'{"hello":"world"}') + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_post_invocations_returns_invocation_id_header(echo_client): + """Response includes x-agent-invocation-id header in UUID format.""" + resp = await echo_client.post("/invocations", content=b'{"hello":"world"}') + invocation_id = resp.headers.get("x-agent-invocation-id") + assert invocation_id is not None + # Validate UUID format + uuid.UUID(invocation_id) + + +@pytest.mark.asyncio +async def test_get_openapi_spec_returns_404_when_not_set(echo_client): + """GET /invocations/docs/openapi.json returns 404 if no spec registered.""" + resp = await echo_client.get("/invocations/docs/openapi.json") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_openapi_spec_returns_spec(validated_client): + """GET /invocations/docs/openapi.json returns registered spec as JSON.""" + resp = await validated_client.get("/invocations/docs/openapi.json") + assert resp.status_code == 200 + data = resp.json() + assert data["openapi"] == "3.0.0" + assert "/invocations" in data["paths"] + + +@pytest.mark.asyncio +async def test_get_invocation_returns_404_default(echo_client): + """GET /invocations/{id} returns 404 when not overridden.""" + resp = await echo_client.get(f"/invocations/{uuid.uuid4()}") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_cancel_invocation_returns_404_default(echo_client): + """POST /invocations/{id}/cancel returns 404 when not overridden.""" + resp = await echo_client.post(f"/invocations/{uuid.uuid4()}/cancel") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_unknown_route_returns_404(echo_client): + """GET /unknown returns 404.""" + resp = await echo_client.get("/unknown") + assert resp.status_code == 404 diff --git a/sdk/agentserver/azure-ai-agentserver/tests/test_types.py b/sdk/agentserver/azure-ai-agentserver/tests/test_types.py new file mode 100644 index 000000000000..97e9211c20aa --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver/tests/test_types.py @@ -0,0 +1,37 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for InvokeRequest dataclass.""" +import pytest + +from azure.ai.agentserver import InvokeRequest + + +class TestInvokeRequest: + """Tests for the InvokeRequest dataclass.""" + + def test_invoke_request_fields(self): + """InvokeRequest has body, headers, invocation_id fields.""" + req = InvokeRequest(body=b"test", headers={"k": "v"}, invocation_id="abc") + assert hasattr(req, "body") + assert hasattr(req, "headers") + assert hasattr(req, "invocation_id") + + def test_invoke_request_body_is_bytes(self): + """body field accepts and stores bytes.""" + req = InvokeRequest(body=b"hello", headers={}, invocation_id="id") + assert isinstance(req.body, bytes) + assert req.body == b"hello" + + def test_invoke_request_headers_is_dict(self): + """headers field is dict[str, str].""" + headers = {"Content-Type": "application/json", "X-Custom": "val"} + req = InvokeRequest(body=b"", headers=headers, invocation_id="id") + assert isinstance(req.headers, dict) + assert req.headers == headers + + def test_invoke_request_invocation_id_is_str(self): + """invocation_id is a string.""" + req = InvokeRequest(body=b"", headers={}, invocation_id="test-id-123") + assert isinstance(req.invocation_id, str) + assert req.invocation_id == "test-id-123" diff --git a/sdk/agentserver/ci.yml b/sdk/agentserver/ci.yml index bb2d6f479b00..7e718801a805 100644 --- a/sdk/agentserver/ci.yml +++ b/sdk/agentserver/ci.yml @@ -40,6 +40,8 @@ extends: Selection: sparse GenerateVMJobs: true Artifacts: + - name: azure-ai-agentserver + safeName: azureaiagentserver - name: azure-ai-agentserver-core safeName: azureaiagentservercore - name: azure-ai-agentserver-agentframework