Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -49,6 +50,7 @@
"create_error_response",
"detach_context",
"end_span",
"experimental",
"flush_spans",
"get_request_context",
"record_error",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# 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"

_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]:
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__
Comment thread
Shivakishore14 marked this conversation as resolved.

def wrapped_init(self, *args, **kwargs): # type: ignore[no-untyped-def]
cache_key = f"class:{cls.__module__}.{cls.__qualname__}"
message = f"Class {cls.__module__}.{cls.__qualname__}: {EXPERIMENTAL_CLASS_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}"
active = _experimental_init_active.get()
if not active and not _should_skip_warning() and not _is_warning_cached(cache_key):
module_logger.warning(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)

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__}: {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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -34,6 +35,7 @@
JSON_CONTENT_TYPE = "application/json; charset=utf-8"


@experimental
class FoundryStorageClient:
"""Base HTTP client for the Foundry storage API.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -23,6 +24,7 @@ def _encode(value: str) -> str:
return _url_quote(value, safe="")


@experimental
class FoundryStorageEndpoint:
"""Immutable Foundry storage endpoint configuration.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -17,6 +18,7 @@
_AnyHttpResponse = Union[HttpResponse, AsyncHttpResponse]


@experimental
class FoundryStorageError(Exception):
"""Base class for errors returned by the Foundry storage API."""

Expand All @@ -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)."""

Expand All @@ -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."""

Expand All @@ -71,6 +77,7 @@ def __init__(
self.current_etag = current_etag


@experimental
class FoundryStorageApiError(FoundryStorageError):
"""Raised for all other non-success HTTP responses."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -444,6 +446,7 @@ def __repr__(self) -> str:
)


@experimental
class Task(Generic[Input, Output]):
"""A decorated resilient task function. Not callable directly.

Expand Down Expand Up @@ -1347,6 +1350,7 @@ def task(
]: ...


@experimental
def task(
fn: Callable[..., Any] | None = None,
*,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -1775,6 +1780,7 @@ def multi_turn_task(
]: ...


@experimental
def multi_turn_task(
fn: Callable[..., Any] | None = None,
*,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
Loading
Loading