Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
33 changes: 33 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-core/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...


Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -292,6 +301,7 @@ namespace azure.ai.agentserver.core.storage
) -> StateStore: ...


@experimental
class azure.ai.agentserver.core.storage.FoundryStorageApiError(FoundryStorageError):

def __init__(
Expand All @@ -303,6 +313,7 @@ namespace azure.ai.agentserver.core.storage
) -> None: ...


@experimental
class azure.ai.agentserver.core.storage.FoundryStorageBadRequestError(FoundryStorageError):

def __init__(
Expand All @@ -315,6 +326,7 @@ namespace azure.ai.agentserver.core.storage
) -> None: ...


@experimental
class azure.ai.agentserver.core.storage.FoundryStorageClient: implements AsyncContextManager

def __init__(
Expand All @@ -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__(
Expand All @@ -342,6 +355,7 @@ namespace azure.ai.agentserver.core.storage
) -> None: ...


@experimental
class azure.ai.agentserver.core.storage.FoundryStorageEndpoint:

def __init__(
Expand Down Expand Up @@ -373,6 +387,7 @@ namespace azure.ai.agentserver.core.storage
) -> str: ...


@experimental
class azure.ai.agentserver.core.storage.FoundryStorageError(Exception):

def __init__(
Expand All @@ -384,6 +399,7 @@ namespace azure.ai.agentserver.core.storage
) -> None: ...


@experimental
class azure.ai.agentserver.core.storage.FoundryStorageNotFoundError(FoundryStorageError):

def __init__(
Expand All @@ -395,6 +411,7 @@ namespace azure.ai.agentserver.core.storage
) -> None: ...


@experimental
class azure.ai.agentserver.core.storage.FoundryStoragePreconditionError(FoundryStorageError):

def __init__(
Expand Down Expand Up @@ -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: ...


Expand All @@ -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__(
Expand All @@ -627,6 +647,7 @@ namespace azure.ai.agentserver.core.tasks
) -> None: ...


@experimental
class azure.ai.agentserver.core.tasks.LastInputIdPreconditionFailed(TaskPreconditionFailed):

def __init__(
Expand All @@ -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

Expand Down Expand Up @@ -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: ...
Expand Down Expand Up @@ -735,6 +758,7 @@ namespace azure.ai.agentserver.core.tasks
) -> bool: ...


@experimental
class azure.ai.agentserver.core.tasks.SteeringQueueFull(RuntimeError):

def __init__(
Expand All @@ -744,6 +768,7 @@ namespace azure.ai.agentserver.core.tasks
) -> None: ...


@experimental
class azure.ai.agentserver.core.tasks.Task(Generic[Input, Output]):

def __init__(
Expand Down Expand Up @@ -775,6 +800,7 @@ namespace azure.ai.agentserver.core.tasks
) -> TaskRun[Output]: ...


@experimental
class azure.ai.agentserver.core.tasks.TaskCancelled(Exception):

def __init__(
Expand All @@ -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__(
Expand All @@ -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

Expand All @@ -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__(
Expand All @@ -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]

Expand All @@ -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: ...
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
apiMdSha256: fd3b7bd1ae87d9b18719027594934c55d968973f159fbc8a5b416d227c67282c
apiMdSha256: 39d46207ea0196162ea0f0969c6a855a2c49ffbb7348d9649db7b0ae1f02ea68
parserVersion: 0.3.30
pythonVersion: 3.12.10
pythonVersion: 3.12.13
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,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__
Comment thread
Shivakishore14 marked this conversation as resolved.
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
Loading
Loading