From 7260d2df65f558619ee643e82a27311092b14819 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 2 Sep 2026 13:09:20 -0500 Subject: [PATCH 01/25] Add pluggable Agent extension packages --- README.md | 2 + azurefunctions-extensions-agents-base/LICENSE | 21 ++ .../MANIFEST.in | 3 + .../README.md | 47 +++ .../azurefunctions/__init__.py | 1 + .../azurefunctions/extensions/__init__.py | 1 + .../extensions/agents_base/__init__.py | 35 ++ .../extensions/agents_base/bindings.py | 302 ++++++++++++++++++ .../extensions/agents_base/durable.py | 228 +++++++++++++ .../extensions/agents_base/providers.py | 110 +++++++ .../extensions/agents_base/py.typed | 1 + .../pyproject.toml | 57 ++++ .../tests/test_bindings.py | 259 +++++++++++++++ .../tests/test_durable.py | 203 ++++++++++++ .../tests/test_imports.py | 21 ++ .../tests/test_providers.py | 139 ++++++++ .../LICENSE | 21 ++ .../MANIFEST.in | 3 + .../README.md | 82 +++++ .../azurefunctions/__init__.py | 1 + .../azurefunctions/extensions/__init__.py | 1 + .../extensions/agents_framework/__init__.py | 12 + .../extensions/agents_framework/apps.py | 195 +++++++++++ .../extensions/agents_framework/provider.py | 112 +++++++ .../extensions/agents_framework/py.typed | 1 + .../pyproject.toml | 61 ++++ .../samples/README.md | 7 + .../samples/hybrid-durable-agent/README.md | 14 + .../hybrid-durable-agent/src/function_app.py | 92 ++++++ .../hybrid-durable-agent/src/host.json | 12 + .../src/local.settings.template.json | 9 + .../src/order-fulfillment.agent.md | 5 + .../src/order_processing.py | 144 +++++++++ .../hybrid-durable-agent/src/requirements.txt | 4 + .../samples/hybrid-function-agent/README.md | 14 + .../hybrid-function-agent/src/function_app.py | 75 +++++ .../hybrid-function-agent/src/host.json | 12 + .../src/local.settings.template.json | 9 + .../src/order-fulfillment.agent.md | 5 + .../src/order_processing.py | 144 +++++++++ .../src/requirements.txt | 4 + .../tests/test_apps.py | 52 +++ .../tests/test_imports.py | 21 ++ .../tests/test_provider.py | 141 ++++++++ .../tests/test_samples.py | 52 +++ eng/templates/jobs/build.yml | 6 + .../official/jobs/build-artifacts.yml | 6 + eng/templates/official/jobs/unit-tests.yml | 55 ++++ 48 files changed, 2802 insertions(+) create mode 100644 azurefunctions-extensions-agents-base/LICENSE create mode 100644 azurefunctions-extensions-agents-base/MANIFEST.in create mode 100644 azurefunctions-extensions-agents-base/README.md create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/__init__.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/__init__.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/__init__.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/bindings.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/durable.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/providers.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/py.typed create mode 100644 azurefunctions-extensions-agents-base/pyproject.toml create mode 100644 azurefunctions-extensions-agents-base/tests/test_bindings.py create mode 100644 azurefunctions-extensions-agents-base/tests/test_durable.py create mode 100644 azurefunctions-extensions-agents-base/tests/test_imports.py create mode 100644 azurefunctions-extensions-agents-base/tests/test_providers.py create mode 100644 azurefunctions-extensions-agents-framework/LICENSE create mode 100644 azurefunctions-extensions-agents-framework/MANIFEST.in create mode 100644 azurefunctions-extensions-agents-framework/README.md create mode 100644 azurefunctions-extensions-agents-framework/azurefunctions/__init__.py create mode 100644 azurefunctions-extensions-agents-framework/azurefunctions/extensions/__init__.py create mode 100644 azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/__init__.py create mode 100644 azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/apps.py create mode 100644 azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/provider.py create mode 100644 azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/py.typed create mode 100644 azurefunctions-extensions-agents-framework/pyproject.toml create mode 100644 azurefunctions-extensions-agents-framework/samples/README.md create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/README.md create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/host.json create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/local.settings.template.json create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order_processing.py create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/requirements.txt create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/host.json create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order_processing.py create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt create mode 100644 azurefunctions-extensions-agents-framework/tests/test_apps.py create mode 100644 azurefunctions-extensions-agents-framework/tests/test_imports.py create mode 100644 azurefunctions-extensions-agents-framework/tests/test_provider.py create mode 100644 azurefunctions-extensions-agents-framework/tests/test_samples.py diff --git a/README.md b/README.md index 6c901d2..2d03d6c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ A supported Python version is required - see ## Available extensions * [Base extension](azurefunctions-extensions-base/README.md) +* [Agent provider base](azurefunctions-extensions-agents-base/README.md) +* [Microsoft Agent Framework](azurefunctions-extensions-agents-framework/README.md) * [Azure Blob Storage bindings](azurefunctions-extensions-bindings-blob/README.md) * [Azure Cosmos DB bindings](azurefunctions-extensions-bindings-cosmosdb/README.md) * [Azure Event Hubs bindings](azurefunctions-extensions-bindings-eventhub/README.md) diff --git a/azurefunctions-extensions-agents-base/LICENSE b/azurefunctions-extensions-agents-base/LICENSE new file mode 100644 index 0000000..22aed37 --- /dev/null +++ b/azurefunctions-extensions-agents-base/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/azurefunctions-extensions-agents-base/MANIFEST.in b/azurefunctions-extensions-agents-base/MANIFEST.in new file mode 100644 index 0000000..4c501a0 --- /dev/null +++ b/azurefunctions-extensions-agents-base/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include azurefunctions *.py *.pyi +recursive-include tests *.py +include LICENSE README.md diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-extensions-agents-base/README.md new file mode 100644 index 0000000..6098dbc --- /dev/null +++ b/azurefunctions-extensions-agents-base/README.md @@ -0,0 +1,47 @@ +# Azure Functions Agents Base Extension + +Framework-neutral provider and lifecycle contracts for Python Agent integrations +with Azure Functions. + +This package is infrastructure for provider extensions. Applications should +install a provider package such as `azurefunctions-extensions-agents-framework`. + +## Provider contract + +Provider packages register a zero-argument factory in the +`azurefunctions.extensions.agents.providers` entry-point group. The entry-point +name is the provider ID. The factory returns an `AgentProvider` with a matching +`provider_id`, its distribution name, and a `compile_binding()` implementation. + +`compile_binding()` receives the complete markdown instructions, logical Agent +name, immutable provider options, and the injected parameter annotation. It +returns a `CompiledAgent` recipe that creates a fresh Agent context for each +invocation and can run an Agent from a Durable activity. + +Applications use `azure.functions.FunctionApp.markdown_agent()` or install a +typed provider package. One provider is pinned to each app instance. Provider +discovery is cached, while live Agents and clients are never cached. + +## Markdown lookup + +An `agent_name` resolves exactly one UTF-8 file: + +```text +/.agent.md +/agents/.agent.md +``` + +The entire file is passed to the provider unchanged. Front matter, YAML, +substitutions, tools, skills, MCP configuration, and history are not parsed by +this package. If both locations exist, lookup fails as ambiguous. Absolute +paths, separators, traversal components, and symlinks outside `app_root` are +rejected. + +## Durable support + +Provider packages expose Durable support through their own `[durable]` extra. +The base extra installs `azure-functions-durable>=1.2.10,<2`; normal imports do +not import or require Durable Functions. `DurableAgentContext.call_agent()` +schedules a hidden activity with a deterministic, JSON-only payload. All file, +client, Agent, model, and tool I/O occurs in that activity, never in the +orchestrator. diff --git a/azurefunctions-extensions-agents-base/azurefunctions/__init__.py b/azurefunctions-extensions-agents-base/azurefunctions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/__init__.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/__init__.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/__init__.py new file mode 100644 index 0000000..8b4bf49 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/__init__.py @@ -0,0 +1,35 @@ +from .bindings import configure_app, markdown_agent +from .providers import ( + AGENT_PROVIDER_ENTRY_POINT_GROUP, + AgentProvider, + CompiledAgent, + InvocationMetadata, + load_provider, +) + + +def configure_durable_app(*args, **kwargs): + from .durable import configure_durable_app as configure + + return configure(*args, **kwargs) + + +def durable_orchestration_trigger(*args, **kwargs): + from .durable import durable_orchestration_trigger as decorate + + return decorate(*args, **kwargs) + + +__all__ = [ + "AGENT_PROVIDER_ENTRY_POINT_GROUP", + "AgentProvider", + "CompiledAgent", + "InvocationMetadata", + "configure_app", + "configure_durable_app", + "durable_orchestration_trigger", + "load_provider", + "markdown_agent", +] + +__version__ = "1.0.0b1" diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/bindings.py new file mode 100644 index 0000000..0b28458 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/bindings.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import functools +import inspect +import os +import threading +import weakref +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath, PureWindowsPath +from types import MappingProxyType +from typing import Any, TypeVar, cast, get_type_hints + +import azure.functions as func + +from .providers import AgentProvider, CompiledAgent, InvocationMetadata, load_provider + +_F = TypeVar("_F", bound=Callable[..., Any]) +_INVALID_FILENAME_CHARACTERS = frozenset('<>:"/\\|?*') + + +@dataclass +class _AppState: + provider_id: str + provider: AgentProvider + app_root: Path + provider_defaults: Mapping[str, Any] + durable_agents: dict[str, CompiledAgent] = field(default_factory=dict) + durable_activity_registered: bool = False + lock: threading.RLock = field(default_factory=threading.RLock) + + +_APP_STATES: weakref.WeakKeyDictionary[func.FunctionApp, _AppState] = ( + weakref.WeakKeyDictionary() +) +_APP_STATES_LOCK = threading.Lock() + + +def _resolve_app_root(app_root: str | os.PathLike[str] | None) -> Path: + if app_root is not None: + return Path(app_root).resolve() + script_root = os.environ.get("AzureWebJobsScriptRoot") + if script_root: + return Path(script_root).resolve() + return Path.cwd().resolve() + + +def _state_for( + app: func.FunctionApp, + *, + provider: str, + app_root: str | os.PathLike[str] | None = None, + provider_defaults: Mapping[str, Any] | None = None, +) -> _AppState: + resolved_root = _resolve_app_root(app_root) + defaults = dict(provider_defaults or {}) + with _APP_STATES_LOCK: + state = _APP_STATES.get(app) + if state is None: + state = _AppState( + provider_id=provider, + provider=load_provider(provider), + app_root=resolved_root, + provider_defaults=MappingProxyType(defaults), + ) + _APP_STATES[app] = state + return state + if state.provider_id != provider: + raise ValueError( + f"FunctionApp is already configured for Agent provider " + f"{state.provider_id!r}; it cannot also use {provider!r}" + ) + if app_root is not None and state.app_root != resolved_root: + raise ValueError( + f"FunctionApp is already configured with app_root " + f"{str(state.app_root)!r}; it cannot also use " + f"{str(resolved_root)!r}" + ) + if provider_defaults is not None and state.provider_defaults != defaults: + raise ValueError( + "FunctionApp Agent provider defaults are already configured" + ) + return state + + +def configure_app( + app: func.FunctionApp, + *, + provider: str, + app_root: str | os.PathLike[str] | None = None, + provider_options: Mapping[str, Any] | None = None, +) -> None: + _state_for( + app, + provider=provider, + app_root=app_root, + provider_defaults=provider_options, + ) + + +def _configured_state(app: func.FunctionApp) -> _AppState: + with _APP_STATES_LOCK: + state = _APP_STATES.get(app) + if state is None: + raise RuntimeError("FunctionApp is not configured for an Agent provider") + return state + + +def _durable_agent(app: func.FunctionApp, agent_name: str) -> CompiledAgent: + state = _configured_state(app) + with state.lock: + compiled = state.durable_agents.get(agent_name) + if compiled is None: + compiled = state.provider.compile_binding( + instructions=_resolve_instructions(state.app_root, agent_name), + agent_name=agent_name, + options=state.provider_defaults, + annotation=inspect.Signature.empty, + ) + state.durable_agents[agent_name] = compiled + return compiled + + +def _validate_agent_name(agent_name: str) -> str: + if not isinstance(agent_name, str) or not agent_name: + raise ValueError("agent_name must be a non-empty string") + if agent_name in {".", ".."}: + raise ValueError("agent_name must be a filename component") + if ( + PurePosixPath(agent_name).is_absolute() + or PureWindowsPath(agent_name).is_absolute() + or any( + character in _INVALID_FILENAME_CHARACTERS or ord(character) < 32 + for character in agent_name + ) + ): + raise ValueError("agent_name must be a portable filename component") + return agent_name + + +def _find_exact_file(directory: Path, expected_name: str) -> Path | None: + if not directory.is_dir(): + return None + for entry in directory.iterdir(): + if entry.name == expected_name and entry.is_file(): + return entry + return None + + +def _resolve_instructions(app_root: Path, agent_name: str) -> str: + expected_name = f"{_validate_agent_name(agent_name)}.agent.md" + matches = [ + match + for match in ( + _find_exact_file(app_root, expected_name), + _find_exact_file(app_root / "agents", expected_name), + ) + if match is not None + ] + if not matches: + raise FileNotFoundError( + f"Agent {agent_name!r} was not found as {expected_name!r} in " + f"{str(app_root)!r} or its 'agents' directory" + ) + if len(matches) > 1: + raise ValueError( + f"Agent {agent_name!r} is ambiguous: both " + f"{str(matches[0])!r} and {str(matches[1])!r} exist" + ) + + source = matches[0].resolve(strict=True) + if not source.is_relative_to(app_root): + raise ValueError( + f"Agent file {str(source)!r} resolves outside app root {str(app_root)!r}" + ) + return source.read_text(encoding="utf-8") + + +def _worker_signature(handler: Callable[..., Any], arg_name: str) -> inspect.Signature: + signature = inspect.signature(handler) + parameter = signature.parameters.get(arg_name) + if parameter is None: + raise TypeError( + f"markdown_agent arg_name {arg_name!r} is not present in handler " + f"{handler.__name__!r}" + ) + if parameter.kind in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + }: + raise TypeError( + f"markdown_agent parameter {arg_name!r} must be " + "positional-or-keyword or keyword-only" + ) + return signature.replace( + parameters=[ + candidate + for candidate in signature.parameters.values() + if candidate.name != arg_name + ] + ) + + +def _source_call( + handler: Callable[..., Any], + source_signature: inspect.Signature, + worker_signature: inspect.Signature, + args: tuple[Any, ...], + kwargs: dict[str, Any], + arg_name: str, + injected: Any, +) -> Any: + if arg_name in kwargs: + raise TypeError(f"markdown_agent parameter {arg_name!r} is runtime-managed") + bound = worker_signature.bind(*args, **kwargs) + bound.apply_defaults() + values = dict(bound.arguments) + values[arg_name] = injected + positional: list[Any] = [] + keywords: dict[str, Any] = {} + for parameter in source_signature.parameters.values(): + if parameter.kind is inspect.Parameter.POSITIONAL_ONLY: + positional.append(values[parameter.name]) + elif parameter.kind is inspect.Parameter.VAR_POSITIONAL: + positional.extend(values.get(parameter.name, ())) + elif parameter.kind is inspect.Parameter.VAR_KEYWORD: + keywords.update(values.get(parameter.name, {})) + elif parameter.name in values: + keywords[parameter.name] = values[parameter.name] + return handler(*positional, **keywords) + + +def _invocation_metadata( + worker_signature: inspect.Signature, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> InvocationMetadata: + bound = worker_signature.bind(*args, **kwargs) + for value in bound.arguments.values(): + if isinstance(value, func.Context): + return InvocationMetadata( + function_name=str(value.function_name or "") or None, + invocation_id=str(value.invocation_id or "") or None, + ) + return InvocationMetadata() + + +def markdown_agent( + app: func.FunctionApp, + *, + provider: str, + arg_name: str, + agent_name: str, + app_root: str | os.PathLike[str] | None = None, + **provider_options: Any, +) -> Callable[[_F], _F]: + state = _state_for(app, provider=provider, app_root=app_root) + + def decorate(handler: _F) -> _F: + if not inspect.isfunction(handler): + raise TypeError( + "markdown_agent must be the innermost decorator, immediately " + "above the handler" + ) + if not inspect.iscoroutinefunction(handler): + raise TypeError("markdown_agent requires an async def handler") + + source_signature = inspect.signature(handler) + visible_signature = _worker_signature(handler, arg_name) + annotation = source_signature.parameters[arg_name].annotation + try: + annotation = get_type_hints(handler).get(arg_name, annotation) + except (NameError, TypeError): + pass + options = {**state.provider_defaults, **provider_options} + instructions = _resolve_instructions(state.app_root, agent_name) + compiled = state.provider.compile_binding( + instructions=instructions, + agent_name=agent_name, + options=options, + annotation=annotation, + ) + + @functools.wraps(handler) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + invocation = _invocation_metadata(visible_signature, args, kwargs) + async with compiled.open_agent(invocation) as agent: + return await _source_call( + handler, + source_signature, + visible_signature, + args, + kwargs, + arg_name, + agent, + ) + + async_wrapper.__signature__ = visible_signature # type: ignore[attr-defined] + return cast(_F, async_wrapper) + + return decorate diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/durable.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/durable.py new file mode 100644 index 0000000..0888ec4 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/durable.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import functools +import inspect +import json +import math +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Dict, List, Literal, TypeVar, Union, cast + +import azure.durable_functions as df +import azure.functions as func +from azure.durable_functions.models.Task import TaskBase + +from .bindings import _configured_state, _durable_agent +from .providers import InvocationMetadata + +if TYPE_CHECKING: + from azure.durable_functions import ( + DurableOrchestrationContext as _DurableContextBase, + ) +else: + + class _DurableContextBase: + pass + + +JSONPrimitive = Union[str, int, float, bool, None] +JSONValue = Union[JSONPrimitive, List["JSONValue"], Dict[str, "JSONValue"]] +_F = TypeVar("_F", bound=Callable[..., Any]) + +_INTERNAL_AGENT_ACTIVITY_NAME = "azurefunctions_agents_run_markdown_agent" +_ACTIVITY_PAYLOAD_VERSION: Literal[1] = 1 + + +def _validate_json_value(value: object) -> None: + if value is None or isinstance(value, (str, bool, int)): + return + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("call_agent input cannot contain NaN or infinity") + return + if isinstance(value, list): + for item in value: + _validate_json_value(item) + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + raise TypeError("call_agent input object keys must be strings") + _validate_json_value(item) + return + raise TypeError( + "call_agent input must contain only JSON values " + f"(received {type(value).__name__})" + ) + + +def _canonicalize_json_value(value: object) -> JSONValue: + _validate_json_value(value) + encoded = json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True) + return cast(JSONValue, json.loads(encoded)) + + +def _parse_activity_input(value: object) -> dict[str, Any]: + if not isinstance(value, dict): + raise TypeError("Markdown Agent activity input must be a JSON object") + expected_fields = { + "schema_version", + "agent_name", + "input", + "durable_instance_id", + } + if set(value) != expected_fields: + raise ValueError( + "Markdown Agent activity input must contain exactly: " + + ", ".join(sorted(expected_fields)) + ) + if type(value["schema_version"]) is not int or value["schema_version"] != 1: + raise ValueError( + "Unsupported Markdown Agent activity payload schema_version; expected 1" + ) + agent_name = value["agent_name"] + if not isinstance(agent_name, str) or not agent_name.strip(): + raise ValueError( + "Markdown Agent activity agent_name must be a non-empty string" + ) + durable_instance_id = value["durable_instance_id"] + if not isinstance(durable_instance_id, str) or not durable_instance_id: + raise ValueError( + "Markdown Agent activity durable_instance_id must be a non-empty string" + ) + return { + "schema_version": 1, + "agent_name": agent_name, + "input": _canonicalize_json_value(value["input"]), + "durable_instance_id": durable_instance_id, + } + + +def _normalize_agent_prompt(value: JSONValue) -> str: + if isinstance(value, str): + return value + return json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True) + + +class DurableAgentContext(_DurableContextBase): # type: ignore[misc] + def __init__(self, context: df.DurableOrchestrationContext) -> None: + self._context = context + + def __getattr__(self, name: str) -> Any: + return getattr(self._context, name) + + def call_agent( + self, + agent_name: str, + input_: JSONValue, + *, + retry_options: df.RetryOptions | None = None, + ) -> TaskBase: + if not isinstance(agent_name, str) or not agent_name.strip(): + raise ValueError("call_agent agent_name must be a non-empty string") + payload = { + "schema_version": _ACTIVITY_PAYLOAD_VERSION, + "agent_name": agent_name, + "input": _canonicalize_json_value(input_), + "durable_instance_id": str(self._context.instance_id), + } + if retry_options is None: + return self._context.call_activity(_INTERNAL_AGENT_ACTIVITY_NAME, payload) + if not isinstance(retry_options, df.RetryOptions): + raise TypeError("call_agent retry_options must be RetryOptions or None") + return self._context.call_activity_with_retry( + _INTERNAL_AGENT_ACTIVITY_NAME, + retry_options, + payload, + ) + + +def configure_durable_app(app: func.FunctionApp) -> None: + state = _configured_state(app) + with state.lock: + if state.durable_activity_registered: + return + blueprint = df.Blueprint() + + @blueprint.activity_trigger(input_name="payload") + async def azurefunctions_agents_run_markdown_agent( + payload: object, + context: func.Context, + ) -> str: + parsed = _parse_activity_input(payload) + compiled = _durable_agent(app, parsed["agent_name"]) + invocation = InvocationMetadata( + function_name=( + str(context.function_name or "") or _INTERNAL_AGENT_ACTIVITY_NAME + ), + invocation_id=str(context.invocation_id or "") or None, + durable_instance_id=parsed["durable_instance_id"], + ) + return await compiled.run_agent( + _normalize_agent_prompt(parsed["input"]), + invocation, + ) + + app.register_blueprint(blueprint) + state.durable_activity_registered = True + + +def durable_orchestration_trigger( + app: func.FunctionApp, + *, + sdk_decorator: Callable[..., Any], + context_name: str, + orchestration: str | None = None, + input_type: type | None = None, +) -> Callable[[_F], Any]: + configure_durable_app(app) + sdk_parameters = inspect.signature(sdk_decorator).parameters + if input_type is None: + decorator = sdk_decorator( + context_name=context_name, + orchestration=orchestration, + ) + elif "input_type" in sdk_parameters: + decorator = sdk_decorator( + context_name=context_name, + orchestration=orchestration, + input_type=input_type, + ) + else: + raise TypeError( + "The installed azure-functions-durable version does not support " + "orchestration_trigger(input_type=...)" + ) + + def decorate(handler: _F) -> Any: + if not inspect.isgeneratorfunction(handler): + raise TypeError( + "DurableAiApp orchestration_trigger requires a synchronous " + "generator function" + ) + signature = inspect.signature(handler) + parameter = signature.parameters.get(context_name) + if parameter is None: + raise TypeError( + f"orchestration context_name {context_name!r} is not present " + f"in handler {handler.__name__!r}" + ) + if parameter.kind is not inspect.Parameter.POSITIONAL_OR_KEYWORD: + raise TypeError( + f"orchestration context parameter {context_name!r} must be " + "positional-or-keyword" + ) + + @functools.wraps(handler) + def proxy_orchestrator(*args: Any, **kwargs: Any) -> Any: + bound = signature.bind(*args, **kwargs) + context = cast( + df.DurableOrchestrationContext, + bound.arguments[context_name], + ) + bound.arguments[context_name] = DurableAgentContext(context) + return (yield from handler(*bound.args, **bound.kwargs)) + + proxy_orchestrator.__signature__ = signature # type: ignore[attr-defined] + return decorator(proxy_orchestrator) + + return decorate diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/providers.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/providers.py new file mode 100644 index 0000000..0a19411 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/providers.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from functools import lru_cache +from importlib import metadata +from typing import Any, Mapping, Protocol + +AGENT_PROVIDER_ENTRY_POINT_GROUP = "azurefunctions.extensions.agents.providers" + + +@dataclass(frozen=True) +class InvocationMetadata: + function_name: str | None = None + invocation_id: str | None = None + durable_instance_id: str | None = None + + +class CompiledAgent(Protocol): + def open_agent( + self, + invocation: InvocationMetadata, + ) -> AbstractAsyncContextManager[Any]: + pass + + async def run_agent( + self, + prompt: str, + invocation: InvocationMetadata, + ) -> str: + pass + + +class AgentProvider(Protocol): + provider_id: str + distribution_name: str + + def compile_binding( + self, + *, + instructions: str, + agent_name: str, + options: Mapping[str, Any], + annotation: Any, + ) -> CompiledAgent: + pass + + +def _provider_distribution_name(provider_id: str) -> str: + normalized = provider_id.replace("_", "-") + if normalized.startswith("agent-"): + normalized = normalized.removeprefix("agent-") + return f"azurefunctions-extensions-agents-{normalized}" + + +def _entry_point_distribution(entry_point: metadata.EntryPoint) -> str: + distribution = getattr(entry_point, "dist", None) + name = getattr(distribution, "name", None) + return str(name or entry_point.value) + + +@lru_cache(maxsize=1) +def _provider_entry_points() -> tuple[metadata.EntryPoint, ...]: + return tuple(metadata.entry_points(group=AGENT_PROVIDER_ENTRY_POINT_GROUP)) + + +def _validate_provider(provider: object, provider_id: str) -> AgentProvider: + actual_id = getattr(provider, "provider_id", None) + if actual_id != provider_id: + raise ValueError( + f"Agent provider entry point {provider_id!r} returned provider " + f"{actual_id!r}" + ) + distribution_name = getattr(provider, "distribution_name", None) + if not isinstance(distribution_name, str) or not distribution_name: + raise TypeError(f"Agent provider {provider_id!r} must define distribution_name") + if not callable(getattr(provider, "compile_binding", None)): + raise TypeError(f"Agent provider {provider_id!r} must define compile_binding()") + return provider # type: ignore[return-value] + + +@lru_cache(maxsize=None) +def load_provider(provider_id: str) -> AgentProvider: + if not isinstance(provider_id, str) or not provider_id.strip(): + raise ValueError("Agent provider must be a non-empty string") + + matches = [ + entry_point + for entry_point in _provider_entry_points() + if entry_point.name == provider_id + ] + if not matches: + distribution = _provider_distribution_name(provider_id) + raise LookupError( + f"Agent provider {provider_id!r} is not installed. " + f"Install {distribution!r}." + ) + if len(matches) > 1: + distributions = sorted(_entry_point_distribution(match) for match in matches) + raise RuntimeError( + f"Multiple Agent providers are registered as {provider_id!r}: " + f"{', '.join(distributions)}" + ) + + factory = matches[0].load() + if not callable(factory): + raise TypeError( + f"Agent provider entry point {provider_id!r} must load a callable factory" + ) + return _validate_provider(factory(), provider_id) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/py.typed b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/py.typed new file mode 100644 index 0000000..5fcb852 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/py.typed @@ -0,0 +1 @@ +partial \ No newline at end of file diff --git a/azurefunctions-extensions-agents-base/pyproject.toml b/azurefunctions-extensions-agents-base/pyproject.toml new file mode 100644 index 0000000..ab30334 --- /dev/null +++ b/azurefunctions-extensions-agents-base/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-extensions-agents-base" +dynamic = ["version"] +requires-python = ">=3.13" +authors = [ + { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, +] +description = "Framework-neutral Agent integration for Azure Functions." +readme = "README.md" +license = { text = "MIT License" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Environment :: Web Environment", + "Development Status :: 3 - Alpha", +] +dependencies = [ + "azure-functions>=2.4.0b1,<3", +] + +[project.optional-dependencies] +durable = [ + "azure-functions-durable>=1.2.10,<2", +] +dev = [ + "azure-functions-durable>=1.2.10,<2", + "coverage", + "flake8", + "mypy", + "pre-commit", + "pytest", + "pytest-cov", + "pytest-instafail", +] + +[tool.setuptools.dynamic] +version = { attr = "azurefunctions.extensions.agents_base.__version__" } + +[tool.setuptools.packages.find] +include = ["azurefunctions.extensions.agents_base*"] + +[tool.setuptools.package-data] +"azurefunctions.extensions.agents_base" = ["py.typed"] + +[[tool.mypy.overrides]] +module = ["azure.durable_functions", "azure.durable_functions.*"] +follow_untyped_imports = true diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py new file mode 100644 index 0000000..1478f67 --- /dev/null +++ b/azurefunctions-extensions-agents-base/tests/test_bindings.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import asyncio +import gc +import inspect +import weakref +from contextlib import asynccontextmanager + +import azure.functions as func +import pytest + +from azurefunctions.extensions.agents_base import bindings, providers + + +class _CompiledAgent: + def __init__(self): + self.opened = 0 + self.closed = 0 + + @asynccontextmanager + async def open_agent(self, invocation): + self.opened += 1 + try: + yield {"instance": self.opened, "invocation": invocation} + finally: + self.closed += 1 + + async def run_agent(self, prompt, invocation): + return prompt + + +class _Provider: + provider_id = "agent_framework" + distribution_name = "azurefunctions-extensions-agents-framework" + + def __init__(self): + self.compiled = _CompiledAgent() + self.compile_args = None + + def compile_binding(self, **kwargs): + self.compile_args = kwargs + return self.compiled + + +@pytest.fixture +def provider(monkeypatch): + instance = _Provider() + monkeypatch.setattr(providers, "load_provider", lambda provider_id: instance) + monkeypatch.setattr(bindings, "load_provider", lambda provider_id: instance) + return instance + + +def test_markdown_agent_injects_fresh_context_and_hides_parameter(tmp_path, provider): + instructions = "---\nnot: parsed\n---\nUse the order API.\n" + (tmp_path / "orders.agent.md").write_text(instructions, encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=tmp_path, + tools=["lookup"], + ) + async def handler(value: str, agent: object) -> tuple[str, object]: + return value, agent + + assert list(inspect.signature(handler).parameters) == ["value"] + assert provider.compile_args == { + "instructions": instructions, + "agent_name": "orders", + "options": {"tools": ["lookup"]}, + "annotation": object, + } + + first = asyncio.run(handler("one")) + second = asyncio.run(handler("two")) + + assert first[1]["instance"] == 1 + assert second[1]["instance"] == 2 + assert provider.compiled.opened == provider.compiled.closed == 2 + + +def test_markdown_agent_closes_context_when_handler_fails(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=tmp_path, + ) + async def handler(agent: object) -> None: + raise RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + asyncio.run(handler()) + + assert provider.compiled.opened == provider.compiled.closed == 1 + + +def test_markdown_agent_closes_context_when_handler_is_cancelled(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=tmp_path, + ) + async def handler(agent: object) -> None: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + asyncio.run(handler()) + + assert provider.compiled.opened == provider.compiled.closed == 1 + + +def test_markdown_agent_rejects_ambiguous_files(tmp_path, provider): + (tmp_path / "agents").mkdir() + (tmp_path / "orders.agent.md").write_text("root", encoding="utf-8") + (tmp_path / "agents" / "orders.agent.md").write_text("nested", encoding="utf-8") + + with pytest.raises(ValueError, match="ambiguous"): + + @bindings.markdown_agent( + func.FunctionApp(), + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=tmp_path, + ) + async def handler(agent: object) -> None: + pass + + +def test_markdown_agent_rejects_symlink_outside_app_root(tmp_path, provider): + app_root = tmp_path / "app" + app_root.mkdir() + outside = tmp_path / "orders.agent.md" + outside.write_text("outside", encoding="utf-8") + try: + (app_root / "orders.agent.md").symlink_to(outside) + except OSError as error: + pytest.skip(f"symlink creation is unavailable: {error}") + + with pytest.raises(ValueError, match="resolves outside app root"): + + @bindings.markdown_agent( + func.FunctionApp(), + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=app_root, + ) + async def handler(agent: object) -> None: + pass + + +@pytest.mark.parametrize( + "agent_name", + ["../orders", "agents/orders", r"agents\orders", "C:orders"], +) +def test_markdown_agent_rejects_nonportable_agent_names( + tmp_path, + provider, + agent_name, +): + app = func.FunctionApp() + + with pytest.raises(ValueError, match="filename component"): + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name=agent_name, + app_root=tmp_path, + ) + async def handler(agent: object) -> None: + pass + + +def test_function_app_rejects_a_second_provider(tmp_path, provider): + bindings.configure_app( + func_app := func.FunctionApp(), + provider="agent_framework", + app_root=tmp_path, + ) + + with pytest.raises(ValueError, match="already configured"): + bindings.configure_app( + func_app, + provider="langgraph", + app_root=tmp_path, + ) + + +def test_function_app_state_does_not_keep_app_alive(tmp_path, provider): + app = func.FunctionApp() + bindings.configure_app( + app, + provider="agent_framework", + app_root=tmp_path, + ) + app_reference = weakref.ref(app) + + del app + gc.collect() + + assert app_reference() is None + + +def test_app_defaults_are_overridden_by_decorator_options(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + bindings.configure_app( + app, + provider="agent_framework", + app_root=tmp_path, + provider_options={"temperature": 0.1, "tools": ["default"]}, + ) + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + temperature=0.5, + ) + async def handler(agent: object) -> None: + pass + + assert provider.compile_args["options"] == { + "temperature": 0.5, + "tools": ["default"], + } + + +def test_markdown_agent_requires_async_handler(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + + with pytest.raises(TypeError, match="async def"): + + @bindings.markdown_agent( + func.FunctionApp(), + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=tmp_path, + ) + def handler(agent: object) -> None: + pass diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-extensions-agents-base/tests/test_durable.py new file mode 100644 index 0000000..58da02d --- /dev/null +++ b/azurefunctions-extensions-agents-base/tests/test_durable.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import asyncio +import math +from contextlib import asynccontextmanager +from types import SimpleNamespace + +import azure.functions as func +import pytest + +from azurefunctions.extensions.agents_base import bindings, durable +from azurefunctions.extensions.agents_base.durable import ( + DurableAgentContext, + _canonicalize_json_value, + _normalize_agent_prompt, + _parse_activity_input, +) + + +class _Context: + instance_id = "instance-1" + + def __init__(self): + self.calls = [] + + def call_activity(self, name, payload): + self.calls.append(("activity", name, payload)) + return "task" + + def call_activity_with_retry(self, name, retry, payload): + self.calls.append(("retry", name, retry, payload)) + return "retry-task" + + +def test_call_agent_schedules_canonical_payload(): + context = _Context() + proxy = DurableAgentContext(context) + + task = proxy.call_agent("orders", {"z": 1, "a": [True, None]}) + + assert task == "task" + assert context.calls == [ + ( + "activity", + "azurefunctions_agents_run_markdown_agent", + { + "schema_version": 1, + "agent_name": "orders", + "input": {"a": [True, None], "z": 1}, + "durable_instance_id": "instance-1", + }, + ) + ] + + +def test_call_agent_schedules_retry_with_same_canonical_payload(monkeypatch): + class RetryOptions: + pass + + context = _Context() + retry_options = RetryOptions() + monkeypatch.setattr(durable.df, "RetryOptions", RetryOptions) + proxy = DurableAgentContext(context) + + task = proxy.call_agent( + "orders", + {"z": 1, "a": 2}, + retry_options=retry_options, + ) + + assert task == "retry-task" + assert context.calls == [ + ( + "retry", + "azurefunctions_agents_run_markdown_agent", + retry_options, + { + "schema_version": 1, + "agent_name": "orders", + "input": {"a": 2, "z": 1}, + "durable_instance_id": "instance-1", + }, + ) + ] + + +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) +def test_call_agent_rejects_nonfinite_numbers(value): + with pytest.raises(ValueError, match="NaN or infinity"): + DurableAgentContext(_Context()).call_agent("orders", value) + + +def test_parse_activity_input_rejects_unknown_schema(): + with pytest.raises(ValueError, match="schema_version"): + _parse_activity_input( + { + "schema_version": 2, + "agent_name": "orders", + "input": "hello", + "durable_instance_id": "instance-1", + } + ) + + +def test_normalize_agent_prompt_preserves_strings_and_encodes_json(): + assert _normalize_agent_prompt("hello") == "hello" + assert _normalize_agent_prompt({"z": 1, "a": 2}) == '{"a":2,"z":1}' + + +def test_canonicalize_json_value_rejects_non_string_keys(): + with pytest.raises(TypeError, match="keys must be strings"): + _canonicalize_json_value({1: "value"}) + + +class _CompiledAgent: + def __init__(self): + self.calls = [] + + @asynccontextmanager + async def open_agent(self, invocation): + yield object() + + async def run_agent(self, prompt, invocation): + self.calls.append((prompt, invocation)) + return f"response:{prompt}" + + +class _Provider: + provider_id = "agent_framework" + distribution_name = "azurefunctions-extensions-agents-framework" + + def __init__(self): + self.compiled = _CompiledAgent() + self.compile_calls = [] + + def compile_binding(self, **kwargs): + self.compile_calls.append(kwargs) + return self.compiled + + +def _configured_app(tmp_path, monkeypatch): + provider = _Provider() + monkeypatch.setattr(bindings, "load_provider", lambda provider_id: provider) + app = func.FunctionApp() + bindings.configure_app( + app, + provider="agent_framework", + app_root=tmp_path, + ) + return app, provider + + +def test_configure_durable_app_registers_hidden_activity_once(tmp_path, monkeypatch): + app, _ = _configured_app(tmp_path, monkeypatch) + + durable.configure_durable_app(app) + durable.configure_durable_app(app) + + names = [function.get_function_name() for function in app.get_functions()] + assert names == ["azurefunctions_agents_run_markdown_agent"] + + +def test_hidden_activity_name_collision_is_rejected(tmp_path, monkeypatch): + app, _ = _configured_app(tmp_path, monkeypatch) + + @app.function_name(name="azurefunctions_agents_run_markdown_agent") + @app.activity_trigger(input_name="payload") + def customer_activity(payload): + return payload + + durable.configure_durable_app(app) + + with pytest.raises(ValueError, match="unique function name"): + app.get_functions() + + +def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypatch): + instructions = "---\nthis remains: raw\n---\nHandle orders.\n" + (tmp_path / "orders.agent.md").write_text(instructions, encoding="utf-8") + app, provider = _configured_app(tmp_path, monkeypatch) + durable.configure_durable_app(app) + activity = app.get_functions()[0].get_user_function() + context = SimpleNamespace( + function_name="activity", + invocation_id="invocation-1", + ) + + result = asyncio.run( + activity( + { + "schema_version": 1, + "agent_name": "orders", + "input": {"z": 1, "a": 2}, + "durable_instance_id": "instance-1", + }, + context, + ) + ) + + assert result == 'response:{"a":2,"z":1}' + assert provider.compile_calls[0]["instructions"] == instructions + assert provider.compiled.calls[0][0] == '{"a":2,"z":1}' + assert provider.compiled.calls[0][1].durable_instance_id == "instance-1" diff --git a/azurefunctions-extensions-agents-base/tests/test_imports.py b/azurefunctions-extensions-agents-base/tests/test_imports.py new file mode 100644 index 0000000..d1e9c63 --- /dev/null +++ b/azurefunctions-extensions-agents-base/tests/test_imports.py @@ -0,0 +1,21 @@ +import subprocess +import sys + + +def test_base_import_does_not_import_durable(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "import azurefunctions.extensions.agents_base; " + "assert 'azure.durable_functions' not in sys.modules" + ), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/azurefunctions-extensions-agents-base/tests/test_providers.py b/azurefunctions-extensions-agents-base/tests/test_providers.py new file mode 100644 index 0000000..141518f --- /dev/null +++ b/azurefunctions-extensions-agents-base/tests/test_providers.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from azurefunctions.extensions.agents_base import providers + + +class _Provider: + provider_id = "agent_framework" + distribution_name = "azurefunctions-extensions-agents-framework" + + def compile_binding(self, **kwargs): + return kwargs + + +class _EntryPoint: + def __init__(self, name, value, factory, distribution): + self.name = name + self.value = value + self._factory = factory + self.dist = SimpleNamespace(name=distribution) + + def load(self): + return self._factory + + +@pytest.fixture(autouse=True) +def _clear_provider_cache(): + providers._provider_entry_points.cache_clear() + providers.load_provider.cache_clear() + yield + providers.load_provider.cache_clear() + providers._provider_entry_points.cache_clear() + + +def test_load_provider_uses_matching_entry_point(monkeypatch): + entry_point = _EntryPoint( + "agent_framework", + "test:provider", + _Provider, + "azurefunctions-extensions-agents-framework", + ) + monkeypatch.setattr( + providers.metadata, + "entry_points", + lambda **kwargs: [entry_point], + ) + + provider = providers.load_provider("agent_framework") + + assert provider.provider_id == "agent_framework" + assert providers.load_provider("agent_framework") is provider + + +def test_provider_entry_points_are_enumerated_once_for_multiple_ids(monkeypatch): + class OtherProvider(_Provider): + provider_id = "other" + distribution_name = "other-provider" + + entry_points = [ + _EntryPoint( + "agent_framework", + "test:provider", + _Provider, + "azurefunctions-extensions-agents-framework", + ), + _EntryPoint("other", "test:other", OtherProvider, "other-provider"), + ] + calls = 0 + + def enumerate_entry_points(**kwargs): + nonlocal calls + calls += 1 + return entry_points + + monkeypatch.setattr(providers.metadata, "entry_points", enumerate_entry_points) + + assert providers.load_provider("agent_framework").provider_id == "agent_framework" + assert providers.load_provider("other").provider_id == "other" + assert calls == 1 + + +def test_load_provider_reports_installable_distribution(monkeypatch): + monkeypatch.setattr(providers.metadata, "entry_points", lambda **kwargs: []) + + with pytest.raises(LookupError, match="azurefunctions-extensions-agents-framework"): + providers.load_provider("agent_framework") + + +def test_load_provider_rejects_duplicate_provider_ids(monkeypatch): + entry_points = [ + _EntryPoint("agent_framework", "one:provider", _Provider, "provider-one"), + _EntryPoint("agent_framework", "two:provider", _Provider, "provider-two"), + ] + monkeypatch.setattr( + providers.metadata, + "entry_points", + lambda **kwargs: entry_points, + ) + + with pytest.raises(RuntimeError, match="provider-one, provider-two"): + providers.load_provider("agent_framework") + + +def test_load_provider_does_not_rewrite_factory_error(monkeypatch): + def fail(): + raise RuntimeError("provider initialization failed") + + entry_point = _EntryPoint("agent_framework", "test:fail", fail, "provider") + monkeypatch.setattr( + providers.metadata, + "entry_points", + lambda **kwargs: [entry_point], + ) + + with pytest.raises(RuntimeError, match="provider initialization failed"): + providers.load_provider("agent_framework") + + +def test_load_provider_validates_returned_provider_id(monkeypatch): + class WrongProvider(_Provider): + provider_id = "wrong" + + entry_point = _EntryPoint( + "agent_framework", + "test:wrong", + WrongProvider, + "provider", + ) + monkeypatch.setattr( + providers.metadata, + "entry_points", + lambda **kwargs: [entry_point], + ) + + with pytest.raises(ValueError, match="returned provider 'wrong'"): + providers.load_provider("agent_framework") diff --git a/azurefunctions-extensions-agents-framework/LICENSE b/azurefunctions-extensions-agents-framework/LICENSE new file mode 100644 index 0000000..22aed37 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/azurefunctions-extensions-agents-framework/MANIFEST.in b/azurefunctions-extensions-agents-framework/MANIFEST.in new file mode 100644 index 0000000..4c501a0 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include azurefunctions *.py *.pyi +recursive-include tests *.py +include LICENSE README.md diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md new file mode 100644 index 0000000..cd7c6c7 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/README.md @@ -0,0 +1,82 @@ +# Azure Functions Microsoft Agent Framework Extension + +Inject Microsoft Agent Framework Agents built from raw `.agent.md` instructions +into Python Azure Functions. + +## Install + +```text +pip install azurefunctions-extensions-agents-framework +``` + +The default package installs `agent-framework-core==1.13.0`. Install the MAF +client package required by your application separately. OpenAI, Foundry, Azure +Identity, storage, YAML, MCP, and the Azure Functions Agents runtime are not +dependencies of this extension. + +## Use a typed Agent app + +Create a zero-argument factory that returns a fresh MAF chat client. A new +client and Agent context are created and closed for every Function invocation. + +```python +import azure.functions as func +from agent_framework import Agent +from azurefunctions.extensions.agents_framework import AiApp + + +def create_chat_client(): + from agent_framework.openai import OpenAIChatClient + + return OpenAIChatClient() + + +app = AiApp(client_factory=create_chat_client) + + +@app.route(route="orders", methods=["POST"]) +@app.markdown_agent(arg_name="agent", agent_name="orders") +async def process_order(req: func.HttpRequest, agent: Agent): + response = await agent.run(req.get_body().decode()) + return response.text +``` + +Place the complete instructions at `orders.agent.md` or +`agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime +configuration is interpreted. + +The generic core form is also supported: + +```python +app = func.FunctionApp() + + +@app.markdown_agent( + provider="agent_framework", + arg_name="agent", + agent_name="orders", + client_factory=create_chat_client, +) +async def process_order(req: func.HttpRequest, agent: Agent): + ... +``` + +Typed constructors and decorators expose the MAF Agent options supported by +this release: tools, description, default options, context providers, +middleware, per-service-call history persistence, compaction strategy, +tokenizer, and additional properties. The extension owns the Agent client, +name, and instructions. + +## Durable Agents + +Durable orchestration support is optional: + +```text +pip install "azurefunctions-extensions-agents-framework[durable]" +``` + +Use `DurableAiApp` and call `context.call_agent(agent_name, input_)` inside a +synchronous generator orchestrator. Agent execution is isolated in an activity +so replay performs no nondeterministic work. Importing the package remains safe +without Durable installed; constructing `DurableAiApp` reports the exact extra +to install when it is absent. diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/__init__.py b/azurefunctions-extensions-agents-framework/azurefunctions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/azurefunctions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/__init__.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/__init__.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/__init__.py new file mode 100644 index 0000000..0247a24 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/__init__.py @@ -0,0 +1,12 @@ +from .apps import AiApp, DurableAiApp, markdown_agent +from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory + +__all__ = [ + "AGENT_FRAMEWORK_PROVIDER_ID", + "AiApp", + "ClientFactory", + "DurableAiApp", + "markdown_agent", +] + +__version__ = "1.0.0b1" diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/apps.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/apps.py new file mode 100644 index 0000000..8083461 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/apps.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import os +from collections.abc import Callable, MutableMapping, Sequence +from typing import Any, TypeVar + +import azure.functions as func +from agent_framework import ( + CompactionStrategy, + ContextProvider, + MiddlewareTypes, + TokenizerProtocol, + ToolTypes, +) + +from azurefunctions.extensions.agents_base import markdown_agent as base_markdown_agent + +from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory + +_F = TypeVar("_F", bound=Callable[..., Any]) + + +def _provider_options( + *, + client_factory: ClientFactory | None = None, + tools: ( + ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None + ) = None, + description: str | None = None, + default_options: Any | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: MutableMapping[str, Any] | None = None, +) -> dict[str, Any]: + options: dict[str, Any] = {} + if client_factory is not None: + options["client_factory"] = client_factory + if tools is not None: + options["tools"] = tools + if description is not None: + options["description"] = description + if default_options is not None: + options["default_options"] = default_options + if context_providers is not None: + options["context_providers"] = context_providers + if middleware is not None: + options["middleware"] = middleware + if require_per_service_call_history_persistence is not None: + options["require_per_service_call_history_persistence"] = ( + require_per_service_call_history_persistence + ) + if compaction_strategy is not None: + options["compaction_strategy"] = compaction_strategy + if tokenizer is not None: + options["tokenizer"] = tokenizer + if additional_properties is not None: + options["additional_properties"] = additional_properties + return options + + +def markdown_agent( + app: func.FunctionApp, + *, + arg_name: str, + agent_name: str, + client_factory: ClientFactory, + app_root: str | os.PathLike[str] | None = None, + tools: ( + ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None + ) = None, + description: str | None = None, + default_options: Any | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: MutableMapping[str, Any] | None = None, +) -> Callable[[_F], _F]: + options = _provider_options( + client_factory=client_factory, + tools=tools, + description=description, + default_options=default_options, + context_providers=context_providers, + middleware=middleware, + require_per_service_call_history_persistence=( + require_per_service_call_history_persistence + ), + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ) + return base_markdown_agent( + app, + provider=AGENT_FRAMEWORK_PROVIDER_ID, + arg_name=arg_name, + agent_name=agent_name, + app_root=app_root, + **options, + ) + + +class AiApp(func.AiApp): + """Azure Functions app configured for Microsoft Agent Framework.""" + + def __init__( + self, + *, + client_factory: ClientFactory, + app_root: str | os.PathLike[str] | None = None, + tools: ( + ToolTypes + | Callable[..., Any] + | Sequence[ToolTypes | Callable[..., Any]] + | None + ) = None, + description: str | None = None, + default_options: Any | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: MutableMapping[str, Any] | None = None, + http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, + ) -> None: + super().__init__( + http_auth_level=http_auth_level, + provider=AGENT_FRAMEWORK_PROVIDER_ID, + app_root=app_root, + **_provider_options( + client_factory=client_factory, + tools=tools, + description=description, + default_options=default_options, + context_providers=context_providers, + middleware=middleware, + require_per_service_call_history_persistence=( + require_per_service_call_history_persistence + ), + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ), + ) + + def markdown_agent( # type: ignore[override] + self, + *, + arg_name: str, + agent_name: str, + client_factory: ClientFactory | None = None, + app_root: str | os.PathLike[str] | None = None, + tools: ( + ToolTypes + | Callable[..., Any] + | Sequence[ToolTypes | Callable[..., Any]] + | None + ) = None, + description: str | None = None, + default_options: Any | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: MutableMapping[str, Any] | None = None, + ) -> Callable[[_F], _F]: + return super().markdown_agent( + arg_name=arg_name, + agent_name=agent_name, + app_root=app_root, + **_provider_options( + client_factory=client_factory, + tools=tools, + description=description, + default_options=default_options, + context_providers=context_providers, + middleware=middleware, + require_per_service_call_history_persistence=( + require_per_service_call_history_persistence + ), + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ), + ) + + +class DurableAiApp(AiApp, func.DurableAiApp): + """Microsoft Agent Framework app with optional Durable Agent support.""" diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/provider.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/provider.py new file mode 100644 index 0000000..f26c460 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/provider.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable, Mapping +from contextlib import asynccontextmanager +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, AsyncIterator, get_origin + +from agent_framework import Agent, BaseChatClient + +from azurefunctions.extensions.agents_base import InvocationMetadata + +AGENT_FRAMEWORK_PROVIDER_ID = "agent_framework" +ClientFactory = Callable[[], BaseChatClient[Any]] +_AGENT_ANNOTATION_TYPE = Agent + +_SUPPORTED_OPTIONS = frozenset( + { + "additional_properties", + "client_factory", + "compaction_strategy", + "context_providers", + "default_options", + "description", + "middleware", + "require_per_service_call_history_persistence", + "tokenizer", + "tools", + } +) + + +@dataclass(frozen=True) +class AgentFrameworkBinding: + instructions: str + agent_name: str + client_factory: ClientFactory + agent_options: Mapping[str, Any] + + def _create_agent(self) -> Agent[Any]: + return Agent( + client=self.client_factory(), + instructions=self.instructions, + name=self.agent_name, + **self.agent_options, + ) + + @asynccontextmanager + async def open_agent( + self, + invocation: InvocationMetadata, + ) -> AsyncIterator[Agent[Any]]: + async with self._create_agent() as agent: + yield agent + + async def run_agent( + self, + prompt: str, + invocation: InvocationMetadata, + ) -> str: + async with self.open_agent(invocation) as agent: + response = await agent.run(prompt) + text = getattr(response, "text", None) + if not isinstance(text, str): + raise TypeError("Microsoft Agent Framework response.text must be a string") + return text + + +class AgentFrameworkProvider: + provider_id = AGENT_FRAMEWORK_PROVIDER_ID + distribution_name = "azurefunctions-extensions-agents-framework" + + def compile_binding( + self, + *, + instructions: str, + agent_name: str, + options: Mapping[str, Any], + annotation: Any, + ) -> AgentFrameworkBinding: + unknown = sorted(set(options) - _SUPPORTED_OPTIONS) + if unknown: + raise TypeError( + "Unsupported Microsoft Agent Framework option(s): " + ", ".join(unknown) + ) + client_factory = options.get("client_factory") + if not callable(client_factory): + raise TypeError("client_factory must be callable") + if annotation is not inspect.Signature.empty: + annotation_origin = get_origin(annotation) + if ( + annotation is not _AGENT_ANNOTATION_TYPE + and annotation_origin is not _AGENT_ANNOTATION_TYPE + ): + raise TypeError( + "Microsoft Agent Framework binding parameter must be annotated " + "as agent_framework.Agent" + ) + + agent_options = dict(options) + del agent_options["client_factory"] + return AgentFrameworkBinding( + instructions=instructions, + agent_name=agent_name, + client_factory=client_factory, + agent_options=MappingProxyType(agent_options), + ) + + +def create_provider() -> AgentFrameworkProvider: + return AgentFrameworkProvider() diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/py.typed b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/py.typed new file mode 100644 index 0000000..5fcb852 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/py.typed @@ -0,0 +1 @@ +partial \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/pyproject.toml b/azurefunctions-extensions-agents-framework/pyproject.toml new file mode 100644 index 0000000..7c2ba5d --- /dev/null +++ b/azurefunctions-extensions-agents-framework/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-extensions-agents-framework" +dynamic = ["version"] +requires-python = ">=3.13" +authors = [ + { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, +] +description = "Microsoft Agent Framework integration for Azure Functions." +readme = "README.md" +license = { text = "MIT License" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Environment :: Web Environment", + "Development Status :: 3 - Alpha", +] +dependencies = [ + "agent-framework-core==1.13.0", + "azurefunctions-extensions-agents-base>=1.0.0b1", +] + +[project.optional-dependencies] +durable = [ + "azurefunctions-extensions-agents-base[durable]>=1.0.0b1", +] +dev = [ + "azure-functions-durable>=1.2.10,<2", + "coverage", + "flake8", + "mypy", + "pre-commit", + "pytest", + "pytest-cov", + "pytest-instafail", +] + +[project.entry-points."azurefunctions.extensions.agents.providers"] +agent_framework = "azurefunctions.extensions.agents_framework.provider:create_provider" + +[tool.setuptools.dynamic] +version = { attr = "azurefunctions.extensions.agents_framework.__version__" } + +[tool.setuptools.packages.find] +include = ["azurefunctions.extensions.agents_framework*"] + +[tool.setuptools.package-data] +"azurefunctions.extensions.agents_framework" = ["py.typed"] + +[[tool.mypy.overrides]] +module = ["azure", "azure.*"] +ignore_missing_imports = true diff --git a/azurefunctions-extensions-agents-framework/samples/README.md b/azurefunctions-extensions-agents-framework/samples/README.md new file mode 100644 index 0000000..0001862 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/README.md @@ -0,0 +1,7 @@ +# Microsoft Agent Framework samples + +- `hybrid-function-agent`: injects a fresh Agent into HTTP and queue Functions. +- `hybrid-durable-agent`: schedules Agent calls from a replay-safe orchestrator. + +Both samples use raw `.agent.md` instructions and an explicit Foundry client +factory. They do not depend on the Azure Functions Agents runtime. \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/README.md b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/README.md new file mode 100644 index 0000000..8123259 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/README.md @@ -0,0 +1,14 @@ +# Hybrid Durable Agent + +This sample keeps orchestration deterministic while scheduling markdown-defined +Agent calls through a hidden activity. Order validation, calculations, and data +minimization remain explicit application code. + +From `src/`, copy `local.settings.template.json` to `local.settings.json`, fill +in the Foundry values, start Azurite, and run `func start`. + +```bash +curl -X POST http://localhost:7071/orders/orchestrations \ + -H "Content-Type: application/json" \ + -d '{"order_id":"D-2048","customer":{"id":"C-1007","loyalty_tier":"gold"},"currency":"usd","shipping":{"country":"ca","method":"overnight"},"items":[{"sku":"A-100","quantity":2,"unit_price":"24.95"}]}' +``` \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py new file mode 100644 index 0000000..d3a52ce --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py @@ -0,0 +1,92 @@ +import json +import os +from typing import Any, cast + +import azure.durable_functions as df +import azure.functions as func +from agent_framework import Agent +from azurefunctions.extensions.agents_framework import DurableAiApp +from order_processing import prepare_order_for_agent + + +def create_chat_client(): + from agent_framework.foundry import FoundryChatClient + from azure.identity.aio import DefaultAzureCredential + + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=DefaultAzureCredential(), + ) + + +app = DurableAiApp(client_factory=create_chat_client) + + +@app.route(route="orders/orchestrations", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_order_orchestration( + req: func.HttpRequest, + client: str, +) -> func.HttpResponse: + durable_client = cast(df.DurableOrchestrationClient, client) + instance_id = await durable_client.start_new( + "order_orchestrator", + client_input=req.get_json(), + ) + management = durable_client.create_http_management_payload(instance_id) + return func.HttpResponse( + body=json.dumps(management), + status_code=202, + media_type="application/json", + headers={ + "Location": management["statusQueryGetUri"], + "Retry-After": "10", + }, + ) + + +@app.activity_trigger(input_name="order") +def prepare_order_activity(order: dict) -> dict[str, object]: + return prepare_order_for_agent(order) + + +@app.orchestration_trigger(context_name="context") +def order_orchestrator(context: Any): + prepared_order = yield context.call_activity( + "prepare_order_activity", + context.get_input(), + ) + + # context.call_agent equivalent to the following commented-out code: + # + # @app.activity_trigger(input_name="payload") + # @app.markdown_agent(arg_name="agent", agent_name="order-fulfillment") + # async def process_order(payload: dict, agent: Agent[Any]) -> dict: + # response = await agent.run(json.dumps(payload)) + # return {"text": response.text} + + assessment = yield context.call_agent( + "order-fulfillment", + { + "order": prepared_order, + "task": "assess fulfillment risk using the trusted calculated fields", + }, + ) + plan = yield context.call_agent( + "order-fulfillment", + { + "order": prepared_order, + "risk_assessment": assessment, + "task": "create a fulfillment plan with prioritized human-review actions", + }, + retry_options=df.RetryOptions( + first_retry_interval_in_milliseconds=5_000, + max_number_of_attempts=3, + ), + ) + return { + "order_id": prepared_order["order_id"], + "risk_assessment": assessment, + "fulfillment_plan": plan, + } \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/host.json b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/host.json new file mode 100644 index 0000000..bab9278 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/local.settings.template.json b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/local.settings.template.json new file mode 100644 index 0000000..361120f --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/local.settings.template.json @@ -0,0 +1,9 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FOUNDRY_PROJECT_ENDPOINT": "https://..services.ai.azure.com/api/projects/", + "FOUNDRY_MODEL": "gpt-5.4" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md new file mode 100644 index 0000000..2be89bb --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md @@ -0,0 +1,5 @@ +You are an order fulfillment specialist. +The supplied order has already been validated and minimized by application code. +Treat its calculated summary and review signals as trusted facts. Explain operational +risk, identify missing fulfillment context, and return a concise actionable response. +Never claim that an external action completed unless a tool result confirms it. \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order_processing.py b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order_processing.py new file mode 100644 index 0000000..5ca775a --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order_processing.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_CENT = Decimal("0.01") + + +class OrderItem(BaseModel): + model_config = ConfigDict(extra="ignore") + + sku: str + quantity: int = Field(gt=0, strict=True) + unit_price: Decimal = Field(ge=0, allow_inf_nan=False) + + @field_validator("sku") + @classmethod + def normalize_sku(cls, value: str) -> str: + normalized = value.strip().upper() + if not normalized: + raise ValueError("SKU cannot be empty") + return normalized + + +class Customer(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + loyalty_tier: Literal["standard", "silver", "gold", "platinum"] = "standard" + + @field_validator("id") + @classmethod + def normalize_id(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("Customer ID cannot be empty") + return normalized + + @field_validator("loyalty_tier", mode="before") + @classmethod + def normalize_loyalty_tier(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class Shipping(BaseModel): + model_config = ConfigDict(extra="ignore") + + country: str + method: Literal["standard", "two_day", "overnight", "same_day"] + + @field_validator("country") + @classmethod + def normalize_country(cls, value: str) -> str: + normalized = value.strip().upper() + if len(normalized) != 2 or not normalized.isalpha(): + raise ValueError("Shipping country must be a two-letter code") + return normalized + + @field_validator("method", mode="before") + @classmethod + def normalize_method(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class Order(BaseModel): + model_config = ConfigDict(extra="ignore") + + order_id: str | None = None + currency: str = "USD" + customer: Customer + shipping: Shipping + items: list[OrderItem] = Field(min_length=1) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + normalized = value.strip().upper() + if len(normalized) != 3 or not normalized.isalpha(): + raise ValueError("Currency must be a three-letter code") + return normalized + + +def _money(value: Decimal) -> str: + return f"{value.quantize(_CENT, rounding=ROUND_HALF_UP):.2f}" + + +def prepare_order_for_agent( + payload: object, + *, + order_id: str | None = None, +) -> dict[str, object]: + order = Order.model_validate(payload) + resolved_order_id = order_id or order.order_id + if not resolved_order_id: + raise ValueError("Order ID is required") + + prepared_items: list[dict[str, object]] = [] + subtotal = Decimal("0") + total_quantity = 0 + for item in order.items: + unit_price = item.unit_price.quantize(_CENT, rounding=ROUND_HALF_UP) + line_total = unit_price * item.quantity + subtotal += line_total + total_quantity += item.quantity + prepared_items.append( + { + "sku": item.sku, + "quantity": item.quantity, + "unit_price": _money(unit_price), + "line_total": _money(line_total), + } + ) + + review_signals: list[str] = [] + if subtotal >= Decimal("1000"): + review_signals.append("high_value_order") + if total_quantity >= 25: + review_signals.append("bulk_quantity") + if order.shipping.method in {"overnight", "same_day"}: + review_signals.append("expedited_shipping") + if order.shipping.country != "US": + review_signals.append("international_shipping") + + return { + "order_id": resolved_order_id, + "currency": order.currency, + "customer": { + "id": order.customer.id, + "loyalty_tier": order.customer.loyalty_tier, + }, + "shipping": { + "country": order.shipping.country, + "method": order.shipping.method, + }, + "items": prepared_items, + "summary": { + "line_items": len(prepared_items), + "total_quantity": total_quantity, + "subtotal": _money(subtotal), + }, + "review_signals": review_signals, + } diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/requirements.txt b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/requirements.txt new file mode 100644 index 0000000..94a9bea --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/requirements.txt @@ -0,0 +1,4 @@ +-e ../../..[durable] +agent-framework-foundry==1.13.0 +azure-identity +pydantic \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md new file mode 100644 index 0000000..32c48bf --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md @@ -0,0 +1,14 @@ +# Hybrid Function Agent + +This sample keeps validation and calculations in ordinary Azure Functions code +while injecting a fresh Microsoft Agent Framework `Agent` for each invocation. +The prompt receives only the validated, minimized order projection. + +From `src/`, copy `local.settings.template.json` to `local.settings.json`, fill +in the Foundry values, start Azurite, and run `func start`. + +```bash +curl -X POST http://localhost:7071/orders/42 \ + -H "Content-Type: application/json" \ + -d '{"customer":{"id":"C-1007","loyalty_tier":"gold"},"currency":"usd","shipping":{"country":"ca","method":"overnight"},"items":[{"sku":"A-100","quantity":2,"unit_price":"24.95"}]}' +``` \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py new file mode 100644 index 0000000..74a3617 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py @@ -0,0 +1,75 @@ +import json +import os + +import azure.functions as func +from agent_framework import Agent +from azurefunctions.extensions.agents_framework import AiApp +from order_processing import prepare_order_for_agent +from pydantic import ValidationError + + +def create_chat_client(): + from agent_framework.foundry import FoundryChatClient + from azure.identity.aio import DefaultAzureCredential + + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=DefaultAzureCredential(), + ) + + +app = AiApp(client_factory=create_chat_client) + + +@app.route(route="orders/{orderId}", methods=["POST"]) +@app.markdown_agent(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order( + req: func.HttpRequest, + order_agent: Agent, +) -> func.HttpResponse: + order_id = req.route_params["orderId"] + order = req.get_json() + try: + prepared_order = prepare_order_for_agent(order, order_id=order_id) + except (ValidationError, ValueError): + return func.HttpResponse( + body=json.dumps({"error": "Order failed validation."}), + status_code=400, + media_type="application/json", + ) + + response = await order_agent.run( + json.dumps( + { + "order": prepared_order, + "task": "assess fulfillment readiness using the trusted calculated fields", + } + ) + ) + return func.HttpResponse( + body=json.dumps({"order_id": order_id, "assessment": response.text}), + media_type="application/json", + ) + + +# @app.queue_trigger( +# arg_name="message", +# queue_name="orders", +# connection="AzureWebJobsStorage", +# ) +# @app.markdown_agent(arg_name="order_agent", agent_name="order-fulfillment") +# async def process_order_event( +# message: func.QueueMessage, +# order_agent: Agent, +# ) -> None: +# event = json.loads(message.get_body().decode("utf-8")) +# prepared_order = prepare_order_for_agent(event) +# await order_agent.run( +# json.dumps( +# { +# "order": prepared_order, +# "task": "triage fulfillment exceptions using the trusted calculated fields", +# } +# ) +# ) \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/host.json b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/host.json new file mode 100644 index 0000000..bab9278 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json new file mode 100644 index 0000000..361120f --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json @@ -0,0 +1,9 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FOUNDRY_PROJECT_ENDPOINT": "https://..services.ai.azure.com/api/projects/", + "FOUNDRY_MODEL": "gpt-5.4" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md new file mode 100644 index 0000000..2be89bb --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md @@ -0,0 +1,5 @@ +You are an order fulfillment specialist. +The supplied order has already been validated and minimized by application code. +Treat its calculated summary and review signals as trusted facts. Explain operational +risk, identify missing fulfillment context, and return a concise actionable response. +Never claim that an external action completed unless a tool result confirms it. \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order_processing.py b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order_processing.py new file mode 100644 index 0000000..5ca775a --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order_processing.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_CENT = Decimal("0.01") + + +class OrderItem(BaseModel): + model_config = ConfigDict(extra="ignore") + + sku: str + quantity: int = Field(gt=0, strict=True) + unit_price: Decimal = Field(ge=0, allow_inf_nan=False) + + @field_validator("sku") + @classmethod + def normalize_sku(cls, value: str) -> str: + normalized = value.strip().upper() + if not normalized: + raise ValueError("SKU cannot be empty") + return normalized + + +class Customer(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + loyalty_tier: Literal["standard", "silver", "gold", "platinum"] = "standard" + + @field_validator("id") + @classmethod + def normalize_id(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("Customer ID cannot be empty") + return normalized + + @field_validator("loyalty_tier", mode="before") + @classmethod + def normalize_loyalty_tier(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class Shipping(BaseModel): + model_config = ConfigDict(extra="ignore") + + country: str + method: Literal["standard", "two_day", "overnight", "same_day"] + + @field_validator("country") + @classmethod + def normalize_country(cls, value: str) -> str: + normalized = value.strip().upper() + if len(normalized) != 2 or not normalized.isalpha(): + raise ValueError("Shipping country must be a two-letter code") + return normalized + + @field_validator("method", mode="before") + @classmethod + def normalize_method(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class Order(BaseModel): + model_config = ConfigDict(extra="ignore") + + order_id: str | None = None + currency: str = "USD" + customer: Customer + shipping: Shipping + items: list[OrderItem] = Field(min_length=1) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + normalized = value.strip().upper() + if len(normalized) != 3 or not normalized.isalpha(): + raise ValueError("Currency must be a three-letter code") + return normalized + + +def _money(value: Decimal) -> str: + return f"{value.quantize(_CENT, rounding=ROUND_HALF_UP):.2f}" + + +def prepare_order_for_agent( + payload: object, + *, + order_id: str | None = None, +) -> dict[str, object]: + order = Order.model_validate(payload) + resolved_order_id = order_id or order.order_id + if not resolved_order_id: + raise ValueError("Order ID is required") + + prepared_items: list[dict[str, object]] = [] + subtotal = Decimal("0") + total_quantity = 0 + for item in order.items: + unit_price = item.unit_price.quantize(_CENT, rounding=ROUND_HALF_UP) + line_total = unit_price * item.quantity + subtotal += line_total + total_quantity += item.quantity + prepared_items.append( + { + "sku": item.sku, + "quantity": item.quantity, + "unit_price": _money(unit_price), + "line_total": _money(line_total), + } + ) + + review_signals: list[str] = [] + if subtotal >= Decimal("1000"): + review_signals.append("high_value_order") + if total_quantity >= 25: + review_signals.append("bulk_quantity") + if order.shipping.method in {"overnight", "same_day"}: + review_signals.append("expedited_shipping") + if order.shipping.country != "US": + review_signals.append("international_shipping") + + return { + "order_id": resolved_order_id, + "currency": order.currency, + "customer": { + "id": order.customer.id, + "loyalty_tier": order.customer.loyalty_tier, + }, + "shipping": { + "country": order.shipping.country, + "method": order.shipping.method, + }, + "items": prepared_items, + "summary": { + "line_items": len(prepared_items), + "total_quantity": total_quantity, + "subtotal": _money(subtotal), + }, + "review_signals": review_signals, + } diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt new file mode 100644 index 0000000..89c947a --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt @@ -0,0 +1,4 @@ +-e ../../.. +agent-framework-foundry==1.13.0 +azure-identity +pydantic \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/tests/test_apps.py b/azurefunctions-extensions-agents-framework/tests/test_apps.py new file mode 100644 index 0000000..1deff10 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/tests/test_apps.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import azure.functions as func + +from azurefunctions.extensions.agents_framework import AiApp, DurableAiApp + + +def test_typed_ai_app_pins_framework_provider(monkeypatch): + parent_init = Mock() + monkeypatch.setattr(func.AiApp, "__init__", parent_init) + factory = lambda: object() + + AiApp(client_factory=factory, app_root="app", description="orders") + + parent_init.assert_called_once_with( + http_auth_level=func.AuthLevel.FUNCTION, + provider="agent_framework", + app_root="app", + client_factory=factory, + description="orders", + require_per_service_call_history_persistence=False, + ) + + +def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(func.AiApp, "markdown_agent", parent_decorator) + app = object.__new__(AiApp) + factory = lambda: object() + + result = app.markdown_agent( + arg_name="agent", + agent_name="orders", + client_factory=factory, + tools=["lookup"], + ) + + assert result is parent_decorator.return_value + parent_decorator.assert_called_once_with( + arg_name="agent", + agent_name="orders", + app_root=None, + client_factory=factory, + tools=["lookup"], + ) + + +def test_typed_durable_ai_app_is_typed_ai_app(): + assert issubclass(DurableAiApp, AiApp) + assert issubclass(DurableAiApp, func.DurableAiApp) diff --git a/azurefunctions-extensions-agents-framework/tests/test_imports.py b/azurefunctions-extensions-agents-framework/tests/test_imports.py new file mode 100644 index 0000000..c33c614 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/tests/test_imports.py @@ -0,0 +1,21 @@ +import subprocess +import sys + + +def test_framework_import_does_not_import_durable(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "import azurefunctions.extensions.agents_framework; " + "assert 'azure.durable_functions' not in sys.modules" + ), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/azurefunctions-extensions-agents-framework/tests/test_provider.py b/azurefunctions-extensions-agents-framework/tests/test_provider.py new file mode 100644 index 0000000..ee74387 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/tests/test_provider.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import asyncio +import inspect +from types import SimpleNamespace + +import pytest +from agent_framework import Agent + +from azurefunctions.extensions.agents_base import InvocationMetadata +from azurefunctions.extensions.agents_framework import provider + + +class _Agent: + created = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.entered = False + self.closed = False + self.created.append(self) + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.closed = True + + async def run(self, prompt): + return SimpleNamespace(text=f"response:{prompt}") + + +@pytest.fixture(autouse=True) +def fake_agent(monkeypatch): + _Agent.created.clear() + monkeypatch.setattr(provider, "Agent", _Agent) + + +def _compile(**overrides): + options = {"client_factory": lambda: object(), "tools": ["lookup"]} + options.update(overrides) + return provider.AgentFrameworkProvider().compile_binding( + instructions="raw instructions", + agent_name="orders", + options=options, + annotation=Agent, + ) + + +def test_binding_creates_and_closes_fresh_agents(): + binding = _compile() + + async def invoke_twice(): + async with binding.open_agent(InvocationMetadata()) as first: + assert first.entered + async with binding.open_agent(InvocationMetadata()) as second: + assert second.entered + + asyncio.run(invoke_twice()) + + assert len(_Agent.created) == 2 + assert all(agent.closed for agent in _Agent.created) + assert _Agent.created[0].kwargs["client"] is not _Agent.created[1].kwargs["client"] + assert _Agent.created[0].kwargs == { + "client": _Agent.created[0].kwargs["client"], + "instructions": "raw instructions", + "name": "orders", + "tools": ["lookup"], + } + + +def test_binding_run_agent_returns_response_text(): + assert ( + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + == "response:hello" + ) + assert _Agent.created[0].closed + + +def test_provider_rejects_non_agent_annotation(): + with pytest.raises(TypeError, match="agent_framework.Agent"): + provider.AgentFrameworkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"client_factory": lambda: object()}, + annotation=str, + ) + + +def test_provider_accepts_missing_annotation_for_durable_activity(): + binding = provider.AgentFrameworkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"client_factory": lambda: object()}, + annotation=inspect.Signature.empty, + ) + + assert binding.agent_name == "orders" + + +def test_provider_rejects_unknown_options(): + with pytest.raises(TypeError, match="unknown"): + _compile(unknown=True) + + +def test_provider_requires_client_factory(): + with pytest.raises(TypeError, match="client_factory"): + provider.AgentFrameworkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={}, + annotation=Agent, + ) + + +def test_provider_rejects_non_callable_client_factory(): + with pytest.raises(TypeError, match="client_factory must be callable"): + _compile(client_factory="not callable") + + +def test_provider_factory_errors_propagate(): + def fail(): + raise RuntimeError("client failed") + + binding = _compile(client_factory=fail) + + with pytest.raises(RuntimeError, match="client failed"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + +def test_binding_rejects_non_string_response_text(monkeypatch): + async def run_without_text(self, prompt): + return SimpleNamespace(text=None) + + monkeypatch.setattr(_Agent, "run", run_without_text) + + with pytest.raises(TypeError, match="response.text must be a string"): + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + + assert _Agent.created[0].closed diff --git a/azurefunctions-extensions-agents-framework/tests/test_samples.py b/azurefunctions-extensions-agents-framework/tests/test_samples.py new file mode 100644 index 0000000..226b137 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/tests/test_samples.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_PACKAGE_ROOT = Path(__file__).parents[1] +_SAMPLES_ROOT = _PACKAGE_ROOT / "samples" + + +@pytest.mark.parametrize( + ("sample_name", "expected_names"), + [ + ("hybrid-function-agent", {"process_order"}), + ( + "hybrid-durable-agent", + { + "azurefunctions_agents_run_markdown_agent", + "order_orchestrator", + "prepare_order_activity", + "start_order_orchestration", + }, + ), + ], +) +def test_sample_indexes_all_functions(sample_name, expected_names): + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) + ) + completed = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json; import function_app; " + "print(json.dumps([function.get_function_name() " + "for function in function_app.app.get_functions()]))" + ), + ], + cwd=_SAMPLES_ROOT / sample_name / "src", + env=environment, + check=True, + capture_output=True, + text=True, + ) + + assert set(json.loads(completed.stdout)) == expected_names diff --git a/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml index 4425295..8502147 100644 --- a/eng/templates/jobs/build.yml +++ b/eng/templates/jobs/build.yml @@ -7,6 +7,12 @@ jobs: base_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-base' EXTENSION_NAME: 'Base' + agents_base_extension: + EXTENSION_DIRECTORY: 'azurefunctions-extensions-agents-base' + EXTENSION_NAME: 'Agents Base' + agents_framework_extension: + EXTENSION_DIRECTORY: 'azurefunctions-extensions-agents-framework' + EXTENSION_NAME: 'Agents Framework' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' EXTENSION_NAME: 'Blob' diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml index de94e58..a5c24c3 100644 --- a/eng/templates/official/jobs/build-artifacts.yml +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -7,6 +7,12 @@ jobs: base_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-base' EXTENSION_NAME: 'Base' + agents_base_extension: + EXTENSION_DIRECTORY: 'azurefunctions-extensions-agents-base' + EXTENSION_NAME: 'Agents Base' + agents_framework_extension: + EXTENSION_DIRECTORY: 'azurefunctions-extensions-agents-framework' + EXTENSION_NAME: 'Agents Framework' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' EXTENSION_NAME: 'Blob' diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index 198e94f..93778ac 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -17,6 +17,61 @@ parameters: PYTHON_VERSION: '3.14' jobs: + - job: "AgentsBaseTests" + displayName: "Agents Base Extension Tests" + dependsOn: [] + strategy: + matrix: + python313: + PYTHON_VERSION: '3.13' + python314: + PYTHON_VERSION: '3.14' + steps: + - task: PipAuthenticate@1 + displayName: 'Pip Authenticate' + inputs: + artifactFeeds: public/upstream-public + onlyAddExtraIndex: false + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + - bash: | + python -m pip install --upgrade pip + cd azurefunctions-extensions-agents-base + python -m pip install -U -e .[dev] + displayName: 'Install Agents Base Dependencies' + - bash: | + python -m pytest -q --instafail azurefunctions-extensions-agents-base/tests/ + displayName: "Run Agents Base Tests for Python $(PYTHON_VERSION)" + + - job: "AgentsFrameworkTests" + displayName: "Agents Framework Extension Tests" + dependsOn: [] + strategy: + matrix: + python313: + PYTHON_VERSION: '3.13' + python314: + PYTHON_VERSION: '3.14' + steps: + - task: PipAuthenticate@1 + displayName: 'Pip Authenticate' + inputs: + artifactFeeds: public/upstream-public + onlyAddExtraIndex: false + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + - bash: | + python -m pip install --upgrade pip + python -m pip install -e ./azurefunctions-extensions-agents-base + cd azurefunctions-extensions-agents-framework + python -m pip install -U -e .[dev] + displayName: 'Install Agents Framework Dependencies' + - bash: | + python -m pytest -q --instafail azurefunctions-extensions-agents-framework/tests/ + displayName: "Run Agents Framework Tests for Python $(PYTHON_VERSION)" + - job: "BaseTests" displayName: "Base Extension Tests" dependsOn: [] From e43d664b226f1110cf92a2c1fd4355a43d7bee50 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 2 Sep 2026 14:52:15 -0500 Subject: [PATCH 02/25] rename --- .../extensions/{agents_base => agents/base}/__init__.py | 0 .../extensions/{agents_base => agents/base}/bindings.py | 0 .../extensions/{agents_base => agents/base}/durable.py | 0 .../extensions/{agents_base => agents/base}/providers.py | 0 .../extensions/{agents_base => agents/base}/py.typed | 0 azurefunctions-extensions-agents-base/pyproject.toml | 6 +++--- .../tests/test_bindings.py | 2 +- .../tests/test_durable.py | 4 ++-- .../tests/test_imports.py | 2 +- .../tests/test_providers.py | 2 +- azurefunctions-extensions-agents-framework/README.md | 2 +- .../{agents_framework => agents/framework}/__init__.py | 0 .../{agents_framework => agents/framework}/apps.py | 2 +- .../{agents_framework => agents/framework}/provider.py | 2 +- .../{agents_framework => agents/framework}/py.typed | 0 azurefunctions-extensions-agents-framework/pyproject.toml | 8 ++++---- .../samples/hybrid-durable-agent/src/function_app.py | 2 +- .../samples/hybrid-function-agent/src/function_app.py | 2 +- .../tests/test_apps.py | 2 +- .../tests/test_imports.py | 2 +- .../tests/test_provider.py | 4 ++-- 21 files changed, 21 insertions(+), 21 deletions(-) rename azurefunctions-extensions-agents-base/azurefunctions/extensions/{agents_base => agents/base}/__init__.py (100%) rename azurefunctions-extensions-agents-base/azurefunctions/extensions/{agents_base => agents/base}/bindings.py (100%) rename azurefunctions-extensions-agents-base/azurefunctions/extensions/{agents_base => agents/base}/durable.py (100%) rename azurefunctions-extensions-agents-base/azurefunctions/extensions/{agents_base => agents/base}/providers.py (100%) rename azurefunctions-extensions-agents-base/azurefunctions/extensions/{agents_base => agents/base}/py.typed (100%) rename azurefunctions-extensions-agents-framework/azurefunctions/extensions/{agents_framework => agents/framework}/__init__.py (100%) rename azurefunctions-extensions-agents-framework/azurefunctions/extensions/{agents_framework => agents/framework}/apps.py (99%) rename azurefunctions-extensions-agents-framework/azurefunctions/extensions/{agents_framework => agents/framework}/provider.py (98%) rename azurefunctions-extensions-agents-framework/azurefunctions/extensions/{agents_framework => agents/framework}/py.typed (100%) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/__init__.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/__init__.py rename to azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/bindings.py rename to azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/durable.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/durable.py rename to azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/providers.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/providers.py rename to azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/py.typed b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/py.typed similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents_base/py.typed rename to azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/py.typed diff --git a/azurefunctions-extensions-agents-base/pyproject.toml b/azurefunctions-extensions-agents-base/pyproject.toml index ab30334..0332f4f 100644 --- a/azurefunctions-extensions-agents-base/pyproject.toml +++ b/azurefunctions-extensions-agents-base/pyproject.toml @@ -44,13 +44,13 @@ dev = [ ] [tool.setuptools.dynamic] -version = { attr = "azurefunctions.extensions.agents_base.__version__" } +version = { attr = "azurefunctions.extensions.agents.base.__version__" } [tool.setuptools.packages.find] -include = ["azurefunctions.extensions.agents_base*"] +include = ["azurefunctions.extensions.agents.base*"] [tool.setuptools.package-data] -"azurefunctions.extensions.agents_base" = ["py.typed"] +"azurefunctions.extensions.agents.base" = ["py.typed"] [[tool.mypy.overrides]] module = ["azure.durable_functions", "azure.durable_functions.*"] diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py index 1478f67..99707ad 100644 --- a/azurefunctions-extensions-agents-base/tests/test_bindings.py +++ b/azurefunctions-extensions-agents-base/tests/test_bindings.py @@ -9,7 +9,7 @@ import azure.functions as func import pytest -from azurefunctions.extensions.agents_base import bindings, providers +from azurefunctions.extensions.agents.base import bindings, providers class _CompiledAgent: diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-extensions-agents-base/tests/test_durable.py index 58da02d..7f7c9fe 100644 --- a/azurefunctions-extensions-agents-base/tests/test_durable.py +++ b/azurefunctions-extensions-agents-base/tests/test_durable.py @@ -8,8 +8,8 @@ import azure.functions as func import pytest -from azurefunctions.extensions.agents_base import bindings, durable -from azurefunctions.extensions.agents_base.durable import ( +from azurefunctions.extensions.agents.base import bindings, durable +from azurefunctions.extensions.agents.base.durable import ( DurableAgentContext, _canonicalize_json_value, _normalize_agent_prompt, diff --git a/azurefunctions-extensions-agents-base/tests/test_imports.py b/azurefunctions-extensions-agents-base/tests/test_imports.py index d1e9c63..eae226d 100644 --- a/azurefunctions-extensions-agents-base/tests/test_imports.py +++ b/azurefunctions-extensions-agents-base/tests/test_imports.py @@ -9,7 +9,7 @@ def test_base_import_does_not_import_durable(): "-c", ( "import sys; " - "import azurefunctions.extensions.agents_base; " + "import azurefunctions.extensions.agents.base; " "assert 'azure.durable_functions' not in sys.modules" ), ], diff --git a/azurefunctions-extensions-agents-base/tests/test_providers.py b/azurefunctions-extensions-agents-base/tests/test_providers.py index 141518f..c160604 100644 --- a/azurefunctions-extensions-agents-base/tests/test_providers.py +++ b/azurefunctions-extensions-agents-base/tests/test_providers.py @@ -4,7 +4,7 @@ import pytest -from azurefunctions.extensions.agents_base import providers +from azurefunctions.extensions.agents.base import providers class _Provider: diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md index cd7c6c7..6f6558b 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-extensions-agents-framework/README.md @@ -22,7 +22,7 @@ client and Agent context are created and closed for every Function invocation. ```python import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents_framework import AiApp +from azurefunctions.extensions.agents.framework import AiApp def create_chat_client(): diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/__init__.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/__init__.py rename to azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/__init__.py diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/apps.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py similarity index 99% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/apps.py rename to azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py index 8083461..4822daf 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/apps.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py @@ -13,7 +13,7 @@ ToolTypes, ) -from azurefunctions.extensions.agents_base import markdown_agent as base_markdown_agent +from azurefunctions.extensions.agents.base import markdown_agent as base_markdown_agent from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/provider.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py similarity index 98% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/provider.py rename to azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py index f26c460..d5e21d7 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/provider.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py @@ -9,7 +9,7 @@ from agent_framework import Agent, BaseChatClient -from azurefunctions.extensions.agents_base import InvocationMetadata +from azurefunctions.extensions.agents.base import InvocationMetadata AGENT_FRAMEWORK_PROVIDER_ID = "agent_framework" ClientFactory = Callable[[], BaseChatClient[Any]] diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/py.typed b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/py.typed similarity index 100% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents_framework/py.typed rename to azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/py.typed diff --git a/azurefunctions-extensions-agents-framework/pyproject.toml b/azurefunctions-extensions-agents-framework/pyproject.toml index 7c2ba5d..1faccb2 100644 --- a/azurefunctions-extensions-agents-framework/pyproject.toml +++ b/azurefunctions-extensions-agents-framework/pyproject.toml @@ -45,16 +45,16 @@ dev = [ ] [project.entry-points."azurefunctions.extensions.agents.providers"] -agent_framework = "azurefunctions.extensions.agents_framework.provider:create_provider" +agent_framework = "azurefunctions.extensions.agents.framework.provider:create_provider" [tool.setuptools.dynamic] -version = { attr = "azurefunctions.extensions.agents_framework.__version__" } +version = { attr = "azurefunctions.extensions.agents.framework.__version__" } [tool.setuptools.packages.find] -include = ["azurefunctions.extensions.agents_framework*"] +include = ["azurefunctions.extensions.agents.framework*"] [tool.setuptools.package-data] -"azurefunctions.extensions.agents_framework" = ["py.typed"] +"azurefunctions.extensions.agents.framework" = ["py.typed"] [[tool.mypy.overrides]] module = ["azure", "azure.*"] diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py index d3a52ce..baadaa0 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py @@ -5,7 +5,7 @@ import azure.durable_functions as df import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents_framework import DurableAiApp +from azurefunctions.extensions.agents.framework import DurableAiApp from order_processing import prepare_order_for_agent diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py index 74a3617..c834617 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py @@ -3,7 +3,7 @@ import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents_framework import AiApp +from azurefunctions.extensions.agents.framework import AiApp from order_processing import prepare_order_for_agent from pydantic import ValidationError diff --git a/azurefunctions-extensions-agents-framework/tests/test_apps.py b/azurefunctions-extensions-agents-framework/tests/test_apps.py index 1deff10..5ba8293 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_apps.py +++ b/azurefunctions-extensions-agents-framework/tests/test_apps.py @@ -4,7 +4,7 @@ import azure.functions as func -from azurefunctions.extensions.agents_framework import AiApp, DurableAiApp +from azurefunctions.extensions.agents.framework import AiApp, DurableAiApp def test_typed_ai_app_pins_framework_provider(monkeypatch): diff --git a/azurefunctions-extensions-agents-framework/tests/test_imports.py b/azurefunctions-extensions-agents-framework/tests/test_imports.py index c33c614..fad8d8b 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_imports.py +++ b/azurefunctions-extensions-agents-framework/tests/test_imports.py @@ -9,7 +9,7 @@ def test_framework_import_does_not_import_durable(): "-c", ( "import sys; " - "import azurefunctions.extensions.agents_framework; " + "import azurefunctions.extensions.agents.framework; " "assert 'azure.durable_functions' not in sys.modules" ), ], diff --git a/azurefunctions-extensions-agents-framework/tests/test_provider.py b/azurefunctions-extensions-agents-framework/tests/test_provider.py index ee74387..6619eb9 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_provider.py +++ b/azurefunctions-extensions-agents-framework/tests/test_provider.py @@ -7,8 +7,8 @@ import pytest from agent_framework import Agent -from azurefunctions.extensions.agents_base import InvocationMetadata -from azurefunctions.extensions.agents_framework import provider +from azurefunctions.extensions.agents.base import InvocationMetadata +from azurefunctions.extensions.agents.framework import provider class _Agent: From b08eee20cc986466a62732941344be7b1e40eee3 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 2 Sep 2026 15:01:11 -0500 Subject: [PATCH 03/25] remove top-level import --- .../extensions/agents/base/durable.py | 10 +++++++--- .../tests/test_durable.py | 8 +++----- .../tests/test_imports.py | 13 ++++++++++--- .../tests/test_imports.py | 11 +++++++++-- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py index 0888ec4..6d2c519 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py @@ -7,17 +7,17 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, Dict, List, Literal, TypeVar, Union, cast -import azure.durable_functions as df import azure.functions as func -from azure.durable_functions.models.Task import TaskBase from .bindings import _configured_state, _durable_agent from .providers import InvocationMetadata if TYPE_CHECKING: + import azure.durable_functions as df from azure.durable_functions import ( DurableOrchestrationContext as _DurableContextBase, ) + from azure.durable_functions.models.Task import TaskBase else: class _DurableContextBase: @@ -127,7 +127,9 @@ def call_agent( } if retry_options is None: return self._context.call_activity(_INTERNAL_AGENT_ACTIVITY_NAME, payload) - if not isinstance(retry_options, df.RetryOptions): + from azure.durable_functions import RetryOptions + + if not isinstance(retry_options, RetryOptions): raise TypeError("call_agent retry_options must be RetryOptions or None") return self._context.call_activity_with_retry( _INTERNAL_AGENT_ACTIVITY_NAME, @@ -137,6 +139,8 @@ def call_agent( def configure_durable_app(app: func.FunctionApp) -> None: + import azure.durable_functions as df + state = _configured_state(app) with state.lock: if state.durable_activity_registered: diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-extensions-agents-base/tests/test_durable.py index 7f7c9fe..8a75f54 100644 --- a/azurefunctions-extensions-agents-base/tests/test_durable.py +++ b/azurefunctions-extensions-agents-base/tests/test_durable.py @@ -53,13 +53,11 @@ def test_call_agent_schedules_canonical_payload(): ] -def test_call_agent_schedules_retry_with_same_canonical_payload(monkeypatch): - class RetryOptions: - pass +def test_call_agent_schedules_retry_with_same_canonical_payload(): + from azure.durable_functions import RetryOptions context = _Context() - retry_options = RetryOptions() - monkeypatch.setattr(durable.df, "RetryOptions", RetryOptions) + retry_options = RetryOptions(1000, 3) proxy = DurableAgentContext(context) task = proxy.call_agent( diff --git a/azurefunctions-extensions-agents-base/tests/test_imports.py b/azurefunctions-extensions-agents-base/tests/test_imports.py index eae226d..1029e5a 100644 --- a/azurefunctions-extensions-agents-base/tests/test_imports.py +++ b/azurefunctions-extensions-agents-base/tests/test_imports.py @@ -2,14 +2,21 @@ import sys -def test_base_import_does_not_import_durable(): +def test_durable_module_import_does_not_require_durable(): result = subprocess.run( [ sys.executable, "-c", ( - "import sys; " - "import azurefunctions.extensions.agents.base; " + "import importlib.abc\n" + "import sys\n" + "class BlockDurable(importlib.abc.MetaPathFinder):\n" + " def find_spec(self, fullname, path, target=None):\n" + " if fullname == 'azure.durable_functions' or " + "fullname.startswith('azure.durable_functions.'):\n" + " raise ModuleNotFoundError(name=fullname)\n" + "sys.meta_path.insert(0, BlockDurable())\n" + "import azurefunctions.extensions.agents.base.durable\n" "assert 'azure.durable_functions' not in sys.modules" ), ], diff --git a/azurefunctions-extensions-agents-framework/tests/test_imports.py b/azurefunctions-extensions-agents-framework/tests/test_imports.py index fad8d8b..777b83f 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_imports.py +++ b/azurefunctions-extensions-agents-framework/tests/test_imports.py @@ -8,8 +8,15 @@ def test_framework_import_does_not_import_durable(): sys.executable, "-c", ( - "import sys; " - "import azurefunctions.extensions.agents.framework; " + "import importlib.abc\n" + "import sys\n" + "class BlockDurable(importlib.abc.MetaPathFinder):\n" + " def find_spec(self, fullname, path, target=None):\n" + " if fullname == 'azure.durable_functions' or " + "fullname.startswith('azure.durable_functions.'):\n" + " raise ModuleNotFoundError(name=fullname)\n" + "sys.meta_path.insert(0, BlockDurable())\n" + "import azurefunctions.extensions.agents.framework\n" "assert 'azure.durable_functions' not in sys.modules" ), ], From 31e55c5016794b0d1c9cd897bce5cf2d750dc8aa Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 09:28:47 -0500 Subject: [PATCH 04/25] Address feedback --- .../README.md | 18 +++++------ .../extensions/agents/framework/apps.py | 2 +- .../tests/test_apps.py | 31 ++++++++++++++++++- eng/templates/official/jobs/unit-tests.yml | 2 ++ 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md index 6f6558b..ce21690 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-extensions-agents-framework/README.md @@ -26,9 +26,9 @@ from azurefunctions.extensions.agents.framework import AiApp def create_chat_client(): - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatClient - return OpenAIChatClient() + return OpenAIChatClient() app = AiApp(client_factory=create_chat_client) @@ -37,8 +37,8 @@ app = AiApp(client_factory=create_chat_client) @app.route(route="orders", methods=["POST"]) @app.markdown_agent(arg_name="agent", agent_name="orders") async def process_order(req: func.HttpRequest, agent: Agent): - response = await agent.run(req.get_body().decode()) - return response.text + response = await agent.run(req.get_body().decode()) + return response.text ``` Place the complete instructions at `orders.agent.md` or @@ -52,13 +52,13 @@ app = func.FunctionApp() @app.markdown_agent( - provider="agent_framework", - arg_name="agent", - agent_name="orders", - client_factory=create_chat_client, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + client_factory=create_chat_client, ) async def process_order(req: func.HttpRequest, agent: Agent): - ... + ... ``` Typed constructors and decorators expose the MAF Agent options supported by diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py index 4822daf..a534c61 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py @@ -75,7 +75,7 @@ def markdown_agent( default_options: Any | None = None, context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - require_per_service_call_history_persistence: bool = False, + require_per_service_call_history_persistence: bool | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: MutableMapping[str, Any] | None = None, diff --git a/azurefunctions-extensions-agents-framework/tests/test_apps.py b/azurefunctions-extensions-agents-framework/tests/test_apps.py index 5ba8293..afd3a1a 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_apps.py +++ b/azurefunctions-extensions-agents-framework/tests/test_apps.py @@ -4,7 +4,12 @@ import azure.functions as func -from azurefunctions.extensions.agents.framework import AiApp, DurableAiApp +from azurefunctions.extensions.agents.framework import ( + AiApp, + DurableAiApp, + markdown_agent, +) +from azurefunctions.extensions.agents.framework import apps def test_typed_ai_app_pins_framework_provider(monkeypatch): @@ -47,6 +52,30 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): ) +def test_typed_decorator_preserves_app_provider_defaults(monkeypatch): + base_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", base_decorator) + app = func.FunctionApp() + factory = lambda: object() + + result = markdown_agent( + app, + arg_name="agent", + agent_name="orders", + client_factory=factory, + ) + + assert result is base_decorator.return_value + base_decorator.assert_called_once_with( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=None, + client_factory=factory, + ) + + def test_typed_durable_ai_app_is_typed_ai_app(): assert issubclass(DurableAiApp, AiApp) assert issubclass(DurableAiApp, func.DurableAiApp) diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index 93778ac..be4d2ae 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -26,6 +26,7 @@ jobs: PYTHON_VERSION: '3.13' python314: PYTHON_VERSION: '3.14' + condition: always() steps: - task: PipAuthenticate@1 displayName: 'Pip Authenticate' @@ -53,6 +54,7 @@ jobs: PYTHON_VERSION: '3.13' python314: PYTHON_VERSION: '3.14' + condition: always() steps: - task: PipAuthenticate@1 displayName: 'Pip Authenticate' From 55c914607e5cbe5040739a37a5e7d15ccd1d7e11 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:32:48 -0500 Subject: [PATCH 05/25] Add validation for client_factory option Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../azurefunctions/extensions/agents/framework/provider.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py index d5e21d7..9b82c0d 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py @@ -85,6 +85,8 @@ def compile_binding( "Unsupported Microsoft Agent Framework option(s): " + ", ".join(unknown) ) client_factory = options.get("client_factory") + if client_factory is None: + raise TypeError("client_factory option is required") if not callable(client_factory): raise TypeError("client_factory must be callable") if annotation is not inspect.Signature.empty: From 9eba138a6a50a6006e8b1af6c1ad29ff622585fb Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 10:00:13 -0500 Subject: [PATCH 06/25] per agent provider --- .../README.md | 27 +++- .../extensions/agents/base/__init__.py | 3 +- .../extensions/agents/base/bindings.py | 118 +++++++++++---- .../extensions/agents/base/durable.py | 46 +++++- .../tests/test_bindings.py | 122 +++++++++++++++- .../tests/test_durable.py | 135 +++++++++++++++++- .../README.md | 35 +++++ .../extensions/agents/framework/apps.py | 32 +++-- .../tests/test_apps.py | 23 +++ 9 files changed, 482 insertions(+), 59 deletions(-) diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-extensions-agents-base/README.md index 6098dbc..de0bccc 100644 --- a/azurefunctions-extensions-agents-base/README.md +++ b/azurefunctions-extensions-agents-base/README.md @@ -19,8 +19,23 @@ returns a `CompiledAgent` recipe that creates a fresh Agent context for each invocation and can run an Agent from a Durable activity. Applications use `azure.functions.FunctionApp.markdown_agent()` or install a -typed provider package. One provider is pinned to each app instance. Provider -discovery is cached, while live Agents and clients are never cached. +typed provider package. Each Agent binding selects a provider, so providers may +coexist in one app. `AiApp` supplies a default provider; an explicit +`markdown_agent(provider=...)` overrides it for one binding. Provider discovery +is cached, while live Agents and clients are never cached. + +Provider defaults are stored independently. Configure reusable defaults for an +additional provider during startup with: + +```python +app.configure_agent_provider( + provider="langgraph", + client_factory=create_langgraph_client, +) +``` + +The first call that uses a provider freezes its defaults. Binding options +override those defaults only for that binding. All providers share one app root. ## Markdown lookup @@ -42,6 +57,10 @@ rejected. Provider packages expose Durable support through their own `[durable]` extra. The base extra installs `azure-functions-durable>=1.2.10,<2`; normal imports do not import or require Durable Functions. `DurableAgentContext.call_agent()` -schedules a hidden activity with a deterministic, JSON-only payload. All file, -client, Agent, model, and tool I/O occurs in that activity, never in the +schedules a hidden activity with a deterministic, JSON-only payload containing +the selected provider ID. It uses the app default unless +`call_agent(..., provider="langgraph")` is explicit. Additional Durable +providers must be registered with `configure_agent_provider()` during startup +so their non-serializable defaults remain outside orchestration state. All file, +client, Agent, model, and tool I/O occurs in the activity, never in the orchestrator. diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py index 8b4bf49..9bc59a3 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py @@ -1,4 +1,4 @@ -from .bindings import configure_app, markdown_agent +from .bindings import configure_agent_provider, configure_app, markdown_agent from .providers import ( AGENT_PROVIDER_ENTRY_POINT_GROUP, AgentProvider, @@ -25,6 +25,7 @@ def durable_orchestration_trigger(*args, **kwargs): "AgentProvider", "CompiledAgent", "InvocationMetadata", + "configure_agent_provider", "configure_app", "configure_durable_app", "durable_orchestration_trigger", diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py index 0b28458..6897b8c 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py @@ -20,12 +20,19 @@ @dataclass -class _AppState: +class _ProviderState: provider_id: str provider: AgentProvider - app_root: Path provider_defaults: Mapping[str, Any] + durable_configured: bool = False durable_agents: dict[str, CompiledAgent] = field(default_factory=dict) + + +@dataclass +class _AppState: + app_root: Path + default_provider_id: str | None = None + providers: dict[str, _ProviderState] = field(default_factory=dict) durable_activity_registered: bool = False lock: threading.RLock = field(default_factory=threading.RLock) @@ -48,39 +55,56 @@ def _resolve_app_root(app_root: str | os.PathLike[str] | None) -> Path: def _state_for( app: func.FunctionApp, *, - provider: str, app_root: str | os.PathLike[str] | None = None, - provider_defaults: Mapping[str, Any] | None = None, ) -> _AppState: resolved_root = _resolve_app_root(app_root) - defaults = dict(provider_defaults or {}) with _APP_STATES_LOCK: state = _APP_STATES.get(app) if state is None: state = _AppState( - provider_id=provider, - provider=load_provider(provider), app_root=resolved_root, - provider_defaults=MappingProxyType(defaults), ) _APP_STATES[app] = state return state - if state.provider_id != provider: - raise ValueError( - f"FunctionApp is already configured for Agent provider " - f"{state.provider_id!r}; it cannot also use {provider!r}" - ) if app_root is not None and state.app_root != resolved_root: raise ValueError( f"FunctionApp is already configured with app_root " f"{str(state.app_root)!r}; it cannot also use " f"{str(resolved_root)!r}" ) - if provider_defaults is not None and state.provider_defaults != defaults: + return state + + +def _provider_state_for( + state: _AppState, + *, + provider: str, + provider_defaults: Mapping[str, Any] | None = None, + configure_for_durable: bool = False, +) -> _ProviderState: + defaults = dict(provider_defaults or {}) + with state.lock: + provider_state = state.providers.get(provider) + if provider_state is None: + provider_state = _ProviderState( + provider_id=provider, + provider=load_provider(provider), + provider_defaults=MappingProxyType(defaults), + durable_configured=configure_for_durable, + ) + state.providers[provider] = provider_state + return provider_state + if ( + provider_defaults is not None + and provider_state.provider_defaults != defaults + ): raise ValueError( - "FunctionApp Agent provider defaults are already configured" + f"FunctionApp Agent provider {provider!r} defaults are " + "already configured" ) - return state + if configure_for_durable: + provider_state.durable_configured = True + return provider_state def configure_app( @@ -90,11 +114,41 @@ def configure_app( app_root: str | os.PathLike[str] | None = None, provider_options: Mapping[str, Any] | None = None, ) -> None: - _state_for( + state = _state_for( app, - provider=provider, app_root=app_root, + ) + with state.lock: + if ( + state.default_provider_id is not None + and state.default_provider_id != provider + ): + raise ValueError( + f"FunctionApp default Agent provider is already " + f"{state.default_provider_id!r}; it cannot also be {provider!r}" + ) + _provider_state_for( + state, + provider=provider, + provider_defaults=provider_options, + configure_for_durable=True, + ) + state.default_provider_id = provider + + +def configure_agent_provider( + app: func.FunctionApp, + *, + provider: str, + app_root: str | os.PathLike[str] | None = None, + provider_options: Mapping[str, Any] | None = None, +) -> None: + state = _state_for(app, app_root=app_root) + _provider_state_for( + state, + provider=provider, provider_defaults=provider_options, + configure_for_durable=True, ) @@ -106,18 +160,29 @@ def _configured_state(app: func.FunctionApp) -> _AppState: return state -def _durable_agent(app: func.FunctionApp, agent_name: str) -> CompiledAgent: +def _durable_agent( + app: func.FunctionApp, + provider_id: str, + agent_name: str, +) -> CompiledAgent: state = _configured_state(app) with state.lock: - compiled = state.durable_agents.get(agent_name) + provider_state = state.providers.get(provider_id) + if provider_state is None or not provider_state.durable_configured: + raise ValueError( + f"Agent provider {provider_id!r} is not configured for Durable " + "use; call app.configure_agent_provider(provider=...) during " + "startup" + ) + compiled = provider_state.durable_agents.get(agent_name) if compiled is None: - compiled = state.provider.compile_binding( + compiled = provider_state.provider.compile_binding( instructions=_resolve_instructions(state.app_root, agent_name), agent_name=agent_name, - options=state.provider_defaults, + options=provider_state.provider_defaults, annotation=inspect.Signature.empty, ) - state.durable_agents[agent_name] = compiled + provider_state.durable_agents[agent_name] = compiled return compiled @@ -255,7 +320,8 @@ def markdown_agent( app_root: str | os.PathLike[str] | None = None, **provider_options: Any, ) -> Callable[[_F], _F]: - state = _state_for(app, provider=provider, app_root=app_root) + state = _state_for(app, app_root=app_root) + provider_state = _provider_state_for(state, provider=provider) def decorate(handler: _F) -> _F: if not inspect.isfunction(handler): @@ -273,9 +339,9 @@ def decorate(handler: _F) -> _F: annotation = get_type_hints(handler).get(arg_name, annotation) except (NameError, TypeError): pass - options = {**state.provider_defaults, **provider_options} + options = {**provider_state.provider_defaults, **provider_options} instructions = _resolve_instructions(state.app_root, agent_name) - compiled = state.provider.compile_binding( + compiled = provider_state.provider.compile_binding( instructions=instructions, agent_name=agent_name, options=options, diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py index 6d2c519..11a6817 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py @@ -29,7 +29,7 @@ class _DurableContextBase: _F = TypeVar("_F", bound=Callable[..., Any]) _INTERNAL_AGENT_ACTIVITY_NAME = "azurefunctions_agents_run_markdown_agent" -_ACTIVITY_PAYLOAD_VERSION: Literal[1] = 1 +_ACTIVITY_PAYLOAD_VERSION: Literal[2] = 2 def _validate_json_value(value: object) -> None: @@ -66,6 +66,7 @@ def _parse_activity_input(value: object) -> dict[str, Any]: raise TypeError("Markdown Agent activity input must be a JSON object") expected_fields = { "schema_version", + "provider_id", "agent_name", "input", "durable_instance_id", @@ -75,9 +76,14 @@ def _parse_activity_input(value: object) -> dict[str, Any]: "Markdown Agent activity input must contain exactly: " + ", ".join(sorted(expected_fields)) ) - if type(value["schema_version"]) is not int or value["schema_version"] != 1: + if type(value["schema_version"]) is not int or value["schema_version"] != 2: raise ValueError( - "Unsupported Markdown Agent activity payload schema_version; expected 1" + "Unsupported Markdown Agent activity payload schema_version; expected 2" + ) + provider_id = value["provider_id"] + if not isinstance(provider_id, str) or not provider_id.strip(): + raise ValueError( + "Markdown Agent activity provider_id must be a non-empty string" ) agent_name = value["agent_name"] if not isinstance(agent_name, str) or not agent_name.strip(): @@ -90,7 +96,8 @@ def _parse_activity_input(value: object) -> dict[str, Any]: "Markdown Agent activity durable_instance_id must be a non-empty string" ) return { - "schema_version": 1, + "schema_version": 2, + "provider_id": provider_id, "agent_name": agent_name, "input": _canonicalize_json_value(value["input"]), "durable_instance_id": durable_instance_id, @@ -104,8 +111,13 @@ def _normalize_agent_prompt(value: JSONValue) -> str: class DurableAgentContext(_DurableContextBase): # type: ignore[misc] - def __init__(self, context: df.DurableOrchestrationContext) -> None: + def __init__( + self, + context: df.DurableOrchestrationContext, + default_provider_id: str, + ) -> None: self._context = context + self._default_provider_id = default_provider_id def __getattr__(self, name: str) -> Any: return getattr(self._context, name) @@ -115,12 +127,17 @@ def call_agent( agent_name: str, input_: JSONValue, *, + provider: str | None = None, retry_options: df.RetryOptions | None = None, ) -> TaskBase: if not isinstance(agent_name, str) or not agent_name.strip(): raise ValueError("call_agent agent_name must be a non-empty string") + provider_id = self._default_provider_id if provider is None else provider + if not isinstance(provider_id, str) or not provider_id.strip(): + raise ValueError("call_agent provider must be a non-empty string or None") payload = { "schema_version": _ACTIVITY_PAYLOAD_VERSION, + "provider_id": provider_id, "agent_name": agent_name, "input": _canonicalize_json_value(input_), "durable_instance_id": str(self._context.instance_id), @@ -143,6 +160,10 @@ def configure_durable_app(app: func.FunctionApp) -> None: state = _configured_state(app) with state.lock: + if state.default_provider_id is None: + raise RuntimeError( + "Durable Agent support requires a default Agent provider" + ) if state.durable_activity_registered: return blueprint = df.Blueprint() @@ -153,7 +174,11 @@ async def azurefunctions_agents_run_markdown_agent( context: func.Context, ) -> str: parsed = _parse_activity_input(payload) - compiled = _durable_agent(app, parsed["agent_name"]) + compiled = _durable_agent( + app, + parsed["provider_id"], + parsed["agent_name"], + ) invocation = InvocationMetadata( function_name=( str(context.function_name or "") or _INTERNAL_AGENT_ACTIVITY_NAME @@ -179,6 +204,10 @@ def durable_orchestration_trigger( input_type: type | None = None, ) -> Callable[[_F], Any]: configure_durable_app(app) + state = _configured_state(app) + default_provider_id = state.default_provider_id + if default_provider_id is None: + raise RuntimeError("Durable Agent support requires a default Agent provider") sdk_parameters = inspect.signature(sdk_decorator).parameters if input_type is None: decorator = sdk_decorator( @@ -223,7 +252,10 @@ def proxy_orchestrator(*args: Any, **kwargs: Any) -> Any: df.DurableOrchestrationContext, bound.arguments[context_name], ) - bound.arguments[context_name] = DurableAgentContext(context) + bound.arguments[context_name] = DurableAgentContext( + context, + default_provider_id, + ) return (yield from handler(*bound.args, **bound.kwargs)) proxy_orchestrator.__signature__ = signature # type: ignore[attr-defined] diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py index 99707ad..6b6d766 100644 --- a/azurefunctions-extensions-agents-base/tests/test_bindings.py +++ b/azurefunctions-extensions-agents-base/tests/test_bindings.py @@ -187,14 +187,14 @@ async def handler(agent: object) -> None: pass -def test_function_app_rejects_a_second_provider(tmp_path, provider): +def test_function_app_rejects_a_second_default_provider(tmp_path, provider): bindings.configure_app( func_app := func.FunctionApp(), provider="agent_framework", app_root=tmp_path, ) - with pytest.raises(ValueError, match="already configured"): + with pytest.raises(ValueError, match="default Agent provider is already"): bindings.configure_app( func_app, provider="langgraph", @@ -202,6 +202,124 @@ def test_function_app_rejects_a_second_provider(tmp_path, provider): ) +def test_function_app_supports_multiple_binding_providers(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + providers_by_id = { + "agent_framework": _Provider(), + "langgraph": _Provider(), + } + providers_by_id["langgraph"].provider_id = "langgraph" + monkeypatch.setattr( + bindings, + "load_provider", + lambda provider_id: providers_by_id[provider_id], + ) + app = func.FunctionApp() + bindings.configure_app( + app, + provider="agent_framework", + app_root=tmp_path, + provider_options={"temperature": 0.1}, + ) + bindings.configure_agent_provider( + app, + provider="langgraph", + provider_options={"recursion_limit": 10}, + ) + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def framework_handler(agent: object) -> None: + pass + + @bindings.markdown_agent( + app, + provider="langgraph", + arg_name="agent", + agent_name="orders", + recursion_limit=20, + ) + async def langgraph_handler(agent: object) -> None: + pass + + assert providers_by_id["agent_framework"].compile_args["options"] == { + "temperature": 0.1 + } + assert providers_by_id["langgraph"].compile_args["options"] == { + "recursion_limit": 20 + } + + +def test_provider_defaults_cannot_change_after_first_use(tmp_path, provider): + app = func.FunctionApp() + bindings.configure_agent_provider( + app, + provider="agent_framework", + app_root=tmp_path, + provider_options={"temperature": 0.1}, + ) + + with pytest.raises(ValueError, match="defaults are already configured"): + bindings.configure_agent_provider( + app, + provider="agent_framework", + provider_options={"temperature": 0.2}, + ) + + +def test_provider_default_callables_compare_by_identity(tmp_path, provider): + app = func.FunctionApp() + factory = lambda: object() + bindings.configure_agent_provider( + app, + provider="agent_framework", + app_root=tmp_path, + provider_options={"client_factory": factory}, + ) + bindings.configure_agent_provider( + app, + provider="agent_framework", + provider_options={"client_factory": factory}, + ) + + with pytest.raises(ValueError, match="defaults are already configured"): + bindings.configure_agent_provider( + app, + provider="agent_framework", + provider_options={"client_factory": lambda: object()}, + ) + + +def test_all_providers_share_the_first_established_app_root(tmp_path, provider): + first_root = tmp_path / "first" + second_root = tmp_path / "second" + first_root.mkdir() + second_root.mkdir() + (first_root / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=first_root, + ) + async def handler(agent: object) -> None: + pass + + with pytest.raises(ValueError, match="already configured with app_root"): + bindings.configure_agent_provider( + app, + provider="langgraph", + app_root=second_root, + ) + + def test_function_app_state_does_not_keep_app_alive(tmp_path, provider): app = func.FunctionApp() bindings.configure_app( diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-extensions-agents-base/tests/test_durable.py index 8a75f54..0fce5b0 100644 --- a/azurefunctions-extensions-agents-base/tests/test_durable.py +++ b/azurefunctions-extensions-agents-base/tests/test_durable.py @@ -34,7 +34,7 @@ def call_activity_with_retry(self, name, retry, payload): def test_call_agent_schedules_canonical_payload(): context = _Context() - proxy = DurableAgentContext(context) + proxy = DurableAgentContext(context, "agent_framework") task = proxy.call_agent("orders", {"z": 1, "a": [True, None]}) @@ -44,7 +44,8 @@ def test_call_agent_schedules_canonical_payload(): "activity", "azurefunctions_agents_run_markdown_agent", { - "schema_version": 1, + "schema_version": 2, + "provider_id": "agent_framework", "agent_name": "orders", "input": {"a": [True, None], "z": 1}, "durable_instance_id": "instance-1", @@ -58,7 +59,7 @@ def test_call_agent_schedules_retry_with_same_canonical_payload(): context = _Context() retry_options = RetryOptions(1000, 3) - proxy = DurableAgentContext(context) + proxy = DurableAgentContext(context, "agent_framework") task = proxy.call_agent( "orders", @@ -73,7 +74,8 @@ def test_call_agent_schedules_retry_with_same_canonical_payload(): "azurefunctions_agents_run_markdown_agent", retry_options, { - "schema_version": 1, + "schema_version": 2, + "provider_id": "agent_framework", "agent_name": "orders", "input": {"a": 2, "z": 1}, "durable_instance_id": "instance-1", @@ -82,17 +84,40 @@ def test_call_agent_schedules_retry_with_same_canonical_payload(): ] +def test_call_agent_schedules_explicit_provider(): + context = _Context() + proxy = DurableAgentContext(context, "agent_framework") + + proxy.call_agent("orders", "hello", provider="langgraph") + + assert context.calls[0][2]["provider_id"] == "langgraph" + + @pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) def test_call_agent_rejects_nonfinite_numbers(value): with pytest.raises(ValueError, match="NaN or infinity"): - DurableAgentContext(_Context()).call_agent("orders", value) + DurableAgentContext(_Context(), "agent_framework").call_agent("orders", value) def test_parse_activity_input_rejects_unknown_schema(): with pytest.raises(ValueError, match="schema_version"): + _parse_activity_input( + { + "schema_version": 1, + "provider_id": "agent_framework", + "agent_name": "orders", + "input": "hello", + "durable_instance_id": "instance-1", + } + ) + + +def test_parse_activity_input_rejects_blank_provider(): + with pytest.raises(ValueError, match="provider_id"): _parse_activity_input( { "schema_version": 2, + "provider_id": " ", "agent_name": "orders", "input": "hello", "durable_instance_id": "instance-1", @@ -186,7 +211,8 @@ def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypat result = asyncio.run( activity( { - "schema_version": 1, + "schema_version": 2, + "provider_id": "agent_framework", "agent_name": "orders", "input": {"z": 1, "a": 2}, "durable_instance_id": "instance-1", @@ -199,3 +225,100 @@ def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypat assert provider.compile_calls[0]["instructions"] == instructions assert provider.compiled.calls[0][0] == '{"a":2,"z":1}' assert provider.compiled.calls[0][1].durable_instance_id == "instance-1" + + +def test_hidden_activity_routes_same_agent_name_by_provider(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + framework = _Provider() + langgraph = _Provider() + langgraph.provider_id = "langgraph" + providers_by_id = { + "agent_framework": framework, + "langgraph": langgraph, + } + monkeypatch.setattr( + bindings, + "load_provider", + lambda provider_id: providers_by_id[provider_id], + ) + app = func.FunctionApp() + bindings.configure_app( + app, + provider="agent_framework", + app_root=tmp_path, + ) + bindings.configure_agent_provider(app, provider="langgraph") + durable.configure_durable_app(app) + activity = app.get_functions()[0].get_user_function() + context = SimpleNamespace(function_name="activity", invocation_id="invocation-1") + + for provider_id in providers_by_id: + asyncio.run( + activity( + { + "schema_version": 2, + "provider_id": provider_id, + "agent_name": "orders", + "input": "hello", + "durable_instance_id": "instance-1", + }, + context, + ) + ) + asyncio.run( + activity( + { + "schema_version": 2, + "provider_id": "agent_framework", + "agent_name": "orders", + "input": "again", + "durable_instance_id": "instance-1", + }, + context, + ) + ) + + assert len(framework.compile_calls) == 1 + assert len(langgraph.compile_calls) == 1 + + +def test_hidden_activity_rejects_unconfigured_provider(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app, _ = _configured_app(tmp_path, monkeypatch) + durable.configure_durable_app(app) + activity = app.get_functions()[0].get_user_function() + context = SimpleNamespace(function_name="activity", invocation_id="invocation-1") + + with pytest.raises(ValueError, match="configure_agent_provider"): + asyncio.run( + activity( + { + "schema_version": 2, + "provider_id": "langgraph", + "agent_name": "orders", + "input": "hello", + "durable_instance_id": "instance-1", + }, + context, + ) + ) + + +def test_equal_registration_enables_provider_for_durable(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + provider = _Provider() + monkeypatch.setattr(bindings, "load_provider", lambda provider_id: provider) + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=tmp_path, + ) + async def handler(agent: object) -> None: + pass + + bindings.configure_agent_provider(app, provider="agent_framework") + assert bindings._durable_agent(app, "agent_framework", "orders") is not None diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md index ce21690..2c6b31d 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-extensions-agents-framework/README.md @@ -67,6 +67,21 @@ middleware, per-service-call history persistence, compaction strategy, tokenizer, and additional properties. The extension owns the Agent client, name, and instructions. +`AiApp` makes `agent_framework` the default provider, but one app may use other +installed providers too. Select another provider on an individual binding and +pass its options directly: + +```python +@app.markdown_agent( + provider="langgraph", + arg_name="agent", + agent_name="researcher", + recursion_limit=10, +) +async def research(agent: object): + ... +``` + ## Durable Agents Durable orchestration support is optional: @@ -80,3 +95,23 @@ synchronous generator orchestrator. Agent execution is isolated in an activity so replay performs no nondeterministic work. Importing the package remains safe without Durable installed; constructing `DurableAiApp` reports the exact extra to install when it is absent. + +To call another provider from the same orchestrator, configure it during app +startup and select it on the call: + +```python +app.configure_agent_provider( + provider="langgraph", + client_factory=create_langgraph_client, +) + + +@app.orchestration_trigger(context_name="context") +def orchestrator(context): + result = yield context.call_agent( + "researcher", + context.get_input(), + provider="langgraph", + ) + return result +``` diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py index a534c61..ddea04a 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py @@ -153,6 +153,7 @@ def markdown_agent( # type: ignore[override] *, arg_name: str, agent_name: str, + provider: str | None = None, client_factory: ClientFactory | None = None, app_root: str | os.PathLike[str] | None = None, tools: ( @@ -169,25 +170,30 @@ def markdown_agent( # type: ignore[override] compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: MutableMapping[str, Any] | None = None, + **provider_options: Any, ) -> Callable[[_F], _F]: return super().markdown_agent( + provider=provider, arg_name=arg_name, agent_name=agent_name, app_root=app_root, - **_provider_options( - client_factory=client_factory, - tools=tools, - description=description, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - require_per_service_call_history_persistence=( - require_per_service_call_history_persistence + **{ + **_provider_options( + client_factory=client_factory, + tools=tools, + description=description, + default_options=default_options, + context_providers=context_providers, + middleware=middleware, + require_per_service_call_history_persistence=( + require_per_service_call_history_persistence + ), + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ), - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - additional_properties=additional_properties, - ), + **provider_options, + }, ) diff --git a/azurefunctions-extensions-agents-framework/tests/test_apps.py b/azurefunctions-extensions-agents-framework/tests/test_apps.py index afd3a1a..8e4d0a4 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_apps.py +++ b/azurefunctions-extensions-agents-framework/tests/test_apps.py @@ -44,6 +44,7 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): assert result is parent_decorator.return_value parent_decorator.assert_called_once_with( + provider=None, arg_name="agent", agent_name="orders", app_root=None, @@ -52,6 +53,28 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): ) +def test_typed_ai_app_can_select_another_provider(monkeypatch): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(func.AiApp, "markdown_agent", parent_decorator) + app = object.__new__(AiApp) + + result = app.markdown_agent( + provider="langgraph", + arg_name="agent", + agent_name="researcher", + recursion_limit=10, + ) + + assert result is parent_decorator.return_value + parent_decorator.assert_called_once_with( + provider="langgraph", + arg_name="agent", + agent_name="researcher", + app_root=None, + recursion_limit=10, + ) + + def test_typed_decorator_preserves_app_provider_defaults(monkeypatch): base_decorator = Mock(return_value=object()) monkeypatch.setattr(apps, "base_markdown_agent", base_decorator) From 10932d09931586c223894d2213731f33ba2f1891 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 10:27:15 -0500 Subject: [PATCH 07/25] v1 for durable --- .../README.md | 24 +--- .../extensions/agents/base/__init__.py | 3 +- .../extensions/agents/base/bindings.py | 39 ++---- .../extensions/agents/base/durable.py | 37 +---- .../tests/test_bindings.py | 49 +------ .../tests/test_durable.py | 132 ++---------------- .../README.md | 21 +-- 7 files changed, 40 insertions(+), 265 deletions(-) diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-extensions-agents-base/README.md index de0bccc..6d5d6f4 100644 --- a/azurefunctions-extensions-agents-base/README.md +++ b/azurefunctions-extensions-agents-base/README.md @@ -24,18 +24,8 @@ coexist in one app. `AiApp` supplies a default provider; an explicit `markdown_agent(provider=...)` overrides it for one binding. Provider discovery is cached, while live Agents and clients are never cached. -Provider defaults are stored independently. Configure reusable defaults for an -additional provider during startup with: - -```python -app.configure_agent_provider( - provider="langgraph", - client_factory=create_langgraph_client, -) -``` - -The first call that uses a provider freezes its defaults. Binding options -override those defaults only for that binding. All providers share one app root. +Provider defaults are stored independently. Binding options override defaults +only for that binding. All providers share one app root. ## Markdown lookup @@ -57,10 +47,6 @@ rejected. Provider packages expose Durable support through their own `[durable]` extra. The base extra installs `azure-functions-durable>=1.2.10,<2`; normal imports do not import or require Durable Functions. `DurableAgentContext.call_agent()` -schedules a hidden activity with a deterministic, JSON-only payload containing -the selected provider ID. It uses the app default unless -`call_agent(..., provider="langgraph")` is explicit. Additional Durable -providers must be registered with `configure_agent_provider()` during startup -so their non-serializable defaults remain outside orchestration state. All file, -client, Agent, model, and tool I/O occurs in the activity, never in the -orchestrator. +schedules a hidden activity with a deterministic, JSON-only payload and always +uses the `DurableAiApp` default provider. All file, client, Agent, model, and +tool I/O occurs in the activity, never in the orchestrator. diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py index 9bc59a3..8b4bf49 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py @@ -1,4 +1,4 @@ -from .bindings import configure_agent_provider, configure_app, markdown_agent +from .bindings import configure_app, markdown_agent from .providers import ( AGENT_PROVIDER_ENTRY_POINT_GROUP, AgentProvider, @@ -25,7 +25,6 @@ def durable_orchestration_trigger(*args, **kwargs): "AgentProvider", "CompiledAgent", "InvocationMetadata", - "configure_agent_provider", "configure_app", "configure_durable_app", "durable_orchestration_trigger", diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py index 6897b8c..dfd6bf0 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py @@ -21,10 +21,8 @@ @dataclass class _ProviderState: - provider_id: str provider: AgentProvider provider_defaults: Mapping[str, Any] - durable_configured: bool = False durable_agents: dict[str, CompiledAgent] = field(default_factory=dict) @@ -80,17 +78,14 @@ def _provider_state_for( *, provider: str, provider_defaults: Mapping[str, Any] | None = None, - configure_for_durable: bool = False, ) -> _ProviderState: defaults = dict(provider_defaults or {}) with state.lock: provider_state = state.providers.get(provider) if provider_state is None: provider_state = _ProviderState( - provider_id=provider, provider=load_provider(provider), provider_defaults=MappingProxyType(defaults), - durable_configured=configure_for_durable, ) state.providers[provider] = provider_state return provider_state @@ -102,8 +97,6 @@ def _provider_state_for( f"FunctionApp Agent provider {provider!r} defaults are " "already configured" ) - if configure_for_durable: - provider_state.durable_configured = True return provider_state @@ -131,27 +124,10 @@ def configure_app( state, provider=provider, provider_defaults=provider_options, - configure_for_durable=True, ) state.default_provider_id = provider -def configure_agent_provider( - app: func.FunctionApp, - *, - provider: str, - app_root: str | os.PathLike[str] | None = None, - provider_options: Mapping[str, Any] | None = None, -) -> None: - state = _state_for(app, app_root=app_root) - _provider_state_for( - state, - provider=provider, - provider_defaults=provider_options, - configure_for_durable=True, - ) - - def _configured_state(app: func.FunctionApp) -> _AppState: with _APP_STATES_LOCK: state = _APP_STATES.get(app) @@ -162,17 +138,18 @@ def _configured_state(app: func.FunctionApp) -> _AppState: def _durable_agent( app: func.FunctionApp, - provider_id: str, agent_name: str, ) -> CompiledAgent: state = _configured_state(app) with state.lock: - provider_state = state.providers.get(provider_id) - if provider_state is None or not provider_state.durable_configured: - raise ValueError( - f"Agent provider {provider_id!r} is not configured for Durable " - "use; call app.configure_agent_provider(provider=...) during " - "startup" + if state.default_provider_id is None: + raise RuntimeError( + "Durable Agent support requires a default Agent provider" + ) + provider_state = state.providers.get(state.default_provider_id) + if provider_state is None: + raise RuntimeError( + "Durable Agent support requires a default Agent provider" ) compiled = provider_state.durable_agents.get(agent_name) if compiled is None: diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py index 11a6817..51f2581 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py @@ -29,7 +29,7 @@ class _DurableContextBase: _F = TypeVar("_F", bound=Callable[..., Any]) _INTERNAL_AGENT_ACTIVITY_NAME = "azurefunctions_agents_run_markdown_agent" -_ACTIVITY_PAYLOAD_VERSION: Literal[2] = 2 +_ACTIVITY_PAYLOAD_VERSION: Literal[1] = 1 def _validate_json_value(value: object) -> None: @@ -66,7 +66,6 @@ def _parse_activity_input(value: object) -> dict[str, Any]: raise TypeError("Markdown Agent activity input must be a JSON object") expected_fields = { "schema_version", - "provider_id", "agent_name", "input", "durable_instance_id", @@ -76,14 +75,9 @@ def _parse_activity_input(value: object) -> dict[str, Any]: "Markdown Agent activity input must contain exactly: " + ", ".join(sorted(expected_fields)) ) - if type(value["schema_version"]) is not int or value["schema_version"] != 2: + if type(value["schema_version"]) is not int or value["schema_version"] != 1: raise ValueError( - "Unsupported Markdown Agent activity payload schema_version; expected 2" - ) - provider_id = value["provider_id"] - if not isinstance(provider_id, str) or not provider_id.strip(): - raise ValueError( - "Markdown Agent activity provider_id must be a non-empty string" + "Unsupported Markdown Agent activity payload schema_version; expected 1" ) agent_name = value["agent_name"] if not isinstance(agent_name, str) or not agent_name.strip(): @@ -96,8 +90,7 @@ def _parse_activity_input(value: object) -> dict[str, Any]: "Markdown Agent activity durable_instance_id must be a non-empty string" ) return { - "schema_version": 2, - "provider_id": provider_id, + "schema_version": 1, "agent_name": agent_name, "input": _canonicalize_json_value(value["input"]), "durable_instance_id": durable_instance_id, @@ -111,13 +104,8 @@ def _normalize_agent_prompt(value: JSONValue) -> str: class DurableAgentContext(_DurableContextBase): # type: ignore[misc] - def __init__( - self, - context: df.DurableOrchestrationContext, - default_provider_id: str, - ) -> None: + def __init__(self, context: df.DurableOrchestrationContext) -> None: self._context = context - self._default_provider_id = default_provider_id def __getattr__(self, name: str) -> Any: return getattr(self._context, name) @@ -127,17 +115,12 @@ def call_agent( agent_name: str, input_: JSONValue, *, - provider: str | None = None, retry_options: df.RetryOptions | None = None, ) -> TaskBase: if not isinstance(agent_name, str) or not agent_name.strip(): raise ValueError("call_agent agent_name must be a non-empty string") - provider_id = self._default_provider_id if provider is None else provider - if not isinstance(provider_id, str) or not provider_id.strip(): - raise ValueError("call_agent provider must be a non-empty string or None") payload = { "schema_version": _ACTIVITY_PAYLOAD_VERSION, - "provider_id": provider_id, "agent_name": agent_name, "input": _canonicalize_json_value(input_), "durable_instance_id": str(self._context.instance_id), @@ -176,7 +159,6 @@ async def azurefunctions_agents_run_markdown_agent( parsed = _parse_activity_input(payload) compiled = _durable_agent( app, - parsed["provider_id"], parsed["agent_name"], ) invocation = InvocationMetadata( @@ -204,10 +186,6 @@ def durable_orchestration_trigger( input_type: type | None = None, ) -> Callable[[_F], Any]: configure_durable_app(app) - state = _configured_state(app) - default_provider_id = state.default_provider_id - if default_provider_id is None: - raise RuntimeError("Durable Agent support requires a default Agent provider") sdk_parameters = inspect.signature(sdk_decorator).parameters if input_type is None: decorator = sdk_decorator( @@ -252,10 +230,7 @@ def proxy_orchestrator(*args: Any, **kwargs: Any) -> Any: df.DurableOrchestrationContext, bound.arguments[context_name], ) - bound.arguments[context_name] = DurableAgentContext( - context, - default_provider_id, - ) + bound.arguments[context_name] = DurableAgentContext(context) return (yield from handler(*bound.args, **bound.kwargs)) proxy_orchestrator.__signature__ = signature # type: ignore[attr-defined] diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py index 6b6d766..822c90a 100644 --- a/azurefunctions-extensions-agents-base/tests/test_bindings.py +++ b/azurefunctions-extensions-agents-base/tests/test_bindings.py @@ -221,11 +221,6 @@ def test_function_app_supports_multiple_binding_providers(tmp_path, monkeypatch) app_root=tmp_path, provider_options={"temperature": 0.1}, ) - bindings.configure_agent_provider( - app, - provider="langgraph", - provider_options={"recursion_limit": 10}, - ) @bindings.markdown_agent( app, @@ -254,46 +249,6 @@ async def langgraph_handler(agent: object) -> None: } -def test_provider_defaults_cannot_change_after_first_use(tmp_path, provider): - app = func.FunctionApp() - bindings.configure_agent_provider( - app, - provider="agent_framework", - app_root=tmp_path, - provider_options={"temperature": 0.1}, - ) - - with pytest.raises(ValueError, match="defaults are already configured"): - bindings.configure_agent_provider( - app, - provider="agent_framework", - provider_options={"temperature": 0.2}, - ) - - -def test_provider_default_callables_compare_by_identity(tmp_path, provider): - app = func.FunctionApp() - factory = lambda: object() - bindings.configure_agent_provider( - app, - provider="agent_framework", - app_root=tmp_path, - provider_options={"client_factory": factory}, - ) - bindings.configure_agent_provider( - app, - provider="agent_framework", - provider_options={"client_factory": factory}, - ) - - with pytest.raises(ValueError, match="defaults are already configured"): - bindings.configure_agent_provider( - app, - provider="agent_framework", - provider_options={"client_factory": lambda: object()}, - ) - - def test_all_providers_share_the_first_established_app_root(tmp_path, provider): first_root = tmp_path / "first" second_root = tmp_path / "second" @@ -313,9 +268,11 @@ async def handler(agent: object) -> None: pass with pytest.raises(ValueError, match="already configured with app_root"): - bindings.configure_agent_provider( + bindings.markdown_agent( app, provider="langgraph", + arg_name="agent", + agent_name="orders", app_root=second_root, ) diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-extensions-agents-base/tests/test_durable.py index 0fce5b0..10c4653 100644 --- a/azurefunctions-extensions-agents-base/tests/test_durable.py +++ b/azurefunctions-extensions-agents-base/tests/test_durable.py @@ -34,7 +34,7 @@ def call_activity_with_retry(self, name, retry, payload): def test_call_agent_schedules_canonical_payload(): context = _Context() - proxy = DurableAgentContext(context, "agent_framework") + proxy = DurableAgentContext(context) task = proxy.call_agent("orders", {"z": 1, "a": [True, None]}) @@ -44,8 +44,7 @@ def test_call_agent_schedules_canonical_payload(): "activity", "azurefunctions_agents_run_markdown_agent", { - "schema_version": 2, - "provider_id": "agent_framework", + "schema_version": 1, "agent_name": "orders", "input": {"a": [True, None], "z": 1}, "durable_instance_id": "instance-1", @@ -59,7 +58,7 @@ def test_call_agent_schedules_retry_with_same_canonical_payload(): context = _Context() retry_options = RetryOptions(1000, 3) - proxy = DurableAgentContext(context, "agent_framework") + proxy = DurableAgentContext(context) task = proxy.call_agent( "orders", @@ -74,8 +73,7 @@ def test_call_agent_schedules_retry_with_same_canonical_payload(): "azurefunctions_agents_run_markdown_agent", retry_options, { - "schema_version": 2, - "provider_id": "agent_framework", + "schema_version": 1, "agent_name": "orders", "input": {"a": 2, "z": 1}, "durable_instance_id": "instance-1", @@ -84,40 +82,26 @@ def test_call_agent_schedules_retry_with_same_canonical_payload(): ] -def test_call_agent_schedules_explicit_provider(): - context = _Context() - proxy = DurableAgentContext(context, "agent_framework") - - proxy.call_agent("orders", "hello", provider="langgraph") - - assert context.calls[0][2]["provider_id"] == "langgraph" +def test_call_agent_does_not_accept_provider_override(): + with pytest.raises(TypeError, match="provider"): + DurableAgentContext(_Context()).call_agent( + "orders", + "hello", + provider="langgraph", # type: ignore[call-arg] + ) @pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) def test_call_agent_rejects_nonfinite_numbers(value): with pytest.raises(ValueError, match="NaN or infinity"): - DurableAgentContext(_Context(), "agent_framework").call_agent("orders", value) + DurableAgentContext(_Context()).call_agent("orders", value) def test_parse_activity_input_rejects_unknown_schema(): with pytest.raises(ValueError, match="schema_version"): - _parse_activity_input( - { - "schema_version": 1, - "provider_id": "agent_framework", - "agent_name": "orders", - "input": "hello", - "durable_instance_id": "instance-1", - } - ) - - -def test_parse_activity_input_rejects_blank_provider(): - with pytest.raises(ValueError, match="provider_id"): _parse_activity_input( { "schema_version": 2, - "provider_id": " ", "agent_name": "orders", "input": "hello", "durable_instance_id": "instance-1", @@ -211,8 +195,7 @@ def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypat result = asyncio.run( activity( { - "schema_version": 2, - "provider_id": "agent_framework", + "schema_version": 1, "agent_name": "orders", "input": {"z": 1, "a": 2}, "durable_instance_id": "instance-1", @@ -225,51 +208,10 @@ def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypat assert provider.compile_calls[0]["instructions"] == instructions assert provider.compiled.calls[0][0] == '{"a":2,"z":1}' assert provider.compiled.calls[0][1].durable_instance_id == "instance-1" - - -def test_hidden_activity_routes_same_agent_name_by_provider(tmp_path, monkeypatch): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - framework = _Provider() - langgraph = _Provider() - langgraph.provider_id = "langgraph" - providers_by_id = { - "agent_framework": framework, - "langgraph": langgraph, - } - monkeypatch.setattr( - bindings, - "load_provider", - lambda provider_id: providers_by_id[provider_id], - ) - app = func.FunctionApp() - bindings.configure_app( - app, - provider="agent_framework", - app_root=tmp_path, - ) - bindings.configure_agent_provider(app, provider="langgraph") - durable.configure_durable_app(app) - activity = app.get_functions()[0].get_user_function() - context = SimpleNamespace(function_name="activity", invocation_id="invocation-1") - - for provider_id in providers_by_id: - asyncio.run( - activity( - { - "schema_version": 2, - "provider_id": provider_id, - "agent_name": "orders", - "input": "hello", - "durable_instance_id": "instance-1", - }, - context, - ) - ) asyncio.run( activity( { - "schema_version": 2, - "provider_id": "agent_framework", + "schema_version": 1, "agent_name": "orders", "input": "again", "durable_instance_id": "instance-1", @@ -277,48 +219,4 @@ def test_hidden_activity_routes_same_agent_name_by_provider(tmp_path, monkeypatc context, ) ) - - assert len(framework.compile_calls) == 1 - assert len(langgraph.compile_calls) == 1 - - -def test_hidden_activity_rejects_unconfigured_provider(tmp_path, monkeypatch): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - app, _ = _configured_app(tmp_path, monkeypatch) - durable.configure_durable_app(app) - activity = app.get_functions()[0].get_user_function() - context = SimpleNamespace(function_name="activity", invocation_id="invocation-1") - - with pytest.raises(ValueError, match="configure_agent_provider"): - asyncio.run( - activity( - { - "schema_version": 2, - "provider_id": "langgraph", - "agent_name": "orders", - "input": "hello", - "durable_instance_id": "instance-1", - }, - context, - ) - ) - - -def test_equal_registration_enables_provider_for_durable(tmp_path, monkeypatch): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - provider = _Provider() - monkeypatch.setattr(bindings, "load_provider", lambda provider_id: provider) - app = func.FunctionApp() - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - app_root=tmp_path, - ) - async def handler(agent: object) -> None: - pass - - bindings.configure_agent_provider(app, provider="agent_framework") - assert bindings._durable_agent(app, "agent_framework", "orders") is not None + assert len(provider.compile_calls) == 1 diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md index 2c6b31d..a9fce34 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-extensions-agents-framework/README.md @@ -96,22 +96,5 @@ so replay performs no nondeterministic work. Importing the package remains safe without Durable installed; constructing `DurableAiApp` reports the exact extra to install when it is absent. -To call another provider from the same orchestrator, configure it during app -startup and select it on the call: - -```python -app.configure_agent_provider( - provider="langgraph", - client_factory=create_langgraph_client, -) - - -@app.orchestration_trigger(context_name="context") -def orchestrator(context): - result = yield context.call_agent( - "researcher", - context.get_input(), - provider="langgraph", - ) - return result -``` +All `call_agent()` invocations use the provider configured by `DurableAiApp`. +V1 does not support selecting another provider from an orchestrator. From 336476c75b24950763e7ef91e8b63bfdde63583d Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:48:50 -0500 Subject: [PATCH 08/25] Update file reading to use context manager Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../azurefunctions/extensions/agents/base/bindings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py index dfd6bf0..faf4ad7 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py @@ -215,7 +215,8 @@ def _resolve_instructions(app_root: Path, agent_name: str) -> str: raise ValueError( f"Agent file {str(source)!r} resolves outside app root {str(app_root)!r}" ) - return source.read_text(encoding="utf-8") + with source.open("r", encoding="utf-8", newline="") as handle: + return handle.read() def _worker_signature(handler: Callable[..., Any], arg_name: str) -> inspect.Signature: From 13ff50266e964d4ab6cc81fa47f11c364f9a98dd Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 10:49:04 -0500 Subject: [PATCH 09/25] feedback --- .../extensions/agents/framework/provider.py | 2 ++ .../tests/test_provider.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py index 9b82c0d..1d81834 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py @@ -89,6 +89,8 @@ def compile_binding( raise TypeError("client_factory option is required") if not callable(client_factory): raise TypeError("client_factory must be callable") + if inspect.iscoroutinefunction(client_factory): + raise TypeError("client_factory must be a synchronous function") if annotation is not inspect.Signature.empty: annotation_origin = get_origin(annotation) if ( diff --git a/azurefunctions-extensions-agents-framework/tests/test_provider.py b/azurefunctions-extensions-agents-framework/tests/test_provider.py index 6619eb9..6354009 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_provider.py +++ b/azurefunctions-extensions-agents-framework/tests/test_provider.py @@ -119,6 +119,16 @@ def test_provider_rejects_non_callable_client_factory(): _compile(client_factory="not callable") +def test_provider_rejects_async_client_factory(): + async def create_client(): + return object() + + with pytest.raises( + TypeError, match="client_factory must be a synchronous function" + ): + _compile(client_factory=create_client) + + def test_provider_factory_errors_propagate(): def fail(): raise RuntimeError("client failed") From 8912f03066487eb53db875c9dbf3ac5cfb254f62 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 13:09:19 -0500 Subject: [PATCH 10/25] clean --- .../README.md | 32 ++++++++++++++- .../extensions/agents/framework/apps.py | 40 ++++++++++--------- .../hybrid-function-agent/src/function_app.py | 40 +++++++++---------- .../tests/test_apps.py | 26 +++++++++++- 4 files changed, 98 insertions(+), 40 deletions(-) diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md index a9fce34..04acc95 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-extensions-agents-framework/README.md @@ -41,6 +41,34 @@ async def process_order(req: func.HttpRequest, agent: Agent): return response.text ``` +Provider IDs are the entry-point names published by provider packages. Each +provider package documents its ID; this package exports +`AGENT_FRAMEWORK_PROVIDER_ID` for code that needs to select it explicitly. A +closed SDK enum is not used because third-party packages may add provider IDs +without an Azure Functions SDK release. + +The standalone typed decorator also defaults to the Agent Framework provider: + +```python +from azurefunctions.extensions.agents.framework import markdown_agent + +app = func.FunctionApp() + + +@markdown_agent( + app, + arg_name="agent", + agent_name="orders", + client_factory=create_chat_client, +) +async def process_order(req: func.HttpRequest, agent: Agent): + ... +``` + +Its optional `provider` parameter can select another installed provider for one +binding. Pass that provider's options as keyword arguments; provider-specific +packages remain the source of truth for their IDs and supported options. + Place the complete instructions at `orders.agent.md` or `agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime configuration is interpreted. @@ -48,11 +76,13 @@ configuration is interpreted. The generic core form is also supported: ```python +from azurefunctions.extensions.agents.framework import AGENT_FRAMEWORK_PROVIDER_ID + app = func.FunctionApp() @app.markdown_agent( - provider="agent_framework", + provider=AGENT_FRAMEWORK_PROVIDER_ID, arg_name="agent", agent_name="orders", client_factory=create_chat_client, diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py index ddea04a..608bbda 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py @@ -66,7 +66,8 @@ def markdown_agent( *, arg_name: str, agent_name: str, - client_factory: ClientFactory, + provider: str = AGENT_FRAMEWORK_PROVIDER_ID, + client_factory: ClientFactory | None = None, app_root: str | os.PathLike[str] | None = None, tools: ( ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None @@ -79,28 +80,31 @@ def markdown_agent( compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: MutableMapping[str, Any] | None = None, + **provider_options: Any, ) -> Callable[[_F], _F]: - options = _provider_options( - client_factory=client_factory, - tools=tools, - description=description, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - require_per_service_call_history_persistence=( - require_per_service_call_history_persistence - ), - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - additional_properties=additional_properties, - ) return base_markdown_agent( app, - provider=AGENT_FRAMEWORK_PROVIDER_ID, + provider=provider, arg_name=arg_name, agent_name=agent_name, app_root=app_root, - **options, + **{ + **_provider_options( + client_factory=client_factory, + tools=tools, + description=description, + default_options=default_options, + context_providers=context_providers, + middleware=middleware, + require_per_service_call_history_persistence=( + require_per_service_call_history_persistence + ), + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ), + **provider_options, + }, ) @@ -153,7 +157,7 @@ def markdown_agent( # type: ignore[override] *, arg_name: str, agent_name: str, - provider: str | None = None, + provider: str = AGENT_FRAMEWORK_PROVIDER_ID, client_factory: ClientFactory | None = None, app_root: str | os.PathLike[str] | None = None, tools: ( diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py index c834617..74f99c4 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py @@ -53,23 +53,23 @@ async def process_order( ) -# @app.queue_trigger( -# arg_name="message", -# queue_name="orders", -# connection="AzureWebJobsStorage", -# ) -# @app.markdown_agent(arg_name="order_agent", agent_name="order-fulfillment") -# async def process_order_event( -# message: func.QueueMessage, -# order_agent: Agent, -# ) -> None: -# event = json.loads(message.get_body().decode("utf-8")) -# prepared_order = prepare_order_for_agent(event) -# await order_agent.run( -# json.dumps( -# { -# "order": prepared_order, -# "task": "triage fulfillment exceptions using the trusted calculated fields", -# } -# ) -# ) \ No newline at end of file +@app.queue_trigger( + arg_name="message", + queue_name="orders", + connection="AzureWebJobsStorage", +) +@app.markdown_agent(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order_event( + message: func.QueueMessage, + order_agent: Agent, +) -> None: + event = json.loads(message.get_body().decode("utf-8")) + prepared_order = prepare_order_for_agent(event) + await order_agent.run( + json.dumps( + { + "order": prepared_order, + "task": "triage fulfillment exceptions using the trusted calculated fields", + } + ) + ) \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/tests/test_apps.py b/azurefunctions-extensions-agents-framework/tests/test_apps.py index 8e4d0a4..10c7b8b 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_apps.py +++ b/azurefunctions-extensions-agents-framework/tests/test_apps.py @@ -44,7 +44,7 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): assert result is parent_decorator.return_value parent_decorator.assert_called_once_with( - provider=None, + provider="agent_framework", arg_name="agent", agent_name="orders", app_root=None, @@ -99,6 +99,30 @@ def test_typed_decorator_preserves_app_provider_defaults(monkeypatch): ) +def test_typed_decorator_can_select_another_provider(monkeypatch): + base_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", base_decorator) + app = func.FunctionApp() + + result = markdown_agent( + app, + provider="langgraph", + arg_name="agent", + agent_name="researcher", + recursion_limit=10, + ) + + assert result is base_decorator.return_value + base_decorator.assert_called_once_with( + app, + provider="langgraph", + arg_name="agent", + agent_name="researcher", + app_root=None, + recursion_limit=10, + ) + + def test_typed_durable_ai_app_is_typed_ai_app(): assert issubclass(DurableAiApp, AiApp) assert issubclass(DurableAiApp, func.DurableAiApp) From a9a051c60046f17f54fced453bff30b6be4ac9e1 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 14:40:32 -0500 Subject: [PATCH 11/25] skill + mcp support --- .../README.md | 32 ++- .../extensions/agents/base/__init__.py | 18 +- .../extensions/agents/base/bindings.py | 30 +++ .../extensions/agents/base/capabilities.py | 35 ++++ .../agents/base/discovery/__init__.py | 21 ++ .../extensions/agents/base/discovery/mcp.py | 149 ++++++++++++++ .../agents/base/discovery/skills.py | 45 +++++ .../extensions/agents/base/durable.py | 4 +- .../extensions/agents/base/providers.py | 11 + .../tests/test_bindings.py | 71 ++++++- .../tests/test_capability_discovery.py | 76 +++++++ .../tests/test_durable.py | 34 +++- .../tests/test_providers.py | 1 + .../README.md | 88 +++++++- .../extensions/agents/framework/provider.py | 188 +++++++++++++++++- .../pyproject.toml | 5 + .../samples/README.md | 3 +- .../samples/hybrid-function-agent/README.md | 8 +- .../src/local.settings.template.json | 3 +- .../hybrid-function-agent/src/mcp.json | 9 + .../src/requirements.txt | 2 +- .../src/skills/order-policy/SKILL.md | 7 + .../tests/test_provider.py | 161 ++++++++++++++- .../tests/test_samples.py | 5 +- 24 files changed, 979 insertions(+), 27 deletions(-) create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/capabilities.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/__init__.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/mcp.py create mode 100644 azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/skills.py create mode 100644 azurefunctions-extensions-agents-base/tests/test_capability_discovery.py create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/mcp.json create mode 100644 azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-extensions-agents-base/README.md index 6d5d6f4..f26e710 100644 --- a/azurefunctions-extensions-agents-base/README.md +++ b/azurefunctions-extensions-agents-base/README.md @@ -14,9 +14,11 @@ name is the provider ID. The factory returns an `AgentProvider` with a matching `provider_id`, its distribution name, and a `compile_binding()` implementation. `compile_binding()` receives the complete markdown instructions, logical Agent -name, immutable provider options, and the injected parameter annotation. It -returns a `CompiledAgent` recipe that creates a fresh Agent context for each -invocation and can run an Agent from a Durable activity. +name, immutable provider options, injected parameter annotation, and an +`AgentCapabilities` bundle. Providers declare `supported_capabilities` and +translate neutral Skill/MCP definitions into their own runtime objects. The +compiled recipe creates a fresh Agent context for each invocation and can run +an Agent from a Durable activity. Applications use `azure.functions.FunctionApp.markdown_agent()` or install a typed provider package. Each Agent binding selects a provider, so providers may @@ -42,6 +44,30 @@ this package. If both locations exist, lookup fails as ambiguous. Absolute paths, separators, traversal components, and symlinks outside `app_root` are rejected. +## Skills and MCP discovery + +The base package discovers immutable definitions from the shared app root: + +```text +skills//SKILL.md +mcp.json +``` + +Base discovery records safely contained directories that contain `SKILL.md` +without reading or interpreting those files. Each provider owns Skill format +parsing and validation. MCP servers must use `http` or `streamable-http`; local +commands and stdio are rejected. Discovery does not execute scripts, resolve +environment references, create credentials, or connect to servers. + +Every Agent binding receives all valid Skills and MCP servers discovered from +the app root. V1 has no app-level or per-binding capability selectors. Treat +placing a definition under the app root as granting every Agent in that app +access to it; use separate Function Apps when capabilities require isolation. + +Only immutable definitions are retained in app state. Provider packages must +create and close clients, credentials, tools, and other live resources within +each invocation. + ## Durable support Provider packages expose Durable support through their own `[durable]` extra. diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py index 8b4bf49..afb6e5c 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py @@ -1,4 +1,13 @@ +from typing import Any + from .bindings import configure_app, markdown_agent +from .capabilities import ( + AgentCapabilities, + MCPAuthConfig, + MCPHTTPConfig, + MCPServerDefinition, + SkillDefinition, +) from .providers import ( AGENT_PROVIDER_ENTRY_POINT_GROUP, AgentProvider, @@ -8,13 +17,13 @@ ) -def configure_durable_app(*args, **kwargs): +def configure_durable_app(*args: Any, **kwargs: Any) -> Any: from .durable import configure_durable_app as configure return configure(*args, **kwargs) -def durable_orchestration_trigger(*args, **kwargs): +def durable_orchestration_trigger(*args: Any, **kwargs: Any) -> Any: from .durable import durable_orchestration_trigger as decorate return decorate(*args, **kwargs) @@ -22,9 +31,14 @@ def durable_orchestration_trigger(*args, **kwargs): __all__ = [ "AGENT_PROVIDER_ENTRY_POINT_GROUP", + "AgentCapabilities", "AgentProvider", "CompiledAgent", "InvocationMetadata", + "MCPAuthConfig", + "MCPHTTPConfig", + "MCPServerDefinition", + "SkillDefinition", "configure_app", "configure_durable_app", "durable_orchestration_trigger", diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py index faf4ad7..66c79a7 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py @@ -13,6 +13,8 @@ import azure.functions as func +from .capabilities import AgentCapabilities +from .discovery import discover_capabilities from .providers import AgentProvider, CompiledAgent, InvocationMetadata, load_provider _F = TypeVar("_F", bound=Callable[..., Any]) @@ -29,6 +31,7 @@ class _ProviderState: @dataclass class _AppState: app_root: Path + capabilities: AgentCapabilities default_provider_id: str | None = None providers: dict[str, _ProviderState] = field(default_factory=dict) durable_activity_registered: bool = False @@ -61,6 +64,7 @@ def _state_for( if state is None: state = _AppState( app_root=resolved_root, + capabilities=discover_capabilities(resolved_root), ) _APP_STATES[app] = state return state @@ -153,11 +157,16 @@ def _durable_agent( ) compiled = provider_state.durable_agents.get(agent_name) if compiled is None: + _validate_provider_capabilities( + provider_state.provider, + state.capabilities, + ) compiled = provider_state.provider.compile_binding( instructions=_resolve_instructions(state.app_root, agent_name), agent_name=agent_name, options=provider_state.provider_defaults, annotation=inspect.Signature.empty, + capabilities=state.capabilities, ) provider_state.durable_agents[agent_name] = compiled return compiled @@ -274,6 +283,22 @@ def _source_call( return handler(*positional, **keywords) +def _validate_provider_capabilities( + provider: AgentProvider, + capabilities: AgentCapabilities, +) -> None: + unsupported = [] + if capabilities.skills and "skills" not in provider.supported_capabilities: + unsupported.append("skills") + if capabilities.mcp_servers and "mcp" not in provider.supported_capabilities: + unsupported.append("mcp") + if unsupported: + raise TypeError( + f"Agent provider {provider.provider_id!r} does not support discovered " + f"capabilities: {', '.join(unsupported)}" + ) + + def _invocation_metadata( worker_signature: inspect.Signature, args: tuple[Any, ...], @@ -319,11 +344,16 @@ def decorate(handler: _F) -> _F: pass options = {**provider_state.provider_defaults, **provider_options} instructions = _resolve_instructions(state.app_root, agent_name) + _validate_provider_capabilities( + provider_state.provider, + state.capabilities, + ) compiled = provider_state.provider.compile_binding( instructions=instructions, agent_name=agent_name, options=options, annotation=annotation, + capabilities=state.capabilities, ) @functools.wraps(handler) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/capabilities.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/capabilities.py new file mode 100644 index 0000000..d6637c7 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/capabilities.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class SkillDefinition: + path: Path + + +@dataclass(frozen=True) +class MCPAuthConfig: + scope: str + client_id: str | None = None + + +@dataclass(frozen=True) +class MCPHTTPConfig: + url: str + allowed_tools: tuple[str, ...] | None = None + headers: tuple[tuple[str, str], ...] = () + auth: MCPAuthConfig | None = None + + +@dataclass(frozen=True) +class MCPServerDefinition: + name: str + config: MCPHTTPConfig + + +@dataclass(frozen=True) +class AgentCapabilities: + skills: tuple[SkillDefinition, ...] = () + mcp_servers: tuple[MCPServerDefinition, ...] = () diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/__init__.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/__init__.py new file mode 100644 index 0000000..c205f4b --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/__init__.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +from ..capabilities import AgentCapabilities +from .mcp import discover_mcp_servers +from .skills import discover_skills + + +def discover_capabilities(app_root: Path) -> AgentCapabilities: + return AgentCapabilities( + skills=discover_skills(app_root), + mcp_servers=discover_mcp_servers(app_root), + ) + + +__all__ = [ + "discover_capabilities", + "discover_mcp_servers", + "discover_skills", +] diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/mcp.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/mcp.py new file mode 100644 index 0000000..5c19b9a --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/mcp.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, cast +from urllib.parse import urlsplit + +from ..capabilities import ( + MCPAuthConfig, + MCPHTTPConfig, + MCPServerDefinition, +) + +_ENV_REFERENCE = re.compile( + r"(?:\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%)" +) +_VALID_SERVER_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + +def _object_without_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate key {key!r} in mcp.json") + result[key] = value + return result + + +def _string(value: Any, *, field: str, required: bool = True) -> str | None: + if value is None and not required: + return None + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"MCP {field} must be a non-empty string") + return value.strip() + + +def _allowed_tools(value: Any) -> tuple[str, ...] | None: + if value is None: + return None + if not isinstance(value, list) or any( + not isinstance(tool, str) or not tool.strip() for tool in value + ): + raise ValueError("MCP tools must be a list of non-empty strings") + tools = tuple(tool.strip() for tool in value) + if len(tools) != len(set(tools)): + raise ValueError("MCP tools must not contain duplicates") + if "*" in tools: + if len(tools) != 1: + raise ValueError("MCP '*' tool selection cannot be combined") + return None + return tools + + +def _headers(value: Any) -> tuple[tuple[str, str], ...]: + if value is None: + return () + if not isinstance(value, dict): + raise ValueError("MCP headers must be an object") + headers: list[tuple[str, str]] = [] + for key, header_value in value.items(): + header_name = _string(key, field="header name") + header_text = _string(header_value, field=f"header {key!r}") + assert header_name is not None and header_text is not None + headers.append((header_name, header_text)) + return tuple(sorted(headers)) + + +def _auth(value: Any) -> MCPAuthConfig | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValueError("MCP auth must be an object") + unknown = sorted(set(value) - {"scope", "client_id"}) + if unknown: + raise ValueError(f"Unknown MCP auth field(s): {', '.join(unknown)}") + scope = _string(value.get("scope"), field="auth scope") + client_id = _string( + value.get("client_id"), + field="auth client_id", + required=False, + ) + assert scope is not None + return MCPAuthConfig(scope=scope, client_id=client_id) + + +def _server_definition(name: str, value: Any) -> MCPServerDefinition: + if _VALID_SERVER_NAME.fullmatch(name) is None: + raise ValueError(f"Invalid MCP server name {name!r}") + if not isinstance(value, dict): + raise ValueError(f"MCP server {name!r} must be an object") + server = cast(dict[str, Any], value) + server_type = str(server.get("type", "")).strip().lower() + if "command" in server or server_type in {"stdio", "local"}: + raise ValueError(f"MCP server {name!r} uses unsupported stdio transport") + if server_type and server_type not in {"http", "streamable-http"}: + raise ValueError(f"MCP server {name!r} has unsupported type {server_type!r}") + + url = _string(server.get("url"), field=f"server {name!r} url") + assert url is not None + if _ENV_REFERENCE.search(url) is None: + parsed_url = urlsplit(url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise ValueError(f"MCP server {name!r} requires an HTTP URL") + unknown = sorted( + set(server) - {"type", "url", "tools", "headers", "auth"} + ) + if unknown: + raise ValueError( + f"Unknown MCP server {name!r} field(s): {', '.join(unknown)}" + ) + return MCPServerDefinition( + name=name, + config=MCPHTTPConfig( + url=url, + allowed_tools=_allowed_tools(server.get("tools")), + headers=_headers(server.get("headers")), + auth=_auth(server.get("auth")), + ), + ) + + +def discover_mcp_servers(app_root: Path) -> tuple[MCPServerDefinition, ...]: + resolved_root = Path(app_root).resolve(strict=True) + candidate = resolved_root / "mcp.json" + if not candidate.exists(): + return () + config_path = candidate.resolve(strict=True) + if not config_path.is_relative_to(resolved_root): + raise ValueError("mcp.json resolves outside the app root") + try: + data = json.loads( + config_path.read_text(encoding="utf-8"), + object_pairs_hook=_object_without_duplicates, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError(f"Failed to read {str(config_path)!r}") from error + if not isinstance(data, dict): + raise ValueError("mcp.json must contain an object") + servers = data.get("servers") + if not isinstance(servers, dict): + raise ValueError("mcp.json 'servers' must be an object") + unknown = sorted(set(data) - {"servers"}) + if unknown: + raise ValueError(f"Unknown mcp.json field(s): {', '.join(unknown)}") + return tuple( + _server_definition(name, servers[name]) + for name in sorted(servers) + ) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/skills.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/skills.py new file mode 100644 index 0000000..201a317 --- /dev/null +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/skills.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path + +from ..capabilities import SkillDefinition + +_SKILL_FILE_NAME = "SKILL.md" + + +def _skills_root(app_root: Path) -> Path | None: + matches: list[Path] = [] + for candidate in (app_root / "skills", app_root / "Skills"): + if candidate.is_dir(): + resolved = candidate.resolve(strict=True) + if resolved not in matches: + matches.append(resolved) + if len(matches) > 1: + raise ValueError("Both 'skills' and 'Skills' directories exist") + if not matches: + return None + skills_root = matches[0] + if not skills_root.is_relative_to(app_root): + raise ValueError("Skills directory resolves outside the app root") + return skills_root + + +def discover_skills(app_root: Path) -> tuple[SkillDefinition, ...]: + resolved_root = Path(app_root).resolve(strict=True) + skills_root = _skills_root(resolved_root) + if skills_root is None: + return () + + skill_files = sorted( + skills_root.rglob(_SKILL_FILE_NAME), + key=lambda path: path.relative_to(skills_root).as_posix().casefold(), + ) + definitions: list[SkillDefinition] = [] + for candidate in skill_files: + if not candidate.is_file(): + continue + skill_file = candidate.resolve(strict=True) + if not skill_file.is_relative_to(skills_root): + raise ValueError(f"Skill file {str(candidate)!r} resolves outside skills") + definitions.append(SkillDefinition(path=skill_file.parent)) + return tuple(definitions) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py index 51f2581..9798e66 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py @@ -151,7 +151,9 @@ def configure_durable_app(app: func.FunctionApp) -> None: return blueprint = df.Blueprint() - @blueprint.activity_trigger(input_name="payload") + @blueprint.activity_trigger( # type: ignore[untyped-decorator] + input_name="payload" + ) async def azurefunctions_agents_run_markdown_agent( payload: object, context: func.Context, diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py index 0a19411..1496342 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py @@ -6,6 +6,8 @@ from importlib import metadata from typing import Any, Mapping, Protocol +from .capabilities import AgentCapabilities + AGENT_PROVIDER_ENTRY_POINT_GROUP = "azurefunctions.extensions.agents.providers" @@ -34,6 +36,7 @@ async def run_agent( class AgentProvider(Protocol): provider_id: str distribution_name: str + supported_capabilities: frozenset[str] def compile_binding( self, @@ -42,6 +45,7 @@ def compile_binding( agent_name: str, options: Mapping[str, Any], annotation: Any, + capabilities: AgentCapabilities, ) -> CompiledAgent: pass @@ -74,6 +78,13 @@ def _validate_provider(provider: object, provider_id: str) -> AgentProvider: distribution_name = getattr(provider, "distribution_name", None) if not isinstance(distribution_name, str) or not distribution_name: raise TypeError(f"Agent provider {provider_id!r} must define distribution_name") + supported_capabilities = getattr(provider, "supported_capabilities", None) + if not isinstance(supported_capabilities, frozenset) or any( + not isinstance(capability, str) for capability in supported_capabilities + ): + raise TypeError( + f"Agent provider {provider_id!r} must define supported_capabilities" + ) if not callable(getattr(provider, "compile_binding", None)): raise TypeError(f"Agent provider {provider_id!r} must define compile_binding()") return provider # type: ignore[return-value] diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py index 822c90a..d6a8b91 100644 --- a/azurefunctions-extensions-agents-base/tests/test_bindings.py +++ b/azurefunctions-extensions-agents-base/tests/test_bindings.py @@ -9,6 +9,7 @@ import azure.functions as func import pytest +from azurefunctions.extensions.agents.base import AgentCapabilities from azurefunctions.extensions.agents.base import bindings, providers @@ -32,6 +33,7 @@ async def run_agent(self, prompt, invocation): class _Provider: provider_id = "agent_framework" distribution_name = "azurefunctions-extensions-agents-framework" + supported_capabilities = frozenset({"skills", "mcp"}) def __init__(self): self.compiled = _CompiledAgent() @@ -52,7 +54,7 @@ def provider(monkeypatch): def test_markdown_agent_injects_fresh_context_and_hides_parameter(tmp_path, provider): instructions = "---\nnot: parsed\n---\nUse the order API.\n" - (tmp_path / "orders.agent.md").write_text(instructions, encoding="utf-8") + (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) app = func.FunctionApp() @bindings.markdown_agent( @@ -72,6 +74,7 @@ async def handler(value: str, agent: object) -> tuple[str, object]: "agent_name": "orders", "options": {"tools": ["lookup"]}, "annotation": object, + "capabilities": AgentCapabilities(), } first = asyncio.run(handler("one")) @@ -318,6 +321,72 @@ async def handler(agent: object) -> None: } +def test_all_bindings_receive_same_discovered_capabilities(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + (tmp_path / "returns.agent.md").write_text("instructions", encoding="utf-8") + skill_directory = tmp_path / "skills" / "inventory" + skill_directory.mkdir(parents=True) + (skill_directory / "SKILL.md").write_text( + "---\nname: inventory\ndescription: Inventory lookup\n---\n", + encoding="utf-8", + ) + + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=tmp_path, + ) + async def handler(agent: object) -> None: + pass + + first_capabilities = provider.compile_args["capabilities"] + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="returns", + ) + async def returns_handler(agent: object) -> None: + pass + + second_capabilities = provider.compile_args["capabilities"] + assert tuple(skill.path for skill in first_capabilities.skills) == ( + skill_directory.resolve(), + ) + assert second_capabilities is first_capabilities + + +def test_binding_rejects_discovered_capability_for_unsupported_provider( + tmp_path, + provider, +): + provider.supported_capabilities = frozenset() + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + skill_directory = tmp_path / "skills" / "inventory" + skill_directory.mkdir(parents=True) + (skill_directory / "SKILL.md").write_text( + "---\nname: inventory\ndescription: Inventory lookup\n---\n", + encoding="utf-8", + ) + + with pytest.raises(TypeError, match="does not support discovered capabilities"): + + @bindings.markdown_agent( + func.FunctionApp(), + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=tmp_path, + ) + async def handler(agent: object) -> None: + pass + + def test_markdown_agent_requires_async_handler(tmp_path, provider): (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") diff --git a/azurefunctions-extensions-agents-base/tests/test_capability_discovery.py b/azurefunctions-extensions-agents-base/tests/test_capability_discovery.py new file mode 100644 index 0000000..91e3ccb --- /dev/null +++ b/azurefunctions-extensions-agents-base/tests/test_capability_discovery.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json + +import pytest + +from azurefunctions.extensions.agents.base.discovery import ( + discover_mcp_servers, + discover_skills, +) + + +def test_discover_skills_returns_paths_in_stable_order_without_parsing(tmp_path): + for directory, contents in ( + ("z-last", "not frontmatter"), + ("a-first", "---\nmalformed: [\n---\n"), + ): + skill_directory = tmp_path / "skills" / directory + skill_directory.mkdir(parents=True) + (skill_directory / "SKILL.md").write_text( + contents, + encoding="utf-8", + ) + + skills = discover_skills(tmp_path) + + assert tuple(skill.path for skill in skills) == ( + (tmp_path / "skills" / "a-first").resolve(), + (tmp_path / "skills" / "z-last").resolve(), + ) + + +def test_discover_mcp_servers_keeps_environment_references_immutable(tmp_path): + config = { + "servers": { + "inventory": { + "type": "streamable-http", + "url": "$INVENTORY_MCP_URL", + "tools": ["lookup", "reserve"], + "headers": {"X-Tenant": "%TENANT_ID%"}, + "auth": { + "scope": "$INVENTORY_SCOPE", + "client_id": "%CLIENT_ID%", + }, + } + } + } + (tmp_path / "mcp.json").write_text(json.dumps(config), encoding="utf-8") + + servers = discover_mcp_servers(tmp_path) + + assert len(servers) == 1 + server = servers[0] + assert server.name == "inventory" + assert server.config.url == "$INVENTORY_MCP_URL" + assert server.config.allowed_tools == ("lookup", "reserve") + assert server.config.headers == (("X-Tenant", "%TENANT_ID%"),) + assert server.config.auth is not None + assert server.config.auth.scope == "$INVENTORY_SCOPE" + assert server.config.auth.client_id == "%CLIENT_ID%" + + +def test_discover_mcp_servers_rejects_stdio(tmp_path): + config = { + "servers": { + "local": { + "type": "stdio", + "command": "python", + "args": ["server.py"], + } + } + } + (tmp_path / "mcp.json").write_text(json.dumps(config), encoding="utf-8") + + with pytest.raises(ValueError, match="stdio"): + discover_mcp_servers(tmp_path) diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-extensions-agents-base/tests/test_durable.py index 10c4653..32090c1 100644 --- a/azurefunctions-extensions-agents-base/tests/test_durable.py +++ b/azurefunctions-extensions-agents-base/tests/test_durable.py @@ -135,6 +135,7 @@ async def run_agent(self, prompt, invocation): class _Provider: provider_id = "agent_framework" distribution_name = "azurefunctions-extensions-agents-framework" + supported_capabilities = frozenset({"skills", "mcp"}) def __init__(self): self.compiled = _CompiledAgent() @@ -183,7 +184,7 @@ def customer_activity(payload): def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypatch): instructions = "---\nthis remains: raw\n---\nHandle orders.\n" - (tmp_path / "orders.agent.md").write_text(instructions, encoding="utf-8") + (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) app, provider = _configured_app(tmp_path, monkeypatch) durable.configure_durable_app(app) activity = app.get_functions()[0].get_user_function() @@ -206,6 +207,7 @@ def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypat assert result == 'response:{"a":2,"z":1}' assert provider.compile_calls[0]["instructions"] == instructions + assert provider.compile_calls[0]["capabilities"].skills == () assert provider.compiled.calls[0][0] == '{"a":2,"z":1}' assert provider.compiled.calls[0][1].durable_instance_id == "instance-1" asyncio.run( @@ -220,3 +222,33 @@ def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypat ) ) assert len(provider.compile_calls) == 1 + + +def test_hidden_activity_receives_all_discovered_capabilities(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + skill_directory = tmp_path / "skills" / "inventory" + skill_directory.mkdir(parents=True) + (skill_directory / "SKILL.md").write_text( + "---\nname: inventory\ndescription: Inventory lookup\n---\n", + encoding="utf-8", + ) + app, provider = _configured_app(tmp_path, monkeypatch) + durable.configure_durable_app(app) + activity = app.get_functions()[0].get_user_function() + + asyncio.run( + activity( + { + "schema_version": 1, + "agent_name": "orders", + "input": "hello", + "durable_instance_id": "instance-1", + }, + SimpleNamespace(function_name="activity", invocation_id="invocation-1"), + ) + ) + + capabilities = provider.compile_calls[0]["capabilities"] + assert tuple(skill.path for skill in capabilities.skills) == ( + skill_directory.resolve(), + ) diff --git a/azurefunctions-extensions-agents-base/tests/test_providers.py b/azurefunctions-extensions-agents-base/tests/test_providers.py index c160604..24c902b 100644 --- a/azurefunctions-extensions-agents-base/tests/test_providers.py +++ b/azurefunctions-extensions-agents-base/tests/test_providers.py @@ -10,6 +10,7 @@ class _Provider: provider_id = "agent_framework" distribution_name = "azurefunctions-extensions-agents-framework" + supported_capabilities = frozenset({"skills", "mcp"}) def compile_binding(self, **kwargs): return kwargs diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md index 04acc95..5b8db36 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-extensions-agents-framework/README.md @@ -10,9 +10,16 @@ pip install azurefunctions-extensions-agents-framework ``` The default package installs `agent-framework-core==1.13.0`. Install the MAF -client package required by your application separately. OpenAI, Foundry, Azure -Identity, storage, YAML, MCP, and the Azure Functions Agents runtime are not -dependencies of this extension. +client package required by your application separately. OpenAI, Foundry, +storage, and the Azure Functions Agents runtime are not dependencies of this +extension. + +Skills use the default package. Install remote MCP transport and Entra support +with the MCP extra: + +```text +pip install "azurefunctions-extensions-agents-framework[mcp]" +``` ## Use a typed Agent app @@ -73,6 +80,76 @@ Place the complete instructions at `orders.agent.md` or `agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime configuration is interpreted. +## Skills and MCP servers + +Skills and MCP servers are discovered automatically from the app root and are +available to each Agent binding by default: + +```text +skills/inventory/SKILL.md +mcp.json +``` + +`SKILL.md` uses Agent Skills frontmatter: + +```markdown +--- +name: inventory +description: Look up inventory policy and warehouse constraints. +--- + +Use the references in this skill when assessing stock. +``` + +The base extension discovers Skill directory paths without reading their +contents. Microsoft Agent Framework parses and validates each `SKILL.md` when +it loads the file-based Skills provider. + +V1 MCP discovery supports remote HTTP transports only: + +```json +{ + "servers": { + "inventory": { + "type": "streamable-http", + "url": "$INVENTORY_MCP_URL", + "tools": ["lookup_stock", "reserve_stock"], + "headers": {"X-Tenant": "%TENANT_ID%"}, + "auth": { + "scope": "$INVENTORY_MCP_SCOPE", + "client_id": "%AZURE_CLIENT_ID%" + } + } + } +} +``` + +`$VAR` and `%VAR%` references are resolved for each invocation, not during +discovery. Missing values fail before connecting. Credentials, tokens, HTTP +clients, MCP tools, and Agents are fresh invocation-owned resources and are +closed on success, error, or cancellation. Do not place secrets directly in +source-controlled `mcp.json`; use environment references. + +Every Agent in the Function App receives all valid Skills and MCP servers +discovered from the app root: + +```python +from azurefunctions.extensions.agents.framework import AiApp + +app = AiApp(client_factory=create_chat_client) + + +@app.markdown_agent(arg_name="agent", agent_name="orders") +async def process_order(agent: Agent): + ... +``` + +V1 has no app-level or per-binding capability selectors. Skill scripts and MCP +tools can perform privileged operations, so placing a definition under the app +root grants every Agent in that app access to it. Use separate Function Apps +when capabilities require isolation. Python `tools=` remain explicit because +they are supplied directly to the Microsoft Agent Framework Agent. + The generic core form is also supported: ```python @@ -127,4 +204,7 @@ without Durable installed; constructing `DurableAiApp` reports the exact extra to install when it is absent. All `call_agent()` invocations use the provider configured by `DurableAiApp`. -V1 does not support selecting another provider from an orchestrator. +They also use the app-level `skills` and `mcp_servers` defaults. V1 does not +support selecting another provider or capability set from an orchestrator, and +the schema-v1 orchestration payload contains no capability paths, settings, or +secrets. diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py index 1d81834..2fa5f90 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py @@ -1,19 +1,33 @@ from __future__ import annotations +import asyncio import inspect -from collections.abc import Callable, Mapping -from contextlib import asynccontextmanager +import os +import re +import warnings +from collections.abc import Callable, Mapping, Sequence +from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from types import MappingProxyType from typing import Any, AsyncIterator, get_origin +from urllib.parse import urlsplit -from agent_framework import Agent, BaseChatClient +from agent_framework import Agent, BaseChatClient, SkillsProvider +from agent_framework._feature_stage import ExperimentalWarning -from azurefunctions.extensions.agents.base import InvocationMetadata +from azurefunctions.extensions.agents.base import ( + AgentCapabilities, + InvocationMetadata, + MCPServerDefinition, + SkillDefinition, +) AGENT_FRAMEWORK_PROVIDER_ID = "agent_framework" ClientFactory = Callable[[], BaseChatClient[Any]] _AGENT_ANNOTATION_TYPE = Agent +_ENV_REFERENCE = re.compile( + r"\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%" +) _SUPPORTED_OPTIONS = frozenset( { @@ -37,13 +51,25 @@ class AgentFrameworkBinding: agent_name: str client_factory: ClientFactory agent_options: Mapping[str, Any] + capabilities: AgentCapabilities - def _create_agent(self) -> Agent[Any]: + def _create_agent( + self, + skills_provider: Any | None, + mcp_tools: Sequence[Any], + ) -> Agent[Any]: + options = dict(self.agent_options) + if skills_provider is not None: + context_providers = _option_values(options.pop("context_providers", None)) + options["context_providers"] = [*context_providers, skills_provider] + if mcp_tools: + tools = _option_values(options.pop("tools", None)) + options["tools"] = [*tools, *mcp_tools] return Agent( client=self.client_factory(), instructions=self.instructions, name=self.agent_name, - **self.agent_options, + **options, ) @asynccontextmanager @@ -51,8 +77,15 @@ async def open_agent( self, invocation: InvocationMetadata, ) -> AsyncIterator[Agent[Any]]: - async with self._create_agent() as agent: - yield agent + async with AsyncExitStack() as stack: + skills_provider = _build_skills_provider(self.capabilities.skills) + mcp_tools = [ + await stack.enter_async_context(_open_mcp_tool(definition)) + for definition in self.capabilities.mcp_servers + ] + agent = self._create_agent(skills_provider, mcp_tools) + entered_agent = await stack.enter_async_context(agent) + yield entered_agent async def run_agent( self, @@ -70,6 +103,7 @@ async def run_agent( class AgentFrameworkProvider: provider_id = AGENT_FRAMEWORK_PROVIDER_ID distribution_name = "azurefunctions-extensions-agents-framework" + supported_capabilities = frozenset({"skills", "mcp"}) def compile_binding( self, @@ -78,6 +112,7 @@ def compile_binding( agent_name: str, options: Mapping[str, Any], annotation: Any, + capabilities: AgentCapabilities, ) -> AgentFrameworkBinding: unknown = sorted(set(options) - _SUPPORTED_OPTIONS) if unknown: @@ -109,7 +144,144 @@ def compile_binding( agent_name=agent_name, client_factory=client_factory, agent_options=MappingProxyType(agent_options), + capabilities=capabilities, + ) + + +def _option_values(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return list(value) + return [value] + + +def _build_skills_provider( + skills: Sequence[SkillDefinition], +) -> SkillsProvider | None: + if not skills: + return None + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=ExperimentalWarning) + return SkillsProvider.from_paths( + [skill.path for skill in skills], + disable_load_skill_approval=True, + disable_read_skill_resource_approval=True, + ) + + +def _resolve_environment(value: str, *, field: str) -> str: + missing: set[str] = set() + + def replace(match: re.Match[str]) -> str: + name = match.group(1) or match.group(2) + resolved = os.environ.get(name) + if resolved is None: + missing.add(name) + return match.group(0) + return resolved + + result = _ENV_REFERENCE.sub(replace, value) + if missing: + raise ValueError( + f"MCP {field} references missing environment variable(s): " + f"{', '.join(sorted(missing))}" + ) + return result + + +@asynccontextmanager +async def _open_mcp_tool( + definition: MCPServerDefinition, +) -> AsyncIterator[Any]: + try: + import mcp # noqa: F401 + from agent_framework import MCPStreamableHTTPTool + from httpx import AsyncClient + except ImportError as error: + raise ImportError( + "MCP support is not installed. Install " + "'azurefunctions-extensions-agents-framework[mcp]'." + ) from error + + config = definition.config + url = _resolve_environment(config.url, field=f"server {definition.name!r} URL") + parsed_url = urlsplit(url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise ValueError( + f"MCP server {definition.name!r} URL must use HTTP or HTTPS" + ) + static_headers = { + name: _resolve_environment( + value, + field=f"server {definition.name!r} header {name!r}", + ) + for name, value in config.headers + } + auth = config.auth + scope = ( + _resolve_environment( + auth.scope, + field=f"server {definition.name!r} auth scope", + ) + if auth is not None + else None + ) + client_id = ( + _resolve_environment( + auth.client_id, + field=f"server {definition.name!r} auth client_id", + ) + if auth is not None and auth.client_id is not None + else None + ) + + async with AsyncExitStack() as stack: + credential = None + if scope is not None: + try: + from azure.identity import DefaultAzureCredential + except ImportError as error: + raise ImportError( + "MCP Entra authentication is not installed. Install " + "'azurefunctions-extensions-agents-framework[mcp]'." + ) from error + credential = DefaultAzureCredential( + managed_identity_client_id=client_id, + ) + stack.callback(credential.close) + + http_client = None + if static_headers or credential is not None: + + async def inject_headers(request: Any) -> None: + for name, value in static_headers.items(): + request.headers[name] = value + if credential is not None and scope is not None: + token = await asyncio.to_thread(credential.get_token, scope) + request.headers["Authorization"] = f"Bearer {token.token}" + + http_client = await stack.enter_async_context( + AsyncClient( + follow_redirects=False, + event_hooks={"request": [inject_headers]}, + ) + ) + + tool = MCPStreamableHTTPTool( + name=definition.name, + url=url, + allowed_tools=( + list(config.allowed_tools) + if config.allowed_tools is not None + else None + ), + load_tools=True, + load_prompts=False, + http_client=http_client, ) + entered_tool = await stack.enter_async_context(tool) + yield entered_tool def create_provider() -> AgentFrameworkProvider: diff --git a/azurefunctions-extensions-agents-framework/pyproject.toml b/azurefunctions-extensions-agents-framework/pyproject.toml index 1faccb2..48965fc 100644 --- a/azurefunctions-extensions-agents-framework/pyproject.toml +++ b/azurefunctions-extensions-agents-framework/pyproject.toml @@ -30,6 +30,11 @@ dependencies = [ ] [project.optional-dependencies] +mcp = [ + "azure-identity>=1.25.3,<2", + "httpx>=0.27,<1", + "mcp>=1.28.1,<2", +] durable = [ "azurefunctions-extensions-agents-base[durable]>=1.0.0b1", ] diff --git a/azurefunctions-extensions-agents-framework/samples/README.md b/azurefunctions-extensions-agents-framework/samples/README.md index 0001862..08ab8bd 100644 --- a/azurefunctions-extensions-agents-framework/samples/README.md +++ b/azurefunctions-extensions-agents-framework/samples/README.md @@ -1,6 +1,7 @@ # Microsoft Agent Framework samples -- `hybrid-function-agent`: injects a fresh Agent into HTTP and queue Functions. +- `hybrid-function-agent`: injects a fresh Agent into HTTP and queue Functions, + with automatic app-wide Skill/MCP discovery. - `hybrid-durable-agent`: schedules Agent calls from a replay-safe orchestrator. Both samples use raw `.agent.md` instructions and an explicit Foundry client diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md index 32c48bf..f1d642c 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md @@ -2,11 +2,17 @@ This sample keeps validation and calculations in ordinary Azure Functions code while injecting a fresh Microsoft Agent Framework `Agent` for each invocation. -The prompt receives only the validated, minimized order projection. +The prompt receives only the validated, minimized order projection. The HTTP +and queue bindings use the discovered `order-policy` Skill and `inventory` MCP +server. All Agent bindings receive every valid capability under the app root. From `src/`, copy `local.settings.template.json` to `local.settings.json`, fill in the Foundry values, start Azurite, and run `func start`. +Install the sample's `[mcp]` dependency profile and set +`INVENTORY_MCP_URL` to a trusted streamable-HTTP MCP endpoint before invoking +the HTTP route. + ```bash curl -X POST http://localhost:7071/orders/42 \ -H "Content-Type: application/json" \ diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json index 361120f..cd85a88 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json @@ -4,6 +4,7 @@ "FUNCTIONS_WORKER_RUNTIME": "python", "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FOUNDRY_PROJECT_ENDPOINT": "https://..services.ai.azure.com/api/projects/", - "FOUNDRY_MODEL": "gpt-5.4" + "FOUNDRY_MODEL": "gpt-5.4", + "INVENTORY_MCP_URL": "https://inventory.example.com/mcp" } } \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/mcp.json b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/mcp.json new file mode 100644 index 0000000..941670f --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "inventory": { + "type": "streamable-http", + "url": "$INVENTORY_MCP_URL", + "tools": ["lookup_stock", "reserve_stock"] + } + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt index 89c947a..cf100f0 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt @@ -1,4 +1,4 @@ --e ../../.. +-e ../../..[mcp] agent-framework-foundry==1.13.0 azure-identity pydantic \ No newline at end of file diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md new file mode 100644 index 0000000..618c330 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md @@ -0,0 +1,7 @@ +--- +name: order-policy +description: Apply fulfillment policy and warehouse constraints to an order. +--- + +Use validated order totals and shipping fields when assessing fulfillment. +Never infer missing customer, payment, or inventory data. diff --git a/azurefunctions-extensions-agents-framework/tests/test_provider.py b/azurefunctions-extensions-agents-framework/tests/test_provider.py index 6354009..c9e85d8 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_provider.py +++ b/azurefunctions-extensions-agents-framework/tests/test_provider.py @@ -2,12 +2,21 @@ import asyncio import inspect +from contextlib import asynccontextmanager +from pathlib import Path from types import SimpleNamespace +from unittest.mock import Mock import pytest from agent_framework import Agent -from azurefunctions.extensions.agents.base import InvocationMetadata +from azurefunctions.extensions.agents.base import ( + AgentCapabilities, + InvocationMetadata, + MCPHTTPConfig, + MCPServerDefinition, + SkillDefinition, +) from azurefunctions.extensions.agents.framework import provider @@ -37,7 +46,7 @@ def fake_agent(monkeypatch): monkeypatch.setattr(provider, "Agent", _Agent) -def _compile(**overrides): +def _compile(*, capabilities=AgentCapabilities(), **overrides): options = {"client_factory": lambda: object(), "tools": ["lookup"]} options.update(overrides) return provider.AgentFrameworkProvider().compile_binding( @@ -45,6 +54,7 @@ def _compile(**overrides): agent_name="orders", options=options, annotation=Agent, + capabilities=capabilities, ) @@ -85,6 +95,7 @@ def test_provider_rejects_non_agent_annotation(): agent_name="orders", options={"client_factory": lambda: object()}, annotation=str, + capabilities=AgentCapabilities(), ) @@ -94,6 +105,7 @@ def test_provider_accepts_missing_annotation_for_durable_activity(): agent_name="orders", options={"client_factory": lambda: object()}, annotation=inspect.Signature.empty, + capabilities=AgentCapabilities(), ) assert binding.agent_name == "orders" @@ -111,6 +123,7 @@ def test_provider_requires_client_factory(): agent_name="orders", options={}, annotation=Agent, + capabilities=AgentCapabilities(), ) @@ -149,3 +162,147 @@ async def run_without_text(self, prompt): asyncio.run(_compile().run_agent("hello", InvocationMetadata())) assert _Agent.created[0].closed + + +def test_binding_translates_and_closes_capabilities(monkeypatch): + skill = SkillDefinition(Path("inventory")) + server = MCPServerDefinition( + "orders", + MCPHTTPConfig("https://mcp.example.test"), + ) + mcp_events = [] + skill_providers = [] + + def build_skills(skills): + skills_provider = {"paths": tuple(item.path for item in skills)} + skill_providers.append(skills_provider) + return skills_provider + + @asynccontextmanager + async def open_mcp(definition): + tool = {"server": definition.name, "instance": len(mcp_events)} + mcp_events.append(("open", tool)) + try: + yield tool + finally: + mcp_events.append(("close", tool)) + + monkeypatch.setattr(provider, "_build_skills_provider", build_skills) + monkeypatch.setattr(provider, "_open_mcp_tool", open_mcp) + binding = _compile( + capabilities=AgentCapabilities( + skills=(skill,), + mcp_servers=(server,), + ) + ) + + async def invoke_twice(): + async with binding.open_agent(InvocationMetadata()): + assert mcp_events[-1][0] == "open" + async with binding.open_agent(InvocationMetadata()): + assert mcp_events[-1][0] == "open" + + asyncio.run(invoke_twice()) + + assert len(skill_providers) == 2 + assert [event for event, _ in mcp_events] == [ + "open", + "close", + "open", + "close", + ] + assert _Agent.created[0].kwargs["context_providers"] == [skill_providers[0]] + assert _Agent.created[0].kwargs["tools"][0] == "lookup" + assert _Agent.created[0].kwargs["tools"][1]["server"] == "orders" + assert _Agent.created[0].closed + + +def test_skills_provider_owns_skill_format_validation(monkeypatch): + from_paths = Mock(return_value=object()) + monkeypatch.setattr(provider.SkillsProvider, "from_paths", from_paths) + skill_path = Path("skills/inventory") + + result = provider._build_skills_provider((SkillDefinition(skill_path),)) + + assert result is from_paths.return_value + from_paths.assert_called_once_with( + [skill_path], + disable_load_skill_approval=True, + disable_read_skill_resource_approval=True, + ) + + +def test_environment_resolution_reports_names_without_values(monkeypatch): + monkeypatch.delenv("PRIVATE_MCP_TOKEN", raising=False) + + with pytest.raises(ValueError, match="PRIVATE_MCP_TOKEN") as error: + provider._resolve_environment( + "Bearer $PRIVATE_MCP_TOKEN", + field="header Authorization", + ) + + assert "Bearer" not in str(error.value) + + +@pytest.mark.parametrize("resolved_url", ["file:///etc/passwd", "ftp://host/path"]) +def test_mcp_url_is_validated_after_environment_resolution( + monkeypatch, + resolved_url, +): + monkeypatch.setenv("MCP_SERVER_URL", resolved_url) + definition = MCPServerDefinition( + "orders", + MCPHTTPConfig("$MCP_SERVER_URL"), + ) + + async def open_tool(): + async with provider._open_mcp_tool(definition): + pass + + with pytest.raises(ValueError, match="HTTP or HTTPS"): + asyncio.run(open_tool()) + + +def test_mcp_client_does_not_follow_redirects(monkeypatch): + import agent_framework + import httpx + + client_options = [] + + class FakeClient: + def __init__(self, **kwargs): + client_options.append(kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + pass + + class FakeTool: + def __init__(self, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + pass + + monkeypatch.setattr(httpx, "AsyncClient", FakeClient) + monkeypatch.setattr(agent_framework, "MCPStreamableHTTPTool", FakeTool) + definition = MCPServerDefinition( + "orders", + MCPHTTPConfig( + "https://mcp.example.test", + headers=(("X-Tenant", "contoso"),), + ), + ) + + async def open_tool(): + async with provider._open_mcp_tool(definition): + pass + + asyncio.run(open_tool()) + + assert client_options[0]["follow_redirects"] is False diff --git a/azurefunctions-extensions-agents-framework/tests/test_samples.py b/azurefunctions-extensions-agents-framework/tests/test_samples.py index 226b137..a26595a 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_samples.py +++ b/azurefunctions-extensions-agents-framework/tests/test_samples.py @@ -15,7 +15,10 @@ @pytest.mark.parametrize( ("sample_name", "expected_names"), [ - ("hybrid-function-agent", {"process_order"}), + ( + "hybrid-function-agent", + {"process_order", "process_order_event"}, + ), ( "hybrid-durable-agent", { From d2c4d04f1a13091dfbd1ae739e71f491ce337c1b Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 15:00:15 -0500 Subject: [PATCH 12/25] simplify --- .../README.md | 16 ++- .../extensions/agents/base/bindings.py | 101 +++++--------- .../extensions/agents/base/durable.py | 4 - .../tests/test_bindings.py | 70 ++++------ .../README.md | 32 ++--- .../extensions/agents/framework/apps.py | 124 +----------------- .../extensions/agents/framework/provider.py | 18 +-- .../tests/test_apps.py | 79 ++++------- 8 files changed, 110 insertions(+), 334 deletions(-) diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-extensions-agents-base/README.md index f26e710..0576c13 100644 --- a/azurefunctions-extensions-agents-base/README.md +++ b/azurefunctions-extensions-agents-base/README.md @@ -21,13 +21,15 @@ compiled recipe creates a fresh Agent context for each invocation and can run an Agent from a Durable activity. Applications use `azure.functions.FunctionApp.markdown_agent()` or install a -typed provider package. Each Agent binding selects a provider, so providers may -coexist in one app. `AiApp` supplies a default provider; an explicit -`markdown_agent(provider=...)` overrides it for one binding. Provider discovery -is cached, while live Agents and clients are never cached. +typed provider package. Each Function App uses one provider. `AiApp` pins it at +construction; a plain `FunctionApp` pins it on its first +`markdown_agent(provider=...)` use. A later different provider is rejected. +Provider discovery is cached, while live Agents and clients are never cached. -Provider defaults are stored independently. Binding options override defaults -only for that binding. All providers share one app root. +Provider defaults are app-scoped. Binding options override defaults only for +that binding. The app root is configured once on `AiApp` or inferred from +`AzureWebJobsScriptRoot` and then the current directory for a plain app; +decorators cannot override it. ## Markdown lookup @@ -74,5 +76,5 @@ Provider packages expose Durable support through their own `[durable]` extra. The base extra installs `azure-functions-durable>=1.2.10,<2`; normal imports do not import or require Durable Functions. `DurableAgentContext.call_agent()` schedules a hidden activity with a deterministic, JSON-only payload and always -uses the `DurableAiApp` default provider. All file, client, Agent, model, and +uses the `DurableAiApp` provider. All file, client, Agent, model, and tool I/O occurs in the activity, never in the orchestrator. diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py index 66c79a7..80ccc02 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py @@ -21,19 +21,14 @@ _INVALID_FILENAME_CHARACTERS = frozenset('<>:"/\\|?*') -@dataclass -class _ProviderState: - provider: AgentProvider - provider_defaults: Mapping[str, Any] - durable_agents: dict[str, CompiledAgent] = field(default_factory=dict) - - @dataclass class _AppState: app_root: Path capabilities: AgentCapabilities - default_provider_id: str | None = None - providers: dict[str, _ProviderState] = field(default_factory=dict) + provider_id: str + provider: AgentProvider + provider_defaults: Mapping[str, Any] + durable_agents: dict[str, CompiledAgent] = field(default_factory=dict) durable_activity_registered: bool = False lock: threading.RLock = field(default_factory=threading.RLock) @@ -56,15 +51,21 @@ def _resolve_app_root(app_root: str | os.PathLike[str] | None) -> Path: def _state_for( app: func.FunctionApp, *, + provider: str, app_root: str | os.PathLike[str] | None = None, + provider_defaults: Mapping[str, Any] | None = None, ) -> _AppState: resolved_root = _resolve_app_root(app_root) + defaults = dict(provider_defaults or {}) with _APP_STATES_LOCK: state = _APP_STATES.get(app) if state is None: state = _AppState( app_root=resolved_root, capabilities=discover_capabilities(resolved_root), + provider_id=provider, + provider=load_provider(provider), + provider_defaults=MappingProxyType(defaults), ) _APP_STATES[app] = state return state @@ -74,34 +75,16 @@ def _state_for( f"{str(state.app_root)!r}; it cannot also use " f"{str(resolved_root)!r}" ) - return state - - -def _provider_state_for( - state: _AppState, - *, - provider: str, - provider_defaults: Mapping[str, Any] | None = None, -) -> _ProviderState: - defaults = dict(provider_defaults or {}) - with state.lock: - provider_state = state.providers.get(provider) - if provider_state is None: - provider_state = _ProviderState( - provider=load_provider(provider), - provider_defaults=MappingProxyType(defaults), + if state.provider_id != provider: + raise ValueError( + f"FunctionApp is already configured with Agent provider " + f"{state.provider_id!r}; it cannot also use {provider!r}" ) - state.providers[provider] = provider_state - return provider_state - if ( - provider_defaults is not None - and provider_state.provider_defaults != defaults - ): + if provider_defaults is not None and state.provider_defaults != defaults: raise ValueError( - f"FunctionApp Agent provider {provider!r} defaults are " - "already configured" + "FunctionApp Agent provider defaults are already configured" ) - return provider_state + return state def configure_app( @@ -111,25 +94,12 @@ def configure_app( app_root: str | os.PathLike[str] | None = None, provider_options: Mapping[str, Any] | None = None, ) -> None: - state = _state_for( + _state_for( app, + provider=provider, app_root=app_root, + provider_defaults=provider_options, ) - with state.lock: - if ( - state.default_provider_id is not None - and state.default_provider_id != provider - ): - raise ValueError( - f"FunctionApp default Agent provider is already " - f"{state.default_provider_id!r}; it cannot also be {provider!r}" - ) - _provider_state_for( - state, - provider=provider, - provider_defaults=provider_options, - ) - state.default_provider_id = provider def _configured_state(app: func.FunctionApp) -> _AppState: @@ -146,29 +116,20 @@ def _durable_agent( ) -> CompiledAgent: state = _configured_state(app) with state.lock: - if state.default_provider_id is None: - raise RuntimeError( - "Durable Agent support requires a default Agent provider" - ) - provider_state = state.providers.get(state.default_provider_id) - if provider_state is None: - raise RuntimeError( - "Durable Agent support requires a default Agent provider" - ) - compiled = provider_state.durable_agents.get(agent_name) + compiled = state.durable_agents.get(agent_name) if compiled is None: _validate_provider_capabilities( - provider_state.provider, + state.provider, state.capabilities, ) - compiled = provider_state.provider.compile_binding( + compiled = state.provider.compile_binding( instructions=_resolve_instructions(state.app_root, agent_name), agent_name=agent_name, - options=provider_state.provider_defaults, + options=state.provider_defaults, annotation=inspect.Signature.empty, capabilities=state.capabilities, ) - provider_state.durable_agents[agent_name] = compiled + state.durable_agents[agent_name] = compiled return compiled @@ -320,11 +281,11 @@ def markdown_agent( provider: str, arg_name: str, agent_name: str, - app_root: str | os.PathLike[str] | None = None, **provider_options: Any, ) -> Callable[[_F], _F]: - state = _state_for(app, app_root=app_root) - provider_state = _provider_state_for(state, provider=provider) + if "app_root" in provider_options: + raise TypeError("markdown_agent app_root is app-scoped; configure it on AiApp") + state = _state_for(app, provider=provider) def decorate(handler: _F) -> _F: if not inspect.isfunction(handler): @@ -342,13 +303,13 @@ def decorate(handler: _F) -> _F: annotation = get_type_hints(handler).get(arg_name, annotation) except (NameError, TypeError): pass - options = {**provider_state.provider_defaults, **provider_options} + options = {**state.provider_defaults, **provider_options} instructions = _resolve_instructions(state.app_root, agent_name) _validate_provider_capabilities( - provider_state.provider, + state.provider, state.capabilities, ) - compiled = provider_state.provider.compile_binding( + compiled = state.provider.compile_binding( instructions=instructions, agent_name=agent_name, options=options, diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py index 9798e66..7bc102f 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py @@ -143,10 +143,6 @@ def configure_durable_app(app: func.FunctionApp) -> None: state = _configured_state(app) with state.lock: - if state.default_provider_id is None: - raise RuntimeError( - "Durable Agent support requires a default Agent provider" - ) if state.durable_activity_registered: return blueprint = df.Blueprint() diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py index d6a8b91..9af5757 100644 --- a/azurefunctions-extensions-agents-base/tests/test_bindings.py +++ b/azurefunctions-extensions-agents-base/tests/test_bindings.py @@ -45,8 +45,9 @@ def compile_binding(self, **kwargs): @pytest.fixture -def provider(monkeypatch): +def provider(monkeypatch, tmp_path): instance = _Provider() + monkeypatch.setenv("AzureWebJobsScriptRoot", str(tmp_path)) monkeypatch.setattr(providers, "load_provider", lambda provider_id: instance) monkeypatch.setattr(bindings, "load_provider", lambda provider_id: instance) return instance @@ -62,7 +63,6 @@ def test_markdown_agent_injects_fresh_context_and_hides_parameter(tmp_path, prov provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=tmp_path, tools=["lookup"], ) async def handler(value: str, agent: object) -> tuple[str, object]: @@ -94,7 +94,6 @@ def test_markdown_agent_closes_context_when_handler_fails(tmp_path, provider): provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=tmp_path, ) async def handler(agent: object) -> None: raise RuntimeError("handler failed") @@ -114,7 +113,6 @@ def test_markdown_agent_closes_context_when_handler_is_cancelled(tmp_path, provi provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=tmp_path, ) async def handler(agent: object) -> None: raise asyncio.CancelledError @@ -137,7 +135,6 @@ def test_markdown_agent_rejects_ambiguous_files(tmp_path, provider): provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=tmp_path, ) async def handler(agent: object) -> None: pass @@ -152,15 +149,16 @@ def test_markdown_agent_rejects_symlink_outside_app_root(tmp_path, provider): (app_root / "orders.agent.md").symlink_to(outside) except OSError as error: pytest.skip(f"symlink creation is unavailable: {error}") + app = func.FunctionApp() + bindings.configure_app(app, provider="agent_framework", app_root=app_root) with pytest.raises(ValueError, match="resolves outside app root"): @bindings.markdown_agent( - func.FunctionApp(), + app, provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=app_root, ) async def handler(agent: object) -> None: pass @@ -184,7 +182,6 @@ def test_markdown_agent_rejects_nonportable_agent_names( provider="agent_framework", arg_name="agent", agent_name=agent_name, - app_root=tmp_path, ) async def handler(agent: object) -> None: pass @@ -197,7 +194,7 @@ def test_function_app_rejects_a_second_default_provider(tmp_path, provider): app_root=tmp_path, ) - with pytest.raises(ValueError, match="default Agent provider is already"): + with pytest.raises(ValueError, match="already configured with Agent provider"): bindings.configure_app( func_app, provider="langgraph", @@ -205,8 +202,9 @@ def test_function_app_rejects_a_second_default_provider(tmp_path, provider): ) -def test_function_app_supports_multiple_binding_providers(tmp_path, monkeypatch): +def test_function_app_rejects_a_second_binding_provider(tmp_path, monkeypatch): (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + monkeypatch.setenv("AzureWebJobsScriptRoot", str(tmp_path)) providers_by_id = { "agent_framework": _Provider(), "langgraph": _Provider(), @@ -218,41 +216,33 @@ def test_function_app_supports_multiple_binding_providers(tmp_path, monkeypatch) lambda provider_id: providers_by_id[provider_id], ) app = func.FunctionApp() - bindings.configure_app( - app, - provider="agent_framework", - app_root=tmp_path, - provider_options={"temperature": 0.1}, - ) @bindings.markdown_agent( app, provider="agent_framework", arg_name="agent", agent_name="orders", + temperature=0.1, ) async def framework_handler(agent: object) -> None: pass - @bindings.markdown_agent( - app, - provider="langgraph", - arg_name="agent", - agent_name="orders", - recursion_limit=20, - ) - async def langgraph_handler(agent: object) -> None: - pass + with pytest.raises(ValueError, match="already configured with Agent provider"): + bindings.markdown_agent( + app, + provider="langgraph", + arg_name="agent", + agent_name="orders", + recursion_limit=20, + ) assert providers_by_id["agent_framework"].compile_args["options"] == { "temperature": 0.1 } - assert providers_by_id["langgraph"].compile_args["options"] == { - "recursion_limit": 20 - } + assert providers_by_id["langgraph"].compile_args is None -def test_all_providers_share_the_first_established_app_root(tmp_path, provider): +def test_markdown_agent_rejects_per_binding_app_root(tmp_path, provider): first_root = tmp_path / "first" second_root = tmp_path / "second" first_root.mkdir() @@ -260,20 +250,12 @@ def test_all_providers_share_the_first_established_app_root(tmp_path, provider): (first_root / "orders.agent.md").write_text("instructions", encoding="utf-8") app = func.FunctionApp() - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - app_root=first_root, - ) - async def handler(agent: object) -> None: - pass + bindings.configure_app(app, provider="agent_framework", app_root=first_root) - with pytest.raises(ValueError, match="already configured with app_root"): + with pytest.raises(TypeError, match="app_root is app-scoped"): bindings.markdown_agent( app, - provider="langgraph", + provider="agent_framework", arg_name="agent", agent_name="orders", app_root=second_root, @@ -332,13 +314,13 @@ def test_all_bindings_receive_same_discovered_capabilities(tmp_path, provider): ) app = func.FunctionApp() + bindings.configure_app(app, provider="agent_framework", app_root=tmp_path) @bindings.markdown_agent( app, provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=tmp_path, ) async def handler(agent: object) -> None: pass @@ -375,13 +357,14 @@ def test_binding_rejects_discovered_capability_for_unsupported_provider( ) with pytest.raises(TypeError, match="does not support discovered capabilities"): + app = func.FunctionApp() + bindings.configure_app(app, provider="agent_framework", app_root=tmp_path) @bindings.markdown_agent( - func.FunctionApp(), + app, provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=tmp_path, ) async def handler(agent: object) -> None: pass @@ -397,7 +380,6 @@ def test_markdown_agent_requires_async_handler(tmp_path, provider): provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=tmp_path, ) def handler(agent: object) -> None: pass diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md index 5b8db36..a0410b1 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-extensions-agents-framework/README.md @@ -54,7 +54,8 @@ provider package documents its ID; this package exports closed SDK enum is not used because third-party packages may add provider IDs without an Azure Functions SDK release. -The standalone typed decorator also defaults to the Agent Framework provider: +The standalone typed decorator pins a plain app to the Agent Framework +provider on first use: ```python from azurefunctions.extensions.agents.framework import markdown_agent @@ -72,9 +73,8 @@ async def process_order(req: func.HttpRequest, agent: Agent): ... ``` -Its optional `provider` parameter can select another installed provider for one -binding. Pass that provider's options as keyword arguments; provider-specific -packages remain the source of truth for their IDs and supported options. +One Function App uses one provider. A later decorator from a different provider +package is rejected. Place the complete instructions at `orders.agent.md` or `agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime @@ -168,26 +168,10 @@ async def process_order(req: func.HttpRequest, agent: Agent): ... ``` -Typed constructors and decorators expose the MAF Agent options supported by -this release: tools, description, default options, context providers, -middleware, per-service-call history persistence, compaction strategy, -tokenizer, and additional properties. The extension owns the Agent client, -name, and instructions. - -`AiApp` makes `agent_framework` the default provider, but one app may use other -installed providers too. Select another provider on an individual binding and -pass its options directly: - -```python -@app.markdown_agent( - provider="langgraph", - arg_name="agent", - agent_name="researcher", - recursion_limit=10, -) -async def research(agent: object): - ... -``` +Typed constructors and decorators expose only `client_factory` and explicit +Python `tools` in V1. The extension owns the Agent client, name, instructions, +and discovered Skills/MCP integration. Configure `app_root` only when +constructing `AiApp` or `DurableAiApp`; decorators do not override it. ## Durable Agents diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py index 608bbda..b9f6717 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py @@ -1,17 +1,11 @@ from __future__ import annotations import os -from collections.abc import Callable, MutableMapping, Sequence +from collections.abc import Callable, Sequence from typing import Any, TypeVar import azure.functions as func -from agent_framework import ( - CompactionStrategy, - ContextProvider, - MiddlewareTypes, - TokenizerProtocol, - ToolTypes, -) +from agent_framework import ToolTypes from azurefunctions.extensions.agents.base import markdown_agent as base_markdown_agent @@ -26,38 +20,12 @@ def _provider_options( tools: ( ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None ) = None, - description: str | None = None, - default_options: Any | None = None, - context_providers: Sequence[ContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - require_per_service_call_history_persistence: bool | None = None, - compaction_strategy: CompactionStrategy | None = None, - tokenizer: TokenizerProtocol | None = None, - additional_properties: MutableMapping[str, Any] | None = None, ) -> dict[str, Any]: options: dict[str, Any] = {} if client_factory is not None: options["client_factory"] = client_factory if tools is not None: options["tools"] = tools - if description is not None: - options["description"] = description - if default_options is not None: - options["default_options"] = default_options - if context_providers is not None: - options["context_providers"] = context_providers - if middleware is not None: - options["middleware"] = middleware - if require_per_service_call_history_persistence is not None: - options["require_per_service_call_history_persistence"] = ( - require_per_service_call_history_persistence - ) - if compaction_strategy is not None: - options["compaction_strategy"] = compaction_strategy - if tokenizer is not None: - options["tokenizer"] = tokenizer - if additional_properties is not None: - options["additional_properties"] = additional_properties return options @@ -66,45 +34,17 @@ def markdown_agent( *, arg_name: str, agent_name: str, - provider: str = AGENT_FRAMEWORK_PROVIDER_ID, client_factory: ClientFactory | None = None, - app_root: str | os.PathLike[str] | None = None, tools: ( ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None ) = None, - description: str | None = None, - default_options: Any | None = None, - context_providers: Sequence[ContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - require_per_service_call_history_persistence: bool | None = None, - compaction_strategy: CompactionStrategy | None = None, - tokenizer: TokenizerProtocol | None = None, - additional_properties: MutableMapping[str, Any] | None = None, - **provider_options: Any, ) -> Callable[[_F], _F]: return base_markdown_agent( app, - provider=provider, + provider=AGENT_FRAMEWORK_PROVIDER_ID, arg_name=arg_name, agent_name=agent_name, - app_root=app_root, - **{ - **_provider_options( - client_factory=client_factory, - tools=tools, - description=description, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - require_per_service_call_history_persistence=( - require_per_service_call_history_persistence - ), - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - additional_properties=additional_properties, - ), - **provider_options, - }, + **_provider_options(client_factory=client_factory, tools=tools), ) @@ -122,82 +62,32 @@ def __init__( | Sequence[ToolTypes | Callable[..., Any]] | None ) = None, - description: str | None = None, - default_options: Any | None = None, - context_providers: Sequence[ContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - require_per_service_call_history_persistence: bool = False, - compaction_strategy: CompactionStrategy | None = None, - tokenizer: TokenizerProtocol | None = None, - additional_properties: MutableMapping[str, Any] | None = None, http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, ) -> None: super().__init__( http_auth_level=http_auth_level, provider=AGENT_FRAMEWORK_PROVIDER_ID, app_root=app_root, - **_provider_options( - client_factory=client_factory, - tools=tools, - description=description, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - require_per_service_call_history_persistence=( - require_per_service_call_history_persistence - ), - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - additional_properties=additional_properties, - ), + **_provider_options(client_factory=client_factory, tools=tools), ) - def markdown_agent( # type: ignore[override] + def markdown_agent( self, *, arg_name: str, agent_name: str, - provider: str = AGENT_FRAMEWORK_PROVIDER_ID, client_factory: ClientFactory | None = None, - app_root: str | os.PathLike[str] | None = None, tools: ( ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None ) = None, - description: str | None = None, - default_options: Any | None = None, - context_providers: Sequence[ContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - require_per_service_call_history_persistence: bool | None = None, - compaction_strategy: CompactionStrategy | None = None, - tokenizer: TokenizerProtocol | None = None, - additional_properties: MutableMapping[str, Any] | None = None, - **provider_options: Any, ) -> Callable[[_F], _F]: return super().markdown_agent( - provider=provider, arg_name=arg_name, agent_name=agent_name, - app_root=app_root, - **{ - **_provider_options( - client_factory=client_factory, - tools=tools, - description=description, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - require_per_service_call_history_persistence=( - require_per_service_call_history_persistence - ), - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - additional_properties=additional_properties, - ), - **provider_options, - }, + **_provider_options(client_factory=client_factory, tools=tools), ) diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py index 2fa5f90..9d7704a 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py @@ -29,20 +29,7 @@ r"\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%" ) -_SUPPORTED_OPTIONS = frozenset( - { - "additional_properties", - "client_factory", - "compaction_strategy", - "context_providers", - "default_options", - "description", - "middleware", - "require_per_service_call_history_persistence", - "tokenizer", - "tools", - } -) +_SUPPORTED_OPTIONS = frozenset({"client_factory", "tools"}) @dataclass(frozen=True) @@ -60,8 +47,7 @@ def _create_agent( ) -> Agent[Any]: options = dict(self.agent_options) if skills_provider is not None: - context_providers = _option_values(options.pop("context_providers", None)) - options["context_providers"] = [*context_providers, skills_provider] + options["context_providers"] = [skills_provider] if mcp_tools: tools = _option_values(options.pop("tools", None)) options["tools"] = [*tools, *mcp_tools] diff --git a/azurefunctions-extensions-agents-framework/tests/test_apps.py b/azurefunctions-extensions-agents-framework/tests/test_apps.py index 10c7b8b..2991702 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_apps.py +++ b/azurefunctions-extensions-agents-framework/tests/test_apps.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect from unittest.mock import Mock import azure.functions as func @@ -12,20 +13,43 @@ from azurefunctions.extensions.agents.framework import apps +def test_typed_api_exposes_only_v1_options(): + assert list(inspect.signature(markdown_agent).parameters) == [ + "app", + "arg_name", + "agent_name", + "client_factory", + "tools", + ] + assert list(inspect.signature(AiApp.__init__).parameters) == [ + "self", + "client_factory", + "app_root", + "tools", + "http_auth_level", + ] + assert list(inspect.signature(AiApp.markdown_agent).parameters) == [ + "self", + "arg_name", + "agent_name", + "client_factory", + "tools", + ] + + def test_typed_ai_app_pins_framework_provider(monkeypatch): parent_init = Mock() monkeypatch.setattr(func.AiApp, "__init__", parent_init) factory = lambda: object() - AiApp(client_factory=factory, app_root="app", description="orders") + AiApp(client_factory=factory, app_root="app", tools=["lookup"]) parent_init.assert_called_once_with( http_auth_level=func.AuthLevel.FUNCTION, provider="agent_framework", app_root="app", client_factory=factory, - description="orders", - require_per_service_call_history_persistence=False, + tools=["lookup"], ) @@ -44,37 +68,13 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): assert result is parent_decorator.return_value parent_decorator.assert_called_once_with( - provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=None, client_factory=factory, tools=["lookup"], ) -def test_typed_ai_app_can_select_another_provider(monkeypatch): - parent_decorator = Mock(return_value=object()) - monkeypatch.setattr(func.AiApp, "markdown_agent", parent_decorator) - app = object.__new__(AiApp) - - result = app.markdown_agent( - provider="langgraph", - arg_name="agent", - agent_name="researcher", - recursion_limit=10, - ) - - assert result is parent_decorator.return_value - parent_decorator.assert_called_once_with( - provider="langgraph", - arg_name="agent", - agent_name="researcher", - app_root=None, - recursion_limit=10, - ) - - def test_typed_decorator_preserves_app_provider_defaults(monkeypatch): base_decorator = Mock(return_value=object()) monkeypatch.setattr(apps, "base_markdown_agent", base_decorator) @@ -94,35 +94,10 @@ def test_typed_decorator_preserves_app_provider_defaults(monkeypatch): provider="agent_framework", arg_name="agent", agent_name="orders", - app_root=None, client_factory=factory, ) -def test_typed_decorator_can_select_another_provider(monkeypatch): - base_decorator = Mock(return_value=object()) - monkeypatch.setattr(apps, "base_markdown_agent", base_decorator) - app = func.FunctionApp() - - result = markdown_agent( - app, - provider="langgraph", - arg_name="agent", - agent_name="researcher", - recursion_limit=10, - ) - - assert result is base_decorator.return_value - base_decorator.assert_called_once_with( - app, - provider="langgraph", - arg_name="agent", - agent_name="researcher", - app_root=None, - recursion_limit=10, - ) - - def test_typed_durable_ai_app_is_typed_ai_app(): assert issubclass(DurableAiApp, AiApp) assert issubclass(DurableAiApp, func.DurableAiApp) From 5103b0c9559ae8b397084826cf6021bdf4f9a7f8 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 15:33:06 -0500 Subject: [PATCH 13/25] feedback --- .../extensions/agents/base/bindings.py | 5 +- .../tests/test_bindings.py | 24 +++++++++ .../extensions/agents/framework/provider.py | 3 +- .../hybrid-function-agent/src/function_app.py | 6 +-- .../tests/test_provider.py | 54 ++++++++++++++++++- .../tests/test_samples.py | 33 ++++++++++++ 6 files changed, 118 insertions(+), 7 deletions(-) diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py index 80ccc02..010e7f9 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py @@ -233,7 +233,10 @@ def _source_call( positional: list[Any] = [] keywords: dict[str, Any] = {} for parameter in source_signature.parameters.values(): - if parameter.kind is inspect.Parameter.POSITIONAL_ONLY: + if parameter.kind in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + }: positional.append(values[parameter.name]) elif parameter.kind is inspect.Parameter.VAR_POSITIONAL: positional.extend(values.get(parameter.name, ())) diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py index 9af5757..5880f5c 100644 --- a/azurefunctions-extensions-agents-base/tests/test_bindings.py +++ b/azurefunctions-extensions-agents-base/tests/test_bindings.py @@ -85,6 +85,30 @@ async def handler(value: str, agent: object) -> tuple[str, object]: assert provider.compiled.opened == provider.compiled.closed == 2 +def test_markdown_agent_preserves_variadic_handler_arguments(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler( + value: str, + agent: object, + *rest: str, + ) -> tuple[str, object, tuple[str, ...]]: + return value, agent, rest + + value, agent, rest = asyncio.run(handler("one", "two", "three")) + + assert value == "one" + assert agent["instance"] == 1 + assert rest == ("two", "three") + + def test_markdown_agent_closes_context_when_handler_fails(tmp_path, provider): (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") app = func.FunctionApp() diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py index 9d7704a..657c78d 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py @@ -266,8 +266,7 @@ async def inject_headers(request: Any) -> None: load_prompts=False, http_client=http_client, ) - entered_tool = await stack.enter_async_context(tool) - yield entered_tool + yield tool def create_provider() -> AgentFrameworkProvider: diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py index 74f99c4..93bf180 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py @@ -29,14 +29,14 @@ async def process_order( order_agent: Agent, ) -> func.HttpResponse: order_id = req.route_params["orderId"] - order = req.get_json() try: + order = req.get_json() prepared_order = prepare_order_for_agent(order, order_id=order_id) except (ValidationError, ValueError): return func.HttpResponse( body=json.dumps({"error": "Order failed validation."}), status_code=400, - media_type="application/json", + mimetype="application/json", ) response = await order_agent.run( @@ -49,7 +49,7 @@ async def process_order( ) return func.HttpResponse( body=json.dumps({"order_id": order_id, "assessment": response.text}), - media_type="application/json", + mimetype="application/json", ) diff --git a/azurefunctions-extensions-agents-framework/tests/test_provider.py b/azurefunctions-extensions-agents-framework/tests/test_provider.py index c9e85d8..0a42778 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_provider.py +++ b/azurefunctions-extensions-agents-framework/tests/test_provider.py @@ -2,7 +2,7 @@ import asyncio import inspect -from contextlib import asynccontextmanager +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock @@ -217,6 +217,58 @@ async def invoke_twice(): assert _Agent.created[0].closed +def test_binding_enters_mcp_tool_once_through_agent(monkeypatch): + import agent_framework + + events = [] + + class FakeTool: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + events.append("connect") + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + events.append("close") + + class AgentOwningTools(_Agent): + async def __aenter__(self): + await super().__aenter__() + self.tool_stack = AsyncExitStack() + await self.tool_stack.__aenter__() + for tool in self.kwargs.get("tools", []): + if isinstance(tool, FakeTool): + await self.tool_stack.enter_async_context(tool) + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.tool_stack.__aexit__(exc_type, exc_value, traceback) + await super().__aexit__(exc_type, exc_value, traceback) + + monkeypatch.setattr(agent_framework, "MCPStreamableHTTPTool", FakeTool) + monkeypatch.setattr(provider, "Agent", AgentOwningTools) + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig("https://mcp.example.test"), + ), + ), + ) + ) + + async def invoke(): + async with binding.open_agent(InvocationMetadata()): + assert events == ["connect"] + + asyncio.run(invoke()) + + assert events == ["connect", "close"] + + def test_skills_provider_owns_skill_format_validation(monkeypatch): from_paths = Mock(return_value=object()) monkeypatch.setattr(provider.SkillsProvider, "from_paths", from_paths) diff --git a/azurefunctions-extensions-agents-framework/tests/test_samples.py b/azurefunctions-extensions-agents-framework/tests/test_samples.py index a26595a..d8ee87c 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_samples.py +++ b/azurefunctions-extensions-agents-framework/tests/test_samples.py @@ -53,3 +53,36 @@ def test_sample_indexes_all_functions(sample_name, expected_names): ) assert set(json.loads(completed.stdout)) == expected_names + + +def test_hybrid_function_sample_rejects_malformed_json(): + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) + ) + completed = subprocess.run( + [ + sys.executable, + "-c", + ( + "import asyncio, json; import azure.functions as func; " + "import function_app; " + "request = func.HttpRequest(method='POST', url='https://example.test', " + "body=b'{not json', route_params={'orderId': '42'}); " + "handler = function_app.process_order._function.get_user_function()" + ".__wrapped__; " + "response = asyncio.run(handler(request, object())); " + "print(json.dumps({'status_code': response.status_code, " + "'body': response.get_body().decode()}))" + ), + ], + cwd=_SAMPLES_ROOT / "hybrid-function-agent" / "src", + env=environment, + check=True, + capture_output=True, + text=True, + ) + + result = json.loads(completed.stdout) + assert result["status_code"] == 400 + assert json.loads(result["body"]) == {"error": "Order failed validation."} From e954fad79e435370cfff5a4043151e6be01d377e Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 15:44:07 -0500 Subject: [PATCH 14/25] fix sample --- .../hybrid-durable-agent/src/function_app.py | 2 +- .../tests/test_samples.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py index baadaa0..64f80ea 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py @@ -38,7 +38,7 @@ async def start_order_orchestration( return func.HttpResponse( body=json.dumps(management), status_code=202, - media_type="application/json", + mimetype="application/json", headers={ "Location": management["statusQueryGetUri"], "Retry-After": "10", diff --git a/azurefunctions-extensions-agents-framework/tests/test_samples.py b/azurefunctions-extensions-agents-framework/tests/test_samples.py index d8ee87c..ade20e0 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_samples.py +++ b/azurefunctions-extensions-agents-framework/tests/test_samples.py @@ -86,3 +86,42 @@ def test_hybrid_function_sample_rejects_malformed_json(): result = json.loads(completed.stdout) assert result["status_code"] == 400 assert json.loads(result["body"]) == {"error": "Order failed validation."} + + +def test_hybrid_durable_sample_starts_orchestration(): + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) + ) + script = ( + "import asyncio, json\n" + "import azure.functions as func\n" + "import function_app\n" + "class FakeClient:\n" + " async def start_new(self, name, *, client_input):\n" + " return 'instance-42'\n" + " def create_http_management_payload(self, instance_id):\n" + " return {'statusQueryGetUri': 'https://example.test/status/42'}\n" + "request = func.HttpRequest(method='POST', url='https://example.test', " + "body=b'{}')\n" + "handler = function_app.start_order_orchestration._function" + ".get_user_function().__wrapped__\n" + "response = asyncio.run(handler(request, FakeClient()))\n" + "print(json.dumps({'status_code': response.status_code, " + "'mimetype': response.mimetype, " + "'location': response.headers['Location']}))\n" + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=_SAMPLES_ROOT / "hybrid-durable-agent" / "src", + env=environment, + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(completed.stdout) == { + "status_code": 202, + "mimetype": "application/json", + "location": "https://example.test/status/42", + } From a48f639449d59e1018f8b0908081445e73b4b066 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 8 Sep 2026 11:28:09 -0500 Subject: [PATCH 15/25] feedback --- .../README.md | 6 +++--- .../extensions/agents/base/bindings.py | 2 +- .../extensions/agents/base/durable.py | 2 +- .../README.md | 16 +++++++-------- .../extensions/agents/framework/__init__.py | 6 +++--- .../extensions/agents/framework/apps.py | 4 ++-- .../hybrid-durable-agent/src/function_app.py | 4 ++-- .../hybrid-function-agent/src/function_app.py | 4 ++-- .../tests/test_apps.py | 20 +++++++++---------- 9 files changed, 32 insertions(+), 32 deletions(-) diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-extensions-agents-base/README.md index 0576c13..0e28394 100644 --- a/azurefunctions-extensions-agents-base/README.md +++ b/azurefunctions-extensions-agents-base/README.md @@ -21,13 +21,13 @@ compiled recipe creates a fresh Agent context for each invocation and can run an Agent from a Durable activity. Applications use `azure.functions.FunctionApp.markdown_agent()` or install a -typed provider package. Each Function App uses one provider. `AiApp` pins it at +typed provider package. Each Function App uses one provider. `AIApp` pins it at construction; a plain `FunctionApp` pins it on its first `markdown_agent(provider=...)` use. A later different provider is rejected. Provider discovery is cached, while live Agents and clients are never cached. Provider defaults are app-scoped. Binding options override defaults only for -that binding. The app root is configured once on `AiApp` or inferred from +that binding. The app root is configured once on `AIApp` or inferred from `AzureWebJobsScriptRoot` and then the current directory for a plain app; decorators cannot override it. @@ -76,5 +76,5 @@ Provider packages expose Durable support through their own `[durable]` extra. The base extra installs `azure-functions-durable>=1.2.10,<2`; normal imports do not import or require Durable Functions. `DurableAgentContext.call_agent()` schedules a hidden activity with a deterministic, JSON-only payload and always -uses the `DurableAiApp` provider. All file, client, Agent, model, and +uses the `DurableAIApp` provider. All file, client, Agent, model, and tool I/O occurs in the activity, never in the orchestrator. diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py index 010e7f9..4defc5c 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py @@ -287,7 +287,7 @@ def markdown_agent( **provider_options: Any, ) -> Callable[[_F], _F]: if "app_root" in provider_options: - raise TypeError("markdown_agent app_root is app-scoped; configure it on AiApp") + raise TypeError("markdown_agent app_root is app-scoped; configure it on AIApp") state = _state_for(app, provider=provider) def decorate(handler: _F) -> _F: diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py index 7bc102f..3aa18f8 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py +++ b/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py @@ -205,7 +205,7 @@ def durable_orchestration_trigger( def decorate(handler: _F) -> Any: if not inspect.isgeneratorfunction(handler): raise TypeError( - "DurableAiApp orchestration_trigger requires a synchronous " + "DurableAIApp orchestration_trigger requires a synchronous " "generator function" ) signature = inspect.signature(handler) diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md index a0410b1..97b0c7e 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-extensions-agents-framework/README.md @@ -29,7 +29,7 @@ client and Agent context are created and closed for every Function invocation. ```python import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents.framework import AiApp +from azurefunctions.extensions.agents.framework import AIApp def create_chat_client(): @@ -38,7 +38,7 @@ def create_chat_client(): return OpenAIChatClient() -app = AiApp(client_factory=create_chat_client) +app = AIApp(client_factory=create_chat_client) @app.route(route="orders", methods=["POST"]) @@ -134,9 +134,9 @@ Every Agent in the Function App receives all valid Skills and MCP servers discovered from the app root: ```python -from azurefunctions.extensions.agents.framework import AiApp +from azurefunctions.extensions.agents.framework import AIApp -app = AiApp(client_factory=create_chat_client) +app = AIApp(client_factory=create_chat_client) @app.markdown_agent(arg_name="agent", agent_name="orders") @@ -171,7 +171,7 @@ async def process_order(req: func.HttpRequest, agent: Agent): Typed constructors and decorators expose only `client_factory` and explicit Python `tools` in V1. The extension owns the Agent client, name, instructions, and discovered Skills/MCP integration. Configure `app_root` only when -constructing `AiApp` or `DurableAiApp`; decorators do not override it. +constructing `AIApp` or `DurableAIApp`; decorators do not override it. ## Durable Agents @@ -181,13 +181,13 @@ Durable orchestration support is optional: pip install "azurefunctions-extensions-agents-framework[durable]" ``` -Use `DurableAiApp` and call `context.call_agent(agent_name, input_)` inside a +Use `DurableAIApp` and call `context.call_agent(agent_name, input_)` inside a synchronous generator orchestrator. Agent execution is isolated in an activity so replay performs no nondeterministic work. Importing the package remains safe -without Durable installed; constructing `DurableAiApp` reports the exact extra +without Durable installed; constructing `DurableAIApp` reports the exact extra to install when it is absent. -All `call_agent()` invocations use the provider configured by `DurableAiApp`. +All `call_agent()` invocations use the provider configured by `DurableAIApp`. They also use the app-level `skills` and `mcp_servers` defaults. V1 does not support selecting another provider or capability set from an orchestrator, and the schema-v1 orchestration payload contains no capability paths, settings, or diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/__init__.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/__init__.py index 0247a24..1430750 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/__init__.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/__init__.py @@ -1,11 +1,11 @@ -from .apps import AiApp, DurableAiApp, markdown_agent +from .apps import AIApp, DurableAIApp, markdown_agent from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory __all__ = [ "AGENT_FRAMEWORK_PROVIDER_ID", - "AiApp", + "AIApp", "ClientFactory", - "DurableAiApp", + "DurableAIApp", "markdown_agent", ] diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py index b9f6717..da5f22e 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py @@ -48,7 +48,7 @@ def markdown_agent( ) -class AiApp(func.AiApp): +class AIApp(func.AIApp): """Azure Functions app configured for Microsoft Agent Framework.""" def __init__( @@ -91,5 +91,5 @@ def markdown_agent( ) -class DurableAiApp(AiApp, func.DurableAiApp): +class DurableAIApp(AIApp, func.DurableAIApp): """Microsoft Agent Framework app with optional Durable Agent support.""" diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py index 64f80ea..10eb898 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py @@ -5,7 +5,7 @@ import azure.durable_functions as df import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents.framework import DurableAiApp +from azurefunctions.extensions.agents.framework import DurableAIApp from order_processing import prepare_order_for_agent @@ -20,7 +20,7 @@ def create_chat_client(): ) -app = DurableAiApp(client_factory=create_chat_client) +app = DurableAIApp(client_factory=create_chat_client) @app.route(route="orders/orchestrations", methods=["POST"]) diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py index 93bf180..ef74532 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py +++ b/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py @@ -3,7 +3,7 @@ import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents.framework import AiApp +from azurefunctions.extensions.agents.framework import AIApp from order_processing import prepare_order_for_agent from pydantic import ValidationError @@ -19,7 +19,7 @@ def create_chat_client(): ) -app = AiApp(client_factory=create_chat_client) +app = AIApp(client_factory=create_chat_client) @app.route(route="orders/{orderId}", methods=["POST"]) diff --git a/azurefunctions-extensions-agents-framework/tests/test_apps.py b/azurefunctions-extensions-agents-framework/tests/test_apps.py index 2991702..795a446 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_apps.py +++ b/azurefunctions-extensions-agents-framework/tests/test_apps.py @@ -6,8 +6,8 @@ import azure.functions as func from azurefunctions.extensions.agents.framework import ( - AiApp, - DurableAiApp, + AIApp, + DurableAIApp, markdown_agent, ) from azurefunctions.extensions.agents.framework import apps @@ -21,14 +21,14 @@ def test_typed_api_exposes_only_v1_options(): "client_factory", "tools", ] - assert list(inspect.signature(AiApp.__init__).parameters) == [ + assert list(inspect.signature(AIApp.__init__).parameters) == [ "self", "client_factory", "app_root", "tools", "http_auth_level", ] - assert list(inspect.signature(AiApp.markdown_agent).parameters) == [ + assert list(inspect.signature(AIApp.markdown_agent).parameters) == [ "self", "arg_name", "agent_name", @@ -39,10 +39,10 @@ def test_typed_api_exposes_only_v1_options(): def test_typed_ai_app_pins_framework_provider(monkeypatch): parent_init = Mock() - monkeypatch.setattr(func.AiApp, "__init__", parent_init) + monkeypatch.setattr(func.AIApp, "__init__", parent_init) factory = lambda: object() - AiApp(client_factory=factory, app_root="app", tools=["lookup"]) + AIApp(client_factory=factory, app_root="app", tools=["lookup"]) parent_init.assert_called_once_with( http_auth_level=func.AuthLevel.FUNCTION, @@ -55,8 +55,8 @@ def test_typed_ai_app_pins_framework_provider(monkeypatch): def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): parent_decorator = Mock(return_value=object()) - monkeypatch.setattr(func.AiApp, "markdown_agent", parent_decorator) - app = object.__new__(AiApp) + monkeypatch.setattr(func.AIApp, "markdown_agent", parent_decorator) + app = object.__new__(AIApp) factory = lambda: object() result = app.markdown_agent( @@ -99,5 +99,5 @@ def test_typed_decorator_preserves_app_provider_defaults(monkeypatch): def test_typed_durable_ai_app_is_typed_ai_app(): - assert issubclass(DurableAiApp, AiApp) - assert issubclass(DurableAiApp, func.DurableAiApp) + assert issubclass(DurableAIApp, AIApp) + assert issubclass(DurableAIApp, func.DurableAIApp) From 82d0fe8afe8e0193781aa8d8ada568a5abd66e4f Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 8 Sep 2026 13:29:26 -0500 Subject: [PATCH 16/25] Rename to agents-extension --- README.md | 4 ++-- .../LICENSE | 0 .../MANIFEST.in | 0 .../README.md | 6 ++--- .../azurefunctions/__init__.py | 0 .../azurefunctions/extensions/__init__.py | 0 .../extensions/agents/framework/__init__.py | 0 .../extensions/agents/framework/apps.py | 0 .../extensions/agents/framework/provider.py | 6 ++--- .../extensions/agents/framework}/py.typed | 0 .../pyproject.toml | 8 +++---- .../samples/README.md | 0 .../samples/hybrid-durable-agent/README.md | 0 .../hybrid-durable-agent/src/function_app.py | 14 +++++------ .../hybrid-durable-agent/src/host.json | 0 .../src/local.settings.template.json | 0 .../src/order-fulfillment.agent.md | 0 .../src/order_processing.py | 0 .../hybrid-durable-agent/src/requirements.txt | 0 .../samples/hybrid-function-agent/README.md | 0 .../hybrid-function-agent/src/function_app.py | 0 .../hybrid-function-agent/src/host.json | 0 .../src/local.settings.template.json | 0 .../hybrid-function-agent/src/mcp.json | 0 .../src/order-fulfillment.agent.md | 0 .../src/order_processing.py | 0 .../src/requirements.txt | 0 .../src/skills/order-policy/SKILL.md | 0 .../tests/test_apps.py | 0 .../tests/test_imports.py | 0 .../tests/test_provider.py | 0 .../tests/test_samples.py | 3 ++- .../LICENSE | 0 .../MANIFEST.in | 0 .../README.md | 4 ++-- .../azurefunctions/__init__.py | 0 .../azurefunctions/extensions/__init__.py | 0 .../extensions/agents/base/__init__.py | 0 .../extensions/agents/base/bindings.py | 0 .../extensions/agents/base/capabilities.py | 0 .../agents/base/discovery/__init__.py | 0 .../extensions/agents/base/discovery/mcp.py | 0 .../agents/base/discovery/skills.py | 0 .../extensions/agents/base/durable.py | 18 ++++++--------- .../extensions/agents/base/providers.py | 4 +--- .../extensions/agents/base}/py.typed | 0 .../pyproject.toml | 6 ++--- .../tests/test_bindings.py | 2 +- .../tests/test_capability_discovery.py | 0 .../tests/test_durable.py | 23 +++++++++++++++---- .../tests/test_imports.py | 0 .../tests/test_providers.py | 10 ++++---- eng/templates/jobs/build.yml | 4 ++-- .../official/jobs/build-artifacts.yml | 4 ++-- eng/templates/official/jobs/unit-tests.yml | 10 ++++---- 55 files changed, 68 insertions(+), 58 deletions(-) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-agent-framework}/LICENSE (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-agent-framework}/MANIFEST.in (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/README.md (96%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-agent-framework}/azurefunctions/__init__.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-agent-framework}/azurefunctions/extensions/__init__.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/azurefunctions/extensions/agents/framework/__init__.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/azurefunctions/extensions/agents/framework/apps.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/azurefunctions/extensions/agents/framework/provider.py (97%) rename {azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base => azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework}/py.typed (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/pyproject.toml (88%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/README.md (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-durable-agent/README.md (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-durable-agent/src/function_app.py (88%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-durable-agent/src/host.json (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-durable-agent/src/local.settings.template.json (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-durable-agent/src/order-fulfillment.agent.md (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-durable-agent/src/order_processing.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-durable-agent/src/requirements.txt (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/README.md (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/src/function_app.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/src/host.json (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/src/local.settings.template.json (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/src/mcp.json (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/src/order-fulfillment.agent.md (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/src/order_processing.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/src/requirements.txt (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/tests/test_apps.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/tests/test_imports.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/tests/test_provider.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-agent-framework}/tests/test_samples.py (97%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-base}/LICENSE (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-base}/MANIFEST.in (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/README.md (95%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-base}/azurefunctions/__init__.py (100%) rename {azurefunctions-extensions-agents-framework => azurefunctions-agents-extension-base}/azurefunctions/extensions/__init__.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/azurefunctions/extensions/agents/base/__init__.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/azurefunctions/extensions/agents/base/bindings.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/azurefunctions/extensions/agents/base/capabilities.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/azurefunctions/extensions/agents/base/discovery/__init__.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/azurefunctions/extensions/agents/base/discovery/mcp.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/azurefunctions/extensions/agents/base/discovery/skills.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/azurefunctions/extensions/agents/base/durable.py (94%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/azurefunctions/extensions/agents/base/providers.py (96%) rename {azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework => azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base}/py.typed (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/pyproject.toml (91%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/tests/test_bindings.py (99%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/tests/test_capability_discovery.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/tests/test_durable.py (92%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/tests/test_imports.py (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extension-base}/tests/test_providers.py (92%) diff --git a/README.md b/README.md index 2d03d6c..8ac6428 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ A supported Python version is required - see ## Available extensions * [Base extension](azurefunctions-extensions-base/README.md) -* [Agent provider base](azurefunctions-extensions-agents-base/README.md) -* [Microsoft Agent Framework](azurefunctions-extensions-agents-framework/README.md) +* [Agent provider base](azurefunctions-agents-extension-base/README.md) +* [Microsoft Agent Framework](azurefunctions-agents-extension-agent-framework/README.md) * [Azure Blob Storage bindings](azurefunctions-extensions-bindings-blob/README.md) * [Azure Cosmos DB bindings](azurefunctions-extensions-bindings-cosmosdb/README.md) * [Azure Event Hubs bindings](azurefunctions-extensions-bindings-eventhub/README.md) diff --git a/azurefunctions-extensions-agents-base/LICENSE b/azurefunctions-agents-extension-agent-framework/LICENSE similarity index 100% rename from azurefunctions-extensions-agents-base/LICENSE rename to azurefunctions-agents-extension-agent-framework/LICENSE diff --git a/azurefunctions-extensions-agents-base/MANIFEST.in b/azurefunctions-agents-extension-agent-framework/MANIFEST.in similarity index 100% rename from azurefunctions-extensions-agents-base/MANIFEST.in rename to azurefunctions-agents-extension-agent-framework/MANIFEST.in diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-agents-extension-agent-framework/README.md similarity index 96% rename from azurefunctions-extensions-agents-framework/README.md rename to azurefunctions-agents-extension-agent-framework/README.md index 97b0c7e..7b2ba73 100644 --- a/azurefunctions-extensions-agents-framework/README.md +++ b/azurefunctions-agents-extension-agent-framework/README.md @@ -6,7 +6,7 @@ into Python Azure Functions. ## Install ```text -pip install azurefunctions-extensions-agents-framework +pip install azurefunctions-agents-extension-agent-framework ``` The default package installs `agent-framework-core==1.13.0`. Install the MAF @@ -18,7 +18,7 @@ Skills use the default package. Install remote MCP transport and Entra support with the MCP extra: ```text -pip install "azurefunctions-extensions-agents-framework[mcp]" +pip install "azurefunctions-agents-extension-agent-framework[mcp]" ``` ## Use a typed Agent app @@ -178,7 +178,7 @@ constructing `AIApp` or `DurableAIApp`; decorators do not override it. Durable orchestration support is optional: ```text -pip install "azurefunctions-extensions-agents-framework[durable]" +pip install "azurefunctions-agents-extension-agent-framework[durable]" ``` Use `DurableAIApp` and call `context.call_agent(agent_name, input_)` inside a diff --git a/azurefunctions-extensions-agents-base/azurefunctions/__init__.py b/azurefunctions-agents-extension-agent-framework/azurefunctions/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/__init__.py rename to azurefunctions-agents-extension-agent-framework/azurefunctions/__init__.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/__init__.py b/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/__init__.py rename to azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/__init__.py diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/__init__.py b/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/__init__.py rename to azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/__init__.py diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py b/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/apps.py similarity index 100% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/apps.py rename to azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/apps.py diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/provider.py similarity index 97% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py rename to azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/provider.py index 657c78d..aaa3217 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py +++ b/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/provider.py @@ -88,7 +88,7 @@ async def run_agent( class AgentFrameworkProvider: provider_id = AGENT_FRAMEWORK_PROVIDER_ID - distribution_name = "azurefunctions-extensions-agents-framework" + distribution_name = "azurefunctions-agents-extension-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) def compile_binding( @@ -187,7 +187,7 @@ async def _open_mcp_tool( except ImportError as error: raise ImportError( "MCP support is not installed. Install " - "'azurefunctions-extensions-agents-framework[mcp]'." + "'azurefunctions-agents-extension-agent-framework[mcp]'." ) from error config = definition.config @@ -230,7 +230,7 @@ async def _open_mcp_tool( except ImportError as error: raise ImportError( "MCP Entra authentication is not installed. Install " - "'azurefunctions-extensions-agents-framework[mcp]'." + "'azurefunctions-agents-extension-agent-framework[mcp]'." ) from error credential = DefaultAzureCredential( managed_identity_client_id=client_id, diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/py.typed b/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/py.typed similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/py.typed rename to azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/py.typed diff --git a/azurefunctions-extensions-agents-framework/pyproject.toml b/azurefunctions-agents-extension-agent-framework/pyproject.toml similarity index 88% rename from azurefunctions-extensions-agents-framework/pyproject.toml rename to azurefunctions-agents-extension-agent-framework/pyproject.toml index 48965fc..c92048c 100644 --- a/azurefunctions-extensions-agents-framework/pyproject.toml +++ b/azurefunctions-agents-extension-agent-framework/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools >= 61.0"] build-backend = "setuptools.build_meta" [project] -name = "azurefunctions-extensions-agents-framework" +name = "azurefunctions-agents-extension-agent-framework" dynamic = ["version"] requires-python = ">=3.13" authors = [ @@ -26,7 +26,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core==1.13.0", - "azurefunctions-extensions-agents-base>=1.0.0b1", + "azurefunctions-agents-extension-base>=1.0.0b1", ] [project.optional-dependencies] @@ -36,10 +36,10 @@ mcp = [ "mcp>=1.28.1,<2", ] durable = [ - "azurefunctions-extensions-agents-base[durable]>=1.0.0b1", + "azurefunctions-agents-extension-base[durable]>=1.0.0b1", ] dev = [ - "azure-functions-durable>=1.2.10,<2", + "azure-functions-durable>=2.0.0b2", "coverage", "flake8", "mypy", diff --git a/azurefunctions-extensions-agents-framework/samples/README.md b/azurefunctions-agents-extension-agent-framework/samples/README.md similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/README.md rename to azurefunctions-agents-extension-agent-framework/samples/README.md diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/README.md b/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/README.md similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/README.md rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/README.md diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py b/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/function_app.py similarity index 88% rename from azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/function_app.py index 10eb898..e9cf18f 100644 --- a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/function_app.py +++ b/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/function_app.py @@ -1,6 +1,7 @@ import json import os -from typing import Any, cast +from datetime import timedelta +from typing import Any import azure.durable_functions as df import azure.functions as func @@ -27,14 +28,13 @@ def create_chat_client(): @app.durable_client_input(client_name="client") async def start_order_orchestration( req: func.HttpRequest, - client: str, + client: df.DurableFunctionsClient, ) -> func.HttpResponse: - durable_client = cast(df.DurableOrchestrationClient, client) - instance_id = await durable_client.start_new( + instance_id = await client.start_new( "order_orchestrator", client_input=req.get_json(), ) - management = durable_client.create_http_management_payload(instance_id) + management = client.create_http_management_payload(req, instance_id) return func.HttpResponse( body=json.dumps(management), status_code=202, @@ -80,8 +80,8 @@ def order_orchestrator(context: Any): "risk_assessment": assessment, "task": "create a fulfillment plan with prioritized human-review actions", }, - retry_options=df.RetryOptions( - first_retry_interval_in_milliseconds=5_000, + retry_options=df.RetryPolicy( + first_retry_interval=timedelta(seconds=5), max_number_of_attempts=3, ), ) diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/host.json b/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/host.json similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/host.json rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/host.json diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/local.settings.template.json b/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/local.settings.template.json similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/local.settings.template.json rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/local.settings.template.json diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md b/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order_processing.py b/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/order_processing.py similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/order_processing.py rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/order_processing.py diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/requirements.txt b/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/requirements.txt similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-durable-agent/src/requirements.txt rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/requirements.txt diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/README.md similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/README.md rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/README.md diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/function_app.py similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/function_app.py rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/function_app.py diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/host.json b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/host.json similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/host.json rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/host.json diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/local.settings.template.json similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/local.settings.template.json rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/local.settings.template.json diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/mcp.json b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/mcp.json similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/mcp.json rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/mcp.json diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order_processing.py b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/order_processing.py similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/order_processing.py rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/order_processing.py diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/requirements.txt similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/requirements.txt rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/requirements.txt diff --git a/azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md b/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md similarity index 100% rename from azurefunctions-extensions-agents-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md rename to azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md diff --git a/azurefunctions-extensions-agents-framework/tests/test_apps.py b/azurefunctions-agents-extension-agent-framework/tests/test_apps.py similarity index 100% rename from azurefunctions-extensions-agents-framework/tests/test_apps.py rename to azurefunctions-agents-extension-agent-framework/tests/test_apps.py diff --git a/azurefunctions-extensions-agents-framework/tests/test_imports.py b/azurefunctions-agents-extension-agent-framework/tests/test_imports.py similarity index 100% rename from azurefunctions-extensions-agents-framework/tests/test_imports.py rename to azurefunctions-agents-extension-agent-framework/tests/test_imports.py diff --git a/azurefunctions-extensions-agents-framework/tests/test_provider.py b/azurefunctions-agents-extension-agent-framework/tests/test_provider.py similarity index 100% rename from azurefunctions-extensions-agents-framework/tests/test_provider.py rename to azurefunctions-agents-extension-agent-framework/tests/test_provider.py diff --git a/azurefunctions-extensions-agents-framework/tests/test_samples.py b/azurefunctions-agents-extension-agent-framework/tests/test_samples.py similarity index 97% rename from azurefunctions-extensions-agents-framework/tests/test_samples.py rename to azurefunctions-agents-extension-agent-framework/tests/test_samples.py index ade20e0..3e6e52d 100644 --- a/azurefunctions-extensions-agents-framework/tests/test_samples.py +++ b/azurefunctions-agents-extension-agent-framework/tests/test_samples.py @@ -100,7 +100,8 @@ def test_hybrid_durable_sample_starts_orchestration(): "class FakeClient:\n" " async def start_new(self, name, *, client_input):\n" " return 'instance-42'\n" - " def create_http_management_payload(self, instance_id):\n" + " def create_http_management_payload(self, request, instance_id):\n" + " assert request is not None\n" " return {'statusQueryGetUri': 'https://example.test/status/42'}\n" "request = func.HttpRequest(method='POST', url='https://example.test', " "body=b'{}')\n" diff --git a/azurefunctions-extensions-agents-framework/LICENSE b/azurefunctions-agents-extension-base/LICENSE similarity index 100% rename from azurefunctions-extensions-agents-framework/LICENSE rename to azurefunctions-agents-extension-base/LICENSE diff --git a/azurefunctions-extensions-agents-framework/MANIFEST.in b/azurefunctions-agents-extension-base/MANIFEST.in similarity index 100% rename from azurefunctions-extensions-agents-framework/MANIFEST.in rename to azurefunctions-agents-extension-base/MANIFEST.in diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-agents-extension-base/README.md similarity index 95% rename from azurefunctions-extensions-agents-base/README.md rename to azurefunctions-agents-extension-base/README.md index 0e28394..72113a6 100644 --- a/azurefunctions-extensions-agents-base/README.md +++ b/azurefunctions-agents-extension-base/README.md @@ -4,7 +4,7 @@ Framework-neutral provider and lifecycle contracts for Python Agent integrations with Azure Functions. This package is infrastructure for provider extensions. Applications should -install a provider package such as `azurefunctions-extensions-agents-framework`. +install a provider package such as `azurefunctions-agents-extension-agent-framework`. ## Provider contract @@ -73,7 +73,7 @@ each invocation. ## Durable support Provider packages expose Durable support through their own `[durable]` extra. -The base extra installs `azure-functions-durable>=1.2.10,<2`; normal imports do +The base extra installs `azure-functions-durable==2.0.0b2`; normal imports do not import or require Durable Functions. `DurableAgentContext.call_agent()` schedules a hidden activity with a deterministic, JSON-only payload and always uses the `DurableAIApp` provider. All file, client, Agent, model, and diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/__init__.py b/azurefunctions-agents-extension-base/azurefunctions/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-framework/azurefunctions/__init__.py rename to azurefunctions-agents-extension-base/azurefunctions/__init__.py diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/__init__.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/__init__.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/__init__.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/__init__.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/__init__.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/bindings.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/bindings.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/bindings.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/capabilities.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/capabilities.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/capabilities.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/capabilities.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/__init__.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/__init__.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/__init__.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/__init__.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/mcp.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/mcp.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/mcp.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/mcp.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/skills.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/skills.py similarity index 100% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/discovery/skills.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/skills.py diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/durable.py similarity index 94% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/durable.py index 3aa18f8..18975a3 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/durable.py +++ b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/durable.py @@ -17,7 +17,7 @@ from azure.durable_functions import ( DurableOrchestrationContext as _DurableContextBase, ) - from azure.durable_functions.models.Task import TaskBase + from durabletask.task import RetryPolicy, Task else: class _DurableContextBase: @@ -115,8 +115,8 @@ def call_agent( agent_name: str, input_: JSONValue, *, - retry_options: df.RetryOptions | None = None, - ) -> TaskBase: + retry_options: RetryPolicy | None = None, + ) -> Task[Any]: if not isinstance(agent_name, str) or not agent_name.strip(): raise ValueError("call_agent agent_name must be a non-empty string") payload = { @@ -127,10 +127,10 @@ def call_agent( } if retry_options is None: return self._context.call_activity(_INTERNAL_AGENT_ACTIVITY_NAME, payload) - from azure.durable_functions import RetryOptions + from durabletask.task import RetryPolicy - if not isinstance(retry_options, RetryOptions): - raise TypeError("call_agent retry_options must be RetryOptions or None") + if not isinstance(retry_options, RetryPolicy): + raise TypeError("call_agent retry_options must be RetryPolicy or None") return self._context.call_activity_with_retry( _INTERNAL_AGENT_ACTIVITY_NAME, retry_options, @@ -139,15 +139,12 @@ def call_agent( def configure_durable_app(app: func.FunctionApp) -> None: - import azure.durable_functions as df - state = _configured_state(app) with state.lock: if state.durable_activity_registered: return - blueprint = df.Blueprint() - @blueprint.activity_trigger( # type: ignore[untyped-decorator] + @app.activity_trigger( input_name="payload" ) async def azurefunctions_agents_run_markdown_agent( @@ -171,7 +168,6 @@ async def azurefunctions_agents_run_markdown_agent( invocation, ) - app.register_blueprint(blueprint) state.durable_activity_registered = True diff --git a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/providers.py similarity index 96% rename from azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/providers.py index 1496342..9fda622 100644 --- a/azurefunctions-extensions-agents-base/azurefunctions/extensions/agents/base/providers.py +++ b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/providers.py @@ -52,9 +52,7 @@ def compile_binding( def _provider_distribution_name(provider_id: str) -> str: normalized = provider_id.replace("_", "-") - if normalized.startswith("agent-"): - normalized = normalized.removeprefix("agent-") - return f"azurefunctions-extensions-agents-{normalized}" + return f"azurefunctions-agents-extension-{normalized}" def _entry_point_distribution(entry_point: metadata.EntryPoint) -> str: diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/py.typed b/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/py.typed similarity index 100% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/py.typed rename to azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/py.typed diff --git a/azurefunctions-extensions-agents-base/pyproject.toml b/azurefunctions-agents-extension-base/pyproject.toml similarity index 91% rename from azurefunctions-extensions-agents-base/pyproject.toml rename to azurefunctions-agents-extension-base/pyproject.toml index 0332f4f..3c1dd8c 100644 --- a/azurefunctions-extensions-agents-base/pyproject.toml +++ b/azurefunctions-agents-extension-base/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools >= 61.0"] build-backend = "setuptools.build_meta" [project] -name = "azurefunctions-extensions-agents-base" +name = "azurefunctions-agents-extension-base" dynamic = ["version"] requires-python = ">=3.13" authors = [ @@ -30,10 +30,10 @@ dependencies = [ [project.optional-dependencies] durable = [ - "azure-functions-durable>=1.2.10,<2", + "azure-functions-durable>=2.0.0b2", ] dev = [ - "azure-functions-durable>=1.2.10,<2", + "azure-functions-durable>=2.0.0b2", "coverage", "flake8", "mypy", diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-agents-extension-base/tests/test_bindings.py similarity index 99% rename from azurefunctions-extensions-agents-base/tests/test_bindings.py rename to azurefunctions-agents-extension-base/tests/test_bindings.py index 5880f5c..d2a096b 100644 --- a/azurefunctions-extensions-agents-base/tests/test_bindings.py +++ b/azurefunctions-agents-extension-base/tests/test_bindings.py @@ -32,7 +32,7 @@ async def run_agent(self, prompt, invocation): class _Provider: provider_id = "agent_framework" - distribution_name = "azurefunctions-extensions-agents-framework" + distribution_name = "azurefunctions-agents-extension-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) def __init__(self): diff --git a/azurefunctions-extensions-agents-base/tests/test_capability_discovery.py b/azurefunctions-agents-extension-base/tests/test_capability_discovery.py similarity index 100% rename from azurefunctions-extensions-agents-base/tests/test_capability_discovery.py rename to azurefunctions-agents-extension-base/tests/test_capability_discovery.py diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-agents-extension-base/tests/test_durable.py similarity index 92% rename from azurefunctions-extensions-agents-base/tests/test_durable.py rename to azurefunctions-agents-extension-base/tests/test_durable.py index 32090c1..70cca3f 100644 --- a/azurefunctions-extensions-agents-base/tests/test_durable.py +++ b/azurefunctions-agents-extension-base/tests/test_durable.py @@ -3,6 +3,7 @@ import asyncio import math from contextlib import asynccontextmanager +from datetime import timedelta from types import SimpleNamespace import azure.functions as func @@ -54,10 +55,13 @@ def test_call_agent_schedules_canonical_payload(): def test_call_agent_schedules_retry_with_same_canonical_payload(): - from azure.durable_functions import RetryOptions + from azure.durable_functions import RetryPolicy context = _Context() - retry_options = RetryOptions(1000, 3) + retry_options = RetryPolicy( + first_retry_interval=timedelta(seconds=1), + max_number_of_attempts=3, + ) proxy = DurableAgentContext(context) task = proxy.call_agent( @@ -134,7 +138,7 @@ async def run_agent(self, prompt, invocation): class _Provider: provider_id = "agent_framework" - distribution_name = "azurefunctions-extensions-agents-framework" + distribution_name = "azurefunctions-agents-extension-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) def __init__(self): @@ -158,6 +162,15 @@ def _configured_app(tmp_path, monkeypatch): return app, provider +def _hidden_activity(app): + return next( + function.get_user_function() + for function in app.get_functions() + if function.get_function_name() + == "azurefunctions_agents_run_markdown_agent" + ) + + def test_configure_durable_app_registers_hidden_activity_once(tmp_path, monkeypatch): app, _ = _configured_app(tmp_path, monkeypatch) @@ -187,7 +200,7 @@ def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypat (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) app, provider = _configured_app(tmp_path, monkeypatch) durable.configure_durable_app(app) - activity = app.get_functions()[0].get_user_function() + activity = _hidden_activity(app) context = SimpleNamespace( function_name="activity", invocation_id="invocation-1", @@ -234,7 +247,7 @@ def test_hidden_activity_receives_all_discovered_capabilities(tmp_path, monkeypa ) app, provider = _configured_app(tmp_path, monkeypatch) durable.configure_durable_app(app) - activity = app.get_functions()[0].get_user_function() + activity = _hidden_activity(app) asyncio.run( activity( diff --git a/azurefunctions-extensions-agents-base/tests/test_imports.py b/azurefunctions-agents-extension-base/tests/test_imports.py similarity index 100% rename from azurefunctions-extensions-agents-base/tests/test_imports.py rename to azurefunctions-agents-extension-base/tests/test_imports.py diff --git a/azurefunctions-extensions-agents-base/tests/test_providers.py b/azurefunctions-agents-extension-base/tests/test_providers.py similarity index 92% rename from azurefunctions-extensions-agents-base/tests/test_providers.py rename to azurefunctions-agents-extension-base/tests/test_providers.py index 24c902b..a7b6513 100644 --- a/azurefunctions-extensions-agents-base/tests/test_providers.py +++ b/azurefunctions-agents-extension-base/tests/test_providers.py @@ -9,7 +9,7 @@ class _Provider: provider_id = "agent_framework" - distribution_name = "azurefunctions-extensions-agents-framework" + distribution_name = "azurefunctions-agents-extension-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) def compile_binding(self, **kwargs): @@ -41,7 +41,7 @@ def test_load_provider_uses_matching_entry_point(monkeypatch): "agent_framework", "test:provider", _Provider, - "azurefunctions-extensions-agents-framework", + "azurefunctions-agents-extension-agent-framework", ) monkeypatch.setattr( providers.metadata, @@ -65,7 +65,7 @@ class OtherProvider(_Provider): "agent_framework", "test:provider", _Provider, - "azurefunctions-extensions-agents-framework", + "azurefunctions-agents-extension-agent-framework", ), _EntryPoint("other", "test:other", OtherProvider, "other-provider"), ] @@ -86,7 +86,9 @@ def enumerate_entry_points(**kwargs): def test_load_provider_reports_installable_distribution(monkeypatch): monkeypatch.setattr(providers.metadata, "entry_points", lambda **kwargs: []) - with pytest.raises(LookupError, match="azurefunctions-extensions-agents-framework"): + with pytest.raises( + LookupError, match="azurefunctions-agents-extension-agent-framework" + ): providers.load_provider("agent_framework") diff --git a/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml index 8502147..da22499 100644 --- a/eng/templates/jobs/build.yml +++ b/eng/templates/jobs/build.yml @@ -8,10 +8,10 @@ jobs: EXTENSION_DIRECTORY: 'azurefunctions-extensions-base' EXTENSION_NAME: 'Base' agents_base_extension: - EXTENSION_DIRECTORY: 'azurefunctions-extensions-agents-base' + EXTENSION_DIRECTORY: 'azurefunctions-agents-extension-base' EXTENSION_NAME: 'Agents Base' agents_framework_extension: - EXTENSION_DIRECTORY: 'azurefunctions-extensions-agents-framework' + EXTENSION_DIRECTORY: 'azurefunctions-agents-extension-agent-framework' EXTENSION_NAME: 'Agents Framework' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml index a5c24c3..ab63be7 100644 --- a/eng/templates/official/jobs/build-artifacts.yml +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -8,10 +8,10 @@ jobs: EXTENSION_DIRECTORY: 'azurefunctions-extensions-base' EXTENSION_NAME: 'Base' agents_base_extension: - EXTENSION_DIRECTORY: 'azurefunctions-extensions-agents-base' + EXTENSION_DIRECTORY: 'azurefunctions-agents-extension-base' EXTENSION_NAME: 'Agents Base' agents_framework_extension: - EXTENSION_DIRECTORY: 'azurefunctions-extensions-agents-framework' + EXTENSION_DIRECTORY: 'azurefunctions-agents-extension-agent-framework' EXTENSION_NAME: 'Agents Framework' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index be4d2ae..a09b512 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -38,11 +38,11 @@ jobs: versionSpec: $(PYTHON_VERSION) - bash: | python -m pip install --upgrade pip - cd azurefunctions-extensions-agents-base + cd azurefunctions-agents-extension-base python -m pip install -U -e .[dev] displayName: 'Install Agents Base Dependencies' - bash: | - python -m pytest -q --instafail azurefunctions-extensions-agents-base/tests/ + python -m pytest -q --instafail azurefunctions-agents-extension-base/tests/ displayName: "Run Agents Base Tests for Python $(PYTHON_VERSION)" - job: "AgentsFrameworkTests" @@ -66,12 +66,12 @@ jobs: versionSpec: $(PYTHON_VERSION) - bash: | python -m pip install --upgrade pip - python -m pip install -e ./azurefunctions-extensions-agents-base - cd azurefunctions-extensions-agents-framework + python -m pip install -e ./azurefunctions-agents-extension-base + cd azurefunctions-agents-extension-agent-framework python -m pip install -U -e .[dev] displayName: 'Install Agents Framework Dependencies' - bash: | - python -m pytest -q --instafail azurefunctions-extensions-agents-framework/tests/ + python -m pytest -q --instafail azurefunctions-agents-extension-agent-framework/tests/ displayName: "Run Agents Framework Tests for Python $(PYTHON_VERSION)" - job: "BaseTests" From a1e876be5fa18ecee7e825b38b599a0ac4bdbfbf Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 8 Sep 2026 13:29:50 -0500 Subject: [PATCH 17/25] update to durable v2.x support --- .../README.md | 80 ++++ .../pyproject.toml | 57 +++ .../tests/test_bindings.py | 409 ++++++++++++++++++ .../tests/test_durable.py | 267 ++++++++++++ .../tests/test_providers.py | 142 ++++++ .../README.md | 194 +++++++++ .../extensions/agents/framework/provider.py | 273 ++++++++++++ .../pyproject.toml | 66 +++ 8 files changed, 1488 insertions(+) create mode 100644 azurefunctions-extensions-agents-base/README.md create mode 100644 azurefunctions-extensions-agents-base/pyproject.toml create mode 100644 azurefunctions-extensions-agents-base/tests/test_bindings.py create mode 100644 azurefunctions-extensions-agents-base/tests/test_durable.py create mode 100644 azurefunctions-extensions-agents-base/tests/test_providers.py create mode 100644 azurefunctions-extensions-agents-framework/README.md create mode 100644 azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py create mode 100644 azurefunctions-extensions-agents-framework/pyproject.toml diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-extensions-agents-base/README.md new file mode 100644 index 0000000..72113a6 --- /dev/null +++ b/azurefunctions-extensions-agents-base/README.md @@ -0,0 +1,80 @@ +# Azure Functions Agents Base Extension + +Framework-neutral provider and lifecycle contracts for Python Agent integrations +with Azure Functions. + +This package is infrastructure for provider extensions. Applications should +install a provider package such as `azurefunctions-agents-extension-agent-framework`. + +## Provider contract + +Provider packages register a zero-argument factory in the +`azurefunctions.extensions.agents.providers` entry-point group. The entry-point +name is the provider ID. The factory returns an `AgentProvider` with a matching +`provider_id`, its distribution name, and a `compile_binding()` implementation. + +`compile_binding()` receives the complete markdown instructions, logical Agent +name, immutable provider options, injected parameter annotation, and an +`AgentCapabilities` bundle. Providers declare `supported_capabilities` and +translate neutral Skill/MCP definitions into their own runtime objects. The +compiled recipe creates a fresh Agent context for each invocation and can run +an Agent from a Durable activity. + +Applications use `azure.functions.FunctionApp.markdown_agent()` or install a +typed provider package. Each Function App uses one provider. `AIApp` pins it at +construction; a plain `FunctionApp` pins it on its first +`markdown_agent(provider=...)` use. A later different provider is rejected. +Provider discovery is cached, while live Agents and clients are never cached. + +Provider defaults are app-scoped. Binding options override defaults only for +that binding. The app root is configured once on `AIApp` or inferred from +`AzureWebJobsScriptRoot` and then the current directory for a plain app; +decorators cannot override it. + +## Markdown lookup + +An `agent_name` resolves exactly one UTF-8 file: + +```text +/.agent.md +/agents/.agent.md +``` + +The entire file is passed to the provider unchanged. Front matter, YAML, +substitutions, tools, skills, MCP configuration, and history are not parsed by +this package. If both locations exist, lookup fails as ambiguous. Absolute +paths, separators, traversal components, and symlinks outside `app_root` are +rejected. + +## Skills and MCP discovery + +The base package discovers immutable definitions from the shared app root: + +```text +skills//SKILL.md +mcp.json +``` + +Base discovery records safely contained directories that contain `SKILL.md` +without reading or interpreting those files. Each provider owns Skill format +parsing and validation. MCP servers must use `http` or `streamable-http`; local +commands and stdio are rejected. Discovery does not execute scripts, resolve +environment references, create credentials, or connect to servers. + +Every Agent binding receives all valid Skills and MCP servers discovered from +the app root. V1 has no app-level or per-binding capability selectors. Treat +placing a definition under the app root as granting every Agent in that app +access to it; use separate Function Apps when capabilities require isolation. + +Only immutable definitions are retained in app state. Provider packages must +create and close clients, credentials, tools, and other live resources within +each invocation. + +## Durable support + +Provider packages expose Durable support through their own `[durable]` extra. +The base extra installs `azure-functions-durable==2.0.0b2`; normal imports do +not import or require Durable Functions. `DurableAgentContext.call_agent()` +schedules a hidden activity with a deterministic, JSON-only payload and always +uses the `DurableAIApp` provider. All file, client, Agent, model, and +tool I/O occurs in the activity, never in the orchestrator. diff --git a/azurefunctions-extensions-agents-base/pyproject.toml b/azurefunctions-extensions-agents-base/pyproject.toml new file mode 100644 index 0000000..3c1dd8c --- /dev/null +++ b/azurefunctions-extensions-agents-base/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-agents-extension-base" +dynamic = ["version"] +requires-python = ">=3.13" +authors = [ + { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, +] +description = "Framework-neutral Agent integration for Azure Functions." +readme = "README.md" +license = { text = "MIT License" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Environment :: Web Environment", + "Development Status :: 3 - Alpha", +] +dependencies = [ + "azure-functions>=2.4.0b1,<3", +] + +[project.optional-dependencies] +durable = [ + "azure-functions-durable>=2.0.0b2", +] +dev = [ + "azure-functions-durable>=2.0.0b2", + "coverage", + "flake8", + "mypy", + "pre-commit", + "pytest", + "pytest-cov", + "pytest-instafail", +] + +[tool.setuptools.dynamic] +version = { attr = "azurefunctions.extensions.agents.base.__version__" } + +[tool.setuptools.packages.find] +include = ["azurefunctions.extensions.agents.base*"] + +[tool.setuptools.package-data] +"azurefunctions.extensions.agents.base" = ["py.typed"] + +[[tool.mypy.overrides]] +module = ["azure.durable_functions", "azure.durable_functions.*"] +follow_untyped_imports = true diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py new file mode 100644 index 0000000..d2a096b --- /dev/null +++ b/azurefunctions-extensions-agents-base/tests/test_bindings.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import asyncio +import gc +import inspect +import weakref +from contextlib import asynccontextmanager + +import azure.functions as func +import pytest + +from azurefunctions.extensions.agents.base import AgentCapabilities +from azurefunctions.extensions.agents.base import bindings, providers + + +class _CompiledAgent: + def __init__(self): + self.opened = 0 + self.closed = 0 + + @asynccontextmanager + async def open_agent(self, invocation): + self.opened += 1 + try: + yield {"instance": self.opened, "invocation": invocation} + finally: + self.closed += 1 + + async def run_agent(self, prompt, invocation): + return prompt + + +class _Provider: + provider_id = "agent_framework" + distribution_name = "azurefunctions-agents-extension-agent-framework" + supported_capabilities = frozenset({"skills", "mcp"}) + + def __init__(self): + self.compiled = _CompiledAgent() + self.compile_args = None + + def compile_binding(self, **kwargs): + self.compile_args = kwargs + return self.compiled + + +@pytest.fixture +def provider(monkeypatch, tmp_path): + instance = _Provider() + monkeypatch.setenv("AzureWebJobsScriptRoot", str(tmp_path)) + monkeypatch.setattr(providers, "load_provider", lambda provider_id: instance) + monkeypatch.setattr(bindings, "load_provider", lambda provider_id: instance) + return instance + + +def test_markdown_agent_injects_fresh_context_and_hides_parameter(tmp_path, provider): + instructions = "---\nnot: parsed\n---\nUse the order API.\n" + (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + tools=["lookup"], + ) + async def handler(value: str, agent: object) -> tuple[str, object]: + return value, agent + + assert list(inspect.signature(handler).parameters) == ["value"] + assert provider.compile_args == { + "instructions": instructions, + "agent_name": "orders", + "options": {"tools": ["lookup"]}, + "annotation": object, + "capabilities": AgentCapabilities(), + } + + first = asyncio.run(handler("one")) + second = asyncio.run(handler("two")) + + assert first[1]["instance"] == 1 + assert second[1]["instance"] == 2 + assert provider.compiled.opened == provider.compiled.closed == 2 + + +def test_markdown_agent_preserves_variadic_handler_arguments(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler( + value: str, + agent: object, + *rest: str, + ) -> tuple[str, object, tuple[str, ...]]: + return value, agent, rest + + value, agent, rest = asyncio.run(handler("one", "two", "three")) + + assert value == "one" + assert agent["instance"] == 1 + assert rest == ("two", "three") + + +def test_markdown_agent_closes_context_when_handler_fails(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + raise RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + asyncio.run(handler()) + + assert provider.compiled.opened == provider.compiled.closed == 1 + + +def test_markdown_agent_closes_context_when_handler_is_cancelled(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + asyncio.run(handler()) + + assert provider.compiled.opened == provider.compiled.closed == 1 + + +def test_markdown_agent_rejects_ambiguous_files(tmp_path, provider): + (tmp_path / "agents").mkdir() + (tmp_path / "orders.agent.md").write_text("root", encoding="utf-8") + (tmp_path / "agents" / "orders.agent.md").write_text("nested", encoding="utf-8") + + with pytest.raises(ValueError, match="ambiguous"): + + @bindings.markdown_agent( + func.FunctionApp(), + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + pass + + +def test_markdown_agent_rejects_symlink_outside_app_root(tmp_path, provider): + app_root = tmp_path / "app" + app_root.mkdir() + outside = tmp_path / "orders.agent.md" + outside.write_text("outside", encoding="utf-8") + try: + (app_root / "orders.agent.md").symlink_to(outside) + except OSError as error: + pytest.skip(f"symlink creation is unavailable: {error}") + app = func.FunctionApp() + bindings.configure_app(app, provider="agent_framework", app_root=app_root) + + with pytest.raises(ValueError, match="resolves outside app root"): + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + pass + + +@pytest.mark.parametrize( + "agent_name", + ["../orders", "agents/orders", r"agents\orders", "C:orders"], +) +def test_markdown_agent_rejects_nonportable_agent_names( + tmp_path, + provider, + agent_name, +): + app = func.FunctionApp() + + with pytest.raises(ValueError, match="filename component"): + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name=agent_name, + ) + async def handler(agent: object) -> None: + pass + + +def test_function_app_rejects_a_second_default_provider(tmp_path, provider): + bindings.configure_app( + func_app := func.FunctionApp(), + provider="agent_framework", + app_root=tmp_path, + ) + + with pytest.raises(ValueError, match="already configured with Agent provider"): + bindings.configure_app( + func_app, + provider="langgraph", + app_root=tmp_path, + ) + + +def test_function_app_rejects_a_second_binding_provider(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + monkeypatch.setenv("AzureWebJobsScriptRoot", str(tmp_path)) + providers_by_id = { + "agent_framework": _Provider(), + "langgraph": _Provider(), + } + providers_by_id["langgraph"].provider_id = "langgraph" + monkeypatch.setattr( + bindings, + "load_provider", + lambda provider_id: providers_by_id[provider_id], + ) + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + temperature=0.1, + ) + async def framework_handler(agent: object) -> None: + pass + + with pytest.raises(ValueError, match="already configured with Agent provider"): + bindings.markdown_agent( + app, + provider="langgraph", + arg_name="agent", + agent_name="orders", + recursion_limit=20, + ) + + assert providers_by_id["agent_framework"].compile_args["options"] == { + "temperature": 0.1 + } + assert providers_by_id["langgraph"].compile_args is None + + +def test_markdown_agent_rejects_per_binding_app_root(tmp_path, provider): + first_root = tmp_path / "first" + second_root = tmp_path / "second" + first_root.mkdir() + second_root.mkdir() + (first_root / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + bindings.configure_app(app, provider="agent_framework", app_root=first_root) + + with pytest.raises(TypeError, match="app_root is app-scoped"): + bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + app_root=second_root, + ) + + +def test_function_app_state_does_not_keep_app_alive(tmp_path, provider): + app = func.FunctionApp() + bindings.configure_app( + app, + provider="agent_framework", + app_root=tmp_path, + ) + app_reference = weakref.ref(app) + + del app + gc.collect() + + assert app_reference() is None + + +def test_app_defaults_are_overridden_by_decorator_options(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + bindings.configure_app( + app, + provider="agent_framework", + app_root=tmp_path, + provider_options={"temperature": 0.1, "tools": ["default"]}, + ) + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + temperature=0.5, + ) + async def handler(agent: object) -> None: + pass + + assert provider.compile_args["options"] == { + "temperature": 0.5, + "tools": ["default"], + } + + +def test_all_bindings_receive_same_discovered_capabilities(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + (tmp_path / "returns.agent.md").write_text("instructions", encoding="utf-8") + skill_directory = tmp_path / "skills" / "inventory" + skill_directory.mkdir(parents=True) + (skill_directory / "SKILL.md").write_text( + "---\nname: inventory\ndescription: Inventory lookup\n---\n", + encoding="utf-8", + ) + + app = func.FunctionApp() + bindings.configure_app(app, provider="agent_framework", app_root=tmp_path) + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + pass + + first_capabilities = provider.compile_args["capabilities"] + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="returns", + ) + async def returns_handler(agent: object) -> None: + pass + + second_capabilities = provider.compile_args["capabilities"] + assert tuple(skill.path for skill in first_capabilities.skills) == ( + skill_directory.resolve(), + ) + assert second_capabilities is first_capabilities + + +def test_binding_rejects_discovered_capability_for_unsupported_provider( + tmp_path, + provider, +): + provider.supported_capabilities = frozenset() + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + skill_directory = tmp_path / "skills" / "inventory" + skill_directory.mkdir(parents=True) + (skill_directory / "SKILL.md").write_text( + "---\nname: inventory\ndescription: Inventory lookup\n---\n", + encoding="utf-8", + ) + + with pytest.raises(TypeError, match="does not support discovered capabilities"): + app = func.FunctionApp() + bindings.configure_app(app, provider="agent_framework", app_root=tmp_path) + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + pass + + +def test_markdown_agent_requires_async_handler(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + + with pytest.raises(TypeError, match="async def"): + + @bindings.markdown_agent( + func.FunctionApp(), + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + def handler(agent: object) -> None: + pass diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-extensions-agents-base/tests/test_durable.py new file mode 100644 index 0000000..70cca3f --- /dev/null +++ b/azurefunctions-extensions-agents-base/tests/test_durable.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import asyncio +import math +from contextlib import asynccontextmanager +from datetime import timedelta +from types import SimpleNamespace + +import azure.functions as func +import pytest + +from azurefunctions.extensions.agents.base import bindings, durable +from azurefunctions.extensions.agents.base.durable import ( + DurableAgentContext, + _canonicalize_json_value, + _normalize_agent_prompt, + _parse_activity_input, +) + + +class _Context: + instance_id = "instance-1" + + def __init__(self): + self.calls = [] + + def call_activity(self, name, payload): + self.calls.append(("activity", name, payload)) + return "task" + + def call_activity_with_retry(self, name, retry, payload): + self.calls.append(("retry", name, retry, payload)) + return "retry-task" + + +def test_call_agent_schedules_canonical_payload(): + context = _Context() + proxy = DurableAgentContext(context) + + task = proxy.call_agent("orders", {"z": 1, "a": [True, None]}) + + assert task == "task" + assert context.calls == [ + ( + "activity", + "azurefunctions_agents_run_markdown_agent", + { + "schema_version": 1, + "agent_name": "orders", + "input": {"a": [True, None], "z": 1}, + "durable_instance_id": "instance-1", + }, + ) + ] + + +def test_call_agent_schedules_retry_with_same_canonical_payload(): + from azure.durable_functions import RetryPolicy + + context = _Context() + retry_options = RetryPolicy( + first_retry_interval=timedelta(seconds=1), + max_number_of_attempts=3, + ) + proxy = DurableAgentContext(context) + + task = proxy.call_agent( + "orders", + {"z": 1, "a": 2}, + retry_options=retry_options, + ) + + assert task == "retry-task" + assert context.calls == [ + ( + "retry", + "azurefunctions_agents_run_markdown_agent", + retry_options, + { + "schema_version": 1, + "agent_name": "orders", + "input": {"a": 2, "z": 1}, + "durable_instance_id": "instance-1", + }, + ) + ] + + +def test_call_agent_does_not_accept_provider_override(): + with pytest.raises(TypeError, match="provider"): + DurableAgentContext(_Context()).call_agent( + "orders", + "hello", + provider="langgraph", # type: ignore[call-arg] + ) + + +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) +def test_call_agent_rejects_nonfinite_numbers(value): + with pytest.raises(ValueError, match="NaN or infinity"): + DurableAgentContext(_Context()).call_agent("orders", value) + + +def test_parse_activity_input_rejects_unknown_schema(): + with pytest.raises(ValueError, match="schema_version"): + _parse_activity_input( + { + "schema_version": 2, + "agent_name": "orders", + "input": "hello", + "durable_instance_id": "instance-1", + } + ) + + +def test_normalize_agent_prompt_preserves_strings_and_encodes_json(): + assert _normalize_agent_prompt("hello") == "hello" + assert _normalize_agent_prompt({"z": 1, "a": 2}) == '{"a":2,"z":1}' + + +def test_canonicalize_json_value_rejects_non_string_keys(): + with pytest.raises(TypeError, match="keys must be strings"): + _canonicalize_json_value({1: "value"}) + + +class _CompiledAgent: + def __init__(self): + self.calls = [] + + @asynccontextmanager + async def open_agent(self, invocation): + yield object() + + async def run_agent(self, prompt, invocation): + self.calls.append((prompt, invocation)) + return f"response:{prompt}" + + +class _Provider: + provider_id = "agent_framework" + distribution_name = "azurefunctions-agents-extension-agent-framework" + supported_capabilities = frozenset({"skills", "mcp"}) + + def __init__(self): + self.compiled = _CompiledAgent() + self.compile_calls = [] + + def compile_binding(self, **kwargs): + self.compile_calls.append(kwargs) + return self.compiled + + +def _configured_app(tmp_path, monkeypatch): + provider = _Provider() + monkeypatch.setattr(bindings, "load_provider", lambda provider_id: provider) + app = func.FunctionApp() + bindings.configure_app( + app, + provider="agent_framework", + app_root=tmp_path, + ) + return app, provider + + +def _hidden_activity(app): + return next( + function.get_user_function() + for function in app.get_functions() + if function.get_function_name() + == "azurefunctions_agents_run_markdown_agent" + ) + + +def test_configure_durable_app_registers_hidden_activity_once(tmp_path, monkeypatch): + app, _ = _configured_app(tmp_path, monkeypatch) + + durable.configure_durable_app(app) + durable.configure_durable_app(app) + + names = [function.get_function_name() for function in app.get_functions()] + assert names == ["azurefunctions_agents_run_markdown_agent"] + + +def test_hidden_activity_name_collision_is_rejected(tmp_path, monkeypatch): + app, _ = _configured_app(tmp_path, monkeypatch) + + @app.function_name(name="azurefunctions_agents_run_markdown_agent") + @app.activity_trigger(input_name="payload") + def customer_activity(payload): + return payload + + durable.configure_durable_app(app) + + with pytest.raises(ValueError, match="unique function name"): + app.get_functions() + + +def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypatch): + instructions = "---\nthis remains: raw\n---\nHandle orders.\n" + (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) + app, provider = _configured_app(tmp_path, monkeypatch) + durable.configure_durable_app(app) + activity = _hidden_activity(app) + context = SimpleNamespace( + function_name="activity", + invocation_id="invocation-1", + ) + + result = asyncio.run( + activity( + { + "schema_version": 1, + "agent_name": "orders", + "input": {"z": 1, "a": 2}, + "durable_instance_id": "instance-1", + }, + context, + ) + ) + + assert result == 'response:{"a":2,"z":1}' + assert provider.compile_calls[0]["instructions"] == instructions + assert provider.compile_calls[0]["capabilities"].skills == () + assert provider.compiled.calls[0][0] == '{"a":2,"z":1}' + assert provider.compiled.calls[0][1].durable_instance_id == "instance-1" + asyncio.run( + activity( + { + "schema_version": 1, + "agent_name": "orders", + "input": "again", + "durable_instance_id": "instance-1", + }, + context, + ) + ) + assert len(provider.compile_calls) == 1 + + +def test_hidden_activity_receives_all_discovered_capabilities(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + skill_directory = tmp_path / "skills" / "inventory" + skill_directory.mkdir(parents=True) + (skill_directory / "SKILL.md").write_text( + "---\nname: inventory\ndescription: Inventory lookup\n---\n", + encoding="utf-8", + ) + app, provider = _configured_app(tmp_path, monkeypatch) + durable.configure_durable_app(app) + activity = _hidden_activity(app) + + asyncio.run( + activity( + { + "schema_version": 1, + "agent_name": "orders", + "input": "hello", + "durable_instance_id": "instance-1", + }, + SimpleNamespace(function_name="activity", invocation_id="invocation-1"), + ) + ) + + capabilities = provider.compile_calls[0]["capabilities"] + assert tuple(skill.path for skill in capabilities.skills) == ( + skill_directory.resolve(), + ) diff --git a/azurefunctions-extensions-agents-base/tests/test_providers.py b/azurefunctions-extensions-agents-base/tests/test_providers.py new file mode 100644 index 0000000..a7b6513 --- /dev/null +++ b/azurefunctions-extensions-agents-base/tests/test_providers.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from azurefunctions.extensions.agents.base import providers + + +class _Provider: + provider_id = "agent_framework" + distribution_name = "azurefunctions-agents-extension-agent-framework" + supported_capabilities = frozenset({"skills", "mcp"}) + + def compile_binding(self, **kwargs): + return kwargs + + +class _EntryPoint: + def __init__(self, name, value, factory, distribution): + self.name = name + self.value = value + self._factory = factory + self.dist = SimpleNamespace(name=distribution) + + def load(self): + return self._factory + + +@pytest.fixture(autouse=True) +def _clear_provider_cache(): + providers._provider_entry_points.cache_clear() + providers.load_provider.cache_clear() + yield + providers.load_provider.cache_clear() + providers._provider_entry_points.cache_clear() + + +def test_load_provider_uses_matching_entry_point(monkeypatch): + entry_point = _EntryPoint( + "agent_framework", + "test:provider", + _Provider, + "azurefunctions-agents-extension-agent-framework", + ) + monkeypatch.setattr( + providers.metadata, + "entry_points", + lambda **kwargs: [entry_point], + ) + + provider = providers.load_provider("agent_framework") + + assert provider.provider_id == "agent_framework" + assert providers.load_provider("agent_framework") is provider + + +def test_provider_entry_points_are_enumerated_once_for_multiple_ids(monkeypatch): + class OtherProvider(_Provider): + provider_id = "other" + distribution_name = "other-provider" + + entry_points = [ + _EntryPoint( + "agent_framework", + "test:provider", + _Provider, + "azurefunctions-agents-extension-agent-framework", + ), + _EntryPoint("other", "test:other", OtherProvider, "other-provider"), + ] + calls = 0 + + def enumerate_entry_points(**kwargs): + nonlocal calls + calls += 1 + return entry_points + + monkeypatch.setattr(providers.metadata, "entry_points", enumerate_entry_points) + + assert providers.load_provider("agent_framework").provider_id == "agent_framework" + assert providers.load_provider("other").provider_id == "other" + assert calls == 1 + + +def test_load_provider_reports_installable_distribution(monkeypatch): + monkeypatch.setattr(providers.metadata, "entry_points", lambda **kwargs: []) + + with pytest.raises( + LookupError, match="azurefunctions-agents-extension-agent-framework" + ): + providers.load_provider("agent_framework") + + +def test_load_provider_rejects_duplicate_provider_ids(monkeypatch): + entry_points = [ + _EntryPoint("agent_framework", "one:provider", _Provider, "provider-one"), + _EntryPoint("agent_framework", "two:provider", _Provider, "provider-two"), + ] + monkeypatch.setattr( + providers.metadata, + "entry_points", + lambda **kwargs: entry_points, + ) + + with pytest.raises(RuntimeError, match="provider-one, provider-two"): + providers.load_provider("agent_framework") + + +def test_load_provider_does_not_rewrite_factory_error(monkeypatch): + def fail(): + raise RuntimeError("provider initialization failed") + + entry_point = _EntryPoint("agent_framework", "test:fail", fail, "provider") + monkeypatch.setattr( + providers.metadata, + "entry_points", + lambda **kwargs: [entry_point], + ) + + with pytest.raises(RuntimeError, match="provider initialization failed"): + providers.load_provider("agent_framework") + + +def test_load_provider_validates_returned_provider_id(monkeypatch): + class WrongProvider(_Provider): + provider_id = "wrong" + + entry_point = _EntryPoint( + "agent_framework", + "test:wrong", + WrongProvider, + "provider", + ) + monkeypatch.setattr( + providers.metadata, + "entry_points", + lambda **kwargs: [entry_point], + ) + + with pytest.raises(ValueError, match="returned provider 'wrong'"): + providers.load_provider("agent_framework") diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md new file mode 100644 index 0000000..7b2ba73 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/README.md @@ -0,0 +1,194 @@ +# Azure Functions Microsoft Agent Framework Extension + +Inject Microsoft Agent Framework Agents built from raw `.agent.md` instructions +into Python Azure Functions. + +## Install + +```text +pip install azurefunctions-agents-extension-agent-framework +``` + +The default package installs `agent-framework-core==1.13.0`. Install the MAF +client package required by your application separately. OpenAI, Foundry, +storage, and the Azure Functions Agents runtime are not dependencies of this +extension. + +Skills use the default package. Install remote MCP transport and Entra support +with the MCP extra: + +```text +pip install "azurefunctions-agents-extension-agent-framework[mcp]" +``` + +## Use a typed Agent app + +Create a zero-argument factory that returns a fresh MAF chat client. A new +client and Agent context are created and closed for every Function invocation. + +```python +import azure.functions as func +from agent_framework import Agent +from azurefunctions.extensions.agents.framework import AIApp + + +def create_chat_client(): + from agent_framework.openai import OpenAIChatClient + + return OpenAIChatClient() + + +app = AIApp(client_factory=create_chat_client) + + +@app.route(route="orders", methods=["POST"]) +@app.markdown_agent(arg_name="agent", agent_name="orders") +async def process_order(req: func.HttpRequest, agent: Agent): + response = await agent.run(req.get_body().decode()) + return response.text +``` + +Provider IDs are the entry-point names published by provider packages. Each +provider package documents its ID; this package exports +`AGENT_FRAMEWORK_PROVIDER_ID` for code that needs to select it explicitly. A +closed SDK enum is not used because third-party packages may add provider IDs +without an Azure Functions SDK release. + +The standalone typed decorator pins a plain app to the Agent Framework +provider on first use: + +```python +from azurefunctions.extensions.agents.framework import markdown_agent + +app = func.FunctionApp() + + +@markdown_agent( + app, + arg_name="agent", + agent_name="orders", + client_factory=create_chat_client, +) +async def process_order(req: func.HttpRequest, agent: Agent): + ... +``` + +One Function App uses one provider. A later decorator from a different provider +package is rejected. + +Place the complete instructions at `orders.agent.md` or +`agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime +configuration is interpreted. + +## Skills and MCP servers + +Skills and MCP servers are discovered automatically from the app root and are +available to each Agent binding by default: + +```text +skills/inventory/SKILL.md +mcp.json +``` + +`SKILL.md` uses Agent Skills frontmatter: + +```markdown +--- +name: inventory +description: Look up inventory policy and warehouse constraints. +--- + +Use the references in this skill when assessing stock. +``` + +The base extension discovers Skill directory paths without reading their +contents. Microsoft Agent Framework parses and validates each `SKILL.md` when +it loads the file-based Skills provider. + +V1 MCP discovery supports remote HTTP transports only: + +```json +{ + "servers": { + "inventory": { + "type": "streamable-http", + "url": "$INVENTORY_MCP_URL", + "tools": ["lookup_stock", "reserve_stock"], + "headers": {"X-Tenant": "%TENANT_ID%"}, + "auth": { + "scope": "$INVENTORY_MCP_SCOPE", + "client_id": "%AZURE_CLIENT_ID%" + } + } + } +} +``` + +`$VAR` and `%VAR%` references are resolved for each invocation, not during +discovery. Missing values fail before connecting. Credentials, tokens, HTTP +clients, MCP tools, and Agents are fresh invocation-owned resources and are +closed on success, error, or cancellation. Do not place secrets directly in +source-controlled `mcp.json`; use environment references. + +Every Agent in the Function App receives all valid Skills and MCP servers +discovered from the app root: + +```python +from azurefunctions.extensions.agents.framework import AIApp + +app = AIApp(client_factory=create_chat_client) + + +@app.markdown_agent(arg_name="agent", agent_name="orders") +async def process_order(agent: Agent): + ... +``` + +V1 has no app-level or per-binding capability selectors. Skill scripts and MCP +tools can perform privileged operations, so placing a definition under the app +root grants every Agent in that app access to it. Use separate Function Apps +when capabilities require isolation. Python `tools=` remain explicit because +they are supplied directly to the Microsoft Agent Framework Agent. + +The generic core form is also supported: + +```python +from azurefunctions.extensions.agents.framework import AGENT_FRAMEWORK_PROVIDER_ID + +app = func.FunctionApp() + + +@app.markdown_agent( + provider=AGENT_FRAMEWORK_PROVIDER_ID, + arg_name="agent", + agent_name="orders", + client_factory=create_chat_client, +) +async def process_order(req: func.HttpRequest, agent: Agent): + ... +``` + +Typed constructors and decorators expose only `client_factory` and explicit +Python `tools` in V1. The extension owns the Agent client, name, instructions, +and discovered Skills/MCP integration. Configure `app_root` only when +constructing `AIApp` or `DurableAIApp`; decorators do not override it. + +## Durable Agents + +Durable orchestration support is optional: + +```text +pip install "azurefunctions-agents-extension-agent-framework[durable]" +``` + +Use `DurableAIApp` and call `context.call_agent(agent_name, input_)` inside a +synchronous generator orchestrator. Agent execution is isolated in an activity +so replay performs no nondeterministic work. Importing the package remains safe +without Durable installed; constructing `DurableAIApp` reports the exact extra +to install when it is absent. + +All `call_agent()` invocations use the provider configured by `DurableAIApp`. +They also use the app-level `skills` and `mcp_servers` defaults. V1 does not +support selecting another provider or capability set from an orchestrator, and +the schema-v1 orchestration payload contains no capability paths, settings, or +secrets. diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py new file mode 100644 index 0000000..aaa3217 --- /dev/null +++ b/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import asyncio +import inspect +import os +import re +import warnings +from collections.abc import Callable, Mapping, Sequence +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, AsyncIterator, get_origin +from urllib.parse import urlsplit + +from agent_framework import Agent, BaseChatClient, SkillsProvider +from agent_framework._feature_stage import ExperimentalWarning + +from azurefunctions.extensions.agents.base import ( + AgentCapabilities, + InvocationMetadata, + MCPServerDefinition, + SkillDefinition, +) + +AGENT_FRAMEWORK_PROVIDER_ID = "agent_framework" +ClientFactory = Callable[[], BaseChatClient[Any]] +_AGENT_ANNOTATION_TYPE = Agent +_ENV_REFERENCE = re.compile( + r"\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%" +) + +_SUPPORTED_OPTIONS = frozenset({"client_factory", "tools"}) + + +@dataclass(frozen=True) +class AgentFrameworkBinding: + instructions: str + agent_name: str + client_factory: ClientFactory + agent_options: Mapping[str, Any] + capabilities: AgentCapabilities + + def _create_agent( + self, + skills_provider: Any | None, + mcp_tools: Sequence[Any], + ) -> Agent[Any]: + options = dict(self.agent_options) + if skills_provider is not None: + options["context_providers"] = [skills_provider] + if mcp_tools: + tools = _option_values(options.pop("tools", None)) + options["tools"] = [*tools, *mcp_tools] + return Agent( + client=self.client_factory(), + instructions=self.instructions, + name=self.agent_name, + **options, + ) + + @asynccontextmanager + async def open_agent( + self, + invocation: InvocationMetadata, + ) -> AsyncIterator[Agent[Any]]: + async with AsyncExitStack() as stack: + skills_provider = _build_skills_provider(self.capabilities.skills) + mcp_tools = [ + await stack.enter_async_context(_open_mcp_tool(definition)) + for definition in self.capabilities.mcp_servers + ] + agent = self._create_agent(skills_provider, mcp_tools) + entered_agent = await stack.enter_async_context(agent) + yield entered_agent + + async def run_agent( + self, + prompt: str, + invocation: InvocationMetadata, + ) -> str: + async with self.open_agent(invocation) as agent: + response = await agent.run(prompt) + text = getattr(response, "text", None) + if not isinstance(text, str): + raise TypeError("Microsoft Agent Framework response.text must be a string") + return text + + +class AgentFrameworkProvider: + provider_id = AGENT_FRAMEWORK_PROVIDER_ID + distribution_name = "azurefunctions-agents-extension-agent-framework" + supported_capabilities = frozenset({"skills", "mcp"}) + + def compile_binding( + self, + *, + instructions: str, + agent_name: str, + options: Mapping[str, Any], + annotation: Any, + capabilities: AgentCapabilities, + ) -> AgentFrameworkBinding: + unknown = sorted(set(options) - _SUPPORTED_OPTIONS) + if unknown: + raise TypeError( + "Unsupported Microsoft Agent Framework option(s): " + ", ".join(unknown) + ) + client_factory = options.get("client_factory") + if client_factory is None: + raise TypeError("client_factory option is required") + if not callable(client_factory): + raise TypeError("client_factory must be callable") + if inspect.iscoroutinefunction(client_factory): + raise TypeError("client_factory must be a synchronous function") + if annotation is not inspect.Signature.empty: + annotation_origin = get_origin(annotation) + if ( + annotation is not _AGENT_ANNOTATION_TYPE + and annotation_origin is not _AGENT_ANNOTATION_TYPE + ): + raise TypeError( + "Microsoft Agent Framework binding parameter must be annotated " + "as agent_framework.Agent" + ) + + agent_options = dict(options) + del agent_options["client_factory"] + return AgentFrameworkBinding( + instructions=instructions, + agent_name=agent_name, + client_factory=client_factory, + agent_options=MappingProxyType(agent_options), + capabilities=capabilities, + ) + + +def _option_values(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return list(value) + return [value] + + +def _build_skills_provider( + skills: Sequence[SkillDefinition], +) -> SkillsProvider | None: + if not skills: + return None + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=ExperimentalWarning) + return SkillsProvider.from_paths( + [skill.path for skill in skills], + disable_load_skill_approval=True, + disable_read_skill_resource_approval=True, + ) + + +def _resolve_environment(value: str, *, field: str) -> str: + missing: set[str] = set() + + def replace(match: re.Match[str]) -> str: + name = match.group(1) or match.group(2) + resolved = os.environ.get(name) + if resolved is None: + missing.add(name) + return match.group(0) + return resolved + + result = _ENV_REFERENCE.sub(replace, value) + if missing: + raise ValueError( + f"MCP {field} references missing environment variable(s): " + f"{', '.join(sorted(missing))}" + ) + return result + + +@asynccontextmanager +async def _open_mcp_tool( + definition: MCPServerDefinition, +) -> AsyncIterator[Any]: + try: + import mcp # noqa: F401 + from agent_framework import MCPStreamableHTTPTool + from httpx import AsyncClient + except ImportError as error: + raise ImportError( + "MCP support is not installed. Install " + "'azurefunctions-agents-extension-agent-framework[mcp]'." + ) from error + + config = definition.config + url = _resolve_environment(config.url, field=f"server {definition.name!r} URL") + parsed_url = urlsplit(url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise ValueError( + f"MCP server {definition.name!r} URL must use HTTP or HTTPS" + ) + static_headers = { + name: _resolve_environment( + value, + field=f"server {definition.name!r} header {name!r}", + ) + for name, value in config.headers + } + auth = config.auth + scope = ( + _resolve_environment( + auth.scope, + field=f"server {definition.name!r} auth scope", + ) + if auth is not None + else None + ) + client_id = ( + _resolve_environment( + auth.client_id, + field=f"server {definition.name!r} auth client_id", + ) + if auth is not None and auth.client_id is not None + else None + ) + + async with AsyncExitStack() as stack: + credential = None + if scope is not None: + try: + from azure.identity import DefaultAzureCredential + except ImportError as error: + raise ImportError( + "MCP Entra authentication is not installed. Install " + "'azurefunctions-agents-extension-agent-framework[mcp]'." + ) from error + credential = DefaultAzureCredential( + managed_identity_client_id=client_id, + ) + stack.callback(credential.close) + + http_client = None + if static_headers or credential is not None: + + async def inject_headers(request: Any) -> None: + for name, value in static_headers.items(): + request.headers[name] = value + if credential is not None and scope is not None: + token = await asyncio.to_thread(credential.get_token, scope) + request.headers["Authorization"] = f"Bearer {token.token}" + + http_client = await stack.enter_async_context( + AsyncClient( + follow_redirects=False, + event_hooks={"request": [inject_headers]}, + ) + ) + + tool = MCPStreamableHTTPTool( + name=definition.name, + url=url, + allowed_tools=( + list(config.allowed_tools) + if config.allowed_tools is not None + else None + ), + load_tools=True, + load_prompts=False, + http_client=http_client, + ) + yield tool + + +def create_provider() -> AgentFrameworkProvider: + return AgentFrameworkProvider() diff --git a/azurefunctions-extensions-agents-framework/pyproject.toml b/azurefunctions-extensions-agents-framework/pyproject.toml new file mode 100644 index 0000000..c92048c --- /dev/null +++ b/azurefunctions-extensions-agents-framework/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-agents-extension-agent-framework" +dynamic = ["version"] +requires-python = ">=3.13" +authors = [ + { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, +] +description = "Microsoft Agent Framework integration for Azure Functions." +readme = "README.md" +license = { text = "MIT License" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Environment :: Web Environment", + "Development Status :: 3 - Alpha", +] +dependencies = [ + "agent-framework-core==1.13.0", + "azurefunctions-agents-extension-base>=1.0.0b1", +] + +[project.optional-dependencies] +mcp = [ + "azure-identity>=1.25.3,<2", + "httpx>=0.27,<1", + "mcp>=1.28.1,<2", +] +durable = [ + "azurefunctions-agents-extension-base[durable]>=1.0.0b1", +] +dev = [ + "azure-functions-durable>=2.0.0b2", + "coverage", + "flake8", + "mypy", + "pre-commit", + "pytest", + "pytest-cov", + "pytest-instafail", +] + +[project.entry-points."azurefunctions.extensions.agents.providers"] +agent_framework = "azurefunctions.extensions.agents.framework.provider:create_provider" + +[tool.setuptools.dynamic] +version = { attr = "azurefunctions.extensions.agents.framework.__version__" } + +[tool.setuptools.packages.find] +include = ["azurefunctions.extensions.agents.framework*"] + +[tool.setuptools.package-data] +"azurefunctions.extensions.agents.framework" = ["py.typed"] + +[[tool.mypy.overrides]] +module = ["azure", "azure.*"] +ignore_missing_imports = true From db1a3716c8f256380f4593ed45da435c7c219797 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 9 Sep 2026 14:34:34 -0500 Subject: [PATCH 18/25] fix directory structure, rename to AgentFunctionApp, remove Durable specific app object --- README.md | 4 +- .../extensions/agents/framework/provider.py | 273 ------------ .../tests/test_apps.py | 103 ----- .../README.md | 80 ---- .../tests/test_durable.py | 267 ------------ .../tests/test_providers.py | 142 ------ .../LICENSE | 0 .../MANIFEST.in | 0 .../README.md | 81 +--- .../azurefunctions/__init__.py | 0 .../azurefunctions/agents}/__init__.py | 0 .../agents/extensions}/__init__.py | 0 .../extensions/agent_framework}/__init__.py | 6 +- .../extensions/agent_framework}/apps.py | 87 ++-- .../extensions/agent_framework}/provider.py | 8 +- .../extensions/agent_framework}/py.typed | 0 .../pyproject.toml | 16 +- .../samples/README.md | 0 .../samples/hybrid-durable-agent/README.md | 0 .../hybrid-durable-agent/src/function_app.py | 5 +- .../hybrid-durable-agent/src/host.json | 0 .../src/local.settings.template.json | 0 .../src/order-fulfillment.agent.md | 0 .../src/order_processing.py | 0 .../hybrid-durable-agent/src/requirements.txt | 0 .../samples/hybrid-function-agent/README.md | 0 .../hybrid-function-agent/src/function_app.py | 4 +- .../hybrid-function-agent/src/host.json | 0 .../src/local.settings.template.json | 0 .../hybrid-function-agent/src/mcp.json | 0 .../src/order-fulfillment.agent.md | 0 .../src/order_processing.py | 0 .../src/requirements.txt | 0 .../src/skills/order-policy/SKILL.md | 0 .../tests/test_apps.py | 117 +++++ .../tests/test_imports.py | 12 +- .../tests/test_provider.py | 4 +- .../tests/test_samples.py | 0 .../LICENSE | 0 .../MANIFEST.in | 0 .../README.md | 20 +- .../azurefunctions}/__init__.py | 0 .../azurefunctions/agents/__init__.py | 1 + .../agents/extensions/__init__.py | 1 + .../agents/extensions}/base/__init__.py | 0 .../agents/extensions}/base/bindings.py | 24 +- .../agents/extensions}/base/capabilities.py | 0 .../extensions}/base/discovery/__init__.py | 0 .../agents/extensions}/base/discovery/mcp.py | 0 .../extensions}/base/discovery/skills.py | 0 .../agents/extensions}/base/durable.py | 2 +- .../agents/extensions}/base/providers.py | 4 +- .../agents/extensions}/base/py.typed | 0 .../pyproject.toml | 14 +- .../tests/test_bindings.py | 10 +- .../tests/test_capability_discovery.py | 2 +- .../tests/test_durable.py | 6 +- .../tests/test_imports.py | 2 +- .../tests/test_providers.py | 10 +- .../pyproject.toml | 57 --- .../tests/test_bindings.py | 409 ------------------ .../README.md | 194 --------- .../pyproject.toml | 66 --- eng/templates/jobs/build.yml | 4 +- .../official/jobs/build-artifacts.yml | 4 +- eng/templates/official/jobs/unit-tests.yml | 10 +- 66 files changed, 276 insertions(+), 1773 deletions(-) delete mode 100644 azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/provider.py delete mode 100644 azurefunctions-agents-extension-agent-framework/tests/test_apps.py delete mode 100644 azurefunctions-agents-extension-base/README.md delete mode 100644 azurefunctions-agents-extension-base/tests/test_durable.py delete mode 100644 azurefunctions-agents-extension-base/tests/test_providers.py rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/LICENSE (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/MANIFEST.in (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/README.md (63%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/azurefunctions/__init__.py (100%) rename {azurefunctions-agents-extension-agent-framework/azurefunctions/extensions => azurefunctions-agents-extensions-agent-framework/azurefunctions/agents}/__init__.py (100%) rename {azurefunctions-agents-extension-base/azurefunctions => azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions}/__init__.py (100%) rename {azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework => azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework}/__init__.py (59%) rename {azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework => azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework}/apps.py (64%) rename {azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework => azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework}/provider.py (96%) rename {azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework => azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework}/py.typed (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/pyproject.toml (71%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/README.md (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-durable-agent/README.md (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-durable-agent/src/function_app.py (95%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-durable-agent/src/host.json (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-durable-agent/src/local.settings.template.json (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-durable-agent/src/order-fulfillment.agent.md (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-durable-agent/src/order_processing.py (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-durable-agent/src/requirements.txt (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/README.md (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/src/function_app.py (94%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/src/host.json (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/src/local.settings.template.json (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/src/mcp.json (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/src/order-fulfillment.agent.md (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/src/order_processing.py (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/src/requirements.txt (100%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md (100%) create mode 100644 azurefunctions-agents-extensions-agent-framework/tests/test_apps.py rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/tests/test_imports.py (71%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/tests/test_provider.py (98%) rename {azurefunctions-agents-extension-agent-framework => azurefunctions-agents-extensions-agent-framework}/tests/test_samples.py (100%) rename {azurefunctions-agents-extension-base => azurefunctions-agents-extensions-base}/LICENSE (100%) rename {azurefunctions-agents-extension-base => azurefunctions-agents-extensions-base}/MANIFEST.in (100%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extensions-base}/README.md (80%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions => azurefunctions-agents-extensions-base/azurefunctions}/__init__.py (100%) create mode 100644 azurefunctions-agents-extensions-base/azurefunctions/agents/__init__.py create mode 100644 azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/__init__.py rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/__init__.py (100%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/bindings.py (94%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/capabilities.py (100%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/discovery/__init__.py (100%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/discovery/mcp.py (100%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/discovery/skills.py (100%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/durable.py (98%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/providers.py (96%) rename {azurefunctions-agents-extension-base/azurefunctions/extensions/agents => azurefunctions-agents-extensions-base/azurefunctions/agents/extensions}/base/py.typed (100%) rename {azurefunctions-agents-extension-base => azurefunctions-agents-extensions-base}/pyproject.toml (79%) rename {azurefunctions-agents-extension-base => azurefunctions-agents-extensions-base}/tests/test_bindings.py (98%) rename {azurefunctions-agents-extension-base => azurefunctions-agents-extensions-base}/tests/test_capability_discovery.py (97%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extensions-base}/tests/test_durable.py (97%) rename {azurefunctions-agents-extension-base => azurefunctions-agents-extensions-base}/tests/test_imports.py (93%) rename {azurefunctions-extensions-agents-base => azurefunctions-agents-extensions-base}/tests/test_providers.py (91%) delete mode 100644 azurefunctions-extensions-agents-base/pyproject.toml delete mode 100644 azurefunctions-extensions-agents-base/tests/test_bindings.py delete mode 100644 azurefunctions-extensions-agents-framework/README.md delete mode 100644 azurefunctions-extensions-agents-framework/pyproject.toml diff --git a/README.md b/README.md index 8ac6428..09d7070 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ A supported Python version is required - see ## Available extensions * [Base extension](azurefunctions-extensions-base/README.md) -* [Agent provider base](azurefunctions-agents-extension-base/README.md) -* [Microsoft Agent Framework](azurefunctions-agents-extension-agent-framework/README.md) +* [Agent provider base](azurefunctions-agents-extensions-base/README.md) +* [Microsoft Agent Framework](azurefunctions-agents-extensions-agent-framework/README.md) * [Azure Blob Storage bindings](azurefunctions-extensions-bindings-blob/README.md) * [Azure Cosmos DB bindings](azurefunctions-extensions-bindings-cosmosdb/README.md) * [Azure Event Hubs bindings](azurefunctions-extensions-bindings-eventhub/README.md) diff --git a/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/provider.py deleted file mode 100644 index aaa3217..0000000 --- a/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/provider.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -import asyncio -import inspect -import os -import re -import warnings -from collections.abc import Callable, Mapping, Sequence -from contextlib import AsyncExitStack, asynccontextmanager -from dataclasses import dataclass -from types import MappingProxyType -from typing import Any, AsyncIterator, get_origin -from urllib.parse import urlsplit - -from agent_framework import Agent, BaseChatClient, SkillsProvider -from agent_framework._feature_stage import ExperimentalWarning - -from azurefunctions.extensions.agents.base import ( - AgentCapabilities, - InvocationMetadata, - MCPServerDefinition, - SkillDefinition, -) - -AGENT_FRAMEWORK_PROVIDER_ID = "agent_framework" -ClientFactory = Callable[[], BaseChatClient[Any]] -_AGENT_ANNOTATION_TYPE = Agent -_ENV_REFERENCE = re.compile( - r"\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%" -) - -_SUPPORTED_OPTIONS = frozenset({"client_factory", "tools"}) - - -@dataclass(frozen=True) -class AgentFrameworkBinding: - instructions: str - agent_name: str - client_factory: ClientFactory - agent_options: Mapping[str, Any] - capabilities: AgentCapabilities - - def _create_agent( - self, - skills_provider: Any | None, - mcp_tools: Sequence[Any], - ) -> Agent[Any]: - options = dict(self.agent_options) - if skills_provider is not None: - options["context_providers"] = [skills_provider] - if mcp_tools: - tools = _option_values(options.pop("tools", None)) - options["tools"] = [*tools, *mcp_tools] - return Agent( - client=self.client_factory(), - instructions=self.instructions, - name=self.agent_name, - **options, - ) - - @asynccontextmanager - async def open_agent( - self, - invocation: InvocationMetadata, - ) -> AsyncIterator[Agent[Any]]: - async with AsyncExitStack() as stack: - skills_provider = _build_skills_provider(self.capabilities.skills) - mcp_tools = [ - await stack.enter_async_context(_open_mcp_tool(definition)) - for definition in self.capabilities.mcp_servers - ] - agent = self._create_agent(skills_provider, mcp_tools) - entered_agent = await stack.enter_async_context(agent) - yield entered_agent - - async def run_agent( - self, - prompt: str, - invocation: InvocationMetadata, - ) -> str: - async with self.open_agent(invocation) as agent: - response = await agent.run(prompt) - text = getattr(response, "text", None) - if not isinstance(text, str): - raise TypeError("Microsoft Agent Framework response.text must be a string") - return text - - -class AgentFrameworkProvider: - provider_id = AGENT_FRAMEWORK_PROVIDER_ID - distribution_name = "azurefunctions-agents-extension-agent-framework" - supported_capabilities = frozenset({"skills", "mcp"}) - - def compile_binding( - self, - *, - instructions: str, - agent_name: str, - options: Mapping[str, Any], - annotation: Any, - capabilities: AgentCapabilities, - ) -> AgentFrameworkBinding: - unknown = sorted(set(options) - _SUPPORTED_OPTIONS) - if unknown: - raise TypeError( - "Unsupported Microsoft Agent Framework option(s): " + ", ".join(unknown) - ) - client_factory = options.get("client_factory") - if client_factory is None: - raise TypeError("client_factory option is required") - if not callable(client_factory): - raise TypeError("client_factory must be callable") - if inspect.iscoroutinefunction(client_factory): - raise TypeError("client_factory must be a synchronous function") - if annotation is not inspect.Signature.empty: - annotation_origin = get_origin(annotation) - if ( - annotation is not _AGENT_ANNOTATION_TYPE - and annotation_origin is not _AGENT_ANNOTATION_TYPE - ): - raise TypeError( - "Microsoft Agent Framework binding parameter must be annotated " - "as agent_framework.Agent" - ) - - agent_options = dict(options) - del agent_options["client_factory"] - return AgentFrameworkBinding( - instructions=instructions, - agent_name=agent_name, - client_factory=client_factory, - agent_options=MappingProxyType(agent_options), - capabilities=capabilities, - ) - - -def _option_values(value: Any) -> list[Any]: - if value is None: - return [] - if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): - return list(value) - return [value] - - -def _build_skills_provider( - skills: Sequence[SkillDefinition], -) -> SkillsProvider | None: - if not skills: - return None - with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=ExperimentalWarning) - return SkillsProvider.from_paths( - [skill.path for skill in skills], - disable_load_skill_approval=True, - disable_read_skill_resource_approval=True, - ) - - -def _resolve_environment(value: str, *, field: str) -> str: - missing: set[str] = set() - - def replace(match: re.Match[str]) -> str: - name = match.group(1) or match.group(2) - resolved = os.environ.get(name) - if resolved is None: - missing.add(name) - return match.group(0) - return resolved - - result = _ENV_REFERENCE.sub(replace, value) - if missing: - raise ValueError( - f"MCP {field} references missing environment variable(s): " - f"{', '.join(sorted(missing))}" - ) - return result - - -@asynccontextmanager -async def _open_mcp_tool( - definition: MCPServerDefinition, -) -> AsyncIterator[Any]: - try: - import mcp # noqa: F401 - from agent_framework import MCPStreamableHTTPTool - from httpx import AsyncClient - except ImportError as error: - raise ImportError( - "MCP support is not installed. Install " - "'azurefunctions-agents-extension-agent-framework[mcp]'." - ) from error - - config = definition.config - url = _resolve_environment(config.url, field=f"server {definition.name!r} URL") - parsed_url = urlsplit(url) - if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: - raise ValueError( - f"MCP server {definition.name!r} URL must use HTTP or HTTPS" - ) - static_headers = { - name: _resolve_environment( - value, - field=f"server {definition.name!r} header {name!r}", - ) - for name, value in config.headers - } - auth = config.auth - scope = ( - _resolve_environment( - auth.scope, - field=f"server {definition.name!r} auth scope", - ) - if auth is not None - else None - ) - client_id = ( - _resolve_environment( - auth.client_id, - field=f"server {definition.name!r} auth client_id", - ) - if auth is not None and auth.client_id is not None - else None - ) - - async with AsyncExitStack() as stack: - credential = None - if scope is not None: - try: - from azure.identity import DefaultAzureCredential - except ImportError as error: - raise ImportError( - "MCP Entra authentication is not installed. Install " - "'azurefunctions-agents-extension-agent-framework[mcp]'." - ) from error - credential = DefaultAzureCredential( - managed_identity_client_id=client_id, - ) - stack.callback(credential.close) - - http_client = None - if static_headers or credential is not None: - - async def inject_headers(request: Any) -> None: - for name, value in static_headers.items(): - request.headers[name] = value - if credential is not None and scope is not None: - token = await asyncio.to_thread(credential.get_token, scope) - request.headers["Authorization"] = f"Bearer {token.token}" - - http_client = await stack.enter_async_context( - AsyncClient( - follow_redirects=False, - event_hooks={"request": [inject_headers]}, - ) - ) - - tool = MCPStreamableHTTPTool( - name=definition.name, - url=url, - allowed_tools=( - list(config.allowed_tools) - if config.allowed_tools is not None - else None - ), - load_tools=True, - load_prompts=False, - http_client=http_client, - ) - yield tool - - -def create_provider() -> AgentFrameworkProvider: - return AgentFrameworkProvider() diff --git a/azurefunctions-agents-extension-agent-framework/tests/test_apps.py b/azurefunctions-agents-extension-agent-framework/tests/test_apps.py deleted file mode 100644 index 795a446..0000000 --- a/azurefunctions-agents-extension-agent-framework/tests/test_apps.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -import inspect -from unittest.mock import Mock - -import azure.functions as func - -from azurefunctions.extensions.agents.framework import ( - AIApp, - DurableAIApp, - markdown_agent, -) -from azurefunctions.extensions.agents.framework import apps - - -def test_typed_api_exposes_only_v1_options(): - assert list(inspect.signature(markdown_agent).parameters) == [ - "app", - "arg_name", - "agent_name", - "client_factory", - "tools", - ] - assert list(inspect.signature(AIApp.__init__).parameters) == [ - "self", - "client_factory", - "app_root", - "tools", - "http_auth_level", - ] - assert list(inspect.signature(AIApp.markdown_agent).parameters) == [ - "self", - "arg_name", - "agent_name", - "client_factory", - "tools", - ] - - -def test_typed_ai_app_pins_framework_provider(monkeypatch): - parent_init = Mock() - monkeypatch.setattr(func.AIApp, "__init__", parent_init) - factory = lambda: object() - - AIApp(client_factory=factory, app_root="app", tools=["lookup"]) - - parent_init.assert_called_once_with( - http_auth_level=func.AuthLevel.FUNCTION, - provider="agent_framework", - app_root="app", - client_factory=factory, - tools=["lookup"], - ) - - -def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): - parent_decorator = Mock(return_value=object()) - monkeypatch.setattr(func.AIApp, "markdown_agent", parent_decorator) - app = object.__new__(AIApp) - factory = lambda: object() - - result = app.markdown_agent( - arg_name="agent", - agent_name="orders", - client_factory=factory, - tools=["lookup"], - ) - - assert result is parent_decorator.return_value - parent_decorator.assert_called_once_with( - arg_name="agent", - agent_name="orders", - client_factory=factory, - tools=["lookup"], - ) - - -def test_typed_decorator_preserves_app_provider_defaults(monkeypatch): - base_decorator = Mock(return_value=object()) - monkeypatch.setattr(apps, "base_markdown_agent", base_decorator) - app = func.FunctionApp() - factory = lambda: object() - - result = markdown_agent( - app, - arg_name="agent", - agent_name="orders", - client_factory=factory, - ) - - assert result is base_decorator.return_value - base_decorator.assert_called_once_with( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - client_factory=factory, - ) - - -def test_typed_durable_ai_app_is_typed_ai_app(): - assert issubclass(DurableAIApp, AIApp) - assert issubclass(DurableAIApp, func.DurableAIApp) diff --git a/azurefunctions-agents-extension-base/README.md b/azurefunctions-agents-extension-base/README.md deleted file mode 100644 index 72113a6..0000000 --- a/azurefunctions-agents-extension-base/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Azure Functions Agents Base Extension - -Framework-neutral provider and lifecycle contracts for Python Agent integrations -with Azure Functions. - -This package is infrastructure for provider extensions. Applications should -install a provider package such as `azurefunctions-agents-extension-agent-framework`. - -## Provider contract - -Provider packages register a zero-argument factory in the -`azurefunctions.extensions.agents.providers` entry-point group. The entry-point -name is the provider ID. The factory returns an `AgentProvider` with a matching -`provider_id`, its distribution name, and a `compile_binding()` implementation. - -`compile_binding()` receives the complete markdown instructions, logical Agent -name, immutable provider options, injected parameter annotation, and an -`AgentCapabilities` bundle. Providers declare `supported_capabilities` and -translate neutral Skill/MCP definitions into their own runtime objects. The -compiled recipe creates a fresh Agent context for each invocation and can run -an Agent from a Durable activity. - -Applications use `azure.functions.FunctionApp.markdown_agent()` or install a -typed provider package. Each Function App uses one provider. `AIApp` pins it at -construction; a plain `FunctionApp` pins it on its first -`markdown_agent(provider=...)` use. A later different provider is rejected. -Provider discovery is cached, while live Agents and clients are never cached. - -Provider defaults are app-scoped. Binding options override defaults only for -that binding. The app root is configured once on `AIApp` or inferred from -`AzureWebJobsScriptRoot` and then the current directory for a plain app; -decorators cannot override it. - -## Markdown lookup - -An `agent_name` resolves exactly one UTF-8 file: - -```text -/.agent.md -/agents/.agent.md -``` - -The entire file is passed to the provider unchanged. Front matter, YAML, -substitutions, tools, skills, MCP configuration, and history are not parsed by -this package. If both locations exist, lookup fails as ambiguous. Absolute -paths, separators, traversal components, and symlinks outside `app_root` are -rejected. - -## Skills and MCP discovery - -The base package discovers immutable definitions from the shared app root: - -```text -skills//SKILL.md -mcp.json -``` - -Base discovery records safely contained directories that contain `SKILL.md` -without reading or interpreting those files. Each provider owns Skill format -parsing and validation. MCP servers must use `http` or `streamable-http`; local -commands and stdio are rejected. Discovery does not execute scripts, resolve -environment references, create credentials, or connect to servers. - -Every Agent binding receives all valid Skills and MCP servers discovered from -the app root. V1 has no app-level or per-binding capability selectors. Treat -placing a definition under the app root as granting every Agent in that app -access to it; use separate Function Apps when capabilities require isolation. - -Only immutable definitions are retained in app state. Provider packages must -create and close clients, credentials, tools, and other live resources within -each invocation. - -## Durable support - -Provider packages expose Durable support through their own `[durable]` extra. -The base extra installs `azure-functions-durable==2.0.0b2`; normal imports do -not import or require Durable Functions. `DurableAgentContext.call_agent()` -schedules a hidden activity with a deterministic, JSON-only payload and always -uses the `DurableAIApp` provider. All file, client, Agent, model, and -tool I/O occurs in the activity, never in the orchestrator. diff --git a/azurefunctions-agents-extension-base/tests/test_durable.py b/azurefunctions-agents-extension-base/tests/test_durable.py deleted file mode 100644 index 70cca3f..0000000 --- a/azurefunctions-agents-extension-base/tests/test_durable.py +++ /dev/null @@ -1,267 +0,0 @@ -from __future__ import annotations - -import asyncio -import math -from contextlib import asynccontextmanager -from datetime import timedelta -from types import SimpleNamespace - -import azure.functions as func -import pytest - -from azurefunctions.extensions.agents.base import bindings, durable -from azurefunctions.extensions.agents.base.durable import ( - DurableAgentContext, - _canonicalize_json_value, - _normalize_agent_prompt, - _parse_activity_input, -) - - -class _Context: - instance_id = "instance-1" - - def __init__(self): - self.calls = [] - - def call_activity(self, name, payload): - self.calls.append(("activity", name, payload)) - return "task" - - def call_activity_with_retry(self, name, retry, payload): - self.calls.append(("retry", name, retry, payload)) - return "retry-task" - - -def test_call_agent_schedules_canonical_payload(): - context = _Context() - proxy = DurableAgentContext(context) - - task = proxy.call_agent("orders", {"z": 1, "a": [True, None]}) - - assert task == "task" - assert context.calls == [ - ( - "activity", - "azurefunctions_agents_run_markdown_agent", - { - "schema_version": 1, - "agent_name": "orders", - "input": {"a": [True, None], "z": 1}, - "durable_instance_id": "instance-1", - }, - ) - ] - - -def test_call_agent_schedules_retry_with_same_canonical_payload(): - from azure.durable_functions import RetryPolicy - - context = _Context() - retry_options = RetryPolicy( - first_retry_interval=timedelta(seconds=1), - max_number_of_attempts=3, - ) - proxy = DurableAgentContext(context) - - task = proxy.call_agent( - "orders", - {"z": 1, "a": 2}, - retry_options=retry_options, - ) - - assert task == "retry-task" - assert context.calls == [ - ( - "retry", - "azurefunctions_agents_run_markdown_agent", - retry_options, - { - "schema_version": 1, - "agent_name": "orders", - "input": {"a": 2, "z": 1}, - "durable_instance_id": "instance-1", - }, - ) - ] - - -def test_call_agent_does_not_accept_provider_override(): - with pytest.raises(TypeError, match="provider"): - DurableAgentContext(_Context()).call_agent( - "orders", - "hello", - provider="langgraph", # type: ignore[call-arg] - ) - - -@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) -def test_call_agent_rejects_nonfinite_numbers(value): - with pytest.raises(ValueError, match="NaN or infinity"): - DurableAgentContext(_Context()).call_agent("orders", value) - - -def test_parse_activity_input_rejects_unknown_schema(): - with pytest.raises(ValueError, match="schema_version"): - _parse_activity_input( - { - "schema_version": 2, - "agent_name": "orders", - "input": "hello", - "durable_instance_id": "instance-1", - } - ) - - -def test_normalize_agent_prompt_preserves_strings_and_encodes_json(): - assert _normalize_agent_prompt("hello") == "hello" - assert _normalize_agent_prompt({"z": 1, "a": 2}) == '{"a":2,"z":1}' - - -def test_canonicalize_json_value_rejects_non_string_keys(): - with pytest.raises(TypeError, match="keys must be strings"): - _canonicalize_json_value({1: "value"}) - - -class _CompiledAgent: - def __init__(self): - self.calls = [] - - @asynccontextmanager - async def open_agent(self, invocation): - yield object() - - async def run_agent(self, prompt, invocation): - self.calls.append((prompt, invocation)) - return f"response:{prompt}" - - -class _Provider: - provider_id = "agent_framework" - distribution_name = "azurefunctions-agents-extension-agent-framework" - supported_capabilities = frozenset({"skills", "mcp"}) - - def __init__(self): - self.compiled = _CompiledAgent() - self.compile_calls = [] - - def compile_binding(self, **kwargs): - self.compile_calls.append(kwargs) - return self.compiled - - -def _configured_app(tmp_path, monkeypatch): - provider = _Provider() - monkeypatch.setattr(bindings, "load_provider", lambda provider_id: provider) - app = func.FunctionApp() - bindings.configure_app( - app, - provider="agent_framework", - app_root=tmp_path, - ) - return app, provider - - -def _hidden_activity(app): - return next( - function.get_user_function() - for function in app.get_functions() - if function.get_function_name() - == "azurefunctions_agents_run_markdown_agent" - ) - - -def test_configure_durable_app_registers_hidden_activity_once(tmp_path, monkeypatch): - app, _ = _configured_app(tmp_path, monkeypatch) - - durable.configure_durable_app(app) - durable.configure_durable_app(app) - - names = [function.get_function_name() for function in app.get_functions()] - assert names == ["azurefunctions_agents_run_markdown_agent"] - - -def test_hidden_activity_name_collision_is_rejected(tmp_path, monkeypatch): - app, _ = _configured_app(tmp_path, monkeypatch) - - @app.function_name(name="azurefunctions_agents_run_markdown_agent") - @app.activity_trigger(input_name="payload") - def customer_activity(payload): - return payload - - durable.configure_durable_app(app) - - with pytest.raises(ValueError, match="unique function name"): - app.get_functions() - - -def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypatch): - instructions = "---\nthis remains: raw\n---\nHandle orders.\n" - (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) - app, provider = _configured_app(tmp_path, monkeypatch) - durable.configure_durable_app(app) - activity = _hidden_activity(app) - context = SimpleNamespace( - function_name="activity", - invocation_id="invocation-1", - ) - - result = asyncio.run( - activity( - { - "schema_version": 1, - "agent_name": "orders", - "input": {"z": 1, "a": 2}, - "durable_instance_id": "instance-1", - }, - context, - ) - ) - - assert result == 'response:{"a":2,"z":1}' - assert provider.compile_calls[0]["instructions"] == instructions - assert provider.compile_calls[0]["capabilities"].skills == () - assert provider.compiled.calls[0][0] == '{"a":2,"z":1}' - assert provider.compiled.calls[0][1].durable_instance_id == "instance-1" - asyncio.run( - activity( - { - "schema_version": 1, - "agent_name": "orders", - "input": "again", - "durable_instance_id": "instance-1", - }, - context, - ) - ) - assert len(provider.compile_calls) == 1 - - -def test_hidden_activity_receives_all_discovered_capabilities(tmp_path, monkeypatch): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - skill_directory = tmp_path / "skills" / "inventory" - skill_directory.mkdir(parents=True) - (skill_directory / "SKILL.md").write_text( - "---\nname: inventory\ndescription: Inventory lookup\n---\n", - encoding="utf-8", - ) - app, provider = _configured_app(tmp_path, monkeypatch) - durable.configure_durable_app(app) - activity = _hidden_activity(app) - - asyncio.run( - activity( - { - "schema_version": 1, - "agent_name": "orders", - "input": "hello", - "durable_instance_id": "instance-1", - }, - SimpleNamespace(function_name="activity", invocation_id="invocation-1"), - ) - ) - - capabilities = provider.compile_calls[0]["capabilities"] - assert tuple(skill.path for skill in capabilities.skills) == ( - skill_directory.resolve(), - ) diff --git a/azurefunctions-agents-extension-base/tests/test_providers.py b/azurefunctions-agents-extension-base/tests/test_providers.py deleted file mode 100644 index a7b6513..0000000 --- a/azurefunctions-agents-extension-base/tests/test_providers.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -from azurefunctions.extensions.agents.base import providers - - -class _Provider: - provider_id = "agent_framework" - distribution_name = "azurefunctions-agents-extension-agent-framework" - supported_capabilities = frozenset({"skills", "mcp"}) - - def compile_binding(self, **kwargs): - return kwargs - - -class _EntryPoint: - def __init__(self, name, value, factory, distribution): - self.name = name - self.value = value - self._factory = factory - self.dist = SimpleNamespace(name=distribution) - - def load(self): - return self._factory - - -@pytest.fixture(autouse=True) -def _clear_provider_cache(): - providers._provider_entry_points.cache_clear() - providers.load_provider.cache_clear() - yield - providers.load_provider.cache_clear() - providers._provider_entry_points.cache_clear() - - -def test_load_provider_uses_matching_entry_point(monkeypatch): - entry_point = _EntryPoint( - "agent_framework", - "test:provider", - _Provider, - "azurefunctions-agents-extension-agent-framework", - ) - monkeypatch.setattr( - providers.metadata, - "entry_points", - lambda **kwargs: [entry_point], - ) - - provider = providers.load_provider("agent_framework") - - assert provider.provider_id == "agent_framework" - assert providers.load_provider("agent_framework") is provider - - -def test_provider_entry_points_are_enumerated_once_for_multiple_ids(monkeypatch): - class OtherProvider(_Provider): - provider_id = "other" - distribution_name = "other-provider" - - entry_points = [ - _EntryPoint( - "agent_framework", - "test:provider", - _Provider, - "azurefunctions-agents-extension-agent-framework", - ), - _EntryPoint("other", "test:other", OtherProvider, "other-provider"), - ] - calls = 0 - - def enumerate_entry_points(**kwargs): - nonlocal calls - calls += 1 - return entry_points - - monkeypatch.setattr(providers.metadata, "entry_points", enumerate_entry_points) - - assert providers.load_provider("agent_framework").provider_id == "agent_framework" - assert providers.load_provider("other").provider_id == "other" - assert calls == 1 - - -def test_load_provider_reports_installable_distribution(monkeypatch): - monkeypatch.setattr(providers.metadata, "entry_points", lambda **kwargs: []) - - with pytest.raises( - LookupError, match="azurefunctions-agents-extension-agent-framework" - ): - providers.load_provider("agent_framework") - - -def test_load_provider_rejects_duplicate_provider_ids(monkeypatch): - entry_points = [ - _EntryPoint("agent_framework", "one:provider", _Provider, "provider-one"), - _EntryPoint("agent_framework", "two:provider", _Provider, "provider-two"), - ] - monkeypatch.setattr( - providers.metadata, - "entry_points", - lambda **kwargs: entry_points, - ) - - with pytest.raises(RuntimeError, match="provider-one, provider-two"): - providers.load_provider("agent_framework") - - -def test_load_provider_does_not_rewrite_factory_error(monkeypatch): - def fail(): - raise RuntimeError("provider initialization failed") - - entry_point = _EntryPoint("agent_framework", "test:fail", fail, "provider") - monkeypatch.setattr( - providers.metadata, - "entry_points", - lambda **kwargs: [entry_point], - ) - - with pytest.raises(RuntimeError, match="provider initialization failed"): - providers.load_provider("agent_framework") - - -def test_load_provider_validates_returned_provider_id(monkeypatch): - class WrongProvider(_Provider): - provider_id = "wrong" - - entry_point = _EntryPoint( - "agent_framework", - "test:wrong", - WrongProvider, - "provider", - ) - monkeypatch.setattr( - providers.metadata, - "entry_points", - lambda **kwargs: [entry_point], - ) - - with pytest.raises(ValueError, match="returned provider 'wrong'"): - providers.load_provider("agent_framework") diff --git a/azurefunctions-agents-extension-agent-framework/LICENSE b/azurefunctions-agents-extensions-agent-framework/LICENSE similarity index 100% rename from azurefunctions-agents-extension-agent-framework/LICENSE rename to azurefunctions-agents-extensions-agent-framework/LICENSE diff --git a/azurefunctions-agents-extension-agent-framework/MANIFEST.in b/azurefunctions-agents-extensions-agent-framework/MANIFEST.in similarity index 100% rename from azurefunctions-agents-extension-agent-framework/MANIFEST.in rename to azurefunctions-agents-extensions-agent-framework/MANIFEST.in diff --git a/azurefunctions-agents-extension-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md similarity index 63% rename from azurefunctions-agents-extension-agent-framework/README.md rename to azurefunctions-agents-extensions-agent-framework/README.md index 7b2ba73..24119d7 100644 --- a/azurefunctions-agents-extension-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -6,7 +6,7 @@ into Python Azure Functions. ## Install ```text -pip install azurefunctions-agents-extension-agent-framework +pip install azurefunctions-agents-extensions-agent-framework ``` The default package installs `agent-framework-core==1.13.0`. Install the MAF @@ -18,10 +18,10 @@ Skills use the default package. Install remote MCP transport and Entra support with the MCP extra: ```text -pip install "azurefunctions-agents-extension-agent-framework[mcp]" +pip install "azurefunctions-agents-extensions-agent-framework[mcp]" ``` -## Use a typed Agent app +## Use an Agent app Create a zero-argument factory that returns a fresh MAF chat client. A new client and Agent context are created and closed for every Function invocation. @@ -29,7 +29,7 @@ client and Agent context are created and closed for every Function invocation. ```python import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents.framework import AIApp +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp def create_chat_client(): @@ -38,7 +38,7 @@ def create_chat_client(): return OpenAIChatClient() -app = AIApp(client_factory=create_chat_client) +app = AgentFunctionApp(client_factory=create_chat_client) @app.route(route="orders", methods=["POST"]) @@ -48,33 +48,10 @@ async def process_order(req: func.HttpRequest, agent: Agent): return response.text ``` -Provider IDs are the entry-point names published by provider packages. Each -provider package documents its ID; this package exports -`AGENT_FRAMEWORK_PROVIDER_ID` for code that needs to select it explicitly. A -closed SDK enum is not used because third-party packages may add provider IDs -without an Azure Functions SDK release. - -The standalone typed decorator pins a plain app to the Agent Framework -provider on first use: - -```python -from azurefunctions.extensions.agents.framework import markdown_agent - -app = func.FunctionApp() - - -@markdown_agent( - app, - arg_name="agent", - agent_name="orders", - client_factory=create_chat_client, -) -async def process_order(req: func.HttpRequest, agent: Agent): - ... -``` - -One Function App uses one provider. A later decorator from a different provider -package is rejected. +`AgentFunctionApp` is owned by this extension and subclasses +`azure.functions.FunctionApp`. The Azure Functions SDK does not need Agent APIs +or modifications. One app uses the Microsoft Agent Framework provider selected +by this package. Place the complete instructions at `orders.agent.md` or `agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime @@ -134,9 +111,9 @@ Every Agent in the Function App receives all valid Skills and MCP servers discovered from the app root: ```python -from azurefunctions.extensions.agents.framework import AIApp +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp -app = AIApp(client_factory=create_chat_client) +app = AgentFunctionApp(client_factory=create_chat_client) @app.markdown_agent(arg_name="agent", agent_name="orders") @@ -150,44 +127,26 @@ root grants every Agent in that app access to it. Use separate Function Apps when capabilities require isolation. Python `tools=` remain explicit because they are supplied directly to the Microsoft Agent Framework Agent. -The generic core form is also supported: - -```python -from azurefunctions.extensions.agents.framework import AGENT_FRAMEWORK_PROVIDER_ID - -app = func.FunctionApp() - - -@app.markdown_agent( - provider=AGENT_FRAMEWORK_PROVIDER_ID, - arg_name="agent", - agent_name="orders", - client_factory=create_chat_client, -) -async def process_order(req: func.HttpRequest, agent: Agent): - ... -``` - -Typed constructors and decorators expose only `client_factory` and explicit -Python `tools` in V1. The extension owns the Agent client, name, instructions, -and discovered Skills/MCP integration. Configure `app_root` only when -constructing `AIApp` or `DurableAIApp`; decorators do not override it. +The constructor and decorator expose only `client_factory` and explicit Python +`tools` in V1. The extension owns the Agent client, name, instructions, and +discovered Skills/MCP integration. Configure `app_root` only when constructing +`AgentFunctionApp`; decorators do not override it. ## Durable Agents Durable orchestration support is optional: ```text -pip install "azurefunctions-agents-extension-agent-framework[durable]" +pip install "azurefunctions-agents-extensions-agent-framework[durable]" ``` -Use `DurableAIApp` and call `context.call_agent(agent_name, input_)` inside a +Use `AgentFunctionApp` and call `context.call_agent(agent_name, input_)` inside a synchronous generator orchestrator. Agent execution is isolated in an activity so replay performs no nondeterministic work. Importing the package remains safe -without Durable installed; constructing `DurableAIApp` reports the exact extra -to install when it is absent. +without Durable installed; using a Durable decorator requires the `[durable]` +extra. -All `call_agent()` invocations use the provider configured by `DurableAIApp`. +All `call_agent()` invocations use the provider configured by `AgentFunctionApp`. They also use the app-level `skills` and `mcp_servers` defaults. V1 does not support selecting another provider or capability set from an orchestrator, and the schema-v1 orchestration payload contains no capability paths, settings, or diff --git a/azurefunctions-agents-extension-agent-framework/azurefunctions/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/__init__.py similarity index 100% rename from azurefunctions-agents-extension-agent-framework/azurefunctions/__init__.py rename to azurefunctions-agents-extensions-agent-framework/azurefunctions/__init__.py diff --git a/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/__init__.py similarity index 100% rename from azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/__init__.py rename to azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/__init__.py diff --git a/azurefunctions-agents-extension-base/azurefunctions/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/__init__.py similarity index 100% rename from azurefunctions-agents-extension-base/azurefunctions/__init__.py rename to azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/__init__.py diff --git a/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py similarity index 59% rename from azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/__init__.py rename to azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py index 1430750..2a853f8 100644 --- a/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/__init__.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py @@ -1,12 +1,10 @@ -from .apps import AIApp, DurableAIApp, markdown_agent +from .apps import AgentFunctionApp from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory __all__ = [ "AGENT_FRAMEWORK_PROVIDER_ID", - "AIApp", + "AgentFunctionApp", "ClientFactory", - "DurableAIApp", - "markdown_agent", ] __version__ = "1.0.0b1" diff --git a/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/apps.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py similarity index 64% rename from azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/apps.py rename to azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py index da5f22e..0da31cc 100644 --- a/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/apps.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py @@ -7,7 +7,11 @@ import azure.functions as func from agent_framework import ToolTypes -from azurefunctions.extensions.agents.base import markdown_agent as base_markdown_agent +from azurefunctions.agents.extensions.base import ( + configure_app, + durable_orchestration_trigger, +) +from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory @@ -29,27 +33,31 @@ def _provider_options( return options -def markdown_agent( - app: func.FunctionApp, - *, - arg_name: str, - agent_name: str, - client_factory: ClientFactory | None = None, - tools: ( - ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None - ) = None, -) -> Callable[[_F], _F]: - return base_markdown_agent( - app, - provider=AGENT_FRAMEWORK_PROVIDER_ID, - arg_name=arg_name, - agent_name=agent_name, - **_provider_options(client_factory=client_factory, tools=tools), - ) +class _AgentFrameworkAppMixin: + def markdown_agent( + self, + *, + arg_name: str, + agent_name: str, + client_factory: ClientFactory | None = None, + tools: ( + ToolTypes + | Callable[..., Any] + | Sequence[ToolTypes | Callable[..., Any]] + | None + ) = None, + ) -> Callable[[_F], _F]: + return base_markdown_agent( + self, + provider=AGENT_FRAMEWORK_PROVIDER_ID, + arg_name=arg_name, + agent_name=agent_name, + **_provider_options(client_factory=client_factory, tools=tools), + ) -class AIApp(func.AIApp): - """Azure Functions app configured for Microsoft Agent Framework.""" +class AgentFunctionApp(_AgentFrameworkAppMixin, func.FunctionApp): + """Azure Functions app configured for Microsoft Agent Framework Agents.""" def __init__( self, @@ -66,30 +74,27 @@ def __init__( ) -> None: super().__init__( http_auth_level=http_auth_level, + ) + configure_app( + self, provider=AGENT_FRAMEWORK_PROVIDER_ID, app_root=app_root, - **_provider_options(client_factory=client_factory, tools=tools), + provider_options=_provider_options( + client_factory=client_factory, + tools=tools, + ), ) - def markdown_agent( + def orchestration_trigger( self, - *, - arg_name: str, - agent_name: str, - client_factory: ClientFactory | None = None, - tools: ( - ToolTypes - | Callable[..., Any] - | Sequence[ToolTypes | Callable[..., Any]] - | None - ) = None, - ) -> Callable[[_F], _F]: - return super().markdown_agent( - arg_name=arg_name, - agent_name=agent_name, - **_provider_options(client_factory=client_factory, tools=tools), + context_name: str, + orchestration: str | None = None, + input_type: type | None = None, + ) -> Callable[..., Any]: + return durable_orchestration_trigger( + self, + sdk_decorator=super().orchestration_trigger, + context_name=context_name, + orchestration=orchestration, + input_type=input_type, ) - - -class DurableAIApp(AIApp, func.DurableAIApp): - """Microsoft Agent Framework app with optional Durable Agent support.""" diff --git a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py similarity index 96% rename from azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py rename to azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py index aaa3217..60dac86 100644 --- a/azurefunctions-extensions-agents-framework/azurefunctions/extensions/agents/framework/provider.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py @@ -15,7 +15,7 @@ from agent_framework import Agent, BaseChatClient, SkillsProvider from agent_framework._feature_stage import ExperimentalWarning -from azurefunctions.extensions.agents.base import ( +from azurefunctions.agents.extensions.base import ( AgentCapabilities, InvocationMetadata, MCPServerDefinition, @@ -88,7 +88,7 @@ async def run_agent( class AgentFrameworkProvider: provider_id = AGENT_FRAMEWORK_PROVIDER_ID - distribution_name = "azurefunctions-agents-extension-agent-framework" + distribution_name = "azurefunctions-agents-extensions-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) def compile_binding( @@ -187,7 +187,7 @@ async def _open_mcp_tool( except ImportError as error: raise ImportError( "MCP support is not installed. Install " - "'azurefunctions-agents-extension-agent-framework[mcp]'." + "'azurefunctions-agents-extensions-agent-framework[mcp]'." ) from error config = definition.config @@ -230,7 +230,7 @@ async def _open_mcp_tool( except ImportError as error: raise ImportError( "MCP Entra authentication is not installed. Install " - "'azurefunctions-agents-extension-agent-framework[mcp]'." + "'azurefunctions-agents-extensions-agent-framework[mcp]'." ) from error credential = DefaultAzureCredential( managed_identity_client_id=client_id, diff --git a/azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/py.typed b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/py.typed similarity index 100% rename from azurefunctions-agents-extension-agent-framework/azurefunctions/extensions/agents/framework/py.typed rename to azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/py.typed diff --git a/azurefunctions-agents-extension-agent-framework/pyproject.toml b/azurefunctions-agents-extensions-agent-framework/pyproject.toml similarity index 71% rename from azurefunctions-agents-extension-agent-framework/pyproject.toml rename to azurefunctions-agents-extensions-agent-framework/pyproject.toml index c92048c..d7651a7 100644 --- a/azurefunctions-agents-extension-agent-framework/pyproject.toml +++ b/azurefunctions-agents-extensions-agent-framework/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools >= 61.0"] build-backend = "setuptools.build_meta" [project] -name = "azurefunctions-agents-extension-agent-framework" +name = "azurefunctions-agents-extensions-agent-framework" dynamic = ["version"] requires-python = ">=3.13" authors = [ @@ -26,7 +26,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core==1.13.0", - "azurefunctions-agents-extension-base>=1.0.0b1", + "azurefunctions-agents-extensions-base>=1.0.0b1", ] [project.optional-dependencies] @@ -36,7 +36,7 @@ mcp = [ "mcp>=1.28.1,<2", ] durable = [ - "azurefunctions-agents-extension-base[durable]>=1.0.0b1", + "azurefunctions-agents-extensions-base[durable]>=1.0.0b1", ] dev = [ "azure-functions-durable>=2.0.0b2", @@ -49,17 +49,17 @@ dev = [ "pytest-instafail", ] -[project.entry-points."azurefunctions.extensions.agents.providers"] -agent_framework = "azurefunctions.extensions.agents.framework.provider:create_provider" +[project.entry-points."azurefunctions.agents.extensions.providers"] +agent_framework = "azurefunctions.agents.extensions.agent_framework.provider:create_provider" [tool.setuptools.dynamic] -version = { attr = "azurefunctions.extensions.agents.framework.__version__" } +version = { attr = "azurefunctions.agents.extensions.agent_framework.__version__" } [tool.setuptools.packages.find] -include = ["azurefunctions.extensions.agents.framework*"] +include = ["azurefunctions.agents.extensions.agent_framework*"] [tool.setuptools.package-data] -"azurefunctions.extensions.agents.framework" = ["py.typed"] +"azurefunctions.agents.extensions.agent_framework" = ["py.typed"] [[tool.mypy.overrides]] module = ["azure", "azure.*"] diff --git a/azurefunctions-agents-extension-agent-framework/samples/README.md b/azurefunctions-agents-extensions-agent-framework/samples/README.md similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/README.md rename to azurefunctions-agents-extensions-agent-framework/samples/README.md diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/README.md b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/README.md similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/README.md rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/README.md diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py similarity index 95% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/function_app.py rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py index e9cf18f..2b1b16a 100644 --- a/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py @@ -6,7 +6,7 @@ import azure.durable_functions as df import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents.framework import DurableAIApp +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp from order_processing import prepare_order_for_agent @@ -20,8 +20,7 @@ def create_chat_client(): credential=DefaultAzureCredential(), ) - -app = DurableAIApp(client_factory=create_chat_client) +app = AgentFunctionApp(client_factory=create_chat_client) @app.route(route="orders/orchestrations", methods=["POST"]) diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/host.json b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/host.json similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/host.json rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/host.json diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/local.settings.template.json b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/local.settings.template.json similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/local.settings.template.json rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/local.settings.template.json diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/order_processing.py b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/order_processing.py similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/order_processing.py rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/order_processing.py diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/requirements.txt b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/requirements.txt similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-durable-agent/src/requirements.txt rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/requirements.txt diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/README.md b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/README.md similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/README.md rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/README.md diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/function_app.py similarity index 94% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/function_app.py rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/function_app.py index ef74532..c3df005 100644 --- a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/function_app.py @@ -3,7 +3,7 @@ import azure.functions as func from agent_framework import Agent -from azurefunctions.extensions.agents.framework import AIApp +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp from order_processing import prepare_order_for_agent from pydantic import ValidationError @@ -19,7 +19,7 @@ def create_chat_client(): ) -app = AIApp(client_factory=create_chat_client) +app = AgentFunctionApp(client_factory=create_chat_client) @app.route(route="orders/{orderId}", methods=["POST"]) diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/host.json b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/host.json similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/host.json rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/host.json diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/local.settings.template.json b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/local.settings.template.json similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/local.settings.template.json rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/local.settings.template.json diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/mcp.json b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/mcp.json similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/mcp.json rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/mcp.json diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/order_processing.py b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/order_processing.py similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/order_processing.py rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/order_processing.py diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/requirements.txt b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/requirements.txt similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/requirements.txt rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/requirements.txt diff --git a/azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md similarity index 100% rename from azurefunctions-agents-extension-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md rename to azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py new file mode 100644 index 0000000..8affad3 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import inspect +from unittest.mock import Mock + +import azure.functions as func + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.agent_framework import apps + + +def test_typed_api_exposes_only_v1_options(): + assert list(inspect.signature(AgentFunctionApp.__init__).parameters) == [ + "self", + "client_factory", + "app_root", + "tools", + "http_auth_level", + ] + assert list(inspect.signature(AgentFunctionApp.markdown_agent).parameters) == [ + "self", + "arg_name", + "agent_name", + "client_factory", + "tools", + ] + assert list( + inspect.signature(AgentFunctionApp.orchestration_trigger).parameters + ) == [ + "self", + "context_name", + "orchestration", + "input_type", + ] + + +def test_typed_agent_function_app_pins_framework_provider(monkeypatch): + parent_init = Mock() + configure_app = Mock() + monkeypatch.setattr(func.FunctionApp, "__init__", parent_init) + monkeypatch.setattr(apps, "configure_app", configure_app) + factory = lambda: object() + + app = AgentFunctionApp( + client_factory=factory, + app_root="app", + tools=["lookup"], + ) + + parent_init.assert_called_once_with( + http_auth_level=func.AuthLevel.FUNCTION, + ) + configure_app.assert_called_once_with( + app, + provider="agent_framework", + app_root="app", + provider_options={"client_factory": factory, "tools": ["lookup"]}, + ) + + +def test_agent_function_app_uses_function_app_directly(): + assert func.FunctionApp in AgentFunctionApp.__bases__ + + +def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", parent_decorator) + app = object.__new__(AgentFunctionApp) + factory = lambda: object() + + result = app.markdown_agent( + arg_name="agent", + agent_name="orders", + client_factory=factory, + tools=["lookup"], + ) + + assert result is parent_decorator.return_value + parent_decorator.assert_called_once_with( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + client_factory=factory, + tools=["lookup"], + ) + + +def test_typed_orchestration_trigger_adds_agent_context(monkeypatch): + parent_decorator = Mock(return_value=object()) + durable_decorator = Mock(return_value=object()) + monkeypatch.setattr( + func.FunctionApp, + "orchestration_trigger", + parent_decorator, + ) + monkeypatch.setattr( + apps, + "durable_orchestration_trigger", + durable_decorator, + ) + app = object.__new__(AgentFunctionApp) + + result = app.orchestration_trigger( + context_name="context", + orchestration="orders", + input_type=dict, + ) + + assert result is durable_decorator.return_value + durable_decorator.assert_called_once_with( + app, + sdk_decorator=parent_decorator, + context_name="context", + orchestration="orders", + input_type=dict, + ) diff --git a/azurefunctions-agents-extension-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py similarity index 71% rename from azurefunctions-agents-extension-agent-framework/tests/test_imports.py rename to azurefunctions-agents-extensions-agent-framework/tests/test_imports.py index 777b83f..bdd6632 100644 --- a/azurefunctions-agents-extension-agent-framework/tests/test_imports.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -2,6 +2,14 @@ import sys +def test_framework_exports_only_app_api(): + import azurefunctions.agents.extensions.agent_framework as framework + + assert framework.AgentFunctionApp is not None + assert not hasattr(framework, "AgentDFApp") + assert not hasattr(framework, "markdown_agent") + + def test_framework_import_does_not_import_durable(): result = subprocess.run( [ @@ -16,7 +24,7 @@ def test_framework_import_does_not_import_durable(): "fullname.startswith('azure.durable_functions.'):\n" " raise ModuleNotFoundError(name=fullname)\n" "sys.meta_path.insert(0, BlockDurable())\n" - "import azurefunctions.extensions.agents.framework\n" + "import azurefunctions.agents.extensions.agent_framework\n" "assert 'azure.durable_functions' not in sys.modules" ), ], @@ -26,3 +34,5 @@ def test_framework_import_does_not_import_durable(): ) assert result.returncode == 0, result.stderr + + diff --git a/azurefunctions-agents-extension-agent-framework/tests/test_provider.py b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py similarity index 98% rename from azurefunctions-agents-extension-agent-framework/tests/test_provider.py rename to azurefunctions-agents-extensions-agent-framework/tests/test_provider.py index 0a42778..1b452ba 100644 --- a/azurefunctions-agents-extension-agent-framework/tests/test_provider.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py @@ -10,14 +10,14 @@ import pytest from agent_framework import Agent -from azurefunctions.extensions.agents.base import ( +from azurefunctions.agents.extensions.base import ( AgentCapabilities, InvocationMetadata, MCPHTTPConfig, MCPServerDefinition, SkillDefinition, ) -from azurefunctions.extensions.agents.framework import provider +from azurefunctions.agents.extensions.agent_framework import provider class _Agent: diff --git a/azurefunctions-agents-extension-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py similarity index 100% rename from azurefunctions-agents-extension-agent-framework/tests/test_samples.py rename to azurefunctions-agents-extensions-agent-framework/tests/test_samples.py diff --git a/azurefunctions-agents-extension-base/LICENSE b/azurefunctions-agents-extensions-base/LICENSE similarity index 100% rename from azurefunctions-agents-extension-base/LICENSE rename to azurefunctions-agents-extensions-base/LICENSE diff --git a/azurefunctions-agents-extension-base/MANIFEST.in b/azurefunctions-agents-extensions-base/MANIFEST.in similarity index 100% rename from azurefunctions-agents-extension-base/MANIFEST.in rename to azurefunctions-agents-extensions-base/MANIFEST.in diff --git a/azurefunctions-extensions-agents-base/README.md b/azurefunctions-agents-extensions-base/README.md similarity index 80% rename from azurefunctions-extensions-agents-base/README.md rename to azurefunctions-agents-extensions-base/README.md index 72113a6..06f78e2 100644 --- a/azurefunctions-extensions-agents-base/README.md +++ b/azurefunctions-agents-extensions-base/README.md @@ -4,12 +4,12 @@ Framework-neutral provider and lifecycle contracts for Python Agent integrations with Azure Functions. This package is infrastructure for provider extensions. Applications should -install a provider package such as `azurefunctions-agents-extension-agent-framework`. +install a provider package such as `azurefunctions-agents-extensions-agent-framework`. ## Provider contract Provider packages register a zero-argument factory in the -`azurefunctions.extensions.agents.providers` entry-point group. The entry-point +`azurefunctions.agents.extensions.providers` entry-point group. The entry-point name is the provider ID. The factory returns an `AgentProvider` with a matching `provider_id`, its distribution name, and a `compile_binding()` implementation. @@ -20,16 +20,14 @@ translate neutral Skill/MCP definitions into their own runtime objects. The compiled recipe creates a fresh Agent context for each invocation and can run an Agent from a Durable activity. -Applications use `azure.functions.FunctionApp.markdown_agent()` or install a -typed provider package. Each Function App uses one provider. `AIApp` pins it at -construction; a plain `FunctionApp` pins it on its first -`markdown_agent(provider=...)` use. A later different provider is rejected. +Applications import the app class supplied by a provider package. Each Agent +Function App uses one provider, configured when the app is constructed. Provider discovery is cached, while live Agents and clients are never cached. Provider defaults are app-scoped. Binding options override defaults only for -that binding. The app root is configured once on `AIApp` or inferred from -`AzureWebJobsScriptRoot` and then the current directory for a plain app; -decorators cannot override it. +that binding. The app root is configured once when the provider app is +constructed, or inferred from `AzureWebJobsScriptRoot` and then the current +directory; decorators cannot override it. ## Markdown lookup @@ -73,8 +71,8 @@ each invocation. ## Durable support Provider packages expose Durable support through their own `[durable]` extra. -The base extra installs `azure-functions-durable==2.0.0b2`; normal imports do +The base extra installs `azure-functions-durable>=2.0.0b2`; normal imports do not import or require Durable Functions. `DurableAgentContext.call_agent()` schedules a hidden activity with a deterministic, JSON-only payload and always -uses the `DurableAIApp` provider. All file, client, Agent, model, and +uses the `AgentFunctionApp` provider. All file, client, Agent, model, and tool I/O occurs in the activity, never in the orchestrator. diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/__init__.py similarity index 100% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/__init__.py rename to azurefunctions-agents-extensions-base/azurefunctions/__init__.py diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py similarity index 100% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/__init__.py rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/bindings.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py similarity index 94% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/bindings.py rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py index 4defc5c..faa564c 100644 --- a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/bindings.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -33,7 +33,7 @@ class _AppState: lock: threading.RLock = field(default_factory=threading.RLock) -_APP_STATES: weakref.WeakKeyDictionary[func.FunctionApp, _AppState] = ( +_APP_STATES: weakref.WeakKeyDictionary[object, _AppState] = ( weakref.WeakKeyDictionary() ) _APP_STATES_LOCK = threading.Lock() @@ -49,7 +49,7 @@ def _resolve_app_root(app_root: str | os.PathLike[str] | None) -> Path: def _state_for( - app: func.FunctionApp, + app: object, *, provider: str, app_root: str | os.PathLike[str] | None = None, @@ -71,24 +71,24 @@ def _state_for( return state if app_root is not None and state.app_root != resolved_root: raise ValueError( - f"FunctionApp is already configured with app_root " + f"Agent app is already configured with app_root " f"{str(state.app_root)!r}; it cannot also use " f"{str(resolved_root)!r}" ) if state.provider_id != provider: raise ValueError( - f"FunctionApp is already configured with Agent provider " + f"Agent app is already configured with provider " f"{state.provider_id!r}; it cannot also use {provider!r}" ) if provider_defaults is not None and state.provider_defaults != defaults: raise ValueError( - "FunctionApp Agent provider defaults are already configured" + "Agent app provider defaults are already configured" ) return state def configure_app( - app: func.FunctionApp, + app: object, *, provider: str, app_root: str | os.PathLike[str] | None = None, @@ -102,16 +102,16 @@ def configure_app( ) -def _configured_state(app: func.FunctionApp) -> _AppState: +def _configured_state(app: object) -> _AppState: with _APP_STATES_LOCK: state = _APP_STATES.get(app) if state is None: - raise RuntimeError("FunctionApp is not configured for an Agent provider") + raise RuntimeError("Agent app is not configured with a provider") return state def _durable_agent( - app: func.FunctionApp, + app: object, agent_name: str, ) -> CompiledAgent: state = _configured_state(app) @@ -279,7 +279,7 @@ def _invocation_metadata( def markdown_agent( - app: func.FunctionApp, + app: object, *, provider: str, arg_name: str, @@ -287,7 +287,9 @@ def markdown_agent( **provider_options: Any, ) -> Callable[[_F], _F]: if "app_root" in provider_options: - raise TypeError("markdown_agent app_root is app-scoped; configure it on AIApp") + raise TypeError( + "markdown_agent app_root is app-scoped; configure it on AgentFunctionApp" + ) state = _state_for(app, provider=provider) def decorate(handler: _F) -> _F: diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/capabilities.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/capabilities.py similarity index 100% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/capabilities.py rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/capabilities.py diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/__init__.py similarity index 100% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/__init__.py rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/__init__.py diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/mcp.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py similarity index 100% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/mcp.py rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/skills.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/skills.py similarity index 100% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/discovery/skills.py rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/skills.py diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/durable.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py similarity index 98% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/durable.py rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py index 18975a3..4c40cac 100644 --- a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/durable.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py @@ -201,7 +201,7 @@ def durable_orchestration_trigger( def decorate(handler: _F) -> Any: if not inspect.isgeneratorfunction(handler): raise TypeError( - "DurableAIApp orchestration_trigger requires a synchronous " + "AgentFunctionApp orchestration_trigger requires a synchronous " "generator function" ) signature = inspect.signature(handler) diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/providers.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py similarity index 96% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/providers.py rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py index 9fda622..a6fe750 100644 --- a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/providers.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py @@ -8,7 +8,7 @@ from .capabilities import AgentCapabilities -AGENT_PROVIDER_ENTRY_POINT_GROUP = "azurefunctions.extensions.agents.providers" +AGENT_PROVIDER_ENTRY_POINT_GROUP = "azurefunctions.agents.extensions.providers" @dataclass(frozen=True) @@ -52,7 +52,7 @@ def compile_binding( def _provider_distribution_name(provider_id: str) -> str: normalized = provider_id.replace("_", "-") - return f"azurefunctions-agents-extension-{normalized}" + return f"azurefunctions-agents-extensions-{normalized}" def _entry_point_distribution(entry_point: metadata.EntryPoint) -> str: diff --git a/azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/py.typed b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/py.typed similarity index 100% rename from azurefunctions-agents-extension-base/azurefunctions/extensions/agents/base/py.typed rename to azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/py.typed diff --git a/azurefunctions-agents-extension-base/pyproject.toml b/azurefunctions-agents-extensions-base/pyproject.toml similarity index 79% rename from azurefunctions-agents-extension-base/pyproject.toml rename to azurefunctions-agents-extensions-base/pyproject.toml index 3c1dd8c..e9f3813 100644 --- a/azurefunctions-agents-extension-base/pyproject.toml +++ b/azurefunctions-agents-extensions-base/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools >= 61.0"] build-backend = "setuptools.build_meta" [project] -name = "azurefunctions-agents-extension-base" +name = "azurefunctions-agents-extensions-base" dynamic = ["version"] requires-python = ">=3.13" authors = [ @@ -25,7 +25,7 @@ classifiers = [ "Development Status :: 3 - Alpha", ] dependencies = [ - "azure-functions>=2.4.0b1,<3", + "azure-functions>=2.3.0,<3", ] [project.optional-dependencies] @@ -44,13 +44,17 @@ dev = [ ] [tool.setuptools.dynamic] -version = { attr = "azurefunctions.extensions.agents.base.__version__" } +version = { attr = "azurefunctions.agents.extensions.base.__version__" } [tool.setuptools.packages.find] -include = ["azurefunctions.extensions.agents.base*"] +include = ["azurefunctions.agents.extensions.base*"] [tool.setuptools.package-data] -"azurefunctions.extensions.agents.base" = ["py.typed"] +"azurefunctions.agents.extensions.base" = ["py.typed"] + +[[tool.mypy.overrides]] +module = ["azure", "azure.*"] +ignore_missing_imports = true [[tool.mypy.overrides]] module = ["azure.durable_functions", "azure.durable_functions.*"] diff --git a/azurefunctions-agents-extension-base/tests/test_bindings.py b/azurefunctions-agents-extensions-base/tests/test_bindings.py similarity index 98% rename from azurefunctions-agents-extension-base/tests/test_bindings.py rename to azurefunctions-agents-extensions-base/tests/test_bindings.py index d2a096b..d584954 100644 --- a/azurefunctions-agents-extension-base/tests/test_bindings.py +++ b/azurefunctions-agents-extensions-base/tests/test_bindings.py @@ -9,8 +9,8 @@ import azure.functions as func import pytest -from azurefunctions.extensions.agents.base import AgentCapabilities -from azurefunctions.extensions.agents.base import bindings, providers +from azurefunctions.agents.extensions.base import AgentCapabilities +from azurefunctions.agents.extensions.base import bindings, providers class _CompiledAgent: @@ -32,7 +32,7 @@ async def run_agent(self, prompt, invocation): class _Provider: provider_id = "agent_framework" - distribution_name = "azurefunctions-agents-extension-agent-framework" + distribution_name = "azurefunctions-agents-extensions-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) def __init__(self): @@ -218,7 +218,7 @@ def test_function_app_rejects_a_second_default_provider(tmp_path, provider): app_root=tmp_path, ) - with pytest.raises(ValueError, match="already configured with Agent provider"): + with pytest.raises(ValueError, match="already configured with provider"): bindings.configure_app( func_app, provider="langgraph", @@ -251,7 +251,7 @@ def test_function_app_rejects_a_second_binding_provider(tmp_path, monkeypatch): async def framework_handler(agent: object) -> None: pass - with pytest.raises(ValueError, match="already configured with Agent provider"): + with pytest.raises(ValueError, match="already configured with provider"): bindings.markdown_agent( app, provider="langgraph", diff --git a/azurefunctions-agents-extension-base/tests/test_capability_discovery.py b/azurefunctions-agents-extensions-base/tests/test_capability_discovery.py similarity index 97% rename from azurefunctions-agents-extension-base/tests/test_capability_discovery.py rename to azurefunctions-agents-extensions-base/tests/test_capability_discovery.py index 91e3ccb..881508f 100644 --- a/azurefunctions-agents-extension-base/tests/test_capability_discovery.py +++ b/azurefunctions-agents-extensions-base/tests/test_capability_discovery.py @@ -4,7 +4,7 @@ import pytest -from azurefunctions.extensions.agents.base.discovery import ( +from azurefunctions.agents.extensions.base.discovery import ( discover_mcp_servers, discover_skills, ) diff --git a/azurefunctions-extensions-agents-base/tests/test_durable.py b/azurefunctions-agents-extensions-base/tests/test_durable.py similarity index 97% rename from azurefunctions-extensions-agents-base/tests/test_durable.py rename to azurefunctions-agents-extensions-base/tests/test_durable.py index 70cca3f..1fdb983 100644 --- a/azurefunctions-extensions-agents-base/tests/test_durable.py +++ b/azurefunctions-agents-extensions-base/tests/test_durable.py @@ -9,8 +9,8 @@ import azure.functions as func import pytest -from azurefunctions.extensions.agents.base import bindings, durable -from azurefunctions.extensions.agents.base.durable import ( +from azurefunctions.agents.extensions.base import bindings, durable +from azurefunctions.agents.extensions.base.durable import ( DurableAgentContext, _canonicalize_json_value, _normalize_agent_prompt, @@ -138,7 +138,7 @@ async def run_agent(self, prompt, invocation): class _Provider: provider_id = "agent_framework" - distribution_name = "azurefunctions-agents-extension-agent-framework" + distribution_name = "azurefunctions-agents-extensions-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) def __init__(self): diff --git a/azurefunctions-agents-extension-base/tests/test_imports.py b/azurefunctions-agents-extensions-base/tests/test_imports.py similarity index 93% rename from azurefunctions-agents-extension-base/tests/test_imports.py rename to azurefunctions-agents-extensions-base/tests/test_imports.py index 1029e5a..0331915 100644 --- a/azurefunctions-agents-extension-base/tests/test_imports.py +++ b/azurefunctions-agents-extensions-base/tests/test_imports.py @@ -16,7 +16,7 @@ def test_durable_module_import_does_not_require_durable(): "fullname.startswith('azure.durable_functions.'):\n" " raise ModuleNotFoundError(name=fullname)\n" "sys.meta_path.insert(0, BlockDurable())\n" - "import azurefunctions.extensions.agents.base.durable\n" + "import azurefunctions.agents.extensions.base.durable\n" "assert 'azure.durable_functions' not in sys.modules" ), ], diff --git a/azurefunctions-extensions-agents-base/tests/test_providers.py b/azurefunctions-agents-extensions-base/tests/test_providers.py similarity index 91% rename from azurefunctions-extensions-agents-base/tests/test_providers.py rename to azurefunctions-agents-extensions-base/tests/test_providers.py index a7b6513..9187d45 100644 --- a/azurefunctions-extensions-agents-base/tests/test_providers.py +++ b/azurefunctions-agents-extensions-base/tests/test_providers.py @@ -4,12 +4,12 @@ import pytest -from azurefunctions.extensions.agents.base import providers +from azurefunctions.agents.extensions.base import providers class _Provider: provider_id = "agent_framework" - distribution_name = "azurefunctions-agents-extension-agent-framework" + distribution_name = "azurefunctions-agents-extensions-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) def compile_binding(self, **kwargs): @@ -41,7 +41,7 @@ def test_load_provider_uses_matching_entry_point(monkeypatch): "agent_framework", "test:provider", _Provider, - "azurefunctions-agents-extension-agent-framework", + "azurefunctions-agents-extensions-agent-framework", ) monkeypatch.setattr( providers.metadata, @@ -65,7 +65,7 @@ class OtherProvider(_Provider): "agent_framework", "test:provider", _Provider, - "azurefunctions-agents-extension-agent-framework", + "azurefunctions-agents-extensions-agent-framework", ), _EntryPoint("other", "test:other", OtherProvider, "other-provider"), ] @@ -87,7 +87,7 @@ def test_load_provider_reports_installable_distribution(monkeypatch): monkeypatch.setattr(providers.metadata, "entry_points", lambda **kwargs: []) with pytest.raises( - LookupError, match="azurefunctions-agents-extension-agent-framework" + LookupError, match="azurefunctions-agents-extensions-agent-framework" ): providers.load_provider("agent_framework") diff --git a/azurefunctions-extensions-agents-base/pyproject.toml b/azurefunctions-extensions-agents-base/pyproject.toml deleted file mode 100644 index 3c1dd8c..0000000 --- a/azurefunctions-extensions-agents-base/pyproject.toml +++ /dev/null @@ -1,57 +0,0 @@ -[build-system] -requires = ["setuptools >= 61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "azurefunctions-agents-extension-base" -dynamic = ["version"] -requires-python = ">=3.13" -authors = [ - { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, -] -description = "Framework-neutral Agent integration for Azure Functions." -readme = "README.md" -license = { text = "MIT License" } -classifiers = [ - "License :: OSI Approved :: MIT License", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX", - "Operating System :: MacOS :: MacOS X", - "Environment :: Web Environment", - "Development Status :: 3 - Alpha", -] -dependencies = [ - "azure-functions>=2.4.0b1,<3", -] - -[project.optional-dependencies] -durable = [ - "azure-functions-durable>=2.0.0b2", -] -dev = [ - "azure-functions-durable>=2.0.0b2", - "coverage", - "flake8", - "mypy", - "pre-commit", - "pytest", - "pytest-cov", - "pytest-instafail", -] - -[tool.setuptools.dynamic] -version = { attr = "azurefunctions.extensions.agents.base.__version__" } - -[tool.setuptools.packages.find] -include = ["azurefunctions.extensions.agents.base*"] - -[tool.setuptools.package-data] -"azurefunctions.extensions.agents.base" = ["py.typed"] - -[[tool.mypy.overrides]] -module = ["azure.durable_functions", "azure.durable_functions.*"] -follow_untyped_imports = true diff --git a/azurefunctions-extensions-agents-base/tests/test_bindings.py b/azurefunctions-extensions-agents-base/tests/test_bindings.py deleted file mode 100644 index d2a096b..0000000 --- a/azurefunctions-extensions-agents-base/tests/test_bindings.py +++ /dev/null @@ -1,409 +0,0 @@ -from __future__ import annotations - -import asyncio -import gc -import inspect -import weakref -from contextlib import asynccontextmanager - -import azure.functions as func -import pytest - -from azurefunctions.extensions.agents.base import AgentCapabilities -from azurefunctions.extensions.agents.base import bindings, providers - - -class _CompiledAgent: - def __init__(self): - self.opened = 0 - self.closed = 0 - - @asynccontextmanager - async def open_agent(self, invocation): - self.opened += 1 - try: - yield {"instance": self.opened, "invocation": invocation} - finally: - self.closed += 1 - - async def run_agent(self, prompt, invocation): - return prompt - - -class _Provider: - provider_id = "agent_framework" - distribution_name = "azurefunctions-agents-extension-agent-framework" - supported_capabilities = frozenset({"skills", "mcp"}) - - def __init__(self): - self.compiled = _CompiledAgent() - self.compile_args = None - - def compile_binding(self, **kwargs): - self.compile_args = kwargs - return self.compiled - - -@pytest.fixture -def provider(monkeypatch, tmp_path): - instance = _Provider() - monkeypatch.setenv("AzureWebJobsScriptRoot", str(tmp_path)) - monkeypatch.setattr(providers, "load_provider", lambda provider_id: instance) - monkeypatch.setattr(bindings, "load_provider", lambda provider_id: instance) - return instance - - -def test_markdown_agent_injects_fresh_context_and_hides_parameter(tmp_path, provider): - instructions = "---\nnot: parsed\n---\nUse the order API.\n" - (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) - app = func.FunctionApp() - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - tools=["lookup"], - ) - async def handler(value: str, agent: object) -> tuple[str, object]: - return value, agent - - assert list(inspect.signature(handler).parameters) == ["value"] - assert provider.compile_args == { - "instructions": instructions, - "agent_name": "orders", - "options": {"tools": ["lookup"]}, - "annotation": object, - "capabilities": AgentCapabilities(), - } - - first = asyncio.run(handler("one")) - second = asyncio.run(handler("two")) - - assert first[1]["instance"] == 1 - assert second[1]["instance"] == 2 - assert provider.compiled.opened == provider.compiled.closed == 2 - - -def test_markdown_agent_preserves_variadic_handler_arguments(tmp_path, provider): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - app = func.FunctionApp() - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - ) - async def handler( - value: str, - agent: object, - *rest: str, - ) -> tuple[str, object, tuple[str, ...]]: - return value, agent, rest - - value, agent, rest = asyncio.run(handler("one", "two", "three")) - - assert value == "one" - assert agent["instance"] == 1 - assert rest == ("two", "three") - - -def test_markdown_agent_closes_context_when_handler_fails(tmp_path, provider): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - app = func.FunctionApp() - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - ) - async def handler(agent: object) -> None: - raise RuntimeError("handler failed") - - with pytest.raises(RuntimeError, match="handler failed"): - asyncio.run(handler()) - - assert provider.compiled.opened == provider.compiled.closed == 1 - - -def test_markdown_agent_closes_context_when_handler_is_cancelled(tmp_path, provider): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - app = func.FunctionApp() - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - ) - async def handler(agent: object) -> None: - raise asyncio.CancelledError - - with pytest.raises(asyncio.CancelledError): - asyncio.run(handler()) - - assert provider.compiled.opened == provider.compiled.closed == 1 - - -def test_markdown_agent_rejects_ambiguous_files(tmp_path, provider): - (tmp_path / "agents").mkdir() - (tmp_path / "orders.agent.md").write_text("root", encoding="utf-8") - (tmp_path / "agents" / "orders.agent.md").write_text("nested", encoding="utf-8") - - with pytest.raises(ValueError, match="ambiguous"): - - @bindings.markdown_agent( - func.FunctionApp(), - provider="agent_framework", - arg_name="agent", - agent_name="orders", - ) - async def handler(agent: object) -> None: - pass - - -def test_markdown_agent_rejects_symlink_outside_app_root(tmp_path, provider): - app_root = tmp_path / "app" - app_root.mkdir() - outside = tmp_path / "orders.agent.md" - outside.write_text("outside", encoding="utf-8") - try: - (app_root / "orders.agent.md").symlink_to(outside) - except OSError as error: - pytest.skip(f"symlink creation is unavailable: {error}") - app = func.FunctionApp() - bindings.configure_app(app, provider="agent_framework", app_root=app_root) - - with pytest.raises(ValueError, match="resolves outside app root"): - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - ) - async def handler(agent: object) -> None: - pass - - -@pytest.mark.parametrize( - "agent_name", - ["../orders", "agents/orders", r"agents\orders", "C:orders"], -) -def test_markdown_agent_rejects_nonportable_agent_names( - tmp_path, - provider, - agent_name, -): - app = func.FunctionApp() - - with pytest.raises(ValueError, match="filename component"): - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name=agent_name, - ) - async def handler(agent: object) -> None: - pass - - -def test_function_app_rejects_a_second_default_provider(tmp_path, provider): - bindings.configure_app( - func_app := func.FunctionApp(), - provider="agent_framework", - app_root=tmp_path, - ) - - with pytest.raises(ValueError, match="already configured with Agent provider"): - bindings.configure_app( - func_app, - provider="langgraph", - app_root=tmp_path, - ) - - -def test_function_app_rejects_a_second_binding_provider(tmp_path, monkeypatch): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - monkeypatch.setenv("AzureWebJobsScriptRoot", str(tmp_path)) - providers_by_id = { - "agent_framework": _Provider(), - "langgraph": _Provider(), - } - providers_by_id["langgraph"].provider_id = "langgraph" - monkeypatch.setattr( - bindings, - "load_provider", - lambda provider_id: providers_by_id[provider_id], - ) - app = func.FunctionApp() - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - temperature=0.1, - ) - async def framework_handler(agent: object) -> None: - pass - - with pytest.raises(ValueError, match="already configured with Agent provider"): - bindings.markdown_agent( - app, - provider="langgraph", - arg_name="agent", - agent_name="orders", - recursion_limit=20, - ) - - assert providers_by_id["agent_framework"].compile_args["options"] == { - "temperature": 0.1 - } - assert providers_by_id["langgraph"].compile_args is None - - -def test_markdown_agent_rejects_per_binding_app_root(tmp_path, provider): - first_root = tmp_path / "first" - second_root = tmp_path / "second" - first_root.mkdir() - second_root.mkdir() - (first_root / "orders.agent.md").write_text("instructions", encoding="utf-8") - app = func.FunctionApp() - - bindings.configure_app(app, provider="agent_framework", app_root=first_root) - - with pytest.raises(TypeError, match="app_root is app-scoped"): - bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - app_root=second_root, - ) - - -def test_function_app_state_does_not_keep_app_alive(tmp_path, provider): - app = func.FunctionApp() - bindings.configure_app( - app, - provider="agent_framework", - app_root=tmp_path, - ) - app_reference = weakref.ref(app) - - del app - gc.collect() - - assert app_reference() is None - - -def test_app_defaults_are_overridden_by_decorator_options(tmp_path, provider): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - app = func.FunctionApp() - bindings.configure_app( - app, - provider="agent_framework", - app_root=tmp_path, - provider_options={"temperature": 0.1, "tools": ["default"]}, - ) - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - temperature=0.5, - ) - async def handler(agent: object) -> None: - pass - - assert provider.compile_args["options"] == { - "temperature": 0.5, - "tools": ["default"], - } - - -def test_all_bindings_receive_same_discovered_capabilities(tmp_path, provider): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - (tmp_path / "returns.agent.md").write_text("instructions", encoding="utf-8") - skill_directory = tmp_path / "skills" / "inventory" - skill_directory.mkdir(parents=True) - (skill_directory / "SKILL.md").write_text( - "---\nname: inventory\ndescription: Inventory lookup\n---\n", - encoding="utf-8", - ) - - app = func.FunctionApp() - bindings.configure_app(app, provider="agent_framework", app_root=tmp_path) - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - ) - async def handler(agent: object) -> None: - pass - - first_capabilities = provider.compile_args["capabilities"] - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="returns", - ) - async def returns_handler(agent: object) -> None: - pass - - second_capabilities = provider.compile_args["capabilities"] - assert tuple(skill.path for skill in first_capabilities.skills) == ( - skill_directory.resolve(), - ) - assert second_capabilities is first_capabilities - - -def test_binding_rejects_discovered_capability_for_unsupported_provider( - tmp_path, - provider, -): - provider.supported_capabilities = frozenset() - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - skill_directory = tmp_path / "skills" / "inventory" - skill_directory.mkdir(parents=True) - (skill_directory / "SKILL.md").write_text( - "---\nname: inventory\ndescription: Inventory lookup\n---\n", - encoding="utf-8", - ) - - with pytest.raises(TypeError, match="does not support discovered capabilities"): - app = func.FunctionApp() - bindings.configure_app(app, provider="agent_framework", app_root=tmp_path) - - @bindings.markdown_agent( - app, - provider="agent_framework", - arg_name="agent", - agent_name="orders", - ) - async def handler(agent: object) -> None: - pass - - -def test_markdown_agent_requires_async_handler(tmp_path, provider): - (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") - - with pytest.raises(TypeError, match="async def"): - - @bindings.markdown_agent( - func.FunctionApp(), - provider="agent_framework", - arg_name="agent", - agent_name="orders", - ) - def handler(agent: object) -> None: - pass diff --git a/azurefunctions-extensions-agents-framework/README.md b/azurefunctions-extensions-agents-framework/README.md deleted file mode 100644 index 7b2ba73..0000000 --- a/azurefunctions-extensions-agents-framework/README.md +++ /dev/null @@ -1,194 +0,0 @@ -# Azure Functions Microsoft Agent Framework Extension - -Inject Microsoft Agent Framework Agents built from raw `.agent.md` instructions -into Python Azure Functions. - -## Install - -```text -pip install azurefunctions-agents-extension-agent-framework -``` - -The default package installs `agent-framework-core==1.13.0`. Install the MAF -client package required by your application separately. OpenAI, Foundry, -storage, and the Azure Functions Agents runtime are not dependencies of this -extension. - -Skills use the default package. Install remote MCP transport and Entra support -with the MCP extra: - -```text -pip install "azurefunctions-agents-extension-agent-framework[mcp]" -``` - -## Use a typed Agent app - -Create a zero-argument factory that returns a fresh MAF chat client. A new -client and Agent context are created and closed for every Function invocation. - -```python -import azure.functions as func -from agent_framework import Agent -from azurefunctions.extensions.agents.framework import AIApp - - -def create_chat_client(): - from agent_framework.openai import OpenAIChatClient - - return OpenAIChatClient() - - -app = AIApp(client_factory=create_chat_client) - - -@app.route(route="orders", methods=["POST"]) -@app.markdown_agent(arg_name="agent", agent_name="orders") -async def process_order(req: func.HttpRequest, agent: Agent): - response = await agent.run(req.get_body().decode()) - return response.text -``` - -Provider IDs are the entry-point names published by provider packages. Each -provider package documents its ID; this package exports -`AGENT_FRAMEWORK_PROVIDER_ID` for code that needs to select it explicitly. A -closed SDK enum is not used because third-party packages may add provider IDs -without an Azure Functions SDK release. - -The standalone typed decorator pins a plain app to the Agent Framework -provider on first use: - -```python -from azurefunctions.extensions.agents.framework import markdown_agent - -app = func.FunctionApp() - - -@markdown_agent( - app, - arg_name="agent", - agent_name="orders", - client_factory=create_chat_client, -) -async def process_order(req: func.HttpRequest, agent: Agent): - ... -``` - -One Function App uses one provider. A later decorator from a different provider -package is rejected. - -Place the complete instructions at `orders.agent.md` or -`agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime -configuration is interpreted. - -## Skills and MCP servers - -Skills and MCP servers are discovered automatically from the app root and are -available to each Agent binding by default: - -```text -skills/inventory/SKILL.md -mcp.json -``` - -`SKILL.md` uses Agent Skills frontmatter: - -```markdown ---- -name: inventory -description: Look up inventory policy and warehouse constraints. ---- - -Use the references in this skill when assessing stock. -``` - -The base extension discovers Skill directory paths without reading their -contents. Microsoft Agent Framework parses and validates each `SKILL.md` when -it loads the file-based Skills provider. - -V1 MCP discovery supports remote HTTP transports only: - -```json -{ - "servers": { - "inventory": { - "type": "streamable-http", - "url": "$INVENTORY_MCP_URL", - "tools": ["lookup_stock", "reserve_stock"], - "headers": {"X-Tenant": "%TENANT_ID%"}, - "auth": { - "scope": "$INVENTORY_MCP_SCOPE", - "client_id": "%AZURE_CLIENT_ID%" - } - } - } -} -``` - -`$VAR` and `%VAR%` references are resolved for each invocation, not during -discovery. Missing values fail before connecting. Credentials, tokens, HTTP -clients, MCP tools, and Agents are fresh invocation-owned resources and are -closed on success, error, or cancellation. Do not place secrets directly in -source-controlled `mcp.json`; use environment references. - -Every Agent in the Function App receives all valid Skills and MCP servers -discovered from the app root: - -```python -from azurefunctions.extensions.agents.framework import AIApp - -app = AIApp(client_factory=create_chat_client) - - -@app.markdown_agent(arg_name="agent", agent_name="orders") -async def process_order(agent: Agent): - ... -``` - -V1 has no app-level or per-binding capability selectors. Skill scripts and MCP -tools can perform privileged operations, so placing a definition under the app -root grants every Agent in that app access to it. Use separate Function Apps -when capabilities require isolation. Python `tools=` remain explicit because -they are supplied directly to the Microsoft Agent Framework Agent. - -The generic core form is also supported: - -```python -from azurefunctions.extensions.agents.framework import AGENT_FRAMEWORK_PROVIDER_ID - -app = func.FunctionApp() - - -@app.markdown_agent( - provider=AGENT_FRAMEWORK_PROVIDER_ID, - arg_name="agent", - agent_name="orders", - client_factory=create_chat_client, -) -async def process_order(req: func.HttpRequest, agent: Agent): - ... -``` - -Typed constructors and decorators expose only `client_factory` and explicit -Python `tools` in V1. The extension owns the Agent client, name, instructions, -and discovered Skills/MCP integration. Configure `app_root` only when -constructing `AIApp` or `DurableAIApp`; decorators do not override it. - -## Durable Agents - -Durable orchestration support is optional: - -```text -pip install "azurefunctions-agents-extension-agent-framework[durable]" -``` - -Use `DurableAIApp` and call `context.call_agent(agent_name, input_)` inside a -synchronous generator orchestrator. Agent execution is isolated in an activity -so replay performs no nondeterministic work. Importing the package remains safe -without Durable installed; constructing `DurableAIApp` reports the exact extra -to install when it is absent. - -All `call_agent()` invocations use the provider configured by `DurableAIApp`. -They also use the app-level `skills` and `mcp_servers` defaults. V1 does not -support selecting another provider or capability set from an orchestrator, and -the schema-v1 orchestration payload contains no capability paths, settings, or -secrets. diff --git a/azurefunctions-extensions-agents-framework/pyproject.toml b/azurefunctions-extensions-agents-framework/pyproject.toml deleted file mode 100644 index c92048c..0000000 --- a/azurefunctions-extensions-agents-framework/pyproject.toml +++ /dev/null @@ -1,66 +0,0 @@ -[build-system] -requires = ["setuptools >= 61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "azurefunctions-agents-extension-agent-framework" -dynamic = ["version"] -requires-python = ">=3.13" -authors = [ - { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, -] -description = "Microsoft Agent Framework integration for Azure Functions." -readme = "README.md" -license = { text = "MIT License" } -classifiers = [ - "License :: OSI Approved :: MIT License", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX", - "Operating System :: MacOS :: MacOS X", - "Environment :: Web Environment", - "Development Status :: 3 - Alpha", -] -dependencies = [ - "agent-framework-core==1.13.0", - "azurefunctions-agents-extension-base>=1.0.0b1", -] - -[project.optional-dependencies] -mcp = [ - "azure-identity>=1.25.3,<2", - "httpx>=0.27,<1", - "mcp>=1.28.1,<2", -] -durable = [ - "azurefunctions-agents-extension-base[durable]>=1.0.0b1", -] -dev = [ - "azure-functions-durable>=2.0.0b2", - "coverage", - "flake8", - "mypy", - "pre-commit", - "pytest", - "pytest-cov", - "pytest-instafail", -] - -[project.entry-points."azurefunctions.extensions.agents.providers"] -agent_framework = "azurefunctions.extensions.agents.framework.provider:create_provider" - -[tool.setuptools.dynamic] -version = { attr = "azurefunctions.extensions.agents.framework.__version__" } - -[tool.setuptools.packages.find] -include = ["azurefunctions.extensions.agents.framework*"] - -[tool.setuptools.package-data] -"azurefunctions.extensions.agents.framework" = ["py.typed"] - -[[tool.mypy.overrides]] -module = ["azure", "azure.*"] -ignore_missing_imports = true diff --git a/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml index da22499..c78ebf9 100644 --- a/eng/templates/jobs/build.yml +++ b/eng/templates/jobs/build.yml @@ -8,10 +8,10 @@ jobs: EXTENSION_DIRECTORY: 'azurefunctions-extensions-base' EXTENSION_NAME: 'Base' agents_base_extension: - EXTENSION_DIRECTORY: 'azurefunctions-agents-extension-base' + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-base' EXTENSION_NAME: 'Agents Base' agents_framework_extension: - EXTENSION_DIRECTORY: 'azurefunctions-agents-extension-agent-framework' + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-agent-framework' EXTENSION_NAME: 'Agents Framework' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml index ab63be7..0053ace 100644 --- a/eng/templates/official/jobs/build-artifacts.yml +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -8,10 +8,10 @@ jobs: EXTENSION_DIRECTORY: 'azurefunctions-extensions-base' EXTENSION_NAME: 'Base' agents_base_extension: - EXTENSION_DIRECTORY: 'azurefunctions-agents-extension-base' + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-base' EXTENSION_NAME: 'Agents Base' agents_framework_extension: - EXTENSION_DIRECTORY: 'azurefunctions-agents-extension-agent-framework' + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-agent-framework' EXTENSION_NAME: 'Agents Framework' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index a09b512..2011707 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -38,11 +38,11 @@ jobs: versionSpec: $(PYTHON_VERSION) - bash: | python -m pip install --upgrade pip - cd azurefunctions-agents-extension-base + cd azurefunctions-agents-extensions-base python -m pip install -U -e .[dev] displayName: 'Install Agents Base Dependencies' - bash: | - python -m pytest -q --instafail azurefunctions-agents-extension-base/tests/ + python -m pytest -q --instafail azurefunctions-agents-extensions-base/tests/ displayName: "Run Agents Base Tests for Python $(PYTHON_VERSION)" - job: "AgentsFrameworkTests" @@ -66,12 +66,12 @@ jobs: versionSpec: $(PYTHON_VERSION) - bash: | python -m pip install --upgrade pip - python -m pip install -e ./azurefunctions-agents-extension-base - cd azurefunctions-agents-extension-agent-framework + python -m pip install -e ./azurefunctions-agents-extensions-base + cd azurefunctions-agents-extensions-agent-framework python -m pip install -U -e .[dev] displayName: 'Install Agents Framework Dependencies' - bash: | - python -m pytest -q --instafail azurefunctions-agents-extension-agent-framework/tests/ + python -m pytest -q --instafail azurefunctions-agents-extensions-agent-framework/tests/ displayName: "Run Agents Framework Tests for Python $(PYTHON_VERSION)" - job: "BaseTests" From 7e3853a7a658503c525dd4fed1c70c88bbf4fddc Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 9 Sep 2026 14:47:54 -0500 Subject: [PATCH 19/25] update docs --- .../README.md | 13 +++++++------ .../pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index 24119d7..64193fb 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -9,13 +9,14 @@ into Python Azure Functions. pip install azurefunctions-agents-extensions-agent-framework ``` -The default package installs `agent-framework-core==1.13.0`. Install the MAF -client package required by your application separately. OpenAI, Foundry, -storage, and the Azure Functions Agents runtime are not dependencies of this -extension. +Install Durable Functions support with the durable extra: -Skills use the default package. Install remote MCP transport and Entra support -with the MCP extra: +```text +pip install "azurefunctions-agents-extensions-agent-framework[durable]" +``` + + +Install remote MCP transport and Entra support with the MCP extra: ```text pip install "azurefunctions-agents-extensions-agent-framework[mcp]" diff --git a/azurefunctions-agents-extensions-agent-framework/pyproject.toml b/azurefunctions-agents-extensions-agent-framework/pyproject.toml index d7651a7..af1bfa9 100644 --- a/azurefunctions-agents-extensions-agent-framework/pyproject.toml +++ b/azurefunctions-agents-extensions-agent-framework/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Development Status :: 3 - Alpha", ] dependencies = [ - "agent-framework-core==1.13.0", + "agent-framework-core>=1.13.0,<2", "azurefunctions-agents-extensions-base>=1.0.0b1", ] From db2526586348513ff86ed2c61ffc685815a8d212 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 9 Sep 2026 15:55:27 -0500 Subject: [PATCH 20/25] improve type checking --- .../agents/extensions/agent_framework/apps.py | 9 +- .../extensions/agent_framework/provider.py | 75 +++++++++------ .../pyproject.toml | 3 + .../tests/test_imports.py | 2 - .../agents/extensions/base/__init__.py | 31 ++++++- .../agents/extensions/base/bindings.py | 10 +- .../agents/extensions/base/discovery/mcp.py | 48 +++++----- .../agents/extensions/base/durable.py | 93 ++++++++++++++----- .../agents/extensions/base/providers.py | 14 +-- .../pyproject.toml | 3 + 10 files changed, 194 insertions(+), 94 deletions(-) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py index 0da31cc..ddc7f98 100644 --- a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py @@ -24,8 +24,8 @@ def _provider_options( tools: ( ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None ) = None, -) -> dict[str, Any]: - options: dict[str, Any] = {} +) -> dict[str, object]: + options: dict[str, object] = {} if client_factory is not None: options["client_factory"] = client_factory if tools is not None: @@ -56,7 +56,10 @@ def markdown_agent( ) -class AgentFunctionApp(_AgentFrameworkAppMixin, func.FunctionApp): +class AgentFunctionApp( + _AgentFrameworkAppMixin, + func.FunctionApp, # type: ignore[misc] # azure-functions lacks py.typed +): """Azure Functions app configured for Microsoft Agent Framework Agents.""" def __init__( diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py index 60dac86..9b52984 100644 --- a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py @@ -8,15 +8,22 @@ from collections.abc import Callable, Mapping, Sequence from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass -from types import MappingProxyType -from typing import Any, AsyncIterator, get_origin +from typing import TYPE_CHECKING, Any, AsyncIterator, TypedDict, cast, get_origin from urllib.parse import urlsplit -from agent_framework import Agent, BaseChatClient, SkillsProvider +from agent_framework import ( + Agent, + BaseChatClient, + ContextProvider, + SkillsProvider, + ToolTypes, +) from agent_framework._feature_stage import ExperimentalWarning from azurefunctions.agents.extensions.base import ( AgentCapabilities, + AgentProvider, + CompiledAgent, InvocationMetadata, MCPServerDefinition, SkillDefinition, @@ -24,6 +31,7 @@ AGENT_FRAMEWORK_PROVIDER_ID = "agent_framework" ClientFactory = Callable[[], BaseChatClient[Any]] +type AgentTool = ToolTypes | Callable[..., Any] _AGENT_ANNOTATION_TYPE = Agent _ENV_REFERENCE = re.compile( r"\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%" @@ -31,28 +39,43 @@ _SUPPORTED_OPTIONS = frozenset({"client_factory", "tools"}) +if TYPE_CHECKING: + from httpx import Request + + +@dataclass(frozen=True) +class _AgentFrameworkOptions: + client_factory: ClientFactory + tools: tuple[AgentTool, ...] + + +class _AgentKeywordOptions(TypedDict, total=False): + context_providers: Sequence[ContextProvider] + tools: Sequence[AgentTool] + @dataclass(frozen=True) -class AgentFrameworkBinding: +class AgentFrameworkBinding(CompiledAgent): instructions: str agent_name: str - client_factory: ClientFactory - agent_options: Mapping[str, Any] + options: _AgentFrameworkOptions capabilities: AgentCapabilities def _create_agent( self, - skills_provider: Any | None, - mcp_tools: Sequence[Any], + skills_provider: SkillsProvider | None, + mcp_tools: Sequence[AgentTool], ) -> Agent[Any]: - options = dict(self.agent_options) + options = _AgentKeywordOptions() if skills_provider is not None: options["context_providers"] = [skills_provider] + tools = list(self.options.tools) if mcp_tools: - tools = _option_values(options.pop("tools", None)) options["tools"] = [*tools, *mcp_tools] + elif tools: + options["tools"] = tools return Agent( - client=self.client_factory(), + client=self.options.client_factory(), instructions=self.instructions, name=self.agent_name, **options, @@ -86,7 +109,7 @@ async def run_agent( return text -class AgentFrameworkProvider: +class AgentFrameworkProvider(AgentProvider): provider_id = AGENT_FRAMEWORK_PROVIDER_ID distribution_name = "azurefunctions-agents-extensions-agent-framework" supported_capabilities = frozenset({"skills", "mcp"}) @@ -96,8 +119,8 @@ def compile_binding( *, instructions: str, agent_name: str, - options: Mapping[str, Any], - annotation: Any, + options: Mapping[str, object], + annotation: object, capabilities: AgentCapabilities, ) -> AgentFrameworkBinding: unknown = sorted(set(options) - _SUPPORTED_OPTIONS) @@ -105,7 +128,7 @@ def compile_binding( raise TypeError( "Unsupported Microsoft Agent Framework option(s): " + ", ".join(unknown) ) - client_factory = options.get("client_factory") + client_factory: object | None = options.get("client_factory") if client_factory is None: raise TypeError("client_factory option is required") if not callable(client_factory): @@ -123,23 +146,23 @@ def compile_binding( "as agent_framework.Agent" ) - agent_options = dict(options) - del agent_options["client_factory"] return AgentFrameworkBinding( instructions=instructions, agent_name=agent_name, - client_factory=client_factory, - agent_options=MappingProxyType(agent_options), + options=_AgentFrameworkOptions( + client_factory=cast(ClientFactory, client_factory), + tools=_normalize_tools(options.get("tools")), + ), capabilities=capabilities, ) -def _option_values(value: Any) -> list[Any]: +def _normalize_tools(value: object) -> tuple[AgentTool, ...]: if value is None: - return [] + return () if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): - return list(value) - return [value] + return tuple(cast(Sequence[AgentTool], value)) + return (value,) def _build_skills_provider( @@ -179,7 +202,7 @@ def replace(match: re.Match[str]) -> str: @asynccontextmanager async def _open_mcp_tool( definition: MCPServerDefinition, -) -> AsyncIterator[Any]: +) -> AsyncIterator[AgentTool]: try: import mcp # noqa: F401 from agent_framework import MCPStreamableHTTPTool @@ -240,7 +263,7 @@ async def _open_mcp_tool( http_client = None if static_headers or credential is not None: - async def inject_headers(request: Any) -> None: + async def inject_headers(request: Request) -> None: for name, value in static_headers.items(): request.headers[name] = value if credential is not None and scope is not None: @@ -266,7 +289,7 @@ async def inject_headers(request: Any) -> None: load_prompts=False, http_client=http_client, ) - yield tool + yield cast(AgentTool, tool) def create_provider() -> AgentFrameworkProvider: diff --git a/azurefunctions-agents-extensions-agent-framework/pyproject.toml b/azurefunctions-agents-extensions-agent-framework/pyproject.toml index af1bfa9..dc3d198 100644 --- a/azurefunctions-agents-extensions-agent-framework/pyproject.toml +++ b/azurefunctions-agents-extensions-agent-framework/pyproject.toml @@ -61,6 +61,9 @@ include = ["azurefunctions.agents.extensions.agent_framework*"] [tool.setuptools.package-data] "azurefunctions.agents.extensions.agent_framework" = ["py.typed"] +[tool.mypy] +strict = true + [[tool.mypy.overrides]] module = ["azure", "azure.*"] ignore_missing_imports = true diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py index bdd6632..76733f5 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -34,5 +34,3 @@ def test_framework_import_does_not_import_durable(): ) assert result.returncode == 0, result.stderr - - diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py index afb6e5c..22b0728 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py @@ -1,4 +1,7 @@ -from typing import Any +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar from .bindings import configure_app, markdown_agent from .capabilities import ( @@ -16,17 +19,35 @@ load_provider, ) +if TYPE_CHECKING: + from .durable import _DurableApp + +_F = TypeVar("_F", bound=Callable[..., Any]) + -def configure_durable_app(*args: Any, **kwargs: Any) -> Any: +def configure_durable_app(app: _DurableApp) -> None: from .durable import configure_durable_app as configure - return configure(*args, **kwargs) + configure(app) -def durable_orchestration_trigger(*args: Any, **kwargs: Any) -> Any: +def durable_orchestration_trigger( + app: _DurableApp, + *, + sdk_decorator: Callable[..., Any], + context_name: str, + orchestration: str | None = None, + input_type: type | None = None, +) -> Callable[[_F], Any]: from .durable import durable_orchestration_trigger as decorate - return decorate(*args, **kwargs) + return decorate( + app, + sdk_decorator=sdk_decorator, + context_name=context_name, + orchestration=orchestration, + input_type=input_type, + ) __all__ = [ diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py index faa564c..8d2e560 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -27,7 +27,7 @@ class _AppState: capabilities: AgentCapabilities provider_id: str provider: AgentProvider - provider_defaults: Mapping[str, Any] + provider_defaults: Mapping[str, object] durable_agents: dict[str, CompiledAgent] = field(default_factory=dict) durable_activity_registered: bool = False lock: threading.RLock = field(default_factory=threading.RLock) @@ -53,7 +53,7 @@ def _state_for( *, provider: str, app_root: str | os.PathLike[str] | None = None, - provider_defaults: Mapping[str, Any] | None = None, + provider_defaults: Mapping[str, object] | None = None, ) -> _AppState: resolved_root = _resolve_app_root(app_root) defaults = dict(provider_defaults or {}) @@ -92,7 +92,7 @@ def configure_app( *, provider: str, app_root: str | os.PathLike[str] | None = None, - provider_options: Mapping[str, Any] | None = None, + provider_options: Mapping[str, object] | None = None, ) -> None: _state_for( app, @@ -222,7 +222,7 @@ def _source_call( args: tuple[Any, ...], kwargs: dict[str, Any], arg_name: str, - injected: Any, + injected: object, ) -> Any: if arg_name in kwargs: raise TypeError(f"markdown_agent parameter {arg_name!r} is runtime-managed") @@ -284,7 +284,7 @@ def markdown_agent( provider: str, arg_name: str, agent_name: str, - **provider_options: Any, + **provider_options: object, ) -> Callable[[_F], _F]: if "app_root" in provider_options: raise TypeError( diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py index 5c19b9a..0fdf133 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py @@ -3,7 +3,7 @@ import json import re from pathlib import Path -from typing import Any, cast +from typing import cast from urllib.parse import urlsplit from ..capabilities import ( @@ -18,8 +18,10 @@ _VALID_SERVER_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") -def _object_without_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - result: dict[str, Any] = {} +def _object_without_duplicates( + pairs: list[tuple[str, object]], +) -> dict[str, object]: + result: dict[str, object] = {} for key, value in pairs: if key in result: raise ValueError(f"Duplicate key {key!r} in mcp.json") @@ -27,7 +29,7 @@ def _object_without_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: return result -def _string(value: Any, *, field: str, required: bool = True) -> str | None: +def _string(value: object, *, field: str, required: bool = True) -> str | None: if value is None and not required: return None if not isinstance(value, str) or not value.strip(): @@ -35,14 +37,15 @@ def _string(value: Any, *, field: str, required: bool = True) -> str | None: return value.strip() -def _allowed_tools(value: Any) -> tuple[str, ...] | None: +def _allowed_tools(value: object) -> tuple[str, ...] | None: if value is None: return None - if not isinstance(value, list) or any( - not isinstance(tool, str) or not tool.strip() for tool in value - ): + if not isinstance(value, list): raise ValueError("MCP tools must be a list of non-empty strings") - tools = tuple(tool.strip() for tool in value) + values = cast(list[object], value) + if any(not isinstance(tool, str) or not tool.strip() for tool in values): + raise ValueError("MCP tools must be a list of non-empty strings") + tools = tuple(cast(str, tool).strip() for tool in values) if len(tools) != len(set(tools)): raise ValueError("MCP tools must not contain duplicates") if "*" in tools: @@ -52,13 +55,13 @@ def _allowed_tools(value: Any) -> tuple[str, ...] | None: return tools -def _headers(value: Any) -> tuple[tuple[str, str], ...]: +def _headers(value: object) -> tuple[tuple[str, str], ...]: if value is None: return () if not isinstance(value, dict): raise ValueError("MCP headers must be an object") headers: list[tuple[str, str]] = [] - for key, header_value in value.items(): + for key, header_value in cast(dict[object, object], value).items(): header_name = _string(key, field="header name") header_text = _string(header_value, field=f"header {key!r}") assert header_name is not None and header_text is not None @@ -66,17 +69,18 @@ def _headers(value: Any) -> tuple[tuple[str, str], ...]: return tuple(sorted(headers)) -def _auth(value: Any) -> MCPAuthConfig | None: +def _auth(value: object) -> MCPAuthConfig | None: if value is None: return None if not isinstance(value, dict): raise ValueError("MCP auth must be an object") - unknown = sorted(set(value) - {"scope", "client_id"}) + auth = cast(dict[str, object], value) + unknown = sorted(set(auth) - {"scope", "client_id"}) if unknown: raise ValueError(f"Unknown MCP auth field(s): {', '.join(unknown)}") - scope = _string(value.get("scope"), field="auth scope") + scope = _string(auth.get("scope"), field="auth scope") client_id = _string( - value.get("client_id"), + auth.get("client_id"), field="auth client_id", required=False, ) @@ -84,12 +88,12 @@ def _auth(value: Any) -> MCPAuthConfig | None: return MCPAuthConfig(scope=scope, client_id=client_id) -def _server_definition(name: str, value: Any) -> MCPServerDefinition: +def _server_definition(name: str, value: object) -> MCPServerDefinition: if _VALID_SERVER_NAME.fullmatch(name) is None: raise ValueError(f"Invalid MCP server name {name!r}") if not isinstance(value, dict): raise ValueError(f"MCP server {name!r} must be an object") - server = cast(dict[str, Any], value) + server = cast(dict[str, object], value) server_type = str(server.get("type", "")).strip().lower() if "command" in server or server_type in {"stdio", "local"}: raise ValueError(f"MCP server {name!r} uses unsupported stdio transport") @@ -129,21 +133,23 @@ def discover_mcp_servers(app_root: Path) -> tuple[MCPServerDefinition, ...]: if not config_path.is_relative_to(resolved_root): raise ValueError("mcp.json resolves outside the app root") try: - data = json.loads( + loaded: object = json.loads( config_path.read_text(encoding="utf-8"), object_pairs_hook=_object_without_duplicates, ) except (OSError, UnicodeError, json.JSONDecodeError) as error: raise ValueError(f"Failed to read {str(config_path)!r}") from error - if not isinstance(data, dict): + if not isinstance(loaded, dict): raise ValueError("mcp.json must contain an object") + data = cast(dict[str, object], loaded) servers = data.get("servers") if not isinstance(servers, dict): raise ValueError("mcp.json 'servers' must be an object") + server_definitions = cast(dict[str, object], servers) unknown = sorted(set(data) - {"servers"}) if unknown: raise ValueError(f"Unknown mcp.json field(s): {', '.join(unknown)}") return tuple( - _server_definition(name, servers[name]) - for name in sorted(servers) + _server_definition(name, server_definitions[name]) + for name in sorted(server_definitions) ) diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py index 4c40cac..cddadd2 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py @@ -4,8 +4,8 @@ import inspect import json import math -from collections.abc import Callable -from typing import TYPE_CHECKING, Any, Dict, List, Literal, TypeVar, Union, cast +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, TypedDict, cast import azure.functions as func @@ -14,24 +14,51 @@ if TYPE_CHECKING: import azure.durable_functions as df - from azure.durable_functions import ( - DurableOrchestrationContext as _DurableContextBase, - ) from durabletask.task import RetryPolicy, Task -else: - class _DurableContextBase: - pass - -JSONPrimitive = Union[str, int, float, bool, None] -JSONValue = Union[JSONPrimitive, List["JSONValue"], Dict[str, "JSONValue"]] +type JSONPrimitive = str | int | float | bool | None +type JSONValue = JSONPrimitive | list[JSONValue] | dict[str, JSONValue] _F = TypeVar("_F", bound=Callable[..., Any]) _INTERNAL_AGENT_ACTIVITY_NAME = "azurefunctions_agents_run_markdown_agent" _ACTIVITY_PAYLOAD_VERSION: Literal[1] = 1 +class _ActivityPayload(TypedDict): + schema_version: Literal[1] + agent_name: str + input: JSONValue + durable_instance_id: str + + +type _ActivityHandler = Callable[[object, func.Context], Awaitable[str]] + + +class _DurableApp(Protocol): + def activity_trigger( + self, + input_name: str, + activity: str | None = None, + ) -> Callable[[_ActivityHandler], object]: + ... + + +class _DurableContext(Protocol): + instance_id: str + + def call_activity(self, name: str, input_: object) -> Task[Any]: + ... + + def call_activity_with_retry( + self, + name: str, + retry_policy: RetryPolicy, + input_: object, + ) -> Task[Any]: + ... + + def _validate_json_value(value: object) -> None: if value is None or isinstance(value, (str, bool, int)): return @@ -61,30 +88,31 @@ def _canonicalize_json_value(value: object) -> JSONValue: return cast(JSONValue, json.loads(encoded)) -def _parse_activity_input(value: object) -> dict[str, Any]: +def _parse_activity_input(value: object) -> _ActivityPayload: if not isinstance(value, dict): raise TypeError("Markdown Agent activity input must be a JSON object") + payload = cast(dict[str, object], value) expected_fields = { "schema_version", "agent_name", "input", "durable_instance_id", } - if set(value) != expected_fields: + if set(payload) != expected_fields: raise ValueError( "Markdown Agent activity input must contain exactly: " + ", ".join(sorted(expected_fields)) ) - if type(value["schema_version"]) is not int or value["schema_version"] != 1: + if type(payload["schema_version"]) is not int or payload["schema_version"] != 1: raise ValueError( "Unsupported Markdown Agent activity payload schema_version; expected 1" ) - agent_name = value["agent_name"] + agent_name = payload["agent_name"] if not isinstance(agent_name, str) or not agent_name.strip(): raise ValueError( "Markdown Agent activity agent_name must be a non-empty string" ) - durable_instance_id = value["durable_instance_id"] + durable_instance_id = payload["durable_instance_id"] if not isinstance(durable_instance_id, str) or not durable_instance_id: raise ValueError( "Markdown Agent activity durable_instance_id must be a non-empty string" @@ -92,7 +120,7 @@ def _parse_activity_input(value: object) -> dict[str, Any]: return { "schema_version": 1, "agent_name": agent_name, - "input": _canonicalize_json_value(value["input"]), + "input": _canonicalize_json_value(payload["input"]), "durable_instance_id": durable_instance_id, } @@ -103,12 +131,8 @@ def _normalize_agent_prompt(value: JSONValue) -> str: return json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True) -class DurableAgentContext(_DurableContextBase): # type: ignore[misc] - def __init__(self, context: df.DurableOrchestrationContext) -> None: - self._context = context - - def __getattr__(self, name: str) -> Any: - return getattr(self._context, name) +class _DurableAgentContextMixin: + _context: _DurableContext def call_agent( self, @@ -138,7 +162,26 @@ def call_agent( ) -def configure_durable_app(app: func.FunctionApp) -> None: +if TYPE_CHECKING: + + class DurableAgentContext( + _DurableAgentContextMixin, + df.DurableOrchestrationContext, + ): + def __init__(self, context: df.DurableOrchestrationContext) -> None: + self._context = cast(_DurableContext, context) + +else: + + class DurableAgentContext(_DurableAgentContextMixin): + def __init__(self, context: _DurableContext) -> None: + self._context = context + + def __getattr__(self, name: str) -> object: + return getattr(self._context, name) + + +def configure_durable_app(app: _DurableApp) -> None: state = _configured_state(app) with state.lock: if state.durable_activity_registered: @@ -172,7 +215,7 @@ async def azurefunctions_agents_run_markdown_agent( def durable_orchestration_trigger( - app: func.FunctionApp, + app: _DurableApp, *, sdk_decorator: Callable[..., Any], context_name: str, diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py index a6fe750..00afecd 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from functools import lru_cache from importlib import metadata -from typing import Any, Mapping, Protocol +from typing import Callable, Mapping, Protocol, cast from .capabilities import AgentCapabilities @@ -22,7 +22,7 @@ class CompiledAgent(Protocol): def open_agent( self, invocation: InvocationMetadata, - ) -> AbstractAsyncContextManager[Any]: + ) -> AbstractAsyncContextManager[object]: pass async def run_agent( @@ -43,8 +43,8 @@ def compile_binding( *, instructions: str, agent_name: str, - options: Mapping[str, Any], - annotation: Any, + options: Mapping[str, object], + annotation: object, capabilities: AgentCapabilities, ) -> CompiledAgent: pass @@ -85,7 +85,7 @@ def _validate_provider(provider: object, provider_id: str) -> AgentProvider: ) if not callable(getattr(provider, "compile_binding", None)): raise TypeError(f"Agent provider {provider_id!r} must define compile_binding()") - return provider # type: ignore[return-value] + return cast(AgentProvider, provider) @lru_cache(maxsize=None) @@ -111,9 +111,9 @@ def load_provider(provider_id: str) -> AgentProvider: f"{', '.join(distributions)}" ) - factory = matches[0].load() + factory: object = matches[0].load() if not callable(factory): raise TypeError( f"Agent provider entry point {provider_id!r} must load a callable factory" ) - return _validate_provider(factory(), provider_id) + return _validate_provider(cast(Callable[[], object], factory)(), provider_id) diff --git a/azurefunctions-agents-extensions-base/pyproject.toml b/azurefunctions-agents-extensions-base/pyproject.toml index e9f3813..f0053a6 100644 --- a/azurefunctions-agents-extensions-base/pyproject.toml +++ b/azurefunctions-agents-extensions-base/pyproject.toml @@ -52,6 +52,9 @@ include = ["azurefunctions.agents.extensions.base*"] [tool.setuptools.package-data] "azurefunctions.agents.extensions.base" = ["py.typed"] +[tool.mypy] +strict = true + [[tool.mypy.overrides]] module = ["azure", "azure.*"] ignore_missing_imports = true From 0901b1a7dfe54c3ab94bb3d3f3122dc552bf6c28 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 9 Sep 2026 16:28:37 -0500 Subject: [PATCH 21/25] fix tests --- .../agents/extensions/agent_framework/__init__.py | 2 +- .../azurefunctions/agents/extensions/base/__init__.py | 2 +- eng/templates/official/jobs/unit-tests.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py index 2a853f8..7a608cf 100644 --- a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py @@ -7,4 +7,4 @@ "ClientFactory", ] -__version__ = "1.0.0b1" +__version__ = '1.0.0b1' diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py index 22b0728..49c0f7b 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py @@ -67,4 +67,4 @@ def durable_orchestration_trigger( "markdown_agent", ] -__version__ = "1.0.0b1" +__version__ = '1.0.0b1' diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index 2011707..569a580 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -68,7 +68,7 @@ jobs: python -m pip install --upgrade pip python -m pip install -e ./azurefunctions-agents-extensions-base cd azurefunctions-agents-extensions-agent-framework - python -m pip install -U -e .[dev] + python -m pip install -U -e .[dev,mcp] displayName: 'Install Agents Framework Dependencies' - bash: | python -m pytest -q --instafail azurefunctions-agents-extensions-agent-framework/tests/ From 55da60ad4b7ec8168664535a98409ba54c597789 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 10 Sep 2026 10:52:17 -0500 Subject: [PATCH 22/25] feedback --- .../README.md | 9 +- .../agents/extensions/agent_framework/apps.py | 2 +- .../extensions/agent_framework/provider.py | 33 +++++- .../hybrid-durable-agent/src/function_app.py | 11 +- .../tests/test_provider.py | 112 ++++++++++++++++++ .../tests/test_samples.py | 34 ++++++ .../agents/extensions/base/durable.py | 6 +- .../tests/test_durable.py | 23 ++++ 8 files changed, 221 insertions(+), 9 deletions(-) diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index 64193fb..eb41a60 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -103,9 +103,12 @@ V1 MCP discovery supports remote HTTP transports only: ``` `$VAR` and `%VAR%` references are resolved for each invocation, not during -discovery. Missing values fail before connecting. Credentials, tokens, HTTP -clients, MCP tools, and Agents are fresh invocation-owned resources and are -closed on success, error, or cancellation. Do not place secrets directly in +discovery. Missing values fail before connecting. Servers configured with +headers or Entra authentication must use HTTPS; HTTP is accepted only for +loopback development. Exposed MCP tool names are prefixed with the server name +to prevent collisions between servers. Credentials, tokens, HTTP clients, MCP +tools, and Agents are fresh invocation-owned resources and are closed on +success, error, or cancellation. Do not place secrets directly in source-controlled `mcp.json`; use environment references. Every Agent in the Function App receives all valid Skills and MCP servers diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py index ddc7f98..07fd8bc 100644 --- a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py @@ -58,7 +58,7 @@ def markdown_agent( class AgentFunctionApp( _AgentFrameworkAppMixin, - func.FunctionApp, # type: ignore[misc] # azure-functions lacks py.typed + func.FunctionApp, ): """Azure Functions app configured for Microsoft Agent Framework Agents.""" diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py index 9b52984..2f8ac36 100644 --- a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Mapping, Sequence from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass +from ipaddress import ip_address from typing import TYPE_CHECKING, Any, AsyncIterator, TypedDict, cast, get_origin from urllib.parse import urlsplit @@ -66,6 +67,14 @@ def _create_agent( skills_provider: SkillsProvider | None, mcp_tools: Sequence[AgentTool], ) -> Agent[Any]: + client = self.options.client_factory() + if inspect.isawaitable(client): + if inspect.iscoroutine(client): + client.close() + raise TypeError( + "client_factory must return a BaseChatClient synchronously, " + "not an awaitable" + ) options = _AgentKeywordOptions() if skills_provider is not None: options["context_providers"] = [skills_provider] @@ -75,7 +84,7 @@ def _create_agent( elif tools: options["tools"] = tools return Agent( - client=self.options.client_factory(), + client=client, instructions=self.instructions, name=self.agent_name, **options, @@ -199,6 +208,18 @@ def replace(match: re.Match[str]) -> str: return result +def _is_loopback_host(hostname: str | None) -> bool: + if hostname is None: + return False + normalized = hostname.rstrip(".").casefold() + if normalized == "localhost": + return True + try: + return ip_address(normalized).is_loopback + except ValueError: + return False + + @asynccontextmanager async def _open_mcp_tool( definition: MCPServerDefinition, @@ -244,6 +265,15 @@ async def _open_mcp_tool( if auth is not None and auth.client_id is not None else None ) + if ( + parsed_url.scheme == "http" + and (static_headers or auth is not None) + and not _is_loopback_host(parsed_url.hostname) + ): + raise ValueError( + f"MCP server {definition.name!r} must use HTTPS when headers or auth " + "are configured; HTTP is allowed only for loopback hosts" + ) async with AsyncExitStack() as stack: credential = None @@ -280,6 +310,7 @@ async def inject_headers(request: Request) -> None: tool = MCPStreamableHTTPTool( name=definition.name, url=url, + tool_name_prefix=definition.name, allowed_tools=( list(config.allowed_tools) if config.allowed_tools is not None diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py index 2b1b16a..5acf994 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py @@ -29,9 +29,18 @@ async def start_order_orchestration( req: func.HttpRequest, client: df.DurableFunctionsClient, ) -> func.HttpResponse: + try: + order = req.get_json() + except ValueError: + return func.HttpResponse( + body=json.dumps({"error": "Order failed validation."}), + status_code=400, + mimetype="application/json", + ) + instance_id = await client.start_new( "order_orchestrator", - client_input=req.get_json(), + client_input=order, ) management = client.create_http_management_payload(req, instance_id) return func.HttpResponse( diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py index 1b452ba..6e7aff9 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py @@ -5,6 +5,7 @@ from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from types import SimpleNamespace +from typing import Any from unittest.mock import Mock import pytest @@ -13,6 +14,7 @@ from azurefunctions.agents.extensions.base import ( AgentCapabilities, InvocationMetadata, + MCPAuthConfig, MCPHTTPConfig, MCPServerDefinition, SkillDefinition, @@ -142,6 +144,27 @@ async def create_client(): _compile(client_factory=create_client) +@pytest.mark.parametrize("factory_kind", ["async_callable", "returns_awaitable"]) +def test_binding_rejects_factory_results_that_are_awaitable(factory_kind): + async def create_client(): + return object() + + if factory_kind == "async_callable": + + class AsyncFactory: + async def __call__(self): + return object() + + client_factory = AsyncFactory() + else: + client_factory = lambda: create_client() + + binding = _compile(client_factory=client_factory) + + with pytest.raises(TypeError, match="must return.*not an awaitable"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + def test_provider_factory_errors_propagate(): def fail(): raise RuntimeError("client failed") @@ -315,6 +338,95 @@ async def open_tool(): asyncio.run(open_tool()) +@pytest.mark.parametrize( + "config", + [ + MCPHTTPConfig("$MCP_SERVER_URL", headers=(("X-Api-Key", "secret"),)), + MCPHTTPConfig( + "$MCP_SERVER_URL", + auth=MCPAuthConfig(scope="api://example/.default"), + ), + ], +) +def test_mcp_credentials_require_https_after_environment_resolution( + monkeypatch, + config, +): + monkeypatch.setenv("MCP_SERVER_URL", "http://mcp.example.test") + definition = MCPServerDefinition("orders", config) + + async def open_tool(): + async with provider._open_mcp_tool(definition): + pass + + with pytest.raises(ValueError, match="HTTPS when headers or auth are configured"): + asyncio.run(open_tool()) + + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.2", "[::1]"]) +def test_mcp_credentials_allow_http_loopback_after_environment_resolution( + monkeypatch, + host, +): + import agent_framework + + class FakeClient: + def __init__(self, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + pass + + class FakeTool: + def __init__(self, **kwargs): + pass + + monkeypatch.setattr("httpx.AsyncClient", FakeClient) + monkeypatch.setattr(agent_framework, "MCPStreamableHTTPTool", FakeTool) + monkeypatch.setenv("MCP_SERVER_URL", f"http://{host}:8080/mcp") + definition = MCPServerDefinition( + "orders", + MCPHTTPConfig( + "$MCP_SERVER_URL", + headers=(("X-Api-Key", "secret"),), + ), + ) + + async def open_tool(): + async with provider._open_mcp_tool(definition): + pass + + asyncio.run(open_tool()) + + +def test_mcp_servers_prefix_duplicate_remote_tool_names(monkeypatch): + import agent_framework + + exposed_names = [] + + class FakeTool: + def __init__(self, **kwargs: Any): + exposed_names.append(f"{kwargs['tool_name_prefix']}_lookup") + + monkeypatch.setattr(agent_framework, "MCPStreamableHTTPTool", FakeTool) + definitions = ( + MCPServerDefinition("inventory", MCPHTTPConfig("https://one.example.test")), + MCPServerDefinition("orders", MCPHTTPConfig("https://two.example.test")), + ) + + async def open_tools(): + async with AsyncExitStack() as stack: + for definition in definitions: + await stack.enter_async_context(provider._open_mcp_tool(definition)) + + asyncio.run(open_tools()) + + assert exposed_names == ["inventory_lookup", "orders_lookup"] + + def test_mcp_client_does_not_follow_redirects(monkeypatch): import agent_framework import httpx diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py index 3e6e52d..6d7cfbc 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -126,3 +126,37 @@ def test_hybrid_durable_sample_starts_orchestration(): "mimetype": "application/json", "location": "https://example.test/status/42", } + + +def test_hybrid_durable_sample_rejects_malformed_json(): + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) + ) + script = ( + "import asyncio, json\n" + "import azure.functions as func\n" + "import function_app\n" + "class FakeClient:\n" + " async def start_new(self, name, *, client_input):\n" + " raise AssertionError('orchestration must not start')\n" + "request = func.HttpRequest(method='POST', url='https://example.test', " + "body=b'{not json')\n" + "handler = function_app.start_order_orchestration._function" + ".get_user_function().__wrapped__\n" + "response = asyncio.run(handler(request, FakeClient()))\n" + "print(json.dumps({'status_code': response.status_code, " + "'body': response.get_body().decode()}))\n" + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=_SAMPLES_ROOT / "hybrid-durable-agent" / "src", + env=environment, + check=True, + capture_output=True, + text=True, + ) + + result = json.loads(completed.stdout) + assert result["status_code"] == 400 + assert json.loads(result["body"]) == {"error": "Order failed validation."} diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py index cddadd2..9069b7c 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py @@ -168,8 +168,8 @@ class DurableAgentContext( _DurableAgentContextMixin, df.DurableOrchestrationContext, ): - def __init__(self, context: df.DurableOrchestrationContext) -> None: - self._context = cast(_DurableContext, context) + def __init__(self, context: _DurableContext) -> None: + self._context = context else: @@ -264,7 +264,7 @@ def decorate(handler: _F) -> Any: def proxy_orchestrator(*args: Any, **kwargs: Any) -> Any: bound = signature.bind(*args, **kwargs) context = cast( - df.DurableOrchestrationContext, + _DurableContext, bound.arguments[context_name], ) bound.arguments[context_name] = DurableAgentContext(context) diff --git a/azurefunctions-agents-extensions-base/tests/test_durable.py b/azurefunctions-agents-extensions-base/tests/test_durable.py index 1fdb983..b148075 100644 --- a/azurefunctions-agents-extensions-base/tests/test_durable.py +++ b/azurefunctions-agents-extensions-base/tests/test_durable.py @@ -195,6 +195,29 @@ def customer_activity(payload): app.get_functions() +def test_orchestration_proxy_wraps_context_at_runtime(tmp_path, monkeypatch): + app, _ = _configured_app(tmp_path, monkeypatch) + + def sdk_decorator(**kwargs): + return lambda handler: handler + + @durable.durable_orchestration_trigger( + app, + sdk_decorator=sdk_decorator, + context_name="context", + ) + def orchestrator(context): + yield context.call_agent("orders", "hello") + + context = _Context() + + assert list(orchestrator(context)) == ["task"] + assert context.calls[0][0:2] == ( + "activity", + "azurefunctions_agents_run_markdown_agent", + ) + + def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypatch): instructions = "---\nthis remains: raw\n---\nHandle orders.\n" (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) From 2777aa3ec39c63eefe9d2c17604516f5c62e27b2 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 10 Sep 2026 12:10:18 -0500 Subject: [PATCH 23/25] improve samples --- .../samples/README.md | 77 +++++- .../agent_samples_agent-framework/README.md | 224 +++++++++++++++++ .../function_app.py | 0 .../host.json | 0 .../local.settings.template.json | 0 .../mcp.json | 0 .../order-fulfillment.agent.md | 0 .../order_processing.py | 0 .../requirements.txt | 2 +- .../skills/order-policy/SKILL.md | 2 +- .../README.md | 226 ++++++++++++++++++ .../function_app.py | 9 - .../host.json | 0 .../local.settings.template.json | 0 .../order-fulfillment.agent.md | 0 .../order_processing.py | 0 .../requirements.txt | 2 +- .../samples/hybrid-durable-agent/README.md | 14 -- .../samples/hybrid-function-agent/README.md | 20 -- .../tests/test_samples.py | 26 +- updated-agent-binding-issue.md | 124 ++++++++++ 21 files changed, 665 insertions(+), 61 deletions(-) create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/README.md rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-function-agent/src => agent_samples_agent-framework}/function_app.py (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-durable-agent/src => agent_samples_agent-framework}/host.json (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-function-agent/src => agent_samples_agent-framework}/local.settings.template.json (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-function-agent/src => agent_samples_agent-framework}/mcp.json (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-durable-agent/src => agent_samples_agent-framework}/order-fulfillment.agent.md (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-durable-agent/src => agent_samples_agent-framework}/order_processing.py (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-function-agent/src => agent_samples_agent-framework}/requirements.txt (76%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-function-agent/src => agent_samples_agent-framework}/skills/order-policy/SKILL.md (75%) create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/README.md rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-durable-agent/src => agent_samples_agent-framework_durable}/function_app.py (86%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-function-agent/src => agent_samples_agent-framework_durable}/host.json (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-durable-agent/src => agent_samples_agent-framework_durable}/local.settings.template.json (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-function-agent/src => agent_samples_agent-framework_durable}/order-fulfillment.agent.md (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-function-agent/src => agent_samples_agent-framework_durable}/order_processing.py (100%) rename azurefunctions-agents-extensions-agent-framework/samples/{hybrid-durable-agent/src => agent_samples_agent-framework_durable}/requirements.txt (72%) delete mode 100644 azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/README.md delete mode 100644 azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/README.md create mode 100644 updated-agent-binding-issue.md diff --git a/azurefunctions-agents-extensions-agent-framework/samples/README.md b/azurefunctions-agents-extensions-agent-framework/samples/README.md index 08ab8bd..8eb2699 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/README.md @@ -1,8 +1,73 @@ -# Microsoft Agent Framework samples +--- +page_type: sample +languages: + - python +products: + - azure + - azure-functions + - azure-functions-extensions + - microsoft-foundry + - azurefunctions-agents-extensions-agent-framework +urlFragment: extension-agent-framework-samples +--- -- `hybrid-function-agent`: injects a fresh Agent into HTTP and queue Functions, - with automatic app-wide Skill/MCP discovery. -- `hybrid-durable-agent`: schedules Agent calls from a replay-safe orchestrator. +# Azure Functions Microsoft Agent Framework Extension for Python samples -Both samples use raw `.agent.md` instructions and an explicit Foundry client -factory. They do not depend on the Azure Functions Agents runtime. \ No newline at end of file +These code samples show common scenarios for using Microsoft Agent Framework +Agents in Python Function Apps. Both samples use raw `.agent.md` instructions +and an explicit Microsoft Foundry client factory. + +* [agent_samples_agent-framework](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework) - Examples for adding an Agent to an existing Function App: + * Inject a fresh Agent into HTTP and queue-triggered Functions + * Discover app-wide Skills and MCP servers + * Keep validation and deterministic processing in application code + +* [agent_samples_agent-framework_durable](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable) - Examples for using Agents in Durable Functions: + * Schedule Agent calls from a replay-safe orchestrator + * Apply Durable retry policies to Agent calls + * Combine deterministic activity output with model-generated results + +## Prerequisites + +* Python 3.13 or later is required. For more details, see the [Python Functions version support policy](https://learn.microsoft.com/azure/azure-functions/functions-versions?tabs=isolated-process%2Cv4&pivots=programming-language-python#languages). +* You must have an [Azure subscription](https://azure.microsoft.com/free/), a Microsoft Foundry project, and a deployed model. +* You must have [Azurite](https://learn.microsoft.com/azure/storage/common/storage-use-azurite) or an Azure Storage account for Functions host storage, queue triggers, and Durable Functions state. +* The non-Durable sample also requires a trusted streamable-HTTP MCP endpoint. + +## Setup + +1. Install [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local?tabs=windows%2Cisolated-process%2Cnode-v4%2Cpython-v2%2Chttp-trigger%2Ccontainer-apps&pivots=programming-language-python). +2. Clone or download this sample repository. +3. Open the sample folder in Visual Studio Code or your IDE of choice. +4. Sign in with an identity authorized to use your Microsoft Foundry project. For example: + +```bash +az login +``` + +## Running the samples + +1. Open a terminal window and `cd` to the directory containing the sample you want to run. +2. Create `local.settings.json` from `local.settings.template.json` and replace the placeholders with your Foundry project and model settings. +3. Create and activate a virtual environment. +4. Install the required dependencies: + +```bash +python -m pip install -r requirements.txt +``` + +5. Start Azurite or configure `AzureWebJobsStorage` to use an Azure Storage account. +6. Start the Functions runtime: + +```bash +func start +``` + +7. Follow the selected sample's README to invoke its HTTP, queue, or Durable Functions and inspect the output. + +## Next steps + +Visit the [Agent Framework extension documentation](../README.md) to learn more +about Agent bindings, automatic Skill and MCP discovery, and replay-safe Durable +Agent calls. For the underlying Agent APIs, see the +[Microsoft Agent Framework documentation](https://learn.microsoft.com/agent-framework/). \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/README.md new file mode 100644 index 0000000..7545835 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/README.md @@ -0,0 +1,224 @@ +--- +page_type: sample +languages: + - python +products: + - azure + - azure-functions + - microsoft-foundry +urlFragment: agent-framework-sample +--- + +# Azure Function Agent sample + +This sample shows how an existing Azure Function App can add agentic reasoning +without replacing its triggers or deterministic application code. An HTTP +trigger and a queue trigger validate and normalize an order before receiving a +fresh Microsoft Agent Framework `Agent` through `@app.markdown_agent`. + +The sample demonstrates: + +- using `AgentFunctionApp` with standard Azure Functions decorators; +- resolving `order-fulfillment.agent.md` by logical Agent name; +- injecting a fresh Agent into HTTP and queue Function handlers; +- keeping validation, calculations, and data minimization in application code; +- discovering the `order-policy` Skill and `inventory` MCP server from the app + root; and +- closing invocation-owned clients, Agents, credentials, and MCP resources. + +## How the sample works + +Both Functions use the same `order-fulfillment` Agent definition: + +```python +@app.markdown_agent( + arg_name="order_agent", + agent_name="order-fulfillment", +) +``` + +The decorator resolves `order-fulfillment.agent.md` and supplies the +constructed Agent as the `order_agent` handler argument. The file contains raw +instructions; client and model configuration remain explicit in +`create_chat_client()`. + +Before invoking the Agent, `order_processing.py` uses Pydantic to validate the +order and application code to calculate totals and review signals. Unknown +input fields are discarded. The Agent receives only this normalized projection, +not the original request or queue message. + +At app startup, the extension also discovers: + +- `skills/order-policy/SKILL.md`, which supplies fulfillment policy; and +- `mcp.json`, which exposes only the `lookup_stock` and `reserve_stock` tools + from the configured `inventory` streamable-HTTP MCP server. + +Discovered Skills and MCP servers are app-wide in V1, so both Agent bindings +receive them. + +## Project structure + +| Path | Purpose | +| --- | --- | +| `function_app.py` | Defines the HTTP and queue Functions and the Foundry client factory. | +| `order_processing.py` | Validates input and calculates the trusted order projection. | +| `order-fulfillment.agent.md` | Contains the raw Agent instructions. | +| `skills/order-policy/SKILL.md` | Defines the automatically discovered order-policy Skill. | +| `mcp.json` | Configures the inventory MCP server and tool allowlist. | +| `local.settings.template.json` | Lists required local application settings. | +| `requirements.txt` | Installs the extension with MCP support and sample dependencies. | + +## Prerequisites + +- Python 3.13 or later. +- [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local). +- [Azurite](https://learn.microsoft.com/azure/storage/common/storage-use-azurite) + or an Azure Storage account for `AzureWebJobsStorage` and the queue trigger. +- An Azure subscription and a Microsoft Foundry project with a deployed model. +- A local identity authorized to use the Foundry project. For example, sign in + with `az login` before running the sample. +- A trusted streamable-HTTP MCP endpoint that exposes the inventory tools. + +## Setup + +1. Change to the Function project directory: + + ```bash + cd azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework + ``` + +2. Create and activate a virtual environment: + + ```bash + python -m venv .venv + # Windows PowerShell + .venv\Scripts\Activate.ps1 + # macOS or Linux + source .venv/bin/activate + ``` + +3. Install the dependencies: + + ```bash + python -m pip install -r requirements.txt + ``` + + The editable dependency in `requirements.txt` installs the Agent Framework + extension from this repository with its `[mcp]` extra. When using the + published package instead, install + `azurefunctions-agents-extensions-agent-framework[mcp]`. + +4. Create local settings from the template: + + ```powershell + Copy-Item local.settings.template.json local.settings.json + ``` + + On macOS or Linux, use `cp local.settings.template.json local.settings.json`. + +5. Replace the placeholders in `local.settings.json`: + + | Setting | Description | + | --- | --- | + | `AzureWebJobsStorage` | Keep `UseDevelopmentStorage=true` for Azurite, or use an Azure Storage connection string. | + | `FOUNDRY_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint. | + | `FOUNDRY_MODEL` | Name of the deployed model used by `FoundryChatClient`. | + | `INVENTORY_MCP_URL` | HTTPS URL of a trusted streamable-HTTP MCP server. | + + Do not commit `local.settings.json`. Use environment references rather than + placing credentials or tokens in `mcp.json`. + +## Run the sample + +1. Start Azurite. With the Azurite CLI installed, run: + + ```bash + azurite --silent --location .azurite + ``` + + You can instead start Azurite from its Visual Studio Code extension. + +2. In another terminal, activate the virtual environment from the sample + directory and start the Functions host: + + ```bash + func start + ``` + +### Invoke the HTTP Function + +Send a valid order. The route supplies the order ID: + +```bash +curl -X POST http://localhost:7071/orders/42 \ + -H "Content-Type: application/json" \ + -d '{"customer":{"id":"C-1007","loyalty_tier":"gold"},"currency":"usd","shipping":{"country":"ca","method":"overnight"},"items":[{"sku":"A-100","quantity":2,"unit_price":"24.95"}]}' +``` + +The response contains the route order ID and the Agent's assessment: + +```json +{ + "order_id": "42", + "assessment": "" +} +``` + +Malformed JSON or an invalid order returns HTTP `400`: + +```json +{"error":"Order failed validation."} +``` + +### Invoke the queue Function + +Create or open the `orders` queue in Azurite with Azure Storage Explorer, then +add a message containing an order. Unlike the HTTP route, a queue message must +include `order_id`: + +```json +{ + "order_id": "Q-1001", + "customer": {"id": "C-1007", "loyalty_tier": "gold"}, + "currency": "USD", + "shipping": {"country": "CA", "method": "overnight"}, + "items": [{"sku": "A-100", "quantity": 2, "unit_price": "24.95"}] +} +``` + +The `process_order_event` Function validates the message and asks the Agent to +triage fulfillment exceptions. It intentionally returns no queue output; inspect +the Functions host and connected model/MCP telemetry to observe the invocation. + +## Expected lifecycle and security behavior + +- A new Foundry client, Agent, MCP tool, HTTP client, and credential are created + for each invocation and closed afterward. +- The MCP URL is resolved from `INVENTORY_MCP_URL` for each invocation. +- MCP configurations with headers or Entra authentication require HTTPS, except + for explicit loopback development endpoints. +- Agent instructions and discovered capability definitions may be cached, but + live clients and Agents are never shared across invocations. +- The Agent must not claim that an external action succeeded unless an MCP tool + result confirms it. + +## Troubleshooting + +- **Agent definition not found:** run `func start` from the sample directory and keep + `order-fulfillment.agent.md` at the app root. +- **Foundry authentication fails:** run `az login`, verify the active tenant and + subscription, and confirm the identity can access the Foundry project. +- **MCP connection fails:** verify `INVENTORY_MCP_URL` uses a supported + streamable-HTTP endpoint and exposes the allowlisted tool names. +- **Queue Function does not run:** confirm Azurite is running and that the + `orders` queue belongs to the account configured by `AzureWebJobsStorage`. +- **HTTP request returns 400:** confirm the request includes a customer, + two-letter shipping country, supported shipping method, and at least one item + with a positive integer quantity. + +## Next steps + +- Review the extension's [package documentation](../../README.md). +- Compare this sample with the [Durable Agent Framework sample](../agent_samples_agent-framework_durable/README.md) + when Agent calls must participate in a replay-safe orchestration. +- Learn more about [Python decorators and bindings](https://learn.microsoft.com/azure/azure-functions/functions-reference-python#programming-model). \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/function_app.py similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/function_app.py rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/function_app.py diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/host.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/host.json similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/host.json rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/host.json diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/local.settings.template.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/local.settings.template.json similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/local.settings.template.json rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/local.settings.template.json diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/mcp.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/mcp.json similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/mcp.json rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/mcp.json diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/order-fulfillment.agent.md similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/order-fulfillment.agent.md rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/order-fulfillment.agent.md diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/order_processing.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/order_processing.py similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/order_processing.py rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/order_processing.py diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/requirements.txt b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/requirements.txt similarity index 76% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/requirements.txt rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/requirements.txt index cf100f0..8c57ab0 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/requirements.txt +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/requirements.txt @@ -1,4 +1,4 @@ --e ../../..[mcp] +-e ../..[mcp] agent-framework-foundry==1.13.0 azure-identity pydantic \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/skills/order-policy/SKILL.md similarity index 75% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/skills/order-policy/SKILL.md index 618c330..56bf99b 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/skills/order-policy/SKILL.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/skills/order-policy/SKILL.md @@ -4,4 +4,4 @@ description: Apply fulfillment policy and warehouse constraints to an order. --- Use validated order totals and shipping fields when assessing fulfillment. -Never infer missing customer, payment, or inventory data. +Never infer missing customer, payment, or inventory data. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/README.md b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/README.md new file mode 100644 index 0000000..317fc73 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/README.md @@ -0,0 +1,226 @@ +--- +page_type: sample +languages: + - python +products: + - azure + - azure-functions + - durable-functions + - microsoft-foundry +urlFragment: agent-framework-durable-sample +--- + +# Hybrid Durable Agent sample + +This sample combines deterministic Durable Functions orchestration with +Microsoft Agent Framework reasoning. The orchestrator coordinates ordinary +application activities and Agent calls while all filesystem, client, model, and +network work runs outside replay through activities. + +The sample demonstrates: + +- starting an orchestration from an HTTP-triggered Function; +- validating and minimizing an order in an ordinary Durable activity; +- using `context.call_agent()` from a synchronous generator orchestrator; +- executing Agent calls through the extension's hidden activity; +- passing deterministic, JSON-only payloads between the orchestrator and Agent + activity; +- applying a Durable retry policy to an Agent call; and +- polling the standard Durable management endpoint for status and output. + +## How the sample works + +The request follows this sequence: + +1. `start_order_orchestration` receives the HTTP request and starts an + `order_orchestrator` instance. +2. The orchestrator calls `prepare_order_activity`, which validates the order, + calculates totals, and produces a minimized projection. +3. `context.call_agent("order-fulfillment", ...)` schedules the extension's + hidden `azurefunctions_agents_run_markdown_agent` activity to assess risk. +4. A second `call_agent()` schedules a fulfillment-plan request with a retry + policy of three attempts and a five-second first retry interval. +5. The orchestration output combines the deterministic order ID with the two + model-generated results. + +The orchestrator never opens files, creates credentials or clients, connects to +a model, or performs network I/O. During replay it only recreates the same +activity schedule from recorded inputs and results. + +The logical Agent name `order-fulfillment` resolves +`order-fulfillment.agent.md`. The file contains raw Agent instructions; +Foundry client and model configuration remain explicit in +`create_chat_client()`. + +## Project structure + +| Path | Purpose | +| --- | --- | +| `function_app.py` | Defines the HTTP starter, preparation activity, orchestrator, and Foundry client factory. | +| `order_processing.py` | Validates input and calculates the trusted order projection. | +| `order-fulfillment.agent.md` | Contains raw instructions used by both Agent activity calls. | +| `local.settings.template.json` | Lists required local application settings. | +| `requirements.txt` | Installs the extension with Durable support and sample dependencies. | + +## Prerequisites + +- Python 3.13 or later. +- [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local). +- [Azurite](https://learn.microsoft.com/azure/storage/common/storage-use-azurite) + or an Azure Storage account. Durable Functions requires storage for history, + control queues, and activity work items. +- An Azure subscription and a Microsoft Foundry project with a deployed model. +- A local identity authorized to use the Foundry project. For example, sign in + with `az login` before running the sample. + +## Setup + +1. Change to the Function project directory: + + ```bash + cd azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable + ``` + +2. Create and activate a virtual environment: + + ```bash + python -m venv .venv + # Windows PowerShell + .venv\Scripts\Activate.ps1 + # macOS or Linux + source .venv/bin/activate + ``` + +3. Install the dependencies: + + ```bash + python -m pip install -r requirements.txt + ``` + + The editable dependency in `requirements.txt` installs the Agent Framework + extension from this repository with its `[durable]` extra. When using the + published package instead, install + `azurefunctions-agents-extensions-agent-framework[durable]`. + +4. Create local settings from the template: + + ```powershell + Copy-Item local.settings.template.json local.settings.json + ``` + + On macOS or Linux, use `cp local.settings.template.json local.settings.json`. + +5. Replace the Foundry placeholders in `local.settings.json`: + + | Setting | Description | + | --- | --- | + | `AzureWebJobsStorage` | Keep `UseDevelopmentStorage=true` for Azurite, or use an Azure Storage connection string. | + | `FOUNDRY_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint. | + | `FOUNDRY_MODEL` | Name of the deployed model used by `FoundryChatClient`. | + + Do not commit `local.settings.json` or place credentials in source-controlled + files. + +## Run the sample + +1. Start Azurite. With the Azurite CLI installed, run: + + ```bash + azurite --silent --location .azurite + ``` + + You can instead start Azurite from its Visual Studio Code extension. + +2. In another terminal, activate the virtual environment from the sample + directory and start the Functions host: + + ```bash + func start + ``` + +3. Start an orchestration with a valid order: + + ```bash + curl -X POST http://localhost:7071/orders/orchestrations \ + -H "Content-Type: application/json" \ + -d '{"order_id":"D-2048","customer":{"id":"C-1007","loyalty_tier":"gold"},"currency":"usd","shipping":{"country":"ca","method":"overnight"},"items":[{"sku":"A-100","quantity":2,"unit_price":"24.95"}]}' + ``` + +The starter returns HTTP `202` with the standard Durable management payload: + +```json +{ + "id": "", + "statusQueryGetUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/?...", + "sendEventPostUri": "...", + "terminatePostUri": "...", + "purgeHistoryDeleteUri": "..." +} +``` + +Copy `statusQueryGetUri` from the response and poll it until `runtimeStatus` is +`Completed`: + +```bash +curl "" +``` + +The completed instance has an output shaped like: + +```json +{ + "order_id": "D-2048", + "risk_assessment": "", + "fulfillment_plan": "" +} +``` + +Malformed JSON returns HTTP `400` and does not start an orchestration: + +```json +{"error":"Order failed validation."} +``` + +Order schema validation occurs in `prepare_order_activity`. A structurally +invalid order therefore starts successfully but later causes the orchestration +to fail; inspect the status endpoint and Functions host logs for the activity +failure. + +## Durable Agent behavior + +- `context.call_agent()` accepts a logical Agent name and a JSON-compatible + input value. +- Each call schedules the hidden Agent activity with a deterministic schema-v1 + payload containing the Agent name, canonical input, and Durable instance ID. +- Agent execution and all related I/O occur in the activity, never in the + orchestrator. +- The extension may cache the compiled Agent recipe, but creates and closes a + fresh Foundry client and Agent for each activity invocation. +- The second Agent call uses `df.RetryPolicy`. Durable Functions records each + attempt and applies the retry without introducing nondeterministic sleeps in + the orchestrator. +- The hidden activity is registered automatically when + `@app.orchestration_trigger` is used. + +## Troubleshooting + +- **Agent definition not found:** run `func start` from the sample directory and keep + `order-fulfillment.agent.md` at the app root. +- **Foundry authentication fails:** run `az login`, verify the active tenant and + subscription, and confirm the identity can access the Foundry project. +- **Durable extension fails to load:** confirm the `[durable]` extra was + installed and the extension bundle in `host.json` can be downloaded. +- **Orchestration remains Pending:** verify Azurite is running and + `AzureWebJobsStorage` points to the same storage service used by the host. +- **Orchestration fails in `prepare_order_activity`:** confirm the request has an + `order_id`, customer, two-letter shipping country, supported shipping method, + and at least one item with a positive integer quantity. +- **Agent activity retries or fails:** inspect the Functions host logs and the + instance status response for Foundry authentication, quota, or model errors. + +## Next steps + +- Review the extension's [package documentation](../../README.md). +- Compare this sample with the [Agent Framework sample](../agent_samples_agent-framework/README.md) + for direct Agent injection into HTTP and queue handlers. +- Learn more about [Durable Functions for Python](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview?tabs=python). \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py similarity index 86% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py index 5acf994..b26ab80 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py @@ -5,7 +5,6 @@ import azure.durable_functions as df import azure.functions as func -from agent_framework import Agent from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp from order_processing import prepare_order_for_agent @@ -66,14 +65,6 @@ def order_orchestrator(context: Any): context.get_input(), ) - # context.call_agent equivalent to the following commented-out code: - # - # @app.activity_trigger(input_name="payload") - # @app.markdown_agent(arg_name="agent", agent_name="order-fulfillment") - # async def process_order(payload: dict, agent: Agent[Any]) -> dict: - # response = await agent.run(json.dumps(payload)) - # return {"text": response.text} - assessment = yield context.call_agent( "order-fulfillment", { diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/host.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/host.json similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/host.json rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/host.json diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/local.settings.template.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/local.settings.template.json similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/local.settings.template.json rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/local.settings.template.json diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/order-fulfillment.agent.md similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/order-fulfillment.agent.md rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/order-fulfillment.agent.md diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/order_processing.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/order_processing.py similarity index 100% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/src/order_processing.py rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/order_processing.py diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/requirements.txt b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/requirements.txt similarity index 72% rename from azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/requirements.txt rename to azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/requirements.txt index 94a9bea..efceb96 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/src/requirements.txt +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/requirements.txt @@ -1,4 +1,4 @@ --e ../../..[durable] +-e ../..[durable] agent-framework-foundry==1.13.0 azure-identity pydantic \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/README.md b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/README.md deleted file mode 100644 index 8123259..0000000 --- a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-durable-agent/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Hybrid Durable Agent - -This sample keeps orchestration deterministic while scheduling markdown-defined -Agent calls through a hidden activity. Order validation, calculations, and data -minimization remain explicit application code. - -From `src/`, copy `local.settings.template.json` to `local.settings.json`, fill -in the Foundry values, start Azurite, and run `func start`. - -```bash -curl -X POST http://localhost:7071/orders/orchestrations \ - -H "Content-Type: application/json" \ - -d '{"order_id":"D-2048","customer":{"id":"C-1007","loyalty_tier":"gold"},"currency":"usd","shipping":{"country":"ca","method":"overnight"},"items":[{"sku":"A-100","quantity":2,"unit_price":"24.95"}]}' -``` \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/README.md b/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/README.md deleted file mode 100644 index f1d642c..0000000 --- a/azurefunctions-agents-extensions-agent-framework/samples/hybrid-function-agent/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Hybrid Function Agent - -This sample keeps validation and calculations in ordinary Azure Functions code -while injecting a fresh Microsoft Agent Framework `Agent` for each invocation. -The prompt receives only the validated, minimized order projection. The HTTP -and queue bindings use the discovered `order-policy` Skill and `inventory` MCP -server. All Agent bindings receive every valid capability under the app root. - -From `src/`, copy `local.settings.template.json` to `local.settings.json`, fill -in the Foundry values, start Azurite, and run `func start`. - -Install the sample's `[mcp]` dependency profile and set -`INVENTORY_MCP_URL` to a trusted streamable-HTTP MCP endpoint before invoking -the HTTP route. - -```bash -curl -X POST http://localhost:7071/orders/42 \ - -H "Content-Type: application/json" \ - -d '{"customer":{"id":"C-1007","loyalty_tier":"gold"},"currency":"usd","shipping":{"country":"ca","method":"overnight"},"items":[{"sku":"A-100","quantity":2,"unit_price":"24.95"}]}' -``` \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py index 6d7cfbc..5976d99 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -16,11 +16,11 @@ ("sample_name", "expected_names"), [ ( - "hybrid-function-agent", + "agent_samples_agent-framework", {"process_order", "process_order_event"}, ), ( - "hybrid-durable-agent", + "agent_samples_agent-framework_durable", { "azurefunctions_agents_run_markdown_agent", "order_orchestrator", @@ -45,7 +45,7 @@ def test_sample_indexes_all_functions(sample_name, expected_names): "for function in function_app.app.get_functions()]))" ), ], - cwd=_SAMPLES_ROOT / sample_name / "src", + cwd=_SAMPLES_ROOT / sample_name, env=environment, check=True, capture_output=True, @@ -55,7 +55,7 @@ def test_sample_indexes_all_functions(sample_name, expected_names): assert set(json.loads(completed.stdout)) == expected_names -def test_hybrid_function_sample_rejects_malformed_json(): +def test_agent_framework_sample_rejects_malformed_json(): environment = os.environ.copy() environment["PYTHONPATH"] = os.pathsep.join( filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) @@ -76,7 +76,7 @@ def test_hybrid_function_sample_rejects_malformed_json(): "'body': response.get_body().decode()}))" ), ], - cwd=_SAMPLES_ROOT / "hybrid-function-agent" / "src", + cwd=_SAMPLES_ROOT / "agent_samples_agent-framework", env=environment, check=True, capture_output=True, @@ -88,7 +88,7 @@ def test_hybrid_function_sample_rejects_malformed_json(): assert json.loads(result["body"]) == {"error": "Order failed validation."} -def test_hybrid_durable_sample_starts_orchestration(): +def test_agent_framework_durable_sample_starts_orchestration(): environment = os.environ.copy() environment["PYTHONPATH"] = os.pathsep.join( filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) @@ -114,7 +114,7 @@ def test_hybrid_durable_sample_starts_orchestration(): ) completed = subprocess.run( [sys.executable, "-c", script], - cwd=_SAMPLES_ROOT / "hybrid-durable-agent" / "src", + cwd=_SAMPLES_ROOT / "agent_samples_agent-framework_durable", env=environment, check=True, capture_output=True, @@ -128,7 +128,7 @@ def test_hybrid_durable_sample_starts_orchestration(): } -def test_hybrid_durable_sample_rejects_malformed_json(): +def test_agent_framework_durable_sample_rejects_malformed_json(): environment = os.environ.copy() environment["PYTHONPATH"] = os.pathsep.join( filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) @@ -150,7 +150,7 @@ def test_hybrid_durable_sample_rejects_malformed_json(): ) completed = subprocess.run( [sys.executable, "-c", script], - cwd=_SAMPLES_ROOT / "hybrid-durable-agent" / "src", + cwd=_SAMPLES_ROOT / "agent_samples_agent-framework_durable", env=environment, check=True, capture_output=True, @@ -160,3 +160,11 @@ def test_hybrid_durable_sample_rejects_malformed_json(): result = json.loads(completed.stdout) assert result["status_code"] == 400 assert json.loads(result["body"]) == {"error": "Order failed validation."} + + +def test_agent_framework_sample_assets_follow_discovery_conventions(): + sample_root = _SAMPLES_ROOT / "agent_samples_agent-framework" + + assert (sample_root / "order-fulfillment.agent.md").is_file() + assert (sample_root / "skills" / "order-policy" / "SKILL.md").is_file() + assert (sample_root / "mcp.json").is_file() diff --git a/updated-agent-binding-issue.md b/updated-agent-binding-issue.md new file mode 100644 index 0000000..b64682f --- /dev/null +++ b/updated-agent-binding-issue.md @@ -0,0 +1,124 @@ +# Enable hybrid Azure Functions with in-process Agent bindings + +Enable Azure Function Apps to invoke Microsoft Agent Framework Agents in-process through an extension-owned smart binding, supporting hybrid deterministic and agentic workflows. + +## Goal + +A Python Function can declare a Markdown Agent binding and receive a fully constructed Microsoft Agent Framework `Agent` in its handler. Customers retain normal Azure Functions triggers and orchestration logic while adding agentic work where needed. + +## Scope + +- Provide two extension packages: + - `azurefunctions-agents-extensions-base` for provider-neutral binding, discovery, lifecycle, and Durable contracts. + - `azurefunctions-agents-extensions-agent-framework` for Microsoft Agent Framework integration. +- Provide `AgentFunctionApp`, a subclass of `azure.functions.FunctionApp`, with a typed `markdown_agent` decorator. +- Resolve raw `.agent.md` instructions from either the Function App root or its `agents/` directory. +- Configure the MAF client through an app-level `client_factory`. +- Support app-level Python tools, with optional per-binding `client_factory` and `tools` overrides. +- Automatically discover Skills and HTTP-based MCP servers from the app root. +- Apply discovered Skills and MCP servers to every Agent binding in the app. +- Create fresh clients, Agents, credentials, HTTP clients, and MCP tools for each invocation and close them on success, failure, or cancellation. +- Cache provider discovery, compiled bindings, and Durable recipes without caching live invocation resources. +- Support optional Durable orchestration through `AgentFunctionApp.orchestration_trigger` and `context.call_agent(...)`. +- Execute Durable Agent calls through a hidden activity using a deterministic, JSON-only schema-v1 payload. +- Preserve function name, invocation ID, and Durable instance ID at the provider boundary where available. +- Validate missing or ambiguous Agent files, unsupported provider options, invalid handler signatures, unsupported capabilities, and malformed MCP configuration. +- Keep Durable Functions and MCP dependencies optional and import-safe. +- Include representative HTTP and Durable hybrid samples and automated lifecycle, validation, discovery, and import-safety tests. +- Document installation, configuration, discovery conventions, lifecycle, and V1 limitations. + +## Implemented authoring model + +Agent files contain raw UTF-8 instructions only. Front matter, model configuration, tools, and runtime configuration are not parsed from `.agent.md`. + +Configuration is divided as follows: + +- `client_factory`: configured on `AgentFunctionApp`, optionally overridden per binding. +- Python `tools`: configured explicitly on the app or binding. +- Skills: discovered from `skills/` or `Skills/`. +- MCP servers: discovered from `mcp.json`. +- MCP tool allowlists: configured per server in `mcp.json`. +- Agent instructions: loaded from `.agent.md` or `agents/.agent.md`. + +## Out of scope + +- Adding Agent decorators directly to `azure.functions.FunctionApp`. +- Modifying the Azure Functions Python SDK or MAF public API. +- Parsing model configuration, tools, or front matter from `.agent.md`. +- App-level or per-binding selection of discovered Skills or MCP servers. +- Per-call provider, client, tool, Skill, or MCP overrides from Durable orchestrators. +- Local-process or stdio MCP servers. +- Standalone declarative Serverless Agent endpoints. +- Multi-agent orchestration, A2A protocol support, or new model-provider policy. +- Caching live clients or Agents across invocations. + +## Success criteria + +- An existing Python Function App can migrate from `func.FunctionApp` to `AgentFunctionApp` without replacing its existing trigger model. +- A normal Function handler can receive a MAF `Agent` through `@app.markdown_agent(...)` and invoke it directly. +- The Agent receives the selected raw instructions, configured client, explicit Python tools, and automatically discovered Skills and MCP servers. +- Every invocation receives fresh, safely managed runtime resources. +- A Durable orchestrator can invoke an Agent through replay-safe `context.call_agent(...)`. +- Invalid definitions, configuration, signatures, or assets fail with actionable diagnostics. +- Importing either extension does not require or import Durable Functions. +- HTTP and Durable samples and automated tests cover invocation, lifecycle, discovery, validation, and compatibility with standard Azure Functions decorators. + +## Python binding API + +```python +import azure.functions as func +from agent_framework import Agent +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +app = AgentFunctionApp(client_factory=create_chat_client) + + +@app.function_name(name="ProcessOrder") +@app.route(route="orders/{orderId}", methods=["POST"]) +@app.markdown_agent( + arg_name="order_agent", + agent_name="order-fulfillment", +) +async def process_order( + req: func.HttpRequest, + order_agent: Agent, +) -> func.HttpResponse: + task = ( + "Validate the order and return fulfillment guidance for " + f"{req.route_params['orderId']}." + ) + response = await order_agent.run(task) + return func.HttpResponse(response.text) +``` + +`order_agent` is the runtime-managed handler parameter. `order-fulfillment` resolves to exactly one of: + +```text +/order-fulfillment.agent.md +/agents/order-fulfillment.agent.md +``` + +The extension loads the file as raw instructions and constructs a fresh MAF `Agent` for each invocation. + +## Durable API + +```python +from typing import Any + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +app = AgentFunctionApp(client_factory=create_chat_client) + + +@app.orchestration_trigger(context_name="context") +def order_orchestrator(context: Any): + assessment = yield context.call_agent( + "order-fulfillment", + {"order": context.get_input()}, + ) + return assessment +``` + +`call_agent()` schedules the extension's hidden activity. It does not execute model, filesystem, credential, or network operations during orchestration replay. From edb9d0a65d1b56cd337b969d38fb6e6a8ed4559f Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 10 Sep 2026 14:27:14 -0500 Subject: [PATCH 24/25] durable context typing --- .../extensions/agent_framework/__init__.py | 3 + .../function_app.py | 11 +- .../tests/test_imports.py | 4 +- updated-agent-binding-issue.md | 124 ------------------ 4 files changed, 13 insertions(+), 129 deletions(-) delete mode 100644 updated-agent-binding-issue.md diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py index 7a608cf..e1eefcd 100644 --- a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py @@ -1,3 +1,5 @@ +from azurefunctions.agents.extensions.base.durable import DurableAgentContext + from .apps import AgentFunctionApp from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory @@ -5,6 +7,7 @@ "AGENT_FRAMEWORK_PROVIDER_ID", "AgentFunctionApp", "ClientFactory", + "DurableAgentContext", ] __version__ = '1.0.0b1' diff --git a/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py index b26ab80..f3913f1 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py @@ -1,11 +1,13 @@ import json import os from datetime import timedelta -from typing import Any import azure.durable_functions as df import azure.functions as func -from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.agent_framework import ( + AgentFunctionApp, + DurableAgentContext, +) from order_processing import prepare_order_for_agent @@ -19,6 +21,7 @@ def create_chat_client(): credential=DefaultAzureCredential(), ) + app = AgentFunctionApp(client_factory=create_chat_client) @@ -59,7 +62,7 @@ def prepare_order_activity(order: dict) -> dict[str, object]: @app.orchestration_trigger(context_name="context") -def order_orchestrator(context: Any): +def order_orchestrator(context: DurableAgentContext): prepared_order = yield context.call_activity( "prepare_order_activity", context.get_input(), @@ -88,4 +91,4 @@ def order_orchestrator(context: Any): "order_id": prepared_order["order_id"], "risk_assessment": assessment, "fulfillment_plan": plan, - } \ No newline at end of file + } diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py index 76733f5..66ea183 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -2,10 +2,12 @@ import sys -def test_framework_exports_only_app_api(): +def test_framework_exports_supported_api(): import azurefunctions.agents.extensions.agent_framework as framework + from azurefunctions.agents.extensions.base.durable import DurableAgentContext assert framework.AgentFunctionApp is not None + assert framework.DurableAgentContext is DurableAgentContext assert not hasattr(framework, "AgentDFApp") assert not hasattr(framework, "markdown_agent") diff --git a/updated-agent-binding-issue.md b/updated-agent-binding-issue.md deleted file mode 100644 index b64682f..0000000 --- a/updated-agent-binding-issue.md +++ /dev/null @@ -1,124 +0,0 @@ -# Enable hybrid Azure Functions with in-process Agent bindings - -Enable Azure Function Apps to invoke Microsoft Agent Framework Agents in-process through an extension-owned smart binding, supporting hybrid deterministic and agentic workflows. - -## Goal - -A Python Function can declare a Markdown Agent binding and receive a fully constructed Microsoft Agent Framework `Agent` in its handler. Customers retain normal Azure Functions triggers and orchestration logic while adding agentic work where needed. - -## Scope - -- Provide two extension packages: - - `azurefunctions-agents-extensions-base` for provider-neutral binding, discovery, lifecycle, and Durable contracts. - - `azurefunctions-agents-extensions-agent-framework` for Microsoft Agent Framework integration. -- Provide `AgentFunctionApp`, a subclass of `azure.functions.FunctionApp`, with a typed `markdown_agent` decorator. -- Resolve raw `.agent.md` instructions from either the Function App root or its `agents/` directory. -- Configure the MAF client through an app-level `client_factory`. -- Support app-level Python tools, with optional per-binding `client_factory` and `tools` overrides. -- Automatically discover Skills and HTTP-based MCP servers from the app root. -- Apply discovered Skills and MCP servers to every Agent binding in the app. -- Create fresh clients, Agents, credentials, HTTP clients, and MCP tools for each invocation and close them on success, failure, or cancellation. -- Cache provider discovery, compiled bindings, and Durable recipes without caching live invocation resources. -- Support optional Durable orchestration through `AgentFunctionApp.orchestration_trigger` and `context.call_agent(...)`. -- Execute Durable Agent calls through a hidden activity using a deterministic, JSON-only schema-v1 payload. -- Preserve function name, invocation ID, and Durable instance ID at the provider boundary where available. -- Validate missing or ambiguous Agent files, unsupported provider options, invalid handler signatures, unsupported capabilities, and malformed MCP configuration. -- Keep Durable Functions and MCP dependencies optional and import-safe. -- Include representative HTTP and Durable hybrid samples and automated lifecycle, validation, discovery, and import-safety tests. -- Document installation, configuration, discovery conventions, lifecycle, and V1 limitations. - -## Implemented authoring model - -Agent files contain raw UTF-8 instructions only. Front matter, model configuration, tools, and runtime configuration are not parsed from `.agent.md`. - -Configuration is divided as follows: - -- `client_factory`: configured on `AgentFunctionApp`, optionally overridden per binding. -- Python `tools`: configured explicitly on the app or binding. -- Skills: discovered from `skills/` or `Skills/`. -- MCP servers: discovered from `mcp.json`. -- MCP tool allowlists: configured per server in `mcp.json`. -- Agent instructions: loaded from `.agent.md` or `agents/.agent.md`. - -## Out of scope - -- Adding Agent decorators directly to `azure.functions.FunctionApp`. -- Modifying the Azure Functions Python SDK or MAF public API. -- Parsing model configuration, tools, or front matter from `.agent.md`. -- App-level or per-binding selection of discovered Skills or MCP servers. -- Per-call provider, client, tool, Skill, or MCP overrides from Durable orchestrators. -- Local-process or stdio MCP servers. -- Standalone declarative Serverless Agent endpoints. -- Multi-agent orchestration, A2A protocol support, or new model-provider policy. -- Caching live clients or Agents across invocations. - -## Success criteria - -- An existing Python Function App can migrate from `func.FunctionApp` to `AgentFunctionApp` without replacing its existing trigger model. -- A normal Function handler can receive a MAF `Agent` through `@app.markdown_agent(...)` and invoke it directly. -- The Agent receives the selected raw instructions, configured client, explicit Python tools, and automatically discovered Skills and MCP servers. -- Every invocation receives fresh, safely managed runtime resources. -- A Durable orchestrator can invoke an Agent through replay-safe `context.call_agent(...)`. -- Invalid definitions, configuration, signatures, or assets fail with actionable diagnostics. -- Importing either extension does not require or import Durable Functions. -- HTTP and Durable samples and automated tests cover invocation, lifecycle, discovery, validation, and compatibility with standard Azure Functions decorators. - -## Python binding API - -```python -import azure.functions as func -from agent_framework import Agent -from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp - - -app = AgentFunctionApp(client_factory=create_chat_client) - - -@app.function_name(name="ProcessOrder") -@app.route(route="orders/{orderId}", methods=["POST"]) -@app.markdown_agent( - arg_name="order_agent", - agent_name="order-fulfillment", -) -async def process_order( - req: func.HttpRequest, - order_agent: Agent, -) -> func.HttpResponse: - task = ( - "Validate the order and return fulfillment guidance for " - f"{req.route_params['orderId']}." - ) - response = await order_agent.run(task) - return func.HttpResponse(response.text) -``` - -`order_agent` is the runtime-managed handler parameter. `order-fulfillment` resolves to exactly one of: - -```text -/order-fulfillment.agent.md -/agents/order-fulfillment.agent.md -``` - -The extension loads the file as raw instructions and constructs a fresh MAF `Agent` for each invocation. - -## Durable API - -```python -from typing import Any - -from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp - - -app = AgentFunctionApp(client_factory=create_chat_client) - - -@app.orchestration_trigger(context_name="context") -def order_orchestrator(context: Any): - assessment = yield context.call_agent( - "order-fulfillment", - {"order": context.get_input()}, - ) - return assessment -``` - -`call_agent()` schedules the extension's hidden activity. It does not execute model, filesystem, credential, or network operations during orchestration replay. From fb72fcd86c4f7daf13548dee5cfc377716ef255f Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 10 Sep 2026 16:51:24 -0500 Subject: [PATCH 25/25] add better log --- .../agents/extensions/base/bindings.py | 12 ++++++ .../agents/extensions/base/durable.py | 3 +- .../tests/test_bindings.py | 29 ++++++++++++++ .../tests/test_durable.py | 38 +++++++++++++------ 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py index 8d2e560..ec892b8 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -2,6 +2,7 @@ import functools import inspect +import logging import os import threading import weakref @@ -19,6 +20,16 @@ _F = TypeVar("_F", bound=Callable[..., Any]) _INVALID_FILENAME_CHARACTERS = frozenset('<>:"/\\|?*') +_logger = logging.getLogger("azure.functions.AgentExtension") + + +def _log_agent_usage(provider: str, agent_name: str) -> None: + _logger.info( + "Agent extension invoked with provider %r and agent %r", + provider, + agent_name, + extra={"provider": provider, "agent_name": agent_name}, + ) @dataclass @@ -325,6 +336,7 @@ def decorate(handler: _F) -> _F: @functools.wraps(handler) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: invocation = _invocation_metadata(visible_signature, args, kwargs) + _log_agent_usage(state.provider_id, agent_name) async with compiled.open_agent(invocation) as agent: return await _source_call( handler, diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py index 9069b7c..1b24c39 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py @@ -9,7 +9,7 @@ import azure.functions as func -from .bindings import _configured_state, _durable_agent +from .bindings import _configured_state, _durable_agent, _log_agent_usage from .providers import InvocationMetadata if TYPE_CHECKING: @@ -206,6 +206,7 @@ async def azurefunctions_agents_run_markdown_agent( invocation_id=str(context.invocation_id or "") or None, durable_instance_id=parsed["durable_instance_id"], ) + _log_agent_usage(state.provider_id, parsed["agent_name"]) return await compiled.run_agent( _normalize_agent_prompt(parsed["input"]), invocation, diff --git a/azurefunctions-agents-extensions-base/tests/test_bindings.py b/azurefunctions-agents-extensions-base/tests/test_bindings.py index d584954..188df21 100644 --- a/azurefunctions-agents-extensions-base/tests/test_bindings.py +++ b/azurefunctions-agents-extensions-base/tests/test_bindings.py @@ -3,6 +3,7 @@ import asyncio import gc import inspect +import logging import weakref from contextlib import asynccontextmanager @@ -85,6 +86,34 @@ async def handler(value: str, agent: object) -> tuple[str, object]: assert provider.compiled.opened == provider.compiled.closed == 2 +def test_markdown_agent_logs_provider_usage(tmp_path, provider, caplog): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + pass + + with caplog.at_level(logging.INFO, logger="azure.functions.AgentExtension"): + asyncio.run(handler()) + + records = [ + record + for record in caplog.records + if record.name == "azure.functions.AgentExtension" + ] + assert [record.getMessage() for record in records] == [ + "Agent extension invoked with provider 'agent_framework' and agent 'orders'" + ] + assert records[0].provider == "agent_framework" + assert records[0].agent_name == "orders" + + def test_markdown_agent_preserves_variadic_handler_arguments(tmp_path, provider): (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") app = func.FunctionApp() diff --git a/azurefunctions-agents-extensions-base/tests/test_durable.py b/azurefunctions-agents-extensions-base/tests/test_durable.py index b148075..1d67650 100644 --- a/azurefunctions-agents-extensions-base/tests/test_durable.py +++ b/azurefunctions-agents-extensions-base/tests/test_durable.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging import math from contextlib import asynccontextmanager from datetime import timedelta @@ -218,7 +219,11 @@ def orchestrator(context): ) -def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypatch): +def test_hidden_activity_resolves_and_executes_dynamic_agent( + tmp_path, + monkeypatch, + caplog, +): instructions = "---\nthis remains: raw\n---\nHandle orders.\n" (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) app, provider = _configured_app(tmp_path, monkeypatch) @@ -229,19 +234,30 @@ def test_hidden_activity_resolves_and_executes_dynamic_agent(tmp_path, monkeypat invocation_id="invocation-1", ) - result = asyncio.run( - activity( - { - "schema_version": 1, - "agent_name": "orders", - "input": {"z": 1, "a": 2}, - "durable_instance_id": "instance-1", - }, - context, + with caplog.at_level(logging.INFO, logger="azure.functions.AgentExtension"): + result = asyncio.run( + activity( + { + "schema_version": 1, + "agent_name": "orders", + "input": {"z": 1, "a": 2}, + "durable_instance_id": "instance-1", + }, + context, + ) ) - ) assert result == 'response:{"a":2,"z":1}' + records = [ + record + for record in caplog.records + if record.name == "azure.functions.AgentExtension" + ] + assert [record.getMessage() for record in records] == [ + "Agent extension invoked with provider 'agent_framework' and agent 'orders'" + ] + assert records[0].provider == "agent_framework" + assert records[0].agent_name == "orders" assert provider.compile_calls[0]["instructions"] == instructions assert provider.compile_calls[0]["capabilities"].skills == () assert provider.compiled.calls[0][0] == '{"a":2,"z":1}'