diff --git a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md index 0327b674ca21..8a74f1a376ae 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features Added +- Added a shared `experimental` decorator for marking Agent Server preview feature surfaces with docstring notes and one-time runtime warnings. The resilient task primitive and Foundry storage public APIs are now marked experimental. - Added public `MiddlewareFactory` and `StreamContent` typing aliases for host middleware and streaming helpers. - Added `set_resilient_tasks_enabled` / `resilient_tasks_enabled` to `azure.ai.agentserver.core.tasks` — a process-global switch (default off) that force-enables the resilient `TaskManager`'s startup recovery scan even before any durable task is declared (useful when tasks are registered lazily after startup). diff --git a/sdk/agentserver/azure-ai-agentserver-core/api.md b/sdk/agentserver/azure-ai-agentserver-core/api.md index 4e174496469d..1bfd57ded9f1 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/api.md +++ b/sdk/agentserver/azure-ai-agentserver-core/api.md @@ -29,6 +29,14 @@ namespace azure.ai.agentserver.core def azure.ai.agentserver.core.end_span(span: Any, exc: Optional[BaseException] = None) -> None: ... + @overload + def azure.ai.agentserver.core.experimental(wrapped: type[T]) -> type[T]: ... + + + @overload + def azure.ai.agentserver.core.experimental(wrapped: Callable[P, T]) -> Callable[P, T]: ... + + def azure.ai.agentserver.core.flush_spans(timeout_millis: int = 5000) -> None: ... @@ -207,6 +215,7 @@ namespace azure.ai.agentserver.core.storage def __init__(self, mapping: Mapping[str, Any]) -> None: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStateStore(FoundryStorageClient): implements AsyncContextManager property name: str # Read-only @@ -292,6 +301,7 @@ namespace azure.ai.agentserver.core.storage ) -> StateStore: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStorageApiError(FoundryStorageError): def __init__( @@ -303,6 +313,7 @@ namespace azure.ai.agentserver.core.storage ) -> None: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStorageBadRequestError(FoundryStorageError): def __init__( @@ -315,6 +326,7 @@ namespace azure.ai.agentserver.core.storage ) -> None: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStorageClient: implements AsyncContextManager def __init__( @@ -330,6 +342,7 @@ namespace azure.ai.agentserver.core.storage async def aclose(self) -> None: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStorageConflictError(FoundryStorageBadRequestError): def __init__( @@ -342,6 +355,7 @@ namespace azure.ai.agentserver.core.storage ) -> None: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStorageEndpoint: def __init__( @@ -373,6 +387,7 @@ namespace azure.ai.agentserver.core.storage ) -> str: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStorageError(Exception): def __init__( @@ -384,6 +399,7 @@ namespace azure.ai.agentserver.core.storage ) -> None: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStorageNotFoundError(FoundryStorageError): def __init__( @@ -395,6 +411,7 @@ namespace azure.ai.agentserver.core.storage ) -> None: ... + @experimental class azure.ai.agentserver.core.storage.FoundryStoragePreconditionError(FoundryStorageError): def __init__( @@ -591,9 +608,11 @@ namespace azure.ai.agentserver.core.tasks ) -> Callable[[Callable[[TaskContext[Input]], Awaitable[Output]]], MultiTurnTask[Input, Output]]: ... + @experimental def azure.ai.agentserver.core.tasks.resilient_tasks_enabled() -> bool: ... + @experimental def azure.ai.agentserver.core.tasks.set_resilient_tasks_enabled(value: bool = True) -> None: ... @@ -618,6 +637,7 @@ namespace azure.ai.agentserver.core.tasks ) -> Callable[[Callable[[TaskContext[Input]], Awaitable[Output]]], Task[Input, Output]]: ... + @experimental class azure.ai.agentserver.core.tasks.InputTooLarge(ValueError): def __init__( @@ -627,6 +647,7 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... + @experimental class azure.ai.agentserver.core.tasks.LastInputIdPreconditionFailed(TaskPreconditionFailed): def __init__( @@ -639,6 +660,7 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... + @experimental class azure.ai.agentserver.core.tasks.MultiTurnTask(Generic[Input, Output]): property name: str # Read-only @@ -677,6 +699,7 @@ namespace azure.ai.agentserver.core.tasks ) -> TaskRun[Output]: ... + @experimental class azure.ai.agentserver.core.tasks.RetryPolicy: def __eq__(self, other: object) -> bool: ... @@ -735,6 +758,7 @@ namespace azure.ai.agentserver.core.tasks ) -> bool: ... + @experimental class azure.ai.agentserver.core.tasks.SteeringQueueFull(RuntimeError): def __init__( @@ -744,6 +768,7 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... + @experimental class azure.ai.agentserver.core.tasks.Task(Generic[Input, Output]): def __init__( @@ -775,6 +800,7 @@ namespace azure.ai.agentserver.core.tasks ) -> TaskRun[Output]: ... + @experimental class azure.ai.agentserver.core.tasks.TaskCancelled(Exception): def __init__( @@ -786,6 +812,7 @@ namespace azure.ai.agentserver.core.tasks def __str__(self) -> str: ... + @experimental class azure.ai.agentserver.core.tasks.TaskConflictError(RuntimeError): def __init__( @@ -796,6 +823,7 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... + @experimental class azure.ai.agentserver.core.tasks.TaskContext(Generic[Input]): property pending_input_count: int # Read-only @@ -819,6 +847,7 @@ namespace azure.ai.agentserver.core.tasks async def exit_for_recovery(self) -> Any: ... + @experimental class azure.ai.agentserver.core.tasks.TaskDeferred(Exception): def __init__( @@ -842,6 +871,7 @@ namespace azure.ai.agentserver.core.tasks key "type": Literal["exhausted_retries"] + @experimental class azure.ai.agentserver.core.tasks.TaskFailed(Exception): error: Union[TaskErrorDict, TaskExhaustedRetriesErrorDict] @@ -853,9 +883,11 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... + @experimental class azure.ai.agentserver.core.tasks.TaskManagerNotInitialized(RuntimeError): + @experimental class azure.ai.agentserver.core.tasks.TaskMetadata(MutableMapping): implements Collection def __call__(self, name: Optional[str] = None) -> TaskMetadata: ... @@ -922,6 +954,7 @@ namespace azure.ai.agentserver.core.tasks def values(self) -> ValuesView[Any]: ... + @experimental class azure.ai.agentserver.core.tasks.TaskRun(Generic[Output]): implements Awaitable property is_queued: bool # Read-only property metadata: TaskMetadata # Read-only diff --git a/sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml index 1ffcb576c382..c51fc2ac00ff 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml +++ b/sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: fd3b7bd1ae87d9b18719027594934c55d968973f159fbc8a5b416d227c67282c +apiMdSha256: 39d46207ea0196162ea0f0969c6a855a2c49ffbb7348d9649db7b0ae1f02ea68 parserVersion: 0.3.30 -pythonVersion: 3.12.10 +pythonVersion: 3.12.13 diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py index 78d21fabd875..10cb683493c7 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py @@ -15,6 +15,7 @@ from ._base import AgentServerHost from ._config import AgentConfig, resolve_state_subdir from ._errors import create_error_response +from ._experimental import experimental from ._middleware import InboundRequestLoggingMiddleware from ._request_context import ( FoundryAgentRequestContext, @@ -49,6 +50,7 @@ "create_error_response", "detach_context", "end_span", + "experimental", "flush_spans", "get_request_context", "record_error", diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_experimental.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_experimental.py new file mode 100644 index 000000000000..ca8d06fa41c7 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_experimental.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Experimental API marker for Agent Server public preview features.""" + +from __future__ import annotations + +import functools +import inspect +import logging +import os +import sys +from collections.abc import Callable +from contextvars import ContextVar +from typing import TypeVar, overload + +from typing_extensions import ParamSpec, TypeGuard + +DOCSTRING_TEMPLATE = ".. note:: {0} {1}\n\n" +DOCSTRING_DEFAULT_INDENTATION = 8 +EXPERIMENTAL_CLASS_MESSAGE = "This is an experimental class," +EXPERIMENTAL_METHOD_MESSAGE = "This is an experimental method," +EXPERIMENTAL_LINK_MESSAGE = ( + "and may change at any time. Please see https://aka.ms/azure-ai-agentserver-experimental " + "for more information." +) +DISABLE_EXPERIMENTAL_WARNING_ENV_VAR = "AZURE_AI_AGENTSERVER_DISABLE_EXPERIMENTAL_WARNING" +_EXPERIMENTAL_CACHE_KEY_ATTR = "_azure_agentserver_experimental_cache_key" +_EXPERIMENTAL_MESSAGE_ATTR = "_azure_agentserver_experimental_message" +_EXPERIMENTAL_WRAPPED_INIT_ATTR = "_azure_agentserver_experimental_wrapped_init" + +_warning_cache: set[str] = set() +_experimental_init_active: ContextVar[bool] = ContextVar("experimental_init_active", default=False) +module_logger = logging.getLogger(__name__) + +P = ParamSpec("P") +T = TypeVar("T") + + +@overload +def experimental(wrapped: type[T]) -> type[T]: ... + + +@overload +def experimental(wrapped: Callable[P, T]) -> Callable[P, T]: ... + + +def experimental(wrapped: type[T] | Callable[P, T]) -> type[T] | Callable[P, T]: + """Add an experimental note and runtime warning to a class or function. + + :param wrapped: Class or callable to mark as experimental. + :type wrapped: type[T] | Callable[P, T] + :return: The wrapped class or callable. + :rtype: type[T] | Callable[P, T] + """ + + def is_class(value: type[T] | Callable[P, T]) -> TypeGuard[type[T]]: + return inspect.isclass(value) + + if is_class(wrapped): + return _add_class_docstring(wrapped) + if inspect.isfunction(wrapped): + return _add_function_docstring(wrapped) + return wrapped + + +def _add_class_docstring(cls: type[T]) -> type[T]: + cache_key = f"class:{cls.__module__}.{cls.__qualname__}" + message = f"Class {cls.__module__}.{cls.__qualname__}: {EXPERIMENTAL_CLASS_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" + setattr(cls, _EXPERIMENTAL_CACHE_KEY_ATTR, cache_key) + setattr(cls, _EXPERIMENTAL_MESSAGE_ATTR, message) + + doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_CLASS_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) + if cls.__doc__: + cls.__doc__ = _add_note_to_docstring(cls.__doc__, doc_string) + else: + cls.__doc__ = doc_string + ">" + + original_init = cls.__init__ + if "__init__" not in cls.__dict__ or getattr(original_init, _EXPERIMENTAL_WRAPPED_INIT_ATTR, False): + return cls + + def wrapped_init(self, *args, **kwargs): # type: ignore[no-untyped-def] + runtime_cls = type(self) + runtime_cache_key = getattr(runtime_cls, _EXPERIMENTAL_CACHE_KEY_ATTR, cache_key) + runtime_message = getattr(runtime_cls, _EXPERIMENTAL_MESSAGE_ATTR, message) + active = _experimental_init_active.get() + if not active and not _should_skip_warning() and not _is_warning_cached(runtime_cache_key): + module_logger.warning(runtime_message) + if active: + return original_init(self, *args, **kwargs) + token = _experimental_init_active.set(True) + try: + return original_init(self, *args, **kwargs) + finally: + _experimental_init_active.reset(token) + + if "__init__" in cls.__dict__ and inspect.isfunction(original_init): + wrapped_init = functools.wraps(original_init)(wrapped_init) + setattr(wrapped_init, _EXPERIMENTAL_WRAPPED_INIT_ATTR, True) + + cls.__init__ = wrapped_init # type: ignore[method-assign] + return cls + + +def _add_function_docstring(func: Callable[P, T]) -> Callable[P, T]: + doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_METHOD_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) + if func.__doc__: + func.__doc__ = _add_note_to_docstring(func.__doc__, doc_string) + else: + func.__doc__ = doc_string + ">" + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: + cache_key = f"function:{func.__module__}.{func.__qualname__}" + message = ( + f"Method {func.__module__}.{func.__qualname__}: " + f"{EXPERIMENTAL_METHOD_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" + ) + if not _should_skip_warning() and not _is_warning_cached(cache_key): + module_logger.warning(message) + return func(*args, **kwargs) + + return wrapped + + +def _add_note_to_docstring(doc_string: str, note: str) -> str: + indent = _get_indentation_size(doc_string) + doc_string = doc_string.rjust(len(doc_string) + indent) + return note + doc_string + + +def _get_indentation_size(doc_string: str) -> int: + lines = doc_string.expandtabs().splitlines() + indent = sys.maxsize + for line in lines[1:]: + stripped = line.lstrip() + if stripped: + indent = min(indent, len(line) - len(stripped)) + return indent if indent < sys.maxsize else DOCSTRING_DEFAULT_INDENTATION + + +def _should_skip_warning() -> bool: + return os.getenv(DISABLE_EXPERIMENTAL_WARNING_ENV_VAR, "false").lower() == "true" + + +def _is_warning_cached(cache_key: str) -> bool: + if cache_key in _warning_cache: + return True + _warning_cache.add(cache_key) + return False diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_client.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_client.py index bf33c482e752..ae1986f4ec2b 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_client.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_client.py @@ -21,6 +21,7 @@ from azure.core.pipeline import policies from azure.core.rest import AsyncHttpResponse, HttpRequest +from azure.ai.agentserver.core._experimental import experimental from azure.ai.agentserver.core._platform_headers import PLATFORM_ERROR_TAG from azure.ai.agentserver.core._version import VERSION @@ -34,6 +35,7 @@ JSON_CONTENT_TYPE = "application/json; charset=utf-8" +@experimental class FoundryStorageClient: """Base HTTP client for the Foundry storage API. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_endpoint.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_endpoint.py index 8d32245a7c6c..8d6b62d93056 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_endpoint.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_endpoint.py @@ -14,6 +14,7 @@ from urllib.parse import quote as _url_quote +from azure.ai.agentserver.core._experimental import experimental from azure.ai.agentserver.core._config import AgentConfig _DEFAULT_API_VERSION = "v1" @@ -23,6 +24,7 @@ def _encode(value: str) -> str: return _url_quote(value, safe="") +@experimental class FoundryStorageEndpoint: """Immutable Foundry storage endpoint configuration. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_errors.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_errors.py index 8901cc3624b3..a3868bf76f94 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_errors.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_errors.py @@ -9,6 +9,7 @@ import json from typing import TYPE_CHECKING, Any, Union +from azure.ai.agentserver.core._experimental import experimental from azure.ai.agentserver.core._platform_headers import PLATFORM_ERROR_TAG if TYPE_CHECKING: @@ -17,6 +18,7 @@ _AnyHttpResponse = Union[HttpResponse, AsyncHttpResponse] +@experimental class FoundryStorageError(Exception): """Base class for errors returned by the Foundry storage API.""" @@ -33,10 +35,12 @@ def __init__( self.status_code = status_code +@experimental class FoundryStorageNotFoundError(FoundryStorageError): """Raised when the requested resource does not exist (HTTP 404).""" +@experimental class FoundryStorageBadRequestError(FoundryStorageError): """Raised for invalid-request errors (HTTP 400).""" @@ -52,10 +56,12 @@ def __init__( self.param = param +@experimental class FoundryStorageConflictError(FoundryStorageBadRequestError): """Raised when the requested create/update conflicts with an existing resource (HTTP 409).""" +@experimental class FoundryStoragePreconditionError(FoundryStorageError): """Raised when an ``If-Match`` precondition fails on a single-item write.""" @@ -71,6 +77,7 @@ def __init__( self.current_etag = current_etag +@experimental class FoundryStorageApiError(FoundryStorageError): """Raised for all other non-success HTTP responses.""" diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_state.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_state.py index 9f9ee11522de..c4066fcaa607 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_state.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_state.py @@ -14,6 +14,8 @@ from azure.core.credentials_async import AsyncTokenCredential from azure.core.rest import HttpRequest +from azure.ai.agentserver.core._experimental import experimental + from .._request_context import get_request_context from ._client import JSON_CONTENT_TYPE, FoundryStorageClient from ._endpoint import FoundryStorageEndpoint @@ -54,6 +56,7 @@ def _validate_key(key: str) -> None: raise ValueError("key must be a non-empty string") +@experimental class FoundryStateStore(FoundryStorageClient): """Developer-facing client for one explicit Foundry state store. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_context.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_context.py index 39354cc84ed8..46112d058568 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_context.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_context.py @@ -18,6 +18,8 @@ import asyncio # pylint: disable=do-not-import-asyncio from typing import Any, Callable, Generic, Literal, TypeVar +from azure.ai.agentserver.core._experimental import experimental + from ._metadata import TaskMetadata Input = TypeVar("Input") @@ -64,6 +66,7 @@ class _ExitForRecovery: __slots__ = () +@experimental class TaskContext(Generic[Input]): # pylint: disable=too-many-instance-attributes """The single parameter to a resilient task function. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_decorator.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_decorator.py index ca9c799ad639..6d8ec9c836c0 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_decorator.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_decorator.py @@ -34,6 +34,8 @@ async def my_task(ctx: TaskContext[MyInput]) -> MyOutput: import re +from azure.ai.agentserver.core._experimental import experimental + from ._client import TransportClassifiedError as _TransportClassifiedError from ._context import TaskContext from ._exceptions_internal import _HostedConflict, _translate_hosted_conflict @@ -444,6 +446,7 @@ def __repr__(self) -> str: ) +@experimental class Task(Generic[Input, Output]): """A decorated resilient task function. Not callable directly. @@ -1347,6 +1350,7 @@ def task( ]: ... +@experimental def task( fn: Callable[..., Any] | None = None, *, @@ -1551,6 +1555,7 @@ def _validate_multi_turn_task_kwargs(**kwargs: Any) -> None: ) +@experimental class MultiTurnTask(Generic[Input, Output]): # pylint: disable=protected-access """A decorated multi-turn resilient task chain. @@ -1775,6 +1780,7 @@ def multi_turn_task( ]: ... +@experimental def multi_turn_task( fn: Callable[..., Any] | None = None, *, diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_enablement.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_enablement.py index 892ef5e9f41f..84dae9c48963 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_enablement.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_enablement.py @@ -25,9 +25,12 @@ set_resilient_tasks_enabled(True) """ +from azure.ai.agentserver.core._experimental import experimental + _RESILIENT_TASKS_ENABLED: bool = False +@experimental def set_resilient_tasks_enabled(value: bool = True) -> None: """Force-enable (or clear) the resilient task recovery scan process-wide. @@ -47,6 +50,7 @@ def set_resilient_tasks_enabled(value: bool = True) -> None: _RESILIENT_TASKS_ENABLED = bool(value) +@experimental def resilient_tasks_enabled() -> bool: """Return whether the recovery scan was explicitly force-enabled. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_exceptions.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_exceptions.py index 12204eec1f82..c24416dfe0a9 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_exceptions.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_exceptions.py @@ -9,10 +9,13 @@ transition, but discard them (the attribute is never set). """ -from typing import Any import inspect +from typing import Any + +from azure.ai.agentserver.core._experimental import experimental +@experimental class TaskFailed(Exception): """Raised when a resilient task function raises an unhandled exception. @@ -46,6 +49,7 @@ def __init__(self, *args: Any, error: dict[str, Any] | None = None) -> None: ) +@experimental class TaskCancelled(Exception): """Raised when a resilient task is cancelled (: bare).""" @@ -71,6 +75,7 @@ def __init__(self, task_id: str | None = None) -> None: super().__init__(f"Task {task_id!r} not found") +@experimental class TaskConflictError(RuntimeError): """Raised when a task lifecycle conflict cannot be resolved. @@ -101,6 +106,7 @@ def __init__(self, *args: Any, current_status: str | None = None) -> None: ) +@experimental class TaskManagerNotInitialized(RuntimeError): """Raised when a resilient-task operation is attempted with no installed manager. @@ -123,6 +129,7 @@ def __init__(self, task_id: str, message: str | None = None) -> None: super().__init__(msg) +@experimental class SteeringQueueFull(RuntimeError): """Raised when the steering pending-input queue is at capacity (: bare).""" @@ -143,6 +150,7 @@ def __init__(self, task_id: str = "", message: str = "") -> None: super().__init__(message or "task precondition failed") +@experimental class LastInputIdPreconditionFailed(TaskPreconditionFailed): """Raised when ``Task.start``'s ``if_last_input_id`` precondition is not met. @@ -178,6 +186,7 @@ def __init__( # pylint: disable=super-init-not-called,unused-argument ) +@experimental class InputTooLarge(ValueError): """Raised when an input's serialized size exceeds the per-input cap (: bare).""" @@ -254,6 +263,7 @@ def __init__(self, task_id: str, current_count: int, max_count: int) -> None: from typing_extensions import Literal, TypedDict # type: ignore[assignment] +@experimental class TaskDeferred(Exception): """Raised when handler called ``ctx.exit_for_recovery``. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_metadata.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_metadata.py index 3de256cab037..85d4b04da2d5 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_metadata.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_metadata.py @@ -33,7 +33,9 @@ import collections.abc import logging from collections.abc import Iterator -from typing import Any, Awaitable, Callable, Optional +from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeAlias, Union + +from azure.ai.agentserver.core._experimental import experimental logger = logging.getLogger("azure.ai.agentserver.tasks") @@ -46,6 +48,7 @@ NamespaceFlushCallback = Callable[[Optional[str], dict[str, Any]], Awaitable[None]] +@experimental class TaskMetadata(collections.abc.MutableMapping): """Mutable progress dict persisted to the task record's payload. @@ -331,13 +334,6 @@ async def _do_flush_one(self) -> None: # Public type alias exported via tasks.__init__. TaskMetadata values # SHOULD be JSON-serializable; this alias documents the value space. -from typing import Union, List, Dict # pylint: disable=wrong-import-position - -try: - from typing import TypeAlias # Python 3.10+ -except ImportError: # pragma: no cover - from typing_extensions import TypeAlias # type: ignore[assignment] - # Recursive JSON type alias. Forward refs allow self-recursion. # Use ForwardRef-via-string for the recursive arms so this type-checks # on all Python versions, and the test's ForwardRef-detection logic diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_retry.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_retry.py index 0ff49d1e58c4..f388825cbd67 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_retry.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_retry.py @@ -13,6 +13,8 @@ import random from datetime import timedelta +from azure.ai.agentserver.core._experimental import experimental + #: Spec 037 #11 — hard caps on the retry knobs. A misconfiguration must not # let a task turn retry unboundedly, so values outside these ranges are # rejected at construction (fail-fast, not clamped). A developer may still @@ -23,6 +25,7 @@ _MAX_RETRY_DELAY = timedelta(hours=1) +@experimental class RetryPolicy: """Retry configuration for resilient tasks. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_run.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_run.py index 8d9b1fae9414..c7d0a0c61cdc 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_run.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_run.py @@ -23,6 +23,8 @@ import asyncio # pylint: disable=do-not-import-asyncio from typing import Any, Generic, TypeVar +from azure.ai.agentserver.core._experimental import experimental + from ._metadata import TaskMetadata Output = TypeVar("Output") @@ -42,6 +44,7 @@ def _unwrap_result(res: Any) -> Any: return res +@experimental class TaskRun(Generic[Output]): # pylint: disable=too-many-instance-attributes """Handle to a running or completed resilient task. diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_experimental.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_experimental.py new file mode 100644 index 000000000000..6d196af790f4 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_experimental.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import logging + +from azure.ai.agentserver.core import experimental +from azure.ai.agentserver.core._experimental import ( + DISABLE_EXPERIMENTAL_WARNING_ENV_VAR, + EXPERIMENTAL_CLASS_MESSAGE, + EXPERIMENTAL_LINK_MESSAGE, + EXPERIMENTAL_METHOD_MESSAGE, + _warning_cache, +) +from azure.ai.agentserver.core.tasks import multi_turn_task, resilient_tasks_enabled, task + + +@experimental +class ExperimentalClass: + """A test class.""" + + def __init__(self) -> None: + self.value = 1 + + +@experimental +def experimental_function() -> bool: + """A test function.""" + return True + + +def _duplicate_init(self) -> None: + self.value = 1 + + +ExperimentalDuplicateA = experimental( + type("ExperimentalDuplicate", (), {"__module__": "test.module_a", "__init__": _duplicate_init}) +) +ExperimentalDuplicateB = experimental( + type("ExperimentalDuplicate", (), {"__module__": "test.module_b", "__init__": _duplicate_init}) +) + + +@experimental +class ExperimentalBase: + """A test base class.""" + + def __init__(self) -> None: + self.value = 1 + + +@experimental +class ExperimentalChild(ExperimentalBase): + """A test child class.""" + + +def test_experimental_decorator_on_class(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + ExperimentalClass() + + assert ExperimentalClass.__doc__.startswith(".. note::") + assert EXPERIMENTAL_CLASS_MESSAGE in ExperimentalClass.__doc__ + assert EXPERIMENTAL_LINK_MESSAGE in ExperimentalClass.__doc__ + assert len(caplog.records) == 1 + assert EXPERIMENTAL_CLASS_MESSAGE in caplog.records[0].message + + +def test_experimental_decorator_on_function(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + assert experimental_function() is True + + assert experimental_function.__doc__.startswith(".. note::") + assert EXPERIMENTAL_METHOD_MESSAGE in experimental_function.__doc__ + assert EXPERIMENTAL_LINK_MESSAGE in experimental_function.__doc__ + assert len(caplog.records) == 1 + assert EXPERIMENTAL_METHOD_MESSAGE in caplog.records[0].message + + +def test_experimental_decorator_no_duplicate_warnings(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + experimental_function() + experimental_function() + + assert len(caplog.records) == 1 + + +def test_experimental_decorator_uses_qualified_cache_keys(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + ExperimentalDuplicateA() + ExperimentalDuplicateB() + + assert len(caplog.records) == 2 + assert "test.module_a.ExperimentalDuplicate" in caplog.records[0].message + assert "test.module_b.ExperimentalDuplicate" in caplog.records[1].message + + +def test_experimental_decorator_only_warns_for_outermost_class(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + ExperimentalChild() + + assert len(caplog.records) == 1 + assert "ExperimentalChild" in caplog.records[0].message + assert "ExperimentalBase" not in caplog.records[0].message + + +def test_experimental_decorator_env_var_suppresses_warning(monkeypatch, caplog) -> None: + _warning_cache.clear() + monkeypatch.setenv(DISABLE_EXPERIMENTAL_WARNING_ENV_VAR, "true") + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + experimental_function() + + assert len(caplog.records) == 0 + + +def test_resilient_task_public_api_is_experimental(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + resilient_tasks_enabled() + + assert resilient_tasks_enabled.__doc__.startswith(".. note::") + assert EXPERIMENTAL_METHOD_MESSAGE in resilient_tasks_enabled.__doc__ + assert len(caplog.records) == 1 + + +def test_resilient_task_decorator_factories_are_experimental(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + task(name="test-task") + multi_turn_task(name="test-multi-turn-task") + + assert task.__doc__.startswith(".. note::") + assert multi_turn_task.__doc__.startswith(".. note::") + assert len(caplog.records) == 2 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md index e38c86ddb51a..c0c301ae1804 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md @@ -10,6 +10,8 @@ ### Features Added +- Marked Foundry storage public APIs as experimental. +- Raised the minimum `azure-ai-agentserver-core` dependency to `>=2.0.0b10` so the shared experimental decorator is always available. - Added the `azure.ai.agentserver.responses.aio` namespace with async `ResponseEventStream` convenience generators that use the same method names as the sync stream, such as `output_item_message()` and `output_item_compaction()`. - Added local `TypedDict` model contract generation for the Responses protocol, including generated type aliases, union aliases, and `py.typed` packaging support. - Added dict-native wire payload helpers and request validators for validating protocol payloads without depending on generated model internals. diff --git a/sdk/agentserver/azure-ai-agentserver-responses/api.md b/sdk/agentserver/azure-ai-agentserver-responses/api.md index 3dea894ec80b..bd552e867886 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/api.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/api.md @@ -186,6 +186,7 @@ namespace azure.ai.agentserver.responses ) -> None: ... + @experimental class azure.ai.agentserver.responses.FoundryApiError(FoundryStorageError): def __init__( @@ -196,6 +197,7 @@ namespace azure.ai.agentserver.responses ) -> None: ... + @experimental class azure.ai.agentserver.responses.FoundryBadRequestError(FoundryStorageError): def __init__( @@ -206,6 +208,7 @@ namespace azure.ai.agentserver.responses ) -> None: ... + @experimental class azure.ai.agentserver.responses.FoundryResourceNotFoundError(FoundryStorageError): def __init__( @@ -216,6 +219,7 @@ namespace azure.ai.agentserver.responses ) -> None: ... + @experimental class azure.ai.agentserver.responses.FoundryStorageError(Exception): def __init__( @@ -226,6 +230,7 @@ namespace azure.ai.agentserver.responses ) -> None: ... + @experimental class azure.ai.agentserver.responses.FoundryStorageProvider: implements AsyncContextManager def __init__( @@ -295,6 +300,7 @@ namespace azure.ai.agentserver.responses ) -> None: ... + @experimental class azure.ai.agentserver.responses.FoundryStorageSettings: def __init__( diff --git a/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml index 6c892122a747..7d064563d670 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml +++ b/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: 2c3ff121746e6028ece71257bcd47d7421233e6f92f2d3f6612a03a5ca34735a +apiMdSha256: 895b7475f68870f634124632b0bf1e85fcf9d04a7162da9f1d12b2c40c33d78c parserVersion: 0.3.30 -pythonVersion: 3.12.10 +pythonVersion: 3.11.15 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_experimental.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_experimental.py new file mode 100644 index 000000000000..19d141fda5ef --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_experimental.py @@ -0,0 +1,153 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Compatibility shim for the shared Agent Server experimental decorator.""" + +from __future__ import annotations + +import functools +import importlib +import inspect +import logging +import os +import sys +from collections.abc import Callable +from contextvars import ContextVar +from typing import Any, TypeVar, overload + +from typing_extensions import ParamSpec, TypeGuard + +try: + _core_experimental_module: Any | None = importlib.import_module("azure.ai.agentserver.core._experimental") +except ImportError: + _core_experimental_module = None + +P = ParamSpec("P") +T = TypeVar("T") + +if _core_experimental_module is not None: + _core_experimental: Any = _core_experimental_module + DOCSTRING_TEMPLATE = _core_experimental.DOCSTRING_TEMPLATE + DOCSTRING_DEFAULT_INDENTATION = _core_experimental.DOCSTRING_DEFAULT_INDENTATION + EXPERIMENTAL_CLASS_MESSAGE = _core_experimental.EXPERIMENTAL_CLASS_MESSAGE + EXPERIMENTAL_METHOD_MESSAGE = _core_experimental.EXPERIMENTAL_METHOD_MESSAGE + EXPERIMENTAL_LINK_MESSAGE = _core_experimental.EXPERIMENTAL_LINK_MESSAGE + DISABLE_EXPERIMENTAL_WARNING_ENV_VAR = _core_experimental.DISABLE_EXPERIMENTAL_WARNING_ENV_VAR + _warning_cache = getattr(_core_experimental, "_warning_cache") + experimental = _core_experimental.experimental +else: + DOCSTRING_TEMPLATE = ".. note:: {0} {1}\n\n" + DOCSTRING_DEFAULT_INDENTATION = 8 + EXPERIMENTAL_CLASS_MESSAGE = "This is an experimental class," + EXPERIMENTAL_METHOD_MESSAGE = "This is an experimental method," + EXPERIMENTAL_LINK_MESSAGE = ( + "and may change at any time. Please see https://aka.ms/azure-ai-agentserver-experimental " + "for more information." + ) + DISABLE_EXPERIMENTAL_WARNING_ENV_VAR = "AZURE_AI_AGENTSERVER_DISABLE_EXPERIMENTAL_WARNING" + _EXPERIMENTAL_CACHE_KEY_ATTR = "_azure_agentserver_experimental_cache_key" + _EXPERIMENTAL_MESSAGE_ATTR = "_azure_agentserver_experimental_message" + _EXPERIMENTAL_WRAPPED_INIT_ATTR = "_azure_agentserver_experimental_wrapped_init" + + _fallback_warning_cache: set[str] = set() + _experimental_init_active: ContextVar[bool] = ContextVar("experimental_init_active", default=False) + module_logger = logging.getLogger(__name__) + + @overload + def _fallback_experimental(wrapped: type[T]) -> type[T]: ... + + @overload + def _fallback_experimental(wrapped: Callable[P, T]) -> Callable[P, T]: ... + + def _fallback_experimental(wrapped: type[T] | Callable[P, T]) -> type[T] | Callable[P, T]: + def is_class(value: type[T] | Callable[P, T]) -> TypeGuard[type[T]]: + return inspect.isclass(value) + + if is_class(wrapped): + return _add_class_docstring(wrapped) + if inspect.isfunction(wrapped): + return _add_function_docstring(wrapped) + return wrapped + + def _add_class_docstring(cls: type[T]) -> type[T]: + cache_key = f"class:{cls.__module__}.{cls.__qualname__}" + message = f"Class {cls.__module__}.{cls.__qualname__}: {EXPERIMENTAL_CLASS_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" + setattr(cls, _EXPERIMENTAL_CACHE_KEY_ATTR, cache_key) + setattr(cls, _EXPERIMENTAL_MESSAGE_ATTR, message) + + doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_CLASS_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) + if cls.__doc__: + cls.__doc__ = _add_note_to_docstring(cls.__doc__, doc_string) + else: + cls.__doc__ = doc_string + ">" + + original_init = cls.__init__ + if "__init__" not in cls.__dict__ or getattr(original_init, _EXPERIMENTAL_WRAPPED_INIT_ATTR, False): + return cls + + def wrapped_init(self, *args, **kwargs): # type: ignore[no-untyped-def] + runtime_cls = type(self) + runtime_cache_key = getattr(runtime_cls, _EXPERIMENTAL_CACHE_KEY_ATTR, cache_key) + runtime_message = getattr(runtime_cls, _EXPERIMENTAL_MESSAGE_ATTR, message) + active = _experimental_init_active.get() + if not active and not _should_skip_warning() and not _is_warning_cached(runtime_cache_key): + module_logger.warning(runtime_message) + if active: + return original_init(self, *args, **kwargs) + token = _experimental_init_active.set(True) + try: + return original_init(self, *args, **kwargs) + finally: + _experimental_init_active.reset(token) + + if "__init__" in cls.__dict__ and inspect.isfunction(original_init): + wrapped_init = functools.wraps(original_init)(wrapped_init) + setattr(wrapped_init, _EXPERIMENTAL_WRAPPED_INIT_ATTR, True) + + cls.__init__ = wrapped_init # type: ignore[method-assign] + return cls + + def _add_function_docstring(func: Callable[P, T]) -> Callable[P, T]: + doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_METHOD_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) + if func.__doc__: + func.__doc__ = _add_note_to_docstring(func.__doc__, doc_string) + else: + func.__doc__ = doc_string + ">" + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: + cache_key = f"function:{func.__module__}.{func.__qualname__}" + message = ( + f"Method {func.__module__}.{func.__qualname__}: " + f"{EXPERIMENTAL_METHOD_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" + ) + if not _should_skip_warning() and not _is_warning_cached(cache_key): + module_logger.warning(message) + return func(*args, **kwargs) + + return wrapped + + def _add_note_to_docstring(doc_string: str, note: str) -> str: + indent = _get_indentation_size(doc_string) + doc_string = doc_string.rjust(len(doc_string) + indent) + return note + doc_string + + def _get_indentation_size(doc_string: str) -> int: + lines = doc_string.expandtabs().splitlines() + indent = sys.maxsize + for line in lines[1:]: + stripped = line.lstrip() + if stripped: + indent = min(indent, len(line) - len(stripped)) + return indent if indent < sys.maxsize else DOCSTRING_DEFAULT_INDENTATION + + def _should_skip_warning() -> bool: + return os.getenv(DISABLE_EXPERIMENTAL_WARNING_ENV_VAR, "false").lower() == "true" + + def _is_warning_cached(cache_key: str) -> bool: + if cache_key in _fallback_warning_cache: + return True + _fallback_warning_cache.add(cache_key) + return False + + _warning_cache = _fallback_warning_cache + experimental = _fallback_experimental diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_errors.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_errors.py index 62972d8ca9d3..32bac6788d58 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_errors.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_errors.py @@ -11,10 +11,13 @@ PLATFORM_ERROR_TAG, ) +from .._experimental import experimental + if TYPE_CHECKING: from azure.core.rest import HttpResponse +@experimental class FoundryStorageError(Exception): """Base class for errors returned by the Foundry storage API.""" @@ -29,14 +32,17 @@ def __init__( self.response_body = response_body +@experimental class FoundryResourceNotFoundError(FoundryStorageError): """Raised when the requested resource does not exist (HTTP 404).""" +@experimental class FoundryBadRequestError(FoundryStorageError): """Raised for invalid-request or conflict errors (HTTP 400, 409).""" +@experimental class FoundryApiError(FoundryStorageError): """Raised for all other non-success HTTP responses.""" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py index 0675f33f0843..7199bd0f265f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py @@ -30,6 +30,7 @@ serialize_response, ) from ._foundry_settings import FoundryStorageSettings +from .._experimental import experimental if TYPE_CHECKING: from .._response_context import PlatformContext @@ -134,6 +135,7 @@ def _apply_platform_headers(request: HttpRequest, context: PlatformContext | Non request.headers[FOUNDRY_CALL_ID] = context.call_id +@experimental class FoundryStorageProvider: """An HTTP-backed response storage provider that persists data via the Foundry storage API. diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_settings.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_settings.py index 1923b9a63e91..7dff3e79bf12 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_settings.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_settings.py @@ -8,6 +8,8 @@ from azure.ai.agentserver.core import AgentConfig +from .._experimental import experimental + _API_VERSION = "v1" @@ -15,6 +17,7 @@ def _encode(value: str) -> str: return _url_quote(value, safe="") +@experimental class FoundryStorageSettings: """Immutable runtime configuration for :class:`FoundryStorageProvider`.""" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/test_experimental.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/test_experimental.py new file mode 100644 index 000000000000..4c389306ba13 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/test_experimental.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import logging + +from azure.ai.agentserver.responses import FoundryStorageSettings, ResponsesAgentServerHost +from azure.ai.agentserver.responses._experimental import EXPERIMENTAL_CLASS_MESSAGE, _warning_cache, experimental + + +def test_foundry_storage_settings_is_experimental(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + FoundryStorageSettings.from_endpoint("https://example.foundry.azure.com") + + assert FoundryStorageSettings.__doc__.startswith(".. note::") + assert EXPERIMENTAL_CLASS_MESSAGE in FoundryStorageSettings.__doc__ + assert len(caplog.records) == 1 + assert EXPERIMENTAL_CLASS_MESSAGE in caplog.records[0].message + + +def test_protocol_hosts_are_not_marked_experimental() -> None: + assert not ResponsesAgentServerHost.__doc__.startswith(".. note::")