Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
35 changes: 34 additions & 1 deletion 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 @@ -491,7 +508,7 @@ namespace azure.ai.agentserver.core.storage
def __init__(self, mapping: Mapping[str, Any]) -> None: ...


@dataclass(eq = True, frozen = False, init = True, kw_only = False, match_args = True, order = False, repr = True, slots = False, unsafe_hash = False, weakref_slot = False)
@dataclass(eq = True, frozen = False, init = True, order = False, repr = True, unsafe_hash = False)
class azure.ai.agentserver.core.storage.StateStoreItemKeyPage:
first_id: Optional[str]
has_more: bool = field(compare = True, default = False, hash = None, init = True, kw_only = False, metadata = {}, name = "has_more", repr = True, type = "bool")
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: 3118464f8dd74ea93739e2319b8b0a926afb81f98bde852250f51def3526a846
parserVersion: 0.3.30
pythonVersion: 3.12.10
pythonVersion: 3.11.15
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,147 @@
# 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__}: {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