Skip to content
Open
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
5 changes: 5 additions & 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,11 @@

### Features Added

- Added protocol-scoped task-runtime startup requests so protocol hosts can
obtain scoped `TaskManager` access without mutating the process-wide
resilient-task switch. This prevents one protocol's enablement from
activating another protocol in a mixed host.

### Breaking Changes

### Bugs Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from . import _config, _tracing
from ._errors import create_error_response
from ._lifecycle import AgentSessionContext, _LifecycleState
from ._middleware import InboundRequestLoggingMiddleware
from ._middleware import InboundRequestLoggingMiddleware, _TaskManagerMiddleware
from ._request_context import (
FoundryAgentRequestContext,
get_request_context,
Expand Down Expand Up @@ -273,6 +273,11 @@ def __init__( # pylint: disable=too-many-statements
self._shutdown_fn: Optional[Callable[[], Awaitable[None]]] = None
self._readiness_fn: Optional[Callable[[Request], Awaitable[Response]]] = None
self._lifecycle = _LifecycleState()
# Protocol mixins contribute independent startup requirements via cooperative init.
task_manager_requirements: list[Callable[[], bool]] = kwargs.pop(
"_task_manager_requirements", [_resilient_tasks_enabled]
)
self._task_manager: Any = None
# Pre-shutdown callbacks invoked SYNCHRONOUSLY from the
# SIGTERM signal handler — before Hypercorn's graceful drain
# begins. Used by responses to set ``_shutdown_requested`` early so
Expand Down Expand Up @@ -316,7 +321,7 @@ def __init__( # pylint: disable=too-many-statements

# Build lifespan context manager
@contextlib.asynccontextmanager
async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF029
async def _run_lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF029
logger.info("AgentServerHost started")

# --- Startup configuration logging ---
Expand Down Expand Up @@ -345,26 +350,10 @@ async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF
protocols,
)

# --- Resilient task manager initialization ---
#
# The resilient ``TaskManager`` is CONSTRUCTED ONLY when resilient
# tasks were explicitly enabled via
# ``set_resilient_tasks_enabled(True)``. Recovery (and the durable
# task subsystem as a whole) is strictly opt-in: a host that does
# not set the switch pays nothing — no manager, no provider client,
# no task-store call, no recovery loop.
#
# When the switch is OFF the manager is NOT installed, so
# ``get_task_manager()`` raises ``TaskManagerNotInitialized``.
# Callers that route through the manager (e.g. the responses
# protocol's ``store=true`` path) are expected to SWALLOW that
# signal and degrade to non-durable, in-process execution — the
# response still runs and persists, it simply is not crash-
# recoverable. Declaring a durable task (``@task`` /
# ``@multi_turn_task``) does NOT implicitly turn the subsystem on;
# an app that wants durable tasks / recovery must set the switch.
# Invocations uses the core switch; Responses contributes its own
# feature flags. Merely registering task callbacks is not an opt-in.
task_manager: Any = None
if _resilient_tasks_enabled():
if any(required() for required in task_manager_requirements):
# Resilient tasks were explicitly enabled. Construct the manager
# and run startup recovery. If EITHER fails, durability was
# explicitly requested, so fail the lifespan (fail-fast at boot)
Expand All @@ -381,6 +370,7 @@ async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF
shutdown_event=asyncio.Event(),
shutdown_grace_seconds=_read_task_manager_shutdown_grace(),
)
self._task_manager = task_manager
set_task_manager(task_manager)
try:
await task_manager.startup()
Expand All @@ -395,14 +385,17 @@ async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF
await task_manager.shutdown()
except Exception: # pylint: disable=broad-exception-caught
logger.warning("Error shutting down TaskManager after startup failure", exc_info=True)
set_task_manager(None)
from .tasks._manager import _clear_task_manager # pylint: disable=import-outside-toplevel

_clear_task_manager(task_manager)
self._task_manager = None
task_manager = None
raise
logger.info("TaskManager initialized with startup recovery")
else:
logger.info(
"TaskManager NOT initialized (resilient tasks disabled; enable via "
"set_resilient_tasks_enabled(True)). Durable tasks and crash recovery "
"TaskManager NOT initialized (no protocol requested task execution). "
"Durable tasks and crash recovery "
"are inactive; store=true work degrades to non-durable execution. "
"tasks_declared=%s",
_has_registered_tasks(),
Expand Down Expand Up @@ -445,15 +438,24 @@ async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF
if task_manager is not None:
try:
await task_manager.shutdown()
from .tasks._manager import ( # pylint: disable=import-outside-toplevel
set_task_manager as _clear_manager,
)

_clear_manager(None)
logger.info("TaskManager shut down")
except Exception: # pylint: disable=broad-exception-caught
logger.warning("Error shutting down TaskManager", exc_info=True)

@contextlib.asynccontextmanager
async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
from .tasks._manager import _task_manager_scope # pylint: disable=import-outside-toplevel

with _task_manager_scope(lambda: self._task_manager):
try:
async with _run_lifespan(app):
yield
finally:
from .tasks._manager import _clear_task_manager # pylint: disable=import-outside-toplevel

_clear_task_manager(self._task_manager)
self._task_manager = None

async def _before_snapshot_route(request: Request) -> Response:
return await AgentServerHost._before_snapshot_endpoint(self, request)

Expand Down Expand Up @@ -498,6 +500,7 @@ async def _after_restore_route(request: Request) -> Response:
routes=all_routes,
lifespan=_lifespan,
middleware=[
Middleware(_TaskManagerMiddleware, get_manager=lambda: self._task_manager),
Middleware(InboundRequestLoggingMiddleware), # type: ignore[arg-type]
Middleware( # type: ignore[arg-type]
_PlatformHeaderMiddleware,
Expand Down Expand Up @@ -737,14 +740,10 @@ def _parse_session_context(payload: dict[str, Any]) -> AgentSessionContext:
if not isinstance(restore_id, str) or not restore_id:
raise ValueError("restore_id must be a non-empty string")
if not isinstance(raw_overrides, dict) or any(
not isinstance(key, str) or not isinstance(value, str)
for key, value in raw_overrides.items()
not isinstance(key, str) or not isinstance(value, str) for key, value in raw_overrides.items()
):
raise ValueError("session_env_overrides must map strings to strings")
if any(
not key or "=" in key or "\0" in key or "\0" in value
for key, value in raw_overrides.items()
):
if any(not key or "=" in key or "\0" in key or "\0" in value for key, value in raw_overrides.items()):
raise ValueError("session_env_overrides contains an invalid environment entry")
override_session_id = raw_overrides.get(_SESSION_ID_ENV)
if override_session_id is not None and override_session_id != session_id:
Expand Down Expand Up @@ -780,8 +779,7 @@ async def _before_snapshot_endpoint(self, request: Request) -> Response:
status_code=500,
)
self._lifecycle.captured_environment_values = {
key: os.environ.get(key)
for key in self._lifecycle.applied_environment_variables
key: os.environ.get(key) for key in self._lifecycle.applied_environment_variables
}
self._lifecycle.before_snapshot_completed = True
return JSONResponse({"status": "ok"})
Expand Down Expand Up @@ -815,20 +813,12 @@ async def _after_restore_endpoint(self, request: Request) -> Response:

effective_environment = dict(context.session_env_overrides)
effective_environment.setdefault(_SESSION_ID_ENV, context.session_id)
environment_variables = (
self._lifecycle.applied_environment_variables
| effective_environment.keys()
)
previous_environment = {
key: os.environ.get(key, _MISSING_ENV)
for key in environment_variables
}
environment_variables = self._lifecycle.applied_environment_variables | effective_environment.keys()
previous_environment = {key: os.environ.get(key, _MISSING_ENV) for key in environment_variables}
captured_environment = dict(self._lifecycle.captured_environment_values)
for key, value in previous_environment.items():
if key not in captured_environment:
captured_environment[key] = (
None if value is _MISSING_ENV else str(value)
)
captured_environment[key] = None if value is _MISSING_ENV else str(value)
previous_config = vars(self.config).copy()
task_manager = _get_active_task_manager()

Expand All @@ -842,9 +832,7 @@ async def _after_restore_endpoint(self, request: Request) -> Response:
restored_config = _config.AgentConfig.from_env()
vars(self.config).update(vars(restored_config))
if task_manager is not None:
task_manager._rehydrate_session_state( # pylint: disable=protected-access
context.restore_id
)
task_manager._rehydrate_session_state(context.restore_id) # pylint: disable=protected-access

current_request_context = get_request_context()
request_context_token = set_request_context(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,51 @@

import logging
import time
from typing import Any, MutableMapping
from typing import Any, Callable, MutableMapping

from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.middleware import Middleware

logger = logging.getLogger("azure.ai.agentserver")


class _TaskManagerMiddleware:
"""Scope task access to the host and protocol serving this request."""

def __init__(self, app: ASGIApp, *, get_manager: Callable[[], Any]) -> None:
self.app = app
self._get_manager = get_manager

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
from .tasks._manager import _task_manager_scope # pylint: disable=import-outside-toplevel

with _task_manager_scope(self._get_manager):
await self.app(scope, receive, send)


def _task_runtime_middleware(host: Any, kwargs: dict[str, Any], enabled: Callable[[], bool]) -> list[Middleware]:
"""Register protocol activation and scope its routes to the host's runtime.

:param host: The protocol host being constructed.
:type host: Any
:param kwargs: Cooperative host-constructor arguments.
:type kwargs: dict[str, Any]
:param enabled: Whether this protocol requests task execution.
:type enabled: Callable[[], bool]
:return: Middleware for this protocol's routes.
:rtype: list[~starlette.middleware.Middleware]
"""
requirements = list(kwargs.pop("_task_manager_requirements", []))
requirements.append(enabled)
kwargs["_task_manager_requirements"] = requirements
return [
Middleware(
_TaskManagerMiddleware,
get_manager=lambda: host._task_manager if enabled() else None, # pylint: disable=protected-access
)
]


def _extract_header(headers: list[tuple[bytes, bytes]], name: bytes) -> str | None:
"""Extract a header value from raw ASGI headers.

Expand Down Expand Up @@ -147,7 +185,10 @@ async def _send_wrapper(message: MutableMapping[str, Any]) -> None:
elapsed_ms = (time.monotonic() - start) * 1000
logger.warning(
"Inbound %s %s failed with status 500 in %.1fms%s",
method, path, elapsed_ms, extra_str,
method,
path,
elapsed_ms,
extra_str,
)
raise

Expand All @@ -156,10 +197,18 @@ async def _send_wrapper(message: MutableMapping[str, Any]) -> None:
if status_code is not None and status_code >= 400:
logger.warning(
"Inbound %s %s completed with status %d in %.1fms%s",
method, path, status_code, elapsed_ms, extra_str,
method,
path,
status_code,
elapsed_ms,
extra_str,
)
else:
logger.info(
"Inbound %s %s completed with status %s in %.1fms%s",
method, path, status_code, elapsed_ms, extra_str,
method,
path,
status_code,
elapsed_ms,
extra_str,
)
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ async def my_task(ctx: TaskContext[MyInput]) -> MyOutput:

from ._client import TransportClassifiedError as _TransportClassifiedError
from ._context import TaskContext
from ._exceptions import TaskManagerNotInitialized
from ._exceptions_internal import _HostedConflict, _translate_hosted_conflict
from ._retry import RetryPolicy
from ._run import TaskRun
Expand Down Expand Up @@ -478,8 +479,10 @@ def __init__(
# resume tables so _recover_stale_tasks / get_active_run can pick
# up the multi-turn opts (_is_multi_turn).
try:
from ._manager import _manager as _live_manager # pylint: disable=import-outside-toplevel
except ImportError: # pragma: no cover
from ._manager import get_task_manager # pylint: disable=import-outside-toplevel

_live_manager = get_task_manager()
except (ImportError, TaskManagerNotInitialized):
_live_manager = None # type: ignore[assignment]
if _live_manager is not None:
try:
Expand Down Expand Up @@ -1798,28 +1801,28 @@ def multi_turn_task(
) -> Any:
"""Decorator producing a multi-turn resilient chain.

Multi-turn chains accept inputs across many turns against the same
``task_id``. The handler's ``return X`` is the implicit-suspend
signal — there is no ``ctx.suspend``. The chain stays
alive across handler raises.

:param fn: The handler to decorate (parameterized form only; the bare
``@multi_turn_task`` form is rejected because it cannot supply a
``name``).
:type fn: Callable[..., Any] | None
:keyword name: **Required** stable chain-identity anchor (no
function-derived default; omitting it raises ``ValueError``).
:keyword title: Static human-readable string. Callable-factory form is
not supported.
:keyword timeout: Per-turn cooperative timeout. Defaults to 1 day when
unset; 7 days is a hard ceiling (larger/negative rejected at
decoration). This is a per-turn cap, not a chain-lifetime bound.
:keyword retry: Default retry policy.
:keyword steerable: When True, ``start()`` against an in-flight chain
queues the new input instead of raising ``TaskConflictError``.
:return: A :class:`MultiTurnTask` instance (distinct public class from
:class:`Task`).
:rtype: Any
Multi-turn chains accept inputs across many turns against the same
``task_id``. The handler's ``return X`` is the implicit-suspend
signal — there is no ``ctx.suspend``. The chain stays
alive across handler raises.

:param fn: The handler to decorate (parameterized form only; the bare
``@multi_turn_task`` form is rejected because it cannot supply a
``name``).
:type fn: Callable[..., Any] | None
:keyword name: **Required** stable chain-identity anchor (no
function-derived default; omitting it raises ``ValueError``).
:keyword title: Static human-readable string. Callable-factory form is
not supported.
:keyword timeout: Per-turn cooperative timeout. Defaults to 1 day when
unset; 7 days is a hard ceiling (larger/negative rejected at
decoration). This is a per-turn cap, not a chain-lifetime bound.
:keyword retry: Default retry policy.
:keyword steerable: When True, ``start()`` against an in-flight chain
queues the new input instead of raising ``TaskConflictError``.
:return: A :class:`MultiTurnTask` instance (distinct public class from
:class:`Task`).
:rtype: Any
"""
# — reject unknown kwargs at decoration time
_validate_multi_turn_task_kwargs(**_extra_kwargs)
Expand Down
Loading
Loading