From 7260d2df65f558619ee643e82a27311092b14819 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 2 Sep 2026 13:09:20 -0500 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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 764b0bd3d80486dbc1e4040395ee7ab4a426d337 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Wed, 9 Sep 2026 16:44:10 -0500 Subject: [PATCH 25/30] Prototype lazy-owned DAFX support for agent bindings --- .../README.md | 6 + .../agents/extensions/agent_framework/apps.py | 89 +++++- .../pyproject.toml | 4 + .../samples/lazy-owned-dafx/README.md | 50 +++ .../samples/lazy-owned-dafx/VALIDATION.md | 61 ++++ .../samples/lazy-owned-dafx/function_app.py | 61 ++++ .../samples/lazy-owned-dafx/host.json | 7 + .../tests/test_dafx.py | 284 ++++++++++++++++++ .../tests/test_imports.py | 85 ++++++ .../tests/test_samples.py | 20 ++ eng/templates/official/jobs/unit-tests.yml | 2 +- 11 files changed, 666 insertions(+), 3 deletions(-) create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/host.json create mode 100644 azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index eb41a60..ca81fc7 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -138,6 +138,12 @@ discovered Skills/MCP integration. Configure `app_root` only when constructing ## Durable Agents +This prototype also supports an explicit DAFX path through +`add_durable_agent()` and `get_agent()`. See the +[lazy-owned DAFX example](samples/lazy-owned-dafx/README.md) for the design, +SDK 2 dependency pins, and test instructions. It does not change the +activity-based API described below. + Durable orchestration support is optional: ```text 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 07fd8bc..cbd0ef9 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 @@ -2,10 +2,11 @@ import os from collections.abc import Callable, Sequence -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar import azure.functions as func -from agent_framework import ToolTypes +from agent_framework import SupportsAgentRun, ToolTypes +from azure.functions.decorators.function_app import Function from azurefunctions.agents.extensions.base import ( configure_app, @@ -15,6 +16,13 @@ from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory +if TYPE_CHECKING: + from agent_framework_azurefunctions import ( + AgentFunctionApp as DurableAgentFunctionApp, + ) + from agent_framework_durabletask import DurableAgentTask, DurableAIAgent + from durabletask.task import OrchestrationContext + _F = TypeVar("_F", bound=Callable[..., Any]) @@ -78,6 +86,8 @@ def __init__( super().__init__( http_auth_level=http_auth_level, ) + self._durable_app: DurableAgentFunctionApp | None = None + self._functions_indexed = False configure_app( self, provider=AGENT_FRAMEWORK_PROVIDER_ID, @@ -88,6 +98,81 @@ def __init__( ), ) + def add_durable_agent(self, agent: SupportsAgentRun) -> None: + """Opt in to DAFX by registering an agent before function indexing. + + Unlike markdown bindings, this accepts a caller-owned agent instance. + It does not construct or close the agent's clients or tools. + """ + if self._functions_indexed: + raise RuntimeError("Register durable agents before function indexing.") + name = getattr(agent, "name", None) + if not isinstance(name, str) or not name.strip(): + raise ValueError("A durable agent must have a non-empty string name.") + + durable_app = self._ensure_durable_app() + for registered_name, registered_agent in durable_app.agents.items(): + if registered_name.casefold() == name.casefold(): + if registered_agent is agent: + return + raise ValueError(f"Durable agent {name!r} is already registered.") + durable_app.add_agent(agent) + + def _ensure_durable_app(self) -> DurableAgentFunctionApp: + if self._durable_app is None: + try: + from agent_framework_azurefunctions import ( + AgentFunctionApp as DurableAgentFunctionApp, + ) + except ModuleNotFoundError as error: + if error.name != "agent_framework_azurefunctions": + raise + raise ImportError( + "DAFX support is not installed. Install " + "'azurefunctions-agents-extensions-agent-framework[durable]'." + ) from error + + self._durable_app = DurableAgentFunctionApp( + http_auth_level=self.auth_level, + enable_health_check=False, + enable_http_endpoints=False, + enable_mcp_tool_trigger=False, + ) + return self._durable_app + + def get_agent( + self, + context: OrchestrationContext, + agent_name: str, + ) -> DurableAIAgent[DurableAgentTask]: + """Get a DAFX proxy without registering functions during execution.""" + if self._durable_app is None: + raise RuntimeError("Call add_durable_agent() during app configuration.") + return self._durable_app.get_agent(context, agent_name) + + def get_functions(self) -> list[Function]: + """Expose both registries through the single worker-indexed app.""" + # The SDK retains name-validation state between indexing calls. Start + # each pass fresh, including retries after an indexing error. + self.functions_bindings = None + functions: list[Function] = super().get_functions() + if self._durable_app is not None: + self._durable_app.functions_bindings = None + functions.extend(self._durable_app.get_functions()) + + names: set[str] = set() + for function in functions: + name = function.get_function_name() + if not name: + raise ValueError("An indexed function must have a name.") + if name.casefold() in names: + raise ValueError( + f"Duplicate function name across app registries: {name}" + ) + names.add(name.casefold()) + self._functions_indexed = True + return functions + def orchestration_trigger( self, context_name: str, diff --git a/azurefunctions-agents-extensions-agent-framework/pyproject.toml b/azurefunctions-agents-extensions-agent-framework/pyproject.toml index dc3d198..6a115c1 100644 --- a/azurefunctions-agents-extensions-agent-framework/pyproject.toml +++ b/azurefunctions-agents-extensions-agent-framework/pyproject.toml @@ -37,6 +37,10 @@ mcp = [ ] durable = [ "azurefunctions-agents-extensions-base[durable]>=1.0.0b1", + # Prototype only. PR #72 supplies DAFX's SDK 2 support. Pin both packages + # to the same revision until compatible distributions are published. + "agent-framework-azurefunctions @ git+https://github.com/microsoft/agent-framework-durable-extension.git@aa9529ec489e16ac64b73bd68d5adbb8e4945258#subdirectory=python/packages/azurefunctions", + "agent-framework-durabletask @ git+https://github.com/microsoft/agent-framework-durable-extension.git@aa9529ec489e16ac64b73bd68d5adbb8e4945258#subdirectory=python/packages/durabletask", ] dev = [ "azure-functions-durable>=2.0.0b2", diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md new file mode 100644 index 0000000..dd292d4 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md @@ -0,0 +1,50 @@ +# Lazy-owned DAFX prototype + +This branch explores app composition, not a replacement of `context.call_agent()`. +The bindings `AgentFunctionApp` remains the only worker-indexed app. Calling +`add_durable_agent()` creates a private DAFX app and registers an entity there. +The outer `get_functions()` combines both registries and rejects name collisions. +`get_agent()` delegates to DAFX without creating functions during execution. + +The example has a normal HTTP function and a two-turn durable orchestration. +It uses a deterministic local chat client, so no model credentials are needed. +The two turns explicitly share a session. Caller-owned registered agents do not +use the markdown binding's per-invocation client/tool lifecycle. + +## Install and verify + +Use Python 3.13 or later. From the repository root, in a fresh virtual environment: + +```powershell +python -m pip install -e ./azurefunctions-agents-extensions-base +python -m pip install -e './azurefunctions-agents-extensions-agent-framework[dev,durable]' +python -m pytest -q --import-mode=importlib azurefunctions-agents-extensions-base/tests azurefunctions-agents-extensions-agent-framework/tests +``` + +The optional extra pins both DAFX packages to commit +`aa9529ec489e16ac64b73bd68d5adbb8e4945258` from +[DAFX PR #72](https://github.com/microsoft/agent-framework-durable-extension/pull/72). +The published DAFX packages currently require SDK 1.x and cannot satisfy this +PR's SDK 2.x requirements. These Git dependencies are for local prototyping, not +for publishing this package to PyPI. Normal installs do not install DAFX. + +The tests invoke the SDK's indexed entity handler with the protobuf request and +response format used by the Functions host. They round-trip entity state between +two turns and complete real DAFX tasks. The scheduler and model service are local +test substitutes. This is not a deployed Functions host or storage integration test. + +Running the example under Core Tools additionally requires the Python SDK 2 +compatible Functions host/extension and a configured Durable backend. Those are +not provisioned by this sample. POST `/api/orders` to start it and follow the +returned status URL. GET `/api/hello` exercises the normal HTTP path. + +## Boundaries + +- No DAFX import, inner app, or entity registration on the non-durable path. +- Register all durable agents before indexing. Late registration is rejected. +- Re-registering the same instance is harmless. Different agents with the same + case-insensitive name are rejected rather than silently shadowed. +- DAFX's generated agent HTTP, health, and MCP endpoints are disabled. The SDK's + built-in durable HTTP activity/orchestrator remain registered. +- Existing `markdown_agent()` and activity-based `context.call_agent()` are + unchanged. Markdown-to-DAFX factory/lifecycle adaptation is not implemented. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md new file mode 100644 index 0000000..c3ba902 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md @@ -0,0 +1,61 @@ +# Prototype verification + +Verified on Windows with Python 3.13.11 on 2026-09-09. Branch base is extensions +PR #185 at `db2526586348513ff86ed2c61ffc685815a8d212`. DAFX dependencies are pinned +to PR #72 at `aa9529ec489e16ac64b73bd68d5adbb8e4945258`. + +## Results + +| Configuration | Result | +| --- | --- | +| Original PR, Functions 2.3.0, Durable 2.0.0rc1, core 1.16.0 | 69 passed | +| Prototype, same SDK/core versions, DAFX PR #72 | 96 passed | +| Prototype, Durable 2.0.0b2, core 1.16.0 | 96 passed | +| Prototype, Durable 2.0.0b2, core 1.13.0 | 96 passed | +| Fresh normal install without Durable/DAFX packages | 5 import tests passed | +| Strict mypy, both agent packages | Passed, 11 source files | +| Flake8, framework source, tests, and new sample | Passed | +| Both package wheels and source distributions | Built | +| Dependency consistency, plain and durable environments | Passed | + +The SDK emits one deprecation warning during entity deserialization about calling +`df_loads` without `expected_type`. Build tooling emits existing license-metadata +deprecation warnings. Neither warning was suppressed. + +No deployed Functions host, external model, or storage service was exercised. +The execution test uses the real indexed SDK entity handler, protobuf transport, +DAFX execution and tasks, and serialized entity state between turns. Only its +model and orchestration scheduler are test substitutes. + +## Change analysis + +- Initialization and provider configuration still happen before binding decoration. + Existing constructor/decorator contract tests and both original suites pass. +- The old activity-based `call_agent()` remains unchanged. Its decoration/indexing + path does not construct the inner app. Explicit registration is a separate API. +- Both function registries are included, including SDK built-ins. HTTP auth is + preserved. Duplicate names, repeated indexing, and retry after correcting a + collision are tested. The SDK name-validation state is reset on each pass. +- Agent lookup does not import or create DAFX. Explicit registration after indexing + is rejected. Re-registering the same instance is idempotent, but a different + instance with the same case-insensitive name is rejected. +- Missing, empty, whitespace-only, and non-string names are rejected before DAFX + creation. Missing DAFX produces installation guidance; a broken transitive + import preserves its original error. Different apps own separate registries. +- SDK built-in names are derived from the real inner registry for collision tests. + The sample index test separately pins the expected complete function list. +- The initial 20 new DAFX tests fail against the untouched PR head because the + new API/state is absent, then pass with the implementation present. In-memory + mutations removing inner functions and removing the inner validation reset + each fail three targeted tests for the expected behavior. No source file was + mutated by those probes. +- An independent read-only review prompted additional app-isolation and + indexing-recovery tests. Its proposed blanket guard against adding any decorator + after indexing was not adopted: the original SDK and PR already allow that; + this prototype guards only its new durable-agent registration API. +- Documentation and dependency declarations were checked together. Both DAFX Git + pins occur only in the optional extra; CI explicitly installs that extra for + framework tests. These prototype Git dependencies are not a PyPI release plan. + +See the adjacent README for installation and test commands. Full suites cover +the two agent packages, not unrelated extensions elsewhere in the repository. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py new file mode 100644 index 0000000..749f844 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py @@ -0,0 +1,61 @@ +"""Local-model example of optional DAFX ownership. No model credentials needed.""" + +from collections.abc import Mapping, Sequence +from typing import Any + +import azure.functions as func +from agent_framework import Agent, BaseChatClient, ChatResponse, Message + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +class LocalChatClient(BaseChatClient): + """Return a deterministic response so the prototype needs no model service.""" + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ): + if stream: + raise TypeError("streaming is not supported by this local client") + + async def respond(): + turns = sum(message.role == "user" for message in messages) + return ChatResponse(messages=[Message( + role="assistant", contents=[f"User turn {turns}: {messages[-1].text}"] + )]) + + return respond() + + +app = AgentFunctionApp(client_factory=LocalChatClient) + + +@app.route(route="hello", methods=["GET"]) +def hello(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("Normal HTTP function on the outer app.") + + +# This is the only opt-in point for DAFX. Register before function indexing. +# The caller owns this agent and any clients/tools it uses. +app.add_durable_agent(Agent(client=LocalChatClient(), name="Orders")) + + +@app.orchestration_trigger(context_name="context") +def orders(context): + agent = app.get_agent(context, "Orders") + session = agent.create_session() + first = yield agent.run("Assess the order.", session=session) + second = yield agent.run("Make a fulfillment plan.", session=session) + return {"assessment": first.text, "plan": second.text} + + +@app.route(route="orders", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_orders(req: func.HttpRequest, client) -> func.HttpResponse: + instance_id = await client.start_new("orders", client_input={}) + return client.create_check_status_response(req, instance_id) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/host.json b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/host.json new file mode 100644 index 0000000..55d1642 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py b/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py new file mode 100644 index 0000000..6baec3b --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import base64 +import json +import uuid +from types import SimpleNamespace +from unittest.mock import Mock + +import azure.functions as func +import pytest +from agent_framework import Agent, AgentResponse, BaseChatClient, ChatResponse, Message +from azure.durable_functions import DurableOrchestrationContext +from durabletask.internal import orchestrator_service_pb2 as pb +from durabletask.task import CompletableTask +from google.protobuf.wrappers_pb2 import StringValue + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.base.durable import DurableAgentContext + + +class RecordingClient(BaseChatClient): + """A local model substitute. DAFX and the Functions SDK are not mocked.""" + + def __init__(self): + super().__init__() + self.inputs = [] + + def _inner_get_response(self, *, messages, stream, options, **kwargs): + if stream: + raise TypeError("streaming is not supported by this test client") + + async def respond(): + self.inputs.append([message.text for message in messages]) + return ChatResponse(messages=[Message( + role="assistant", contents=[f"reply-{len(self.inputs)}"] + )]) + + return respond() + + +@pytest.fixture +def app(tmp_path): + return AgentFunctionApp(client_factory=RecordingClient, app_root=tmp_path) + + +def make_agent(name="Orders", client=None): + return Agent(client=client or RecordingClient(), name=name) + + +def test_initialization_does_not_construct_dafx(app): + assert app._durable_app is None + assert app.get_functions() == [] + assert app._durable_app is None + + +def test_registration_owns_one_real_dafx_app(app): + from agent_framework_azurefunctions import AgentFunctionApp as DafxApp + + first = make_agent() + app.add_durable_agent(first) + inner = app._durable_app + assert isinstance(inner, DafxApp) + app.add_durable_agent(first) + app.add_durable_agent(make_agent("Shipping")) + + assert app._durable_app is inner + assert set(inner.agents) == {"Orders", "Shipping"} + assert not inner.enable_health_check + assert not inner.enable_http_endpoints + assert not inner.enable_mcp_tool_trigger + assert inner.auth_level == app.auth_level + functions = app.get_functions() + entities = { + function.get_function_name() + for function in functions + if function.get_bindings_dict()["bindings"][0]["type"] == "entityTrigger" + } + assert entities == {"dafx-Orders", "dafx-Shipping"} + assert not any(function.is_http_function() for function in functions) + + +@pytest.mark.parametrize("name", [None, "", " ", 42]) +def test_invalid_name_does_not_enable_dafx(app, name): + with pytest.raises(ValueError, match="non-empty string name"): + app.add_durable_agent(SimpleNamespace(name=name)) + assert app._durable_app is None + + +@pytest.mark.parametrize("name", ["Orders", "orders", "ORDERS"]) +def test_different_agent_with_duplicate_name_is_rejected(app, name): + app.add_durable_agent(make_agent()) + with pytest.raises(ValueError, match="already registered"): + app.add_durable_agent(make_agent(name)) + assert len(app._durable_app.agents) == 1 + + +def test_lookup_does_not_enable_dafx(app): + with pytest.raises(RuntimeError, match="add_durable_agent"): + app.get_agent(object(), "Orders") + assert app._durable_app is None + + +def test_unknown_agent_uses_dafx_validation(app): + app.add_durable_agent(make_agent()) + with pytest.raises(ValueError, match="not registered"): + app.get_agent(object(), "Unknown") + + +def test_distinct_apps_do_not_share_durable_registries(app, tmp_path): + other = AgentFunctionApp(client_factory=RecordingClient, app_root=tmp_path) + app.add_durable_agent(make_agent("First")) + assert other._durable_app is None + other.add_durable_agent(make_agent("Second")) + assert app._durable_app is not other._durable_app + assert set(app._durable_app.agents) == {"First"} + assert set(other._durable_app.agents) == {"Second"} + + +def test_indexing_recovers_after_collision_is_removed(app): + @app.function_name(name="dafx-Orders") + @app.route(route="collision") + def collision(req): + return func.HttpResponse("ok") + + app.add_durable_agent(make_agent()) + with pytest.raises(ValueError, match="Duplicate function name"): + app.get_functions() + # Test-only removal simulates correcting the conflicting declaration. + app._function_builders.remove(collision) + names = [fn.get_function_name() for fn in app.get_functions()] + assert names.count("dafx-Orders") == 1 + assert app._functions_indexed + + +def test_duplicate_outer_names_are_still_validated_on_each_index(app): + for route in ["first", "second"]: + @app.route(route=route) + def duplicate(req): + return func.HttpResponse("ok") + + for _ in range(2): + with pytest.raises(ValueError, match="unique function name"): + app.get_functions() + + +@pytest.mark.parametrize("enable_dafx", [False, True]) +def test_registration_after_indexing_is_rejected(app, enable_dafx): + if enable_dafx: + app.add_durable_agent(make_agent()) + app.get_functions() + with pytest.raises(RuntimeError, match="before function indexing"): + app.add_durable_agent(make_agent("Late")) + + +@pytest.mark.parametrize("auth", [func.AuthLevel.ANONYMOUS, func.AuthLevel.FUNCTION]) +def test_combined_index_preserves_http_auth_and_is_repeatable(tmp_path, auth): + app = AgentFunctionApp( + client_factory=RecordingClient, app_root=tmp_path, http_auth_level=auth + ) + + @app.route(route="orders") + def orders(req): + return func.HttpResponse("ok") + + app.add_durable_agent(make_agent()) + first = app.get_functions() + second = app.get_functions() + assert [fn.get_function_name() for fn in first] == [ + fn.get_function_name() for fn in second + ] + assert len(first) == 4 # HTTP + entity + the SDK's two built-in functions. + http = next(fn for fn in first if fn.get_function_name() == "orders") + trigger = next( + binding for binding in http.get_bindings_dict()["bindings"] + if binding["type"] == "httpTrigger" + ) + assert trigger["authLevel"] == auth + assert http.get_user_function()(None).get_body() == b"ok" + assert app._durable_app.auth_level == auth + + +@pytest.mark.parametrize("name", ["dafx-Orders", "DAFX-ORDERS"]) +def test_cross_registry_collision_is_rejected_on_every_index(app, name): + @app.function_name(name=name) + @app.route(route="collision") + def collision(req): + return func.HttpResponse("ok") + + app.add_durable_agent(make_agent()) + for _ in range(2): + with pytest.raises(ValueError, match="Duplicate function name"): + app.get_functions() + assert not app._functions_indexed + + +def test_sdk_builtin_names_cannot_be_shadowed(app, tmp_path): + app.add_durable_agent(make_agent()) + # Derive the SDK-owned names rather than duplicating a hard-coded list. + sdk_functions = app._durable_app.get_functions() + builtins = [fn for fn in sdk_functions if fn.get_function_name() != "dafx-Orders"] + assert builtins + for index, builtin in enumerate(builtins): + candidate = AgentFunctionApp( + client_factory=RecordingClient, app_root=tmp_path + ) + candidate.add_durable_agent(make_agent()) + + @candidate.function_name(name=builtin.get_function_name()) + @candidate.route(route=f"collision/{index}") + def collision(req): + return func.HttpResponse("ok") + + with pytest.raises(ValueError, match="Duplicate function name"): + candidate.get_functions() + + +def test_existing_activity_path_does_not_create_dafx(app): + @app.orchestration_trigger(context_name="context") + def orchestrator(context): + yield context.call_agent("orders", "hello") + + names = {fn.get_function_name() for fn in app.get_functions()} + assert names == {"orchestrator", "azurefunctions_agents_run_markdown_agent"} + assert app._durable_app is None + + +def _execute_entity(handler, entity_id, request, state): + """Invoke the indexed SDK handler using the host's protobuf wire format.""" + batch = pb.EntityBatchRequest( + instanceId=str(entity_id), + operations=[pb.OperationRequest( + operation="run", input=StringValue(value=json.dumps(request)) + )], + ) + if state is not None: + batch.entityState.CopyFrom(StringValue(value=state)) + transport = func.EntityContext(base64.b64encode(batch.SerializeToString())) + encoded_result = handler(transport) + result = pb.EntityBatchResult.FromString(base64.b64decode(encoded_result)) + assert not result.HasField("failureDetails"), result + assert len(result.results) == 1 + operation = result.results[0] + assert operation.HasField("success"), operation + return json.loads(operation.success.result.value), result.entityState.value + + +def test_proxy_runs_indexed_entity_and_restores_session_between_turns(app): + client = RecordingClient() + app.add_durable_agent(make_agent(client=client)) + entity = next( + fn for fn in app.get_functions() if fn.get_function_name() == "dafx-Orders" + ) + handler = entity.get_user_function() + scheduled = [] + + def call_entity(entity_id, operation, input_=None): + assert operation == "run" + task = CompletableTask() + scheduled.append((entity_id, input_, task)) + return task + + # Only the scheduler is replaced. Use the SDK wrapper the PR receives, + # its context proxy, real DAFX tasks, and the real indexed entity handler. + scheduler = Mock(instance_id="workflow-1") + scheduler.new_uuid.side_effect = [str(uuid.UUID(int=n)) for n in range(1, 5)] + scheduler.call_entity.side_effect = call_entity + context = DurableAgentContext(DurableOrchestrationContext(scheduler)) + agent = app.get_agent(context, "Orders") + session = agent.create_session() + state = None + + for turn, prompt in enumerate(["first", "second"], start=1): + task = agent.run(prompt, session=session) + assert not task.is_complete + entity_id, request, pending = scheduled[-1] + response, state = _execute_entity(handler, entity_id, request, state) + pending.complete(response) + result = task.get_result() + assert isinstance(result, AgentResponse) + assert result.text == f"reply-{turn}" + + assert scheduled[0][0] == scheduled[1][0] + assert client.inputs == [["first"], ["first", "reply-1", "second"]] + assert state diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py index 66ea183..4822c35 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -1,5 +1,8 @@ import subprocess import sys +import textwrap + +import pytest def test_framework_exports_supported_api(): @@ -36,3 +39,85 @@ def test_framework_import_does_not_import_durable(): ) assert result.returncode == 0, result.stderr + + +def test_non_durable_binding_runs_with_all_durable_imports_blocked(tmp_path): + (tmp_path / "orders.agent.md").write_text("Handle orders.", encoding="utf-8") + script = textwrap.dedent("""\ + import asyncio + import importlib.abc + import sys + + blocked = ( + 'agent_framework_azurefunctions', + 'agent_framework_durabletask', + 'azure.durable_functions', + 'durabletask', + ) + + class BlockDurable(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if any(fullname == name or fullname.startswith(name + '.') + for name in blocked): + raise ModuleNotFoundError(name=fullname) + + sys.meta_path.insert(0, BlockDurable()) + import azure.functions as func + from agent_framework import Agent, BaseChatClient + from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + class LocalClient(BaseChatClient): + def _inner_get_response(self, **kwargs): + raise AssertionError('No model calls are expected') + + app = AgentFunctionApp(client_factory=LocalClient, app_root=sys.argv[1]) + + @app.route(route='orders') + @app.markdown_agent(arg_name='agent', agent_name='orders') + async def orders(req: func.HttpRequest, agent: Agent): + return func.HttpResponse(agent.name) + + indexed = app.get_functions() + assert [f.get_function_name() for f in indexed] == ['orders'] + response = asyncio.run(indexed[0].get_user_function()( + func.HttpRequest(method='GET', url='http://localhost/orders', body=b'') + )) + assert response.get_body() == b'orders' + assert app._durable_app is None + assert not any(module == name or module.startswith(name + '.') + for module in sys.modules for name in blocked) + """) + result = subprocess.run( + [sys.executable, "-c", script, str(tmp_path)], + check=False, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("missing", ["agent_framework_azurefunctions", "grpc"]) +def test_dafx_import_errors_are_actionable_without_hiding_broken_installs( + monkeypatch, tmp_path, missing +): + import builtins + from types import SimpleNamespace + + from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) + original_import = builtins.__import__ + + def fail_dafx_import(name, *args, **kwargs): + if name == "agent_framework_azurefunctions": + raise ModuleNotFoundError(f"No module named {missing!r}", name=missing) + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fail_dafx_import) + with pytest.raises(ImportError) as caught: + app.add_durable_agent(SimpleNamespace(name="Orders")) + if missing == "agent_framework_azurefunctions": + assert "[durable]" in str(caught.value) + assert isinstance(caught.value.__cause__, ModuleNotFoundError) + else: + assert isinstance(caught.value, ModuleNotFoundError) + assert caught.value.name == "grpc" + assert app._durable_app is None diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py index 5976d99..b3126d1 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -168,3 +168,23 @@ def test_agent_framework_sample_assets_follow_discovery_conventions(): 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() + + +def test_lazy_owned_dafx_sample_indexes_both_registries(): + completed = subprocess.run( + [sys.executable, "-c", ( + "import json; import function_app; " + "first = function_app.app.get_functions(); " + "second = function_app.app.get_functions(); " + "assert [f.get_function_name() for f in first] == " + "[f.get_function_name() for f in second]; " + "print(json.dumps([f.get_function_name() for f in first]))" + )], + cwd=_SAMPLES_ROOT / "lazy-owned-dafx", + check=True, capture_output=True, text=True, + ) + assert set(json.loads(completed.stdout)) == { + "hello", "orders", "start_orders", "dafx-Orders", + "azurefunctions_agents_run_markdown_agent", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + } diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index 569a580..55da262 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,mcp] + python -m pip install -U -e .[dev,durable,mcp] displayName: 'Install Agents Framework Dependencies' - bash: | python -m pytest -q --instafail azurefunctions-agents-extensions-agent-framework/tests/ From 923e77ff9f182513c374c5046d8362b0876c2baa Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Wed, 9 Sep 2026 17:36:31 -0500 Subject: [PATCH 26/30] Host durable markdown agents through discovery and bindings --- .../README.md | 76 ++- .../extensions/agent_framework/__init__.py | 3 - .../extensions/agent_framework/_durable.py | 95 ++++ .../agents/extensions/agent_framework/apps.py | 149 +++++- .../samples/README.md | 27 +- .../README.md | 105 +++-- .../function_app.py | 42 +- .../durable-markdown-binding/README.md | 47 ++ .../agents/orders.agent.md | 3 + .../durable-markdown-binding/function_app.py | 34 ++ .../durable-markdown-binding/host.json | 7 + .../local_chat_client.py | 46 ++ .../samples/lazy-owned-dafx/README.md | 72 ++- .../samples/lazy-owned-dafx/VALIDATION.md | 101 ++-- .../samples/lazy-owned-dafx/function_app.py | 61 +-- .../lazy-owned-dafx/local_chat_client.py | 46 ++ .../samples/lazy-owned-dafx/orders.agent.md | 3 + .../tests/test_apps.py | 29 +- .../tests/test_dafx.py | 27 +- .../tests/test_durable_markdown.py | 444 ++++++++++++++++++ .../tests/test_imports.py | 3 +- .../tests/test_provider.py | 2 +- .../tests/test_samples.py | 386 ++++++++++----- .../README.md | 24 +- .../agents/extensions/base/__init__.py | 40 +- .../agents/extensions/base/bindings.py | 58 ++- .../agents/extensions/base/durable.py | 276 ----------- .../tests/test_durable.py | 290 ------------ .../tests/test_imports.py | 5 +- 29 files changed, 1504 insertions(+), 997 deletions(-) create mode 100644 azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_durable.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/agents/orders.agent.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/host.json create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/local_chat_client.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/local_chat_client.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/orders.agent.md create mode 100644 azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py delete mode 100644 azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py delete mode 100644 azurefunctions-agents-extensions-base/tests/test_durable.py diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index ca81fc7..a37f43d 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -131,33 +131,69 @@ 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 constructor and decorator expose only `client_factory` and explicit Python -`tools` in V1. The extension owns the Agent client, name, instructions, and +The normal markdown binding accepts `client_factory` and explicit Python +`tools` overrides. 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 -This prototype also supports an explicit DAFX path through -`add_durable_agent()` and `get_agent()`. See the -[lazy-owned DAFX example](samples/lazy-owned-dafx/README.md) for the design, -SDK 2 dependency pins, and test instructions. It does not change the -activity-based API described below. - -Durable orchestration support is optional: +Durable support is optional. This prototype pins both DAFX packages to +[DAFX PR #72](https://github.com/microsoft/agent-framework-durable-extension/pull/72) +at `aa9529ec489e16ac64b73bd68d5adbb8e4945258` for SDK 2 compatibility. +These Git dependencies are for local prototyping, not a PyPI release. ```text pip install "azurefunctions-agents-extensions-agent-framework[durable]" ``` -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; using a Durable decorator requires the `[durable]` -extra. - -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 -secrets. +Set `durable=True` to discover every `.agent.md` file directly in the app root +or its `agents/` directory. Each discovered agent gets a DAFX entity and an +automatic `POST /api/agents/{name}/run` endpoint with the default HTTP route +prefix. No handwritten HTTP function or orchestrator is required. + +This explicitly publishes every discovered definition. Durable names must start +with an ASCII letter or digit and contain only ASCII letters, digits, hyphens, and +underscores. Ambiguous definitions and generated function-name collisions fail +rather than silently selecting an agent. + +```python +app = AgentFunctionApp(client_factory=create_chat_client, durable=True) +``` + +For orchestration, place `durable_markdown_agent` below `orchestration_trigger` +on a synchronous generator. The binding registers the selected markdown agent +and its HTTP endpoint even without `durable=True`. The injected object is a +DAFX proxy, not a live Agent. Yield its tasks and share a session across turns. + +```python +app = AgentFunctionApp(client_factory=create_chat_client) + + +@app.orchestration_trigger(context_name="context") +@app.durable_markdown_agent( + arg_name="agent", agent_name="orders", context_name="context" +) +def orders(context, agent): + session = agent.create_session() + assessment = yield agent.run("Assess the order.", session=session) + plan = yield agent.run("Make a fulfillment plan.", session=session) + return {"assessment": assessment.text, "plan": plan.text} +``` + +Registration compiles recipes without constructing clients. At entity execution, +the lifecycle adapter enters the compiled binding's `open_agent()` context and +closes it after the run. Each execution creates fresh clients and tools; DAFX +restores conversation history from durable session state. Orchestrators keep the +native SDK context. The old `context.call_agent()` activity path is replaced by +the injected proxy. + +Normal `markdown_agent()` remains invocation-scoped and unchanged. Without a +durable opt-in, it does not create an inner DAFX app. With durable agents, the +outer app indexes both registries, including the SDK's `BuiltIn__HttpActivity` +and `BuiltIn__HttpPollOrchestrator`. Agent HTTP endpoints are enabled; health +and MCP endpoints are disabled. + +See the [endpoint-only local sample](samples/lazy-owned-dafx/README.md) and the +[durable binding sample](samples/durable-markdown-binding/README.md) for setup +and deterministic examples that do not need a model service. 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 e1eefcd..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 @@ -1,5 +1,3 @@ -from azurefunctions.agents.extensions.base.durable import DurableAgentContext - from .apps import AgentFunctionApp from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory @@ -7,7 +5,6 @@ "AGENT_FRAMEWORK_PROVIDER_ID", "AgentFunctionApp", "ClientFactory", - "DurableAgentContext", ] __version__ = '1.0.0b1' diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_durable.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_durable.py new file mode 100644 index 0000000..eb0d511 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_durable.py @@ -0,0 +1,95 @@ +"""Execution-time adapter between markdown recipes and DAFX agent entities.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Awaitable, Sequence +from typing import Any, Literal, overload + +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + AgentSession, + ResponseStream, +) + +from azurefunctions.agents.extensions.base import InvocationMetadata + +from .provider import AgentFrameworkBinding + + +class MarkdownDurableAgent: + """Register a recipe, not a live client, with DAFX. + + Every entity run opens a fresh agent and resources on the execution loop. + No clients, MCP connections, or per-run session objects are cached here. + """ + + def __init__(self, binding: AgentFrameworkBinding) -> None: + self._binding = binding + self.name: str | None = binding.agent_name + self.id = f"markdown:{self.name}" + self.description: str | None = None + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + return AgentSession(session_id=session_id) + + def get_session( + self, service_session_id: Any, *, session_id: str | None = None + ) -> AgentSession: + return AgentSession( + service_session_id=service_session_id, session_id=session_id + ) + + @overload + def run( + self, messages: Any = None, *, stream: Literal[False] = False, + session: AgentSession | None = None, **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: + ... + + @overload + def run( + self, messages: Any = None, *, stream: Literal[True], + session: AgentSession | None = None, **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + ... + + def run( + self, messages: Any = None, *, stream: bool = False, + session: AgentSession | None = None, **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[ + AgentResponseUpdate, AgentResponse[Any] + ]: + invocation = InvocationMetadata(function_name=f"dafx-{self.name}") + + async def invoke() -> AgentResponse[Any]: + async with self._binding.open_agent(invocation) as agent: + response = await agent.run(messages, session=session, **kwargs) + if not isinstance(response, AgentResponse): + raise TypeError("A durable agent must return AgentResponse.") + return response + + if not stream: + return invoke() + + final: AgentResponse[Any] | None = None + + async def updates() -> AsyncGenerator[AgentResponseUpdate, None]: + nonlocal final + async with self._binding.open_agent(invocation) as agent: + inner = agent.run(messages, stream=True, session=session, **kwargs) + async for update in inner: + yield update + # Finalization can run provider hooks. Keep resources alive until + # it completes and preserve the complete response, not just text. + final = await inner.get_final_response() + + def finalize(_: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: + if final is None: + raise RuntimeError("The durable agent stream did not complete.") + return final + + iterator = updates() + return ResponseStream( + iterator, finalizer=finalize, cleanup_hooks=[iterator.aclose] + ) 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 cbd0ef9..63065d3 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 @@ -1,20 +1,24 @@ from __future__ import annotations +import functools +import inspect import os +import re from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, cast import azure.functions as func from agent_framework import SupportsAgentRun, ToolTypes from azure.functions.decorators.function_app import Function from azurefunctions.agents.extensions.base import ( + compile_agent, configure_app, - durable_orchestration_trigger, + discover_agent_names, ) from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent -from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory +from .provider import AGENT_FRAMEWORK_PROVIDER_ID, AgentFrameworkBinding, ClientFactory if TYPE_CHECKING: from agent_framework_azurefunctions import ( @@ -82,12 +86,16 @@ def __init__( | None ) = None, http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, + durable: bool = False, ) -> None: + if not isinstance(durable, bool): + raise TypeError("durable must be a bool") super().__init__( http_auth_level=http_auth_level, ) self._durable_app: DurableAgentFunctionApp | None = None self._functions_indexed = False + self._markdown_agents: dict[str, str] = {} configure_app( self, provider=AGENT_FRAMEWORK_PROVIDER_ID, @@ -97,6 +105,109 @@ def __init__( tools=tools, ), ) + if durable: + # Validate/compile the complete discovery set before registering any + # endpoints. Compilation creates recipes, not clients or live agents. + bindings = [ + self._compile_durable_markdown(name) + for name in discover_agent_names(self) + ] + self._ensure_durable_app() + for binding in bindings: + self._register_durable_markdown(binding) + + def _compile_durable_markdown(self, name: str) -> AgentFrameworkBinding: + # The name is also used in an HTTP route and a Durable Entity ID, not + # only a filename. Reject route placeholders and entity-ID separators. + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", name) is None: + raise ValueError( + "Durable agent names must start with a letter or digit and " + "contain only ASCII letters, digits, hyphens, and underscores." + ) + compiled = compile_agent(self, name) + if not isinstance(compiled, AgentFrameworkBinding): + raise TypeError("Durable markdown agents require the MAF provider.") + return compiled + + def _register_durable_markdown(self, binding: AgentFrameworkBinding) -> None: + from ._durable import MarkdownDurableAgent + + self.add_durable_agent(MarkdownDurableAgent(binding)) + self._markdown_agents[binding.agent_name.casefold()] = binding.agent_name + + def durable_markdown_agent( + self, + *, + arg_name: str, + agent_name: str, + context_name: str = "context", + ) -> Callable[[_F], _F]: + """Declare a durable markdown agent and inject its orchestration proxy. + + Apply below orchestration_trigger, above a synchronous generator. The + declaration also publishes DAFX's default agent HTTP endpoint. + """ + if not isinstance(agent_name, str) or not agent_name.strip(): + raise ValueError("agent_name must be a non-empty string") + + def decorate(handler: _F) -> _F: + if self._functions_indexed: + raise RuntimeError("Declare durable agents before function indexing.") + if not inspect.isgeneratorfunction(handler): + raise TypeError( + "durable_markdown_agent requires a synchronous generator " + "below orchestration_trigger." + ) + signature = inspect.signature(handler) + parameter = signature.parameters.get(arg_name) + if parameter is None or parameter.kind not in { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + }: + raise TypeError(f"Invalid injected agent parameter {arg_name!r}.") + context_parameter = signature.parameters.get(context_name) + if arg_name == context_name or context_parameter is None: + raise TypeError(f"Missing distinct context parameter {context_name!r}.") + visible = signature.replace(parameters=[ + item for name, item in signature.parameters.items() if name != arg_name + ]) + parameters = list(visible.parameters.values()) + if ( + not parameters or parameters[0].name != context_name + or len(parameters) > 2 + or any(p.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD + for p in parameters) + ): + raise TypeError( + "The orchestrator must accept context first and optionally input." + ) + existing_context = getattr(handler, "_durable_agent_context_name", None) + if existing_context is not None and existing_context != context_name: + raise TypeError("Durable bindings must use the same context_name.") + + if agent_name.casefold() in self._markdown_agents: + if self._markdown_agents[agent_name.casefold()] != agent_name: + raise ValueError(f"Ambiguous agent name {agent_name!r}.") + else: + self._register_durable_markdown( + self._compile_durable_markdown(agent_name) + ) + + @functools.wraps(handler) + def inject(*args: Any, **kwargs: Any) -> Any: + bound = visible.bind(*args, **kwargs) + bound.apply_defaults() + bound.arguments[arg_name] = self.get_agent( + bound.arguments[context_name], agent_name + ) + call = inspect.BoundArguments(signature, bound.arguments) + return (yield from handler(*call.args, **call.kwargs)) + + inject.__signature__ = visible # type: ignore[attr-defined] + setattr(inject, "_durable_agent_context_name", context_name) + return cast(_F, inject) + + return decorate def add_durable_agent(self, agent: SupportsAgentRun) -> None: """Opt in to DAFX by registering an agent before function indexing. @@ -135,7 +246,7 @@ def _ensure_durable_app(self) -> DurableAgentFunctionApp: self._durable_app = DurableAgentFunctionApp( http_auth_level=self.auth_level, enable_health_check=False, - enable_http_endpoints=False, + enable_http_endpoints=True, enable_mcp_tool_trigger=False, ) return self._durable_app @@ -147,7 +258,9 @@ def get_agent( ) -> DurableAIAgent[DurableAgentTask]: """Get a DAFX proxy without registering functions during execution.""" if self._durable_app is None: - raise RuntimeError("Call add_durable_agent() during app configuration.") + raise RuntimeError( + "Enable durable=True or declare a durable markdown agent." + ) return self._durable_app.get_agent(context, agent_name) def get_functions(self) -> list[Function]: @@ -179,10 +292,22 @@ def orchestration_trigger( 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, - ) + # Keep the native SDK context and task semantics; no hidden activity or + # custom call_agent context wrapper is installed. + sdk = super().orchestration_trigger + options: dict[str, Any] = { + "context_name": context_name, "orchestration": orchestration, + } + if input_type is not None: + if "input_type" not in inspect.signature(sdk).parameters: + raise TypeError("The installed SDK does not support input_type.") + options["input_type"] = input_type + decorator = sdk(**options) + + def decorate(handler: _F) -> Any: + declared_context = getattr(handler, "_durable_agent_context_name", None) + if declared_context is not None and declared_context != context_name: + raise TypeError("Binding and trigger context_name must match.") + return decorator(handler) + + return decorate diff --git a/azurefunctions-agents-extensions-agent-framework/samples/README.md b/azurefunctions-agents-extensions-agent-framework/samples/README.md index 8eb2699..e7bc66e 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/README.md @@ -14,8 +14,9 @@ urlFragment: extension-agent-framework-samples # Azure Functions Microsoft Agent Framework Extension for Python samples 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. +Agents in Python Function Apps. All samples use raw `.agent.md` instructions. +The first two use explicit Microsoft Foundry client factories, while the local +examples use deterministic clients without model credentials. * [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 @@ -24,22 +25,33 @@ and an explicit Microsoft Foundry client factory. * [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 + * Inject a durable markdown agent and run two turns in one shared session * Combine deterministic activity output with model-generated results +* [Endpoint-only local agent](lazy-owned-dafx/README.md) uses `durable=True` + discovery and the generated DAFX HTTP endpoint. No handwritten handlers or + model credentials are needed. +* [Durable markdown binding](durable-markdown-binding/README.md) injects a proxy + into a generator orchestrator, runs two turns in one session, and includes an + HTTP starter. It also uses a deterministic local client. + ## 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. +* The Foundry samples require 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. +* Durable samples require the prototype's SDK 2-compatible DAFX dependencies + and a compatible Functions host/backend to run under Core Tools. They do not + depend on the Azure Functions Agents runtime. Follow the + [prototype setup steps](lazy-owned-dafx/README.md#install-and-verify) first. ## 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: +4. For a Foundry sample, sign in with an identity authorized to use your Microsoft Foundry project. For example: ```bash az login @@ -47,6 +59,9 @@ az login ## Running the samples +The following steps apply to the Foundry samples. For the local-client examples, +follow the linked README for its installation steps, settings, and HTTP requests. + 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. @@ -70,4 +85,4 @@ func start 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 +[Microsoft Agent Framework documentation](https://learn.microsoft.com/agent-framework/). 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 index 317fc73..aaa7c43 100644 --- 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 @@ -15,17 +15,18 @@ urlFragment: agent-framework-durable-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 +network work runs outside replay. An ordinary activity prepares the order, and +DAFX entities execute the Agent calls. + +The sample demonstrates + +- starting an orchestration from an HTTP-triggered Function +- validating and minimizing an order in an ordinary Durable activity +- injecting a DAFX proxy with `durable_markdown_agent` below + `orchestration_trigger` +- yielding `agent.run()` tasks from a synchronous generator orchestrator +- sharing one durable session between assessment and planning +- passing the prepared order as JSON and returning the responses' text - polling the standard Durable management endpoint for status and output. ## How the sample works @@ -36,29 +37,34 @@ The request follows this sequence: `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. +3. The injected Agent proxy creates a session. The first `agent.run()` schedules + an assessment of the prepared order through the DAFX entity. +4. A second `agent.run()` requests a fulfillment plan using the same session and + the assessment's text. 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. +a model, or performs network I/O. During replay it recreates the activity and +Agent task 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()`. +The binding registers the selected markdown definition and enables its Agent +HTTP endpoint without `durable=True`. Clients are created and closed per entity +execution through the compiled markdown binding, not during indexing or replay. +No custom orchestration context wrapper or hidden Agent activity is used. + ## 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. | +| `order-fulfillment.agent.md` | Contains raw instructions used by both Agent turns. | | `local.settings.template.json` | Lists required local application settings. | | `requirements.txt` | Installs the extension with Durable support and sample dependencies. | @@ -68,7 +74,9 @@ Foundry client and model configuration remain explicit in - [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. + control queues, activity work items, and entity state. +- The prototype's SDK 2-compatible DAFX packages and a compatible Functions + host/extension and Durable backend. These are not provisioned by this sample. - 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. @@ -93,14 +101,18 @@ Foundry client and model configuration remain explicit in 3. Install the dependencies: + First follow the [prototype setup steps](../lazy-owned-dafx/README.md#install-and-verify) + to install both local extension packages and the pinned SDK 2-compatible DAFX + dependencies in the same virtual environment. Then, from this sample directory, + install the Foundry and order-validation 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]`. + extension from this repository with its `[durable]` extra. Use the prototype + dependencies rather than substituting the published SDK 1.x DAFX packages. 4. Create local settings from the template: @@ -132,7 +144,7 @@ Foundry client and model configuration remain explicit in 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: + directory and start the Functions host: ```bash func start @@ -183,24 +195,38 @@ Malformed JSON returns HTTP `400` and does not start an orchestration: 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 +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 +- `@app.durable_markdown_agent` sits below `@app.orchestration_trigger` and + injects a `DurableAIAgent[DurableAgentTask]` proxy into the generator. +- `agent.create_session()` creates one session that both `agent.run()` calls + reuse. DAFX stores conversation history in durable session state. +- Each Agent call receives a JSON string containing the trusted prepared order. + The planning request also includes `assessment.text`. +- Agent execution and all related I/O occur in the DAFX entity, 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. +- The extension compiles the Agent recipe during registration, then creates and + closes a fresh Foundry client and Agent for each entity execution. +- The output contains only the order ID, `assessment.text`, and `plan.text`. + +### Direct Agent endpoint + +This sample's `host.json` removes the default `api` prefix. The binding also +publishes `POST /agents/order-fulfillment/run`: + +```bash +curl -X POST http://localhost:7071/agents/order-fulfillment/run \ + -H "Content-Type: application/json" \ + -d '{"message":"Describe the fulfillment review process.","session_id":"order-demo"}' +``` + +Reuse the `session_id` to continue that conversation. This direct route accepts +a message and bypasses the order-preparation activity. Use the orchestration +route above for the validated order flow. Include a function key when invoking +the Agent endpoint on a hosted app. ## Troubleshooting @@ -209,13 +235,14 @@ failure. - **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. + installed, the SDK 2-compatible host/extension is available, 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 +- **Agent execution fails:** inspect the Functions host logs and the instance status response for Foundry authentication, quota, or model errors. ## Next steps @@ -223,4 +250,6 @@ failure. - 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. +- Try the [durable markdown binding sample](../durable-markdown-binding/README.md) + for the same shared-session pattern with a deterministic local client. - 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/agent_samples_agent-framework_durable/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py index f3913f1..98ef8b7 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,13 +1,10 @@ import json import os -from datetime import timedelta import azure.durable_functions as df import azure.functions as func -from azurefunctions.agents.extensions.agent_framework import ( - AgentFunctionApp, - DurableAgentContext, -) +from agent_framework_durabletask import DurableAgentTask, DurableAIAgent +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp from order_processing import prepare_order_for_agent @@ -62,33 +59,36 @@ def prepare_order_activity(order: dict) -> dict[str, object]: @app.orchestration_trigger(context_name="context") -def order_orchestrator(context: DurableAgentContext): +@app.durable_markdown_agent( + arg_name="agent", agent_name="order-fulfillment", context_name="context" +) +def order_orchestrator( + context: df.DurableOrchestrationContext, + agent: DurableAIAgent[DurableAgentTask], +): prepared_order = yield context.call_activity( "prepare_order_activity", context.get_input(), ) - assessment = yield context.call_agent( - "order-fulfillment", - { + session = agent.create_session() + assessment = yield agent.run( + json.dumps({ "order": prepared_order, "task": "assess fulfillment risk using the trusted calculated fields", - }, + }), + session=session, ) - plan = yield context.call_agent( - "order-fulfillment", - { + plan = yield agent.run( + json.dumps({ "order": prepared_order, - "risk_assessment": assessment, + "risk_assessment": assessment.text, "task": "create a fulfillment plan with prioritized human-review actions", - }, - retry_options=df.RetryPolicy( - first_retry_interval=timedelta(seconds=5), - max_number_of_attempts=3, - ), + }), + session=session, ) return { "order_id": prepared_order["order_id"], - "risk_assessment": assessment, - "fulfillment_plan": plan, + "risk_assessment": assessment.text, + "fulfillment_plan": plan.text, } diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md new file mode 100644 index 0000000..7a2d83a --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md @@ -0,0 +1,47 @@ +# Durable markdown binding + +This local example places `durable_markdown_agent` below `orchestration_trigger` +on a synchronous generator. The binding selects `agents/orders.agent.md`, +registers its DAFX entity and HTTP endpoint, and injects an orchestration proxy. +It does not require `durable=True` or explicit agent instance registration. + +The orchestrator creates one session and yields two `agent.run()` tasks with +that session. The deterministic client counts user messages in the restored +history, so the output is: + +```json +{ + "assessment": "User turn 1: Assess the order.", + "plan": "User turn 2: Make a fulfillment plan." +} +``` + +The same small `LocalChatClient` helper is included in each local sample so +either directory can be run on its own. There are no model credentials or +network calls in the client. Registration compiles recipes; each entity run +opens and closes a fresh Agent through `open_agent()`. Session history belongs +to DAFX, not the client instance or orchestrator process. + +## Run locally + +Follow the [endpoint-only sample's installation steps](../lazy-owned-dafx/README.md#install-and-verify). +Both samples use the optional dependencies pinned to DAFX PR #72. Running under +Core Tools requires an SDK 2-compatible Functions host/extension and configured +Durable backend, which this sample does not provision. Set +`FUNCTIONS_WORKER_RUNTIME=python` and `AzureWebJobsStorage`, then run `func start` +from this directory. + +```bash +curl -X POST http://localhost:7071/api/orders/orchestrations +``` + +The HTTP starter returns a check-status response. Follow its status URL to read +the orchestration output. The starter uses fixed prompts and ignores the request +body. Each orchestration creates a new session. + +The binding also enables `POST /api/agents/orders/run`. Send JSON with `message` +and `session_id` to use the agent directly instead of starting the orchestration. +Add a function key when calling a hosted app. + +For automatic registration of all root and `agents/` markdown files without +handwritten functions, see the [endpoint-only sample](../lazy-owned-dafx/README.md). \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/agents/orders.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/agents/orders.agent.md new file mode 100644 index 0000000..ea7a90a --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/agents/orders.agent.md @@ -0,0 +1,3 @@ +You are an order fulfillment assistant. +Assess the order, then propose a concise fulfillment plan. +Never claim that an external action completed without a confirming tool result. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py new file mode 100644 index 0000000..a835ae7 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py @@ -0,0 +1,34 @@ +"""Inject a durable markdown agent into a two-turn generator orchestrator.""" + +import azure.durable_functions as df +import azure.functions as func +from agent_framework_durabletask import DurableAgentTask, DurableAIAgent +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from local_chat_client import LocalChatClient + +# The binding below opts in only the selected agent, without durable=True. +app = AgentFunctionApp(client_factory=LocalChatClient) + + +@app.orchestration_trigger(context_name="context") +@app.durable_markdown_agent( + arg_name="agent", agent_name="orders", context_name="context" +) +def orders( + context: df.DurableOrchestrationContext, + agent: DurableAIAgent[DurableAgentTask], +): + session = agent.create_session() + first = yield agent.run("Assess the order.", session=session) + second = yield agent.run("Make a fulfillment plan.", session=session) + return {"assessment": first.text, "plan": second.text} + + +@app.route(route="orders/orchestrations", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_orders( + req: func.HttpRequest, client: df.DurableFunctionsClient +) -> func.HttpResponse: + # Fixed prompts keep this example focused on durable session continuity. + instance_id = await client.start_new("orders", client_input={}) + return client.create_check_status_response(req, instance_id) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/host.json b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/host.json new file mode 100644 index 0000000..55d1642 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/local_chat_client.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/local_chat_client.py new file mode 100644 index 0000000..71fa95b --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/local_chat_client.py @@ -0,0 +1,46 @@ +"""A deterministic model substitute with no credentials or network resources.""" + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import Any + +from agent_framework import ( + BaseChatClient, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + ResponseStream, +) + + +class LocalChatClient(BaseChatClient): + """Count user messages in the supplied history and echo the latest prompt.""" + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ): + turns = sum(message.role == "user" for message in messages) + text = f"User turn {turns}: {messages[-1].text}" + created_at = datetime.now(timezone.utc).isoformat() + + async def updates(): + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text(text)], + created_at=created_at, + ) + + async def respond(): + return ChatResponse( + messages=[Message(role="assistant", contents=[text])], + created_at=created_at, + ) + + if stream: + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + return respond() diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md index dd292d4..c534161 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md @@ -1,15 +1,20 @@ -# Lazy-owned DAFX prototype +# Endpoint-only durable markdown agent -This branch explores app composition, not a replacement of `context.call_agent()`. -The bindings `AgentFunctionApp` remains the only worker-indexed app. Calling -`add_durable_agent()` creates a private DAFX app and registers an entity there. -The outer `get_functions()` combines both registries and rejects name collisions. -`get_agent()` delegates to DAFX without creating functions during execution. +This sample sets `durable=True` on `AgentFunctionApp` and supplies +`orders.agent.md`. Discovery registers the agent's DAFX entity and +`POST /api/agents/orders/run` endpoint. There are no handwritten HTTP functions, +orchestrators, or agent instance registrations. -The example has a normal HTTP function and a two-turn durable orchestration. -It uses a deterministic local chat client, so no model credentials are needed. -The two turns explicitly share a session. Caller-owned registered agents do not -use the markdown binding's per-invocation client/tool lifecycle. +Every `.agent.md` file directly in the app root or `agents/` is discovered. +Indexing compiles recipes without constructing clients. Each entity execution +opens and closes a fresh Agent through the compiled binding's `open_agent()` +lifecycle. DAFX stores conversation history separately in durable session state. + +`LocalChatClient` counts user messages and echoes the latest prompt. It makes +no model calls and needs no model credentials. It is kept in a standalone module +so tests can import it without indexing the app. For a generator orchestrator +with an injected proxy, see the +[durable binding sample](../durable-markdown-binding/README.md). ## Install and verify @@ -28,23 +33,44 @@ The published DAFX packages currently require SDK 1.x and cannot satisfy this PR's SDK 2.x requirements. These Git dependencies are for local prototyping, not for publishing this package to PyPI. Normal installs do not install DAFX. -The tests invoke the SDK's indexed entity handler with the protobuf request and -response format used by the Functions host. They round-trip entity state between -two turns and complete real DAFX tasks. The scheduler and model service are local -test substitutes. This is not a deployed Functions host or storage integration test. +Run only the sample tests with: + +```powershell +python -m pytest -q azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +``` + +The tests exercise indexing and local entity execution. They do not replace a +deployed Functions host or storage integration test. See +[VALIDATION.md](VALIDATION.md) for the current verification results and limitations. + +## Run locally Running the example under Core Tools additionally requires the Python SDK 2 compatible Functions host/extension and a configured Durable backend. Those are -not provisioned by this sample. POST `/api/orders` to start it and follow the -returned status URL. GET `/api/hello` exercises the normal HTTP path. +not provisioned by this sample. Set `FUNCTIONS_WORKER_RUNTIME=python` and +`AzureWebJobsStorage` for your backend, then run `func start` from this directory. + +```bash +curl -X POST http://localhost:7071/api/agents/orders/run \ + -H "Content-Type: application/json" \ + -d '{"message":"Assess the order.","session_id":"orders-demo"}' +``` + +Send another request with the same `session_id` to continue the conversation. +The local client responds with `User turn 1: Assess the order.` on the first +turn and counts subsequent turns from the restored history. Use a new session +ID to start over. Add a function key when calling a hosted app. ## Boundaries - No DAFX import, inner app, or entity registration on the non-durable path. -- Register all durable agents before indexing. Late registration is rejected. -- Re-registering the same instance is harmless. Different agents with the same - case-insensitive name are rejected rather than silently shadowed. -- DAFX's generated agent HTTP, health, and MCP endpoints are disabled. The SDK's - built-in durable HTTP activity/orchestrator remain registered. -- Existing `markdown_agent()` and activity-based `context.call_agent()` are - unchanged. Markdown-to-DAFX factory/lifecycle adaptation is not implemented. \ No newline at end of file +- Declare durable agents before indexing. Ambiguous markdown names fail instead + of silently selecting a file. +- Durable markdown names start with an ASCII letter or digit and contain only + ASCII letters, digits, hyphens, and underscores. These names become routes and + entity identifiers, not just filenames. +- The outer app remains the only worker-indexed app and combines both registries. +- Agent HTTP endpoints are enabled. Health and MCP endpoints are disabled. The + SDK's built-in durable HTTP activity/orchestrator remain registered. +- Normal `markdown_agent()` is unchanged. Durable orchestrators use the new + binding and yield proxy tasks instead of calling `context.call_agent()`. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md index c3ba902..9465cd2 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md @@ -1,61 +1,62 @@ -# Prototype verification +# Durable markdown prototype verification -Verified on Windows with Python 3.13.11 on 2026-09-09. Branch base is extensions -PR #185 at `db2526586348513ff86ed2c61ffc685815a8d212`. DAFX dependencies are pinned -to PR #72 at `aa9529ec489e16ac64b73bd68d5adbb8e4945258`. +Verified on Windows with Python 3.13.11 on 2026-09-09. This revision replaces +the explicit-registration example at `5d77570`. Branch base is extensions +PR #185 at `db2526586348513ff86ed2c61ffc685815a8d212`. Both DAFX packages remain +pinned to PR #72 at `aa9529ec489e16ac64b73bd68d5adbb8e4945258`. ## Results | Configuration | Result | | --- | --- | -| Original PR, Functions 2.3.0, Durable 2.0.0rc1, core 1.16.0 | 69 passed | -| Prototype, same SDK/core versions, DAFX PR #72 | 96 passed | -| Prototype, Durable 2.0.0b2, core 1.16.0 | 96 passed | -| Prototype, Durable 2.0.0b2, core 1.13.0 | 96 passed | -| Fresh normal install without Durable/DAFX packages | 5 import tests passed | +| Functions 2.3.0, Durable 2.0.0rc1, core 1.16.0 | 135 passed | +| Functions 2.3.0, Durable 2.0.0b2, core 1.13.0 | 135 passed | +| Normal install without Durable/DAFX packages | 5 import tests passed | | Strict mypy, both agent packages | Passed, 11 source files | -| Flake8, framework source, tests, and new sample | Passed | -| Both package wheels and source distributions | Built | -| Dependency consistency, plain and durable environments | Passed | +| Flake8, both package sources, framework tests and local samples | Passed | +| Both wheels and source distributions | Built | +| Wheel contents | New adapter included, removed base durable module absent | -The SDK emits one deprecation warning during entity deserialization about calling -`df_loads` without `expected_type`. Build tooling emits existing license-metadata -deprecation warnings. Neither warning was suppressed. - -No deployed Functions host, external model, or storage service was exercised. -The execution test uses the real indexed SDK entity handler, protobuf transport, -DAFX execution and tasks, and serialized entity state between turns. Only its -model and orchestration scheduler are test substitutes. +The SDK emits one deprecation warning about `df_loads` without `expected_type`. +It is not suppressed. Tests exercise the SDK's real orchestration and entity +protobuf handlers, not a running Functions host or storage backend. Local clients +substitute for the model service; external MCP servers were not contacted. ## Change analysis -- Initialization and provider configuration still happen before binding decoration. - Existing constructor/decorator contract tests and both original suites pass. -- The old activity-based `call_agent()` remains unchanged. Its decoration/indexing - path does not construct the inner app. Explicit registration is a separate API. -- Both function registries are included, including SDK built-ins. HTTP auth is - preserved. Duplicate names, repeated indexing, and retry after correcting a - collision are tested. The SDK name-validation state is reset on each pass. -- Agent lookup does not import or create DAFX. Explicit registration after indexing - is rejected. Re-registering the same instance is idempotent, but a different - instance with the same case-insensitive name is rejected. -- Missing, empty, whitespace-only, and non-string names are rejected before DAFX - creation. Missing DAFX produces installation guidance; a broken transitive - import preserves its original error. Different apps own separate registries. -- SDK built-in names are derived from the real inner registry for collision tests. - The sample index test separately pins the expected complete function list. -- The initial 20 new DAFX tests fail against the untouched PR head because the - new API/state is absent, then pass with the implementation present. In-memory - mutations removing inner functions and removing the inner validation reset - each fail three targeted tests for the expected behavior. No source file was - mutated by those probes. -- An independent read-only review prompted additional app-isolation and - indexing-recovery tests. Its proposed blanket guard against adding any decorator - after indexing was not adopted: the original SDK and PR already allow that; - this prototype guards only its new durable-agent registration API. -- Documentation and dependency declarations were checked together. Both DAFX Git - pins occur only in the optional extra; CI explicitly installs that extra for - framework tests. These prototype Git dependencies are not a PyPI release plan. - -See the adjacent README for installation and test commands. Full suites cover -the two agent packages, not unrelated extensions elsewhere in the repository. +- Normal markdown binding construction/invocation remains import-safe without + DAFX. Durable discovery is explicit and creates recipes, not clients. The old + context wrapper, hidden activity, exports, and activity-specific tests are removed. +- Both discovery and the binding publish entity and HTTP functions before indexing. + Binding and discovery share one registration. The SDK's built-in functions remain + in the combined index. Existing auth, collision, reindex, and isolation tests pass. +- Raw instructions are preserved. Discovery rejects duplicate names across both + directories, case collisions, directories masquerading as files, and symlinks + escaping the app root. Durable markdown names are restricted to safe ASCII + route/entity identifiers. Colliding generated HTTP function names fail indexing. +- The binding validates generator shape, hides the injected parameter, forwards + context and optional native input, and rejects mismatched context names and late + declarations. Both one- and two-argument forms run through the real SDK dispatcher. +- A replay probe schedules the same entity, correlation ID, and input without + constructing a client. Pinned DAFX supplies a wall-clock `created_at` on each + request, so this is not a claim of byte-identical replay payloads. That upstream + timestamp behavior is unchanged by this prototype. +- Endpoint and binding samples execute two turns through indexed entity handlers + with serialized state carried between operations. Clients are newly created, + entered, and closed for each turn. Session identity and history remain stable. +- Adapter tests cover finalization before cleanup, full response identity/value, + fresh resources, stream/run failures, and cancellation during an active pull. + An abandoned stream that is neither consumed nor cancelled is not covered. +- All sample apps are enumerated into index tests. Every SDK-owned function name + is independently checked for collisions. A read-only adversarial review found + no concrete additional defect; it is not a substitute for these runtime checks. +- Previous-commit probes fail on the new constructor/decorator APIs. In-memory + mutations disabling discovery fail two tests, substituting a non-agent proxy + fails three, and dropping the final response fails one. The unmodified adapter + suite then passes all 40 tests. No source files were mutated by the probes. +- Current docs and samples use discovery or binding declarations, not the deleted + custom activity API. `add_durable_agent()` remains a lower-level instance API but + is not required by either markdown sample. + +The Git-pinned SDK 2 migration is still an open PR. These dependencies and tests +support local exploration, not a PyPI release or deployed-host compatibility claim. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py index 749f844..60511bf 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py @@ -1,61 +1,6 @@ -"""Local-model example of optional DAFX ownership. No model credentials needed.""" - -from collections.abc import Mapping, Sequence -from typing import Any - -import azure.functions as func -from agent_framework import Agent, BaseChatClient, ChatResponse, Message +"""Discover markdown agents and expose their DAFX endpoints without handlers.""" from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from local_chat_client import LocalChatClient - -class LocalChatClient(BaseChatClient): - """Return a deterministic response so the prototype needs no model service.""" - - def _inner_get_response( - self, - *, - messages: Sequence[Message], - stream: bool, - options: Mapping[str, Any], - **kwargs: Any, - ): - if stream: - raise TypeError("streaming is not supported by this local client") - - async def respond(): - turns = sum(message.role == "user" for message in messages) - return ChatResponse(messages=[Message( - role="assistant", contents=[f"User turn {turns}: {messages[-1].text}"] - )]) - - return respond() - - -app = AgentFunctionApp(client_factory=LocalChatClient) - - -@app.route(route="hello", methods=["GET"]) -def hello(req: func.HttpRequest) -> func.HttpResponse: - return func.HttpResponse("Normal HTTP function on the outer app.") - - -# This is the only opt-in point for DAFX. Register before function indexing. -# The caller owns this agent and any clients/tools it uses. -app.add_durable_agent(Agent(client=LocalChatClient(), name="Orders")) - - -@app.orchestration_trigger(context_name="context") -def orders(context): - agent = app.get_agent(context, "Orders") - session = agent.create_session() - first = yield agent.run("Assess the order.", session=session) - second = yield agent.run("Make a fulfillment plan.", session=session) - return {"assessment": first.text, "plan": second.text} - - -@app.route(route="orders", methods=["POST"]) -@app.durable_client_input(client_name="client") -async def start_orders(req: func.HttpRequest, client) -> func.HttpResponse: - instance_id = await client.start_new("orders", client_input={}) - return client.create_check_status_response(req, instance_id) +app = AgentFunctionApp(client_factory=LocalChatClient, durable=True) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/local_chat_client.py b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/local_chat_client.py new file mode 100644 index 0000000..71fa95b --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/local_chat_client.py @@ -0,0 +1,46 @@ +"""A deterministic model substitute with no credentials or network resources.""" + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import Any + +from agent_framework import ( + BaseChatClient, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + ResponseStream, +) + + +class LocalChatClient(BaseChatClient): + """Count user messages in the supplied history and echo the latest prompt.""" + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ): + turns = sum(message.role == "user" for message in messages) + text = f"User turn {turns}: {messages[-1].text}" + created_at = datetime.now(timezone.utc).isoformat() + + async def updates(): + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text(text)], + created_at=created_at, + ) + + async def respond(): + return ChatResponse( + messages=[Message(role="assistant", contents=[text])], + created_at=created_at, + ) + + if stream: + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + return respond() diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/orders.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/orders.agent.md new file mode 100644 index 0000000..ea7a90a --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/orders.agent.md @@ -0,0 +1,3 @@ +You are an order fulfillment assistant. +Assess the order, then propose a concise fulfillment plan. +Never claim that an external action completed without a confirming tool result. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py index 8affad3..f009531 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py @@ -16,6 +16,7 @@ def test_typed_api_exposes_only_v1_options(): "app_root", "tools", "http_auth_level", + "durable", ] assert list(inspect.signature(AgentFunctionApp.markdown_agent).parameters) == [ "self", @@ -86,18 +87,17 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): ) -def test_typed_orchestration_trigger_adds_agent_context(monkeypatch): +def test_typed_orchestration_trigger_keeps_native_context(monkeypatch): parent_decorator = Mock(return_value=object()) - durable_decorator = Mock(return_value=object()) + + def sdk(context_name, orchestration=None, input_type=None): + assert (context_name, orchestration, input_type) == ("context", "orders", dict) + return parent_decorator + monkeypatch.setattr( func.FunctionApp, "orchestration_trigger", - parent_decorator, - ) - monkeypatch.setattr( - apps, - "durable_orchestration_trigger", - durable_decorator, + staticmethod(sdk), ) app = object.__new__(AgentFunctionApp) @@ -107,11 +107,8 @@ def test_typed_orchestration_trigger_adds_agent_context(monkeypatch): 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, - ) + def handler(context): + yield context + + assert result(handler) is parent_decorator.return_value + parent_decorator.assert_called_once_with(handler) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py b/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py index 6baec3b..931c385 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py @@ -15,7 +15,6 @@ from google.protobuf.wrappers_pb2 import StringValue from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp -from azurefunctions.agents.extensions.base.durable import DurableAgentContext class RecordingClient(BaseChatClient): @@ -66,7 +65,7 @@ def test_registration_owns_one_real_dafx_app(app): assert app._durable_app is inner assert set(inner.agents) == {"Orders", "Shipping"} assert not inner.enable_health_check - assert not inner.enable_http_endpoints + assert inner.enable_http_endpoints assert not inner.enable_mcp_tool_trigger assert inner.auth_level == app.auth_level functions = app.get_functions() @@ -76,7 +75,7 @@ def test_registration_owns_one_real_dafx_app(app): if function.get_bindings_dict()["bindings"][0]["type"] == "entityTrigger" } assert entities == {"dafx-Orders", "dafx-Shipping"} - assert not any(function.is_http_function() for function in functions) + assert sum(function.is_http_function() for function in functions) == 2 @pytest.mark.parametrize("name", [None, "", " ", 42]) @@ -95,7 +94,7 @@ def test_different_agent_with_duplicate_name_is_rejected(app, name): def test_lookup_does_not_enable_dafx(app): - with pytest.raises(RuntimeError, match="add_durable_agent"): + with pytest.raises(RuntimeError, match="durable=True"): app.get_agent(object(), "Orders") assert app._durable_app is None @@ -168,7 +167,7 @@ def orders(req): assert [fn.get_function_name() for fn in first] == [ fn.get_function_name() for fn in second ] - assert len(first) == 4 # HTTP + entity + the SDK's two built-in functions. + assert len(first) == 5 # Two HTTP routes + entity + SDK built-ins. http = next(fn for fn in first if fn.get_function_name() == "orders") trigger = next( binding for binding in http.get_bindings_dict()["bindings"] @@ -197,7 +196,10 @@ def test_sdk_builtin_names_cannot_be_shadowed(app, tmp_path): app.add_durable_agent(make_agent()) # Derive the SDK-owned names rather than duplicating a hard-coded list. sdk_functions = app._durable_app.get_functions() - builtins = [fn for fn in sdk_functions if fn.get_function_name() != "dafx-Orders"] + builtins = [ + fn for fn in sdk_functions + if fn.get_function_name().startswith("BuiltIn__") + ] assert builtins for index, builtin in enumerate(builtins): candidate = AgentFunctionApp( @@ -214,13 +216,14 @@ def collision(req): candidate.get_functions() -def test_existing_activity_path_does_not_create_dafx(app): +def test_native_orchestration_does_not_add_hidden_activity(app): @app.orchestration_trigger(context_name="context") def orchestrator(context): - yield context.call_agent("orders", "hello") + assert not hasattr(context, "call_agent") + yield context.call_activity("orders", "hello") names = {fn.get_function_name() for fn in app.get_functions()} - assert names == {"orchestrator", "azurefunctions_agents_run_markdown_agent"} + assert names == {"orchestrator"} assert app._durable_app is None @@ -259,12 +262,12 @@ def call_entity(entity_id, operation, input_=None): scheduled.append((entity_id, input_, task)) return task - # Only the scheduler is replaced. Use the SDK wrapper the PR receives, - # its context proxy, real DAFX tasks, and the real indexed entity handler. + # Only the scheduler is replaced. Use the SDK context, real DAFX tasks, + # and the real indexed entity handler. scheduler = Mock(instance_id="workflow-1") scheduler.new_uuid.side_effect = [str(uuid.UUID(int=n)) for n in range(1, 5)] scheduler.call_entity.side_effect = call_entity - context = DurableAgentContext(DurableOrchestrationContext(scheduler)) + context = DurableOrchestrationContext(scheduler) agent = app.get_agent(context, "Orders") session = agent.create_session() state = None diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py b/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py new file mode 100644 index 0000000..259f081 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py @@ -0,0 +1,444 @@ +from __future__ import annotations + +import asyncio +import base64 +import inspect +import json +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import azure.functions as func +from agent_framework import ( + AgentResponse, AgentResponseUpdate, AgentSession, Content, Message, ResponseStream, + SupportsAgentRun, +) + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.agent_framework._durable import ( + MarkdownDurableAgent, +) +from azurefunctions.agents.extensions.base import compile_agent, discover_agent_names + + +def make_app(tmp_path, **kwargs): + return AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, **kwargs) + + +def definition(root, name="orders", text="Instructions"): + root.mkdir(parents=True, exist_ok=True) + (root / f"{name}.agent.md").write_text(text, encoding="utf-8") + + +def test_discovery_is_opt_in_and_creates_recipes_not_live_agents(tmp_path): + definition(tmp_path) + definition(tmp_path / "agents", "shipping") + definition(tmp_path / "nested", "ignored") + factory = Mock(side_effect=AssertionError("client created during indexing")) + plain = AgentFunctionApp(client_factory=factory, app_root=tmp_path) + assert plain.get_functions() == [] + assert plain._durable_app is None + + app = AgentFunctionApp(client_factory=factory, app_root=tmp_path, durable=True) + assert set(app._durable_app.agents) == {"orders", "shipping"} + indexed = app.get_functions() + assert set(fn.get_function_name() for fn in indexed) == { + "dafx-orders", "dafx-shipping", "http-orders", "http-shipping", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + } + factory.assert_not_called() + assert all(isinstance(agent, SupportsAgentRun) + for agent in app._durable_app.agents.values()) + + +@pytest.mark.parametrize("value", [None, 0, 1, "true", [], {}]) +def test_durable_flag_is_explicit_bool(tmp_path, value): + with pytest.raises(TypeError, match="durable must be a bool"): + make_app(tmp_path, durable=value) + + +@pytest.mark.parametrize("second", ["orders", "ORDERS"]) +def test_discovery_rejects_ambiguous_names_before_registration( + tmp_path, monkeypatch, second +): + definition(tmp_path, "orders") + definition(tmp_path / "agents", second) + ensure = Mock(side_effect=AssertionError("partial durable registration")) + monkeypatch.setattr(AgentFunctionApp, "_ensure_durable_app", ensure) + with pytest.raises(ValueError, match="[Aa]mbiguous"): + make_app(tmp_path, durable=True) + ensure.assert_not_called() + + +def test_discovery_ignores_unrelated_files_and_rejects_definition_directories(tmp_path): + (tmp_path / "readme.md").write_text("ignore", encoding="utf-8") + (tmp_path / "orders.agent.md").mkdir() + with pytest.raises(ValueError, match="not a file"): + make_app(tmp_path, durable=True) + + +def test_discovery_rejects_escaping_symlink(tmp_path): + outside = tmp_path.parent / f"{tmp_path.name}-outside.agent.md" + outside.write_text("Outside", encoding="utf-8") + try: + (tmp_path / "orders.agent.md").symlink_to(outside) + except OSError as error: + pytest.skip(f"Symlinks unavailable: {error}") + with pytest.raises(ValueError, match="outside app root"): + make_app(tmp_path, durable=True) + + +def test_compile_preserves_raw_instructions(tmp_path): + path = tmp_path / "orders.agent.md" + path.write_bytes(b"---\r\nname: ignored\r\n---\r\nRaw instructions\r\n") + app = make_app(tmp_path) + assert discover_agent_names(app) == ["orders"] + assert compile_agent(app, "orders").instructions == path.read_bytes().decode() + + +def test_binding_registers_once_and_hides_injected_parameter(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def first(context, *, agent): + yield agent + + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def second(context, agent): + yield agent + + assert len(app._durable_app.agents) == 1 + assert list(inspect.signature(first).parameters) == ["context"] + proxy = object() + app.get_agent = Mock(return_value=proxy) + context = object() + assert next(first(context)) is proxy + assert next(second(context=context)) is proxy + app.get_agent.assert_called_with(context, "orders") + with pytest.raises(TypeError): + next(first(context, agent=object())) + + +def test_binding_and_discovery_share_one_entity(tmp_path): + definition(tmp_path) + app = make_app(tmp_path, durable=True) + original = app._durable_app.agents["orders"] + + @app.orchestration_trigger(context_name="context") + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def workflow(context, agent): + yield agent.run("hello") + + assert app._durable_app.agents["orders"] is original + assert len(app.get_functions()) == 5 + + +def test_binding_custom_context_and_native_input_are_forwarded(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + + @app.orchestration_trigger(context_name="ctx") + @app.durable_markdown_agent( + arg_name="agent", agent_name="orders", context_name="ctx" + ) + def workflow(ctx, input, agent): + yield (ctx, input, agent) + + proxy = object() + app.get_agent = Mock(return_value=proxy) + context = object() + original = workflow._function.get_user_function().orchestrator_function + assert next(original(context, {"message": "hello"})) == ( + context, {"message": "hello"}, proxy + ) + + +@pytest.mark.parametrize("native_input", [False, True]) +def test_indexed_orchestrator_transport_schedules_entity(tmp_path, native_input): + from durabletask.internal import orchestrator_service_pb2 as pb + from google.protobuf.timestamp_pb2 import Timestamp + from google.protobuf.wrappers_pb2 import StringValue + + definition(tmp_path) + factory = Mock(side_effect=AssertionError("live agent opened by orchestrator")) + app = AgentFunctionApp(client_factory=factory, app_root=tmp_path) + + if native_input: + def workflow(context, input, agent): + yield agent.run(input["message"]) + else: + def workflow(context, agent): + yield agent.run(context.get_input()["message"]) + + workflow = app.durable_markdown_agent( + arg_name="agent", agent_name="orders" + )(workflow) + app.orchestration_trigger(context_name="context")(workflow) + handler = next(fn.get_user_function() for fn in app.get_functions() + if fn.get_function_name() == "workflow") + timestamp = Timestamp(seconds=1_700_000_000) + request = pb.OrchestratorRequest(instanceId="test-workflow", newEvents=[ + pb.HistoryEvent(eventId=-1, timestamp=timestamp, + orchestratorStarted=pb.OrchestratorStartedEvent()), + pb.HistoryEvent(eventId=0, timestamp=timestamp, + executionStarted=pb.ExecutionStartedEvent( + name="workflow", + input=StringValue(value=json.dumps({"message": "hello"})), + orchestrationInstance=pb.OrchestrationInstance( + instanceId="test-workflow", + executionId=StringValue(value="execution-1"), + ), + )), + ]) + output = handler(func.OrchestrationContext( + base64.b64encode(request.SerializeToString()) + )) + result = pb.OrchestratorResponse.FromString(base64.b64decode(output)) + assert len(result.actions) == 1, result + action = result.actions[0] + assert action.HasField("sendEntityMessage"), result + assert "dafx-orders" in str(action), result + factory.assert_not_called() + + +def test_binding_trigger_context_mismatch_is_rejected(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def workflow(context, agent): + yield agent + + with pytest.raises(TypeError, match="context_name must match"): + app.orchestration_trigger(context_name="wrong")(workflow) + + +@pytest.mark.parametrize("name", [ + None, "", " ", 1, "../orders", "/orders", "{order}", "a@b", "a b", "a#b", +]) +def test_binding_invalid_or_escaping_names_fail_without_dafx(tmp_path, name): + definition(tmp_path) + app = make_app(tmp_path) + + def workflow(context, agent): + yield agent + + with pytest.raises(ValueError): + app.durable_markdown_agent(arg_name="agent", agent_name=name)(workflow) + assert app._durable_app is None + + +def test_replay_only_schedules_tasks_without_opening_agents(tmp_path): + from azure.durable_functions import DurableOrchestrationContext + from durabletask.task import CompletableTask + + definition(tmp_path) + factory = Mock(side_effect=AssertionError("agent opened in orchestration")) + app = AgentFunctionApp(client_factory=factory, app_root=tmp_path) + + @app.orchestration_trigger(context_name="context") + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def workflow(context, agent): + session = agent.create_session() + yield agent.run("hello", session=session) + + requests = [] + for replay in [False, True]: + scheduler = Mock(instance_id="workflow-1", is_replaying=replay) + scheduler.new_uuid.side_effect = ["session-key", "correlation-id"] + scheduler.call_entity.return_value = CompletableTask() + context = DurableOrchestrationContext(scheduler) + handler = workflow._function.get_user_function().orchestrator_function + next(handler(context)) + entity, operation, payload = scheduler.call_entity.call_args.args + # Pinned DAFX's RunRequest supplies a wall-clock created_at by default. + # Check stable routing/identity/input here, not byte-identical payloads. + assert payload.pop("created_at") + requests.append((str(entity), operation, payload)) + assert requests[0] == requests[1] + factory.assert_not_called() + + +def test_binding_missing_file_fails_before_registration(tmp_path): + app = make_app(tmp_path) + + def workflow(context, agent): + yield agent + + with pytest.raises(FileNotFoundError): + app.durable_markdown_agent(arg_name="agent", agent_name="missing")(workflow) + assert app._durable_app is None + + +def test_binding_invalid_handler_shapes_do_not_register(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + bind = app.durable_markdown_agent(arg_name="agent", agent_name="orders") + + async def async_handler(context, agent): + return agent + + def nongenerator(context, agent): + return agent + + def missing_agent(context): + yield context + + def missing_context(agent): + yield agent + + def wrong_order(input, context, agent): + yield agent + + def variadic(context, *agent): + yield agent + + for handler in [async_handler, nongenerator, missing_agent, + missing_context, wrong_order, variadic]: + with pytest.raises(TypeError): + bind(handler) + assert app._durable_app is None + + +def test_binding_late_declaration_fails(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + app.get_functions() + + def workflow(context, agent): + yield agent + + with pytest.raises(RuntimeError, match="before function indexing"): + app.durable_markdown_agent(arg_name="agent", agent_name="orders")(workflow) + assert app._durable_app is None + + +def test_generated_http_function_name_collision_is_not_silent(tmp_path): + definition(tmp_path, "order-one") + definition(tmp_path, "order_one") + app = make_app(tmp_path, durable=True) + with pytest.raises(ValueError, match="unique function name"): + app.get_functions() + + +def lifecycle_adapter(*, failure=None): + """Exercise adapter lifetimes separately from the real SDK sample tests.""" + events = [] + calls = [] + response = AgentResponse( + messages=[Message(role="assistant", contents=["done"])], value={"answer": 42} + ) + + @asynccontextmanager + async def open_agent(invocation): + instance = len(calls) + events.append((instance, "open")) + if failure == "open": + raise RuntimeError("open failed") + + def run(messages, *, stream=False, session=None, **kwargs): + calls.append((messages, session, kwargs)) + + async def updates(): + events.append((instance, "pull")) + yield AgentResponseUpdate(contents=[Content.from_text("done")]) + if failure == "stream": + raise RuntimeError("stream failed") + if failure == "cancel": + await asyncio.Event().wait() + + def finalize(_): + assert (instance, "close") not in events + events.append((instance, "finalize")) + if failure == "finalize": + raise RuntimeError("finalize failed") + return response + + async def invoke(): + if failure == "run": + raise RuntimeError("run failed") + return response + + return ResponseStream(updates(), finalizer=finalize) if stream else invoke() + + try: + yield SimpleNamespace(run=run) + finally: + events.append((instance, "close")) + + recipe = SimpleNamespace(agent_name="orders", open_agent=open_agent) + return MarkdownDurableAgent(recipe), events, calls, response + + +def test_adapter_stream_keeps_resources_alive_through_finalization(): + adapter, events, calls, expected = lifecycle_adapter() + session = AgentSession() + + async def invoke(): + for _ in range(2): + stream = adapter.run( + "hello", stream=True, session=session, options={"x": 1} + ) + # Constructing and awaiting the stream must not create live resources. + assert len(events) == 4 * len(calls) + await stream + result = await stream.get_final_response() + assert result is expected + assert result.value == {"answer": 42} + + asyncio.run(invoke()) + assert events == [(i, event) for i in range(2) + for event in ["open", "pull", "finalize", "close"]] + assert calls == [("hello", session, {"options": {"x": 1}})] * 2 + + +@pytest.mark.parametrize("failure", ["stream", "finalize"]) +def test_adapter_closes_resources_on_stream_failure(failure): + adapter, events, _, _ = lifecycle_adapter(failure=failure) + + async def invoke(): + with pytest.raises(RuntimeError, match=failure): + await adapter.run("hello", stream=True).get_final_response() + + asyncio.run(invoke()) + assert events[-1] == (0, "close") + + +def test_adapter_closes_resources_on_cancelled_pull(): + adapter, events, _, _ = lifecycle_adapter(failure="cancel") + + async def invoke(): + stream = adapter.run("hello", stream=True) + await anext(stream) + started = asyncio.Event() + + async def pull(): + started.set() + return await anext(stream) + + task = asyncio.create_task(pull()) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(invoke()) + assert events[-1] == (0, "close") + + +@pytest.mark.parametrize("failure", [None, "run", "open"]) +def test_adapter_nonstream_response_and_cleanup(failure): + adapter, events, _, expected = lifecycle_adapter(failure=failure) + + async def invoke(): + if failure: + with pytest.raises(RuntimeError, match=failure): + await adapter.run("hello") + else: + assert await adapter.run("hello") is expected + + asyncio.run(invoke()) + if failure != "open": + assert events[-1] == (0, "close") diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py index 4822c35..106831f 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -7,10 +7,9 @@ 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, "DurableAgentContext") assert not hasattr(framework, "AgentDFApp") assert not hasattr(framework, "markdown_agent") diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py index 6e7aff9..0c30e51 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py @@ -101,7 +101,7 @@ def test_provider_rejects_non_agent_annotation(): ) -def test_provider_accepts_missing_annotation_for_durable_activity(): +def test_provider_accepts_missing_annotation_for_compiled_recipe(): binding = provider.AgentFrameworkProvider().compile_binding( instructions="instructions", agent_name="orders", diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py index b3126d1..227c0d1 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -4,101 +4,114 @@ import os import subprocess import sys +import textwrap from pathlib import Path import pytest _PACKAGE_ROOT = Path(__file__).parents[1] _SAMPLES_ROOT = _PACKAGE_ROOT / "samples" +_SAMPLE_INDEXES = { + "agent_samples_agent-framework": {"process_order", "process_order_event"}, + "agent_samples_agent-framework_durable": { + "order_orchestrator", "prepare_order_activity", "start_order_orchestration", + "dafx-order-fulfillment", "http-order_fulfillment", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + }, + "lazy-owned-dafx": { + "dafx-orders", "http-orders", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + }, + "durable-markdown-binding": { + "orders", "start_orders", "dafx-orders", "http-orders", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + }, +} +_LOCAL_SAMPLES = ("lazy-owned-dafx", "durable-markdown-binding") -@pytest.mark.parametrize( - ("sample_name", "expected_names"), - [ - ( - "agent_samples_agent-framework", - {"process_order", "process_order_event"}, - ), - ( - "agent_samples_agent-framework_durable", - { - "azurefunctions_agents_run_markdown_agent", - "order_orchestrator", - "prepare_order_activity", - "start_order_orchestration", - }, - ), - ], -) -def test_sample_indexes_all_functions(sample_name, expected_names): +def _run_sample(sample_path, script): environment = os.environ.copy() environment["PYTHONPATH"] = os.pathsep.join( - filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) + filter(None, [ + str(_PACKAGE_ROOT), + str(_PACKAGE_ROOT.parent / "azurefunctions-agents-extensions-base"), + 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, + [sys.executable, "-c", textwrap.dedent(script)], + cwd=_SAMPLES_ROOT / sample_path, env=environment, - check=True, capture_output=True, text=True, ) + assert completed.returncode == 0, completed.stdout + completed.stderr + return json.loads(completed.stdout) - assert set(json.loads(completed.stdout)) == expected_names +def test_index_cases_cover_every_sample_app(): + assert set(_SAMPLE_INDEXES) == { + path.parent.relative_to(_SAMPLES_ROOT).as_posix() + for path in _SAMPLES_ROOT.rglob("function_app.py") + } + + +@pytest.mark.parametrize("sample_path", _SAMPLE_INDEXES) +def test_sample_indexes_all_functions(sample_path): + # Exact names were recorded from the SDK 2/DAFX PR #72 index. DAFX sanitizes + # HTTP names (_build_function_name), but preserves hyphens in entity names. + result = _run_sample(sample_path, """ + import json + from unittest.mock import patch + from azurefunctions.agents.extensions.agent_framework.provider import ( + AgentFrameworkBinding, + ) + with patch.object(AgentFrameworkBinding, '_create_agent', + side_effect=AssertionError('live agent during indexing')): + import function_app + first = function_app.app.get_functions() + second = function_app.app.get_functions() + names = [fn.get_function_name() for fn in first] + assert names == [fn.get_function_name() for fn in second] + assert len(names) == len(set(names)) + for fn in first: + if fn.get_function_name().startswith('http-'): + trigger = next(b for b in fn.get_bindings_dict()['bindings'] + if b['type'] == 'httpTrigger') + agent = next(iter(function_app.app._durable_app.agents)) + assert trigger['route'] == f'agents/{agent}/run' + assert [method.value for method in trigger['methods']] == ['POST'] + assert trigger['authLevel'].value == 'function' + print(json.dumps(names)) + """) + assert set(result) == _SAMPLE_INDEXES[sample_path] -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")]) - ) - 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 / "agent_samples_agent-framework", - env=environment, - check=True, - capture_output=True, - text=True, - ) - result = json.loads(completed.stdout) +def test_agent_framework_sample_rejects_malformed_json(): + result = _run_sample("agent_samples_agent-framework", """ + 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()})) + """) assert result["status_code"] == 400 assert json.loads(result["body"]) == {"error": "Order failed validation."} 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")]) - ) 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" + " assert name == 'order_orchestrator' and client_input == {}\n" " return 'instance-42'\n" " def create_http_management_payload(self, request, instance_id):\n" " assert request is not None\n" @@ -112,16 +125,7 @@ def test_agent_framework_durable_sample_starts_orchestration(): "'mimetype': response.mimetype, " "'location': response.headers['Location']}))\n" ) - completed = subprocess.run( - [sys.executable, "-c", script], - cwd=_SAMPLES_ROOT / "agent_samples_agent-framework_durable", - env=environment, - check=True, - capture_output=True, - text=True, - ) - - assert json.loads(completed.stdout) == { + assert _run_sample("agent_samples_agent-framework_durable", script) == { "status_code": 202, "mimetype": "application/json", "location": "https://example.test/status/42", @@ -129,10 +133,6 @@ def test_agent_framework_durable_sample_starts_orchestration(): 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")]) - ) script = ( "import asyncio, json\n" "import azure.functions as func\n" @@ -148,16 +148,7 @@ def test_agent_framework_durable_sample_rejects_malformed_json(): "print(json.dumps({'status_code': response.status_code, " "'body': response.get_body().decode()}))\n" ) - completed = subprocess.run( - [sys.executable, "-c", script], - cwd=_SAMPLES_ROOT / "agent_samples_agent-framework_durable", - env=environment, - check=True, - capture_output=True, - text=True, - ) - - result = json.loads(completed.stdout) + result = _run_sample("agent_samples_agent-framework_durable", script) assert result["status_code"] == 400 assert json.loads(result["body"]) == {"error": "Order failed validation."} @@ -170,21 +161,206 @@ def test_agent_framework_sample_assets_follow_discovery_conventions(): assert (sample_root / "mcp.json").is_file() -def test_lazy_owned_dafx_sample_indexes_both_registries(): - completed = subprocess.run( - [sys.executable, "-c", ( - "import json; import function_app; " - "first = function_app.app.get_functions(); " - "second = function_app.app.get_functions(); " - "assert [f.get_function_name() for f in first] == " - "[f.get_function_name() for f in second]; " - "print(json.dumps([f.get_function_name() for f in first]))" - )], - cwd=_SAMPLES_ROOT / "lazy-owned-dafx", - check=True, capture_output=True, text=True, - ) - assert set(json.loads(completed.stdout)) == { - "hello", "orders", "start_orders", "dafx-Orders", - "azurefunctions_agents_run_markdown_agent", - "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", +def test_binding_sample_starts_orchestration(): + result = _run_sample("durable-markdown-binding", """ + import asyncio, json + import azure.functions as func + import function_app + class FakeClient: + async def start_new(self, name, *, client_input): + assert name == 'orders' and client_input == {} + return 'instance-42' + def create_check_status_response(self, request, instance_id): + assert request is not None and instance_id == 'instance-42' + return func.HttpResponse(status_code=202) + request = func.HttpRequest(method='POST', url='https://example.test', body=b'') + handler = function_app.start_orders._function.get_user_function().__wrapped__ + response = asyncio.run(handler(request, FakeClient())) + print(json.dumps(response.status_code)) + """) + assert result == 202 + + +@pytest.mark.parametrize("sample_path", _LOCAL_SAMPLES) +def test_local_sample_preserves_history_with_fresh_execution_clients(sample_path): + result = _run_sample(sample_path, """ + import asyncio, base64, json, uuid + from unittest.mock import Mock + import azure.functions as func + from azure.durable_functions import DurableOrchestrationContext + from durabletask.internal import orchestrator_service_pb2 as pb + from durabletask.task import CompletableTask + from google.protobuf.wrappers_pb2 import StringValue + import local_chat_client + + clients = [] + class TrackingClient(local_chat_client.LocalChatClient): + def __init__(self): + super().__init__() + self.entered = self.closed = False + clients.append(self) + async def __aenter__(self): + self.entered = True + return self + async def __aexit__(self, *args): + self.closed = True + local_chat_client.LocalChatClient = TrackingClient + import function_app + assert clients == [], 'client constructed during app import' + functions = function_app.app.get_functions() + assert clients == [], 'client constructed during indexing' + entity = next(fn.get_user_function() for fn in functions + if fn.get_function_name() == 'dafx-orders') + scheduled = [] + def call_entity(entity_id, operation, input_=None): + assert operation == 'run' + task = CompletableTask() + scheduled.append((entity_id, input_, task)) + return task + scheduler = Mock(instance_id='workflow-1') + scheduler.new_uuid.side_effect = [str(uuid.UUID(int=n)) for n in range(1, 5)] + scheduler.call_entity.side_effect = call_entity + context = DurableOrchestrationContext(scheduler) + + generator = None + if hasattr(function_app, 'orders'): + # Keep the actual binding injection; bypass only the SDK host transport. + handler = function_app.orders._function.get_user_function() + generator = handler.orchestrator_function(context) + task = next(generator) + else: + http = next(fn.get_user_function().__wrapped__ for fn in functions + if fn.get_function_name() == 'http-orders') + class Client: + async def signal_entity(self, entity_id, operation, input_): + call_entity(entity_id, operation, input_) + def submit(prompt): + request = func.HttpRequest( + method='POST', url='https://example.test/api/agents/orders/run', + headers={'Content-Type': 'application/json'}, + body=json.dumps({'message': prompt, 'session_id': 'orders-demo', + 'wait_for_response': False}).encode()) + response = asyncio.run(http(request, Client())) + assert response.status_code == 202, response.get_body() + return scheduled[-1][2] + task = submit('Assess the order.') + + state = None + replies = [] + for turn in (1, 2): + assert not task.is_complete + entity_id, request, pending = scheduled[-1] + batch = pb.EntityBatchRequest( + instanceId=str(entity_id), operations=[pb.OperationRequest( + operation='run', input=StringValue(value=json.dumps(request)))]) + if state is not None: + batch.entityState.CopyFrom(StringValue(value=state)) + transport = func.EntityContext(base64.b64encode(batch.SerializeToString())) + result = pb.EntityBatchResult.FromString( + base64.b64decode(entity(transport))) + assert not result.HasField('failureDetails'), result + assert len(result.results) == 1 + assert result.results[0].HasField('success'), result + payload = json.loads(result.results[0].success.result.value) + state = result.entityState.value + pending.complete(payload) + assert len(clients) == turn + assert all(client.entered and client.closed for client in clients) + if generator is not None: + response = task.get_result() + replies.append(response.text) + if turn == 1: + task = generator.send(response) + else: + try: + generator.send(response) + except StopIteration as done: + assert done.value == dict(zip(['assessment', 'plan'], replies)) + else: + raise AssertionError('orchestrator did not finish') + else: + from agent_framework import AgentResponse + replies.append(AgentResponse.from_dict(payload).text) + if turn == 1: + task = submit('Make a fulfillment plan.') + assert scheduled[0][0] == scheduled[1][0], 'turns used different entities' + assert state + print(json.dumps(replies)) + """) + assert result == [ + "User turn 1: Assess the order.", + "User turn 2: Make a fulfillment plan.", + ] + + +@pytest.mark.parametrize("sample_path", _LOCAL_SAMPLES) +@pytest.mark.parametrize("body", ["{not json", "{}", '{"message":""}']) +def test_local_agent_endpoint_rejects_invalid_input(sample_path, body): + result = _run_sample(sample_path, f""" + import asyncio, json + from unittest.mock import Mock + import azure.functions as func + import function_app + handler = next(fn.get_user_function().__wrapped__ + for fn in function_app.app.get_functions() + if fn.get_function_name() == 'http-orders') + client = Mock() + request = func.HttpRequest(method='POST', url='https://example.test', + headers={{'Content-Type': 'application/json'}}, + body={body.encode()!r}) + response = asyncio.run(handler(request, client)) + client.signal_entity.assert_not_called() + print(json.dumps(response.status_code)) + """) + assert result == 400 + + +def test_agent_framework_durable_sample_uses_prepared_order_and_shared_session(): + result = _run_sample("agent_samples_agent-framework_durable", """ + import json + from types import SimpleNamespace + import function_app + calls = [] + session = object() + class Agent: + def create_session(self): + return session + def run(self, message, *, session): + calls.append((json.loads(message), session)) + return f'turn-{len(calls)}' + class Context: + def get_input(self): + return {'untrusted': 'order'} + def call_activity(self, name, payload): + assert name == 'prepare_order_activity' + assert payload == self.get_input() + return 'prepare' + context = Context() + def get_agent(received_context, name): + assert received_context is context and name == 'order-fulfillment' + return Agent() + function_app.app.get_agent = get_agent + handler = function_app.order_orchestrator._function.get_user_function() + generator = handler.orchestrator_function(context) + assert next(generator) == 'prepare' + assert not calls + prepared = {'order_id': 'D-2048', 'summary': {'subtotal': '49.90'}} + assert generator.send(prepared) == 'turn-1' + assert generator.send(SimpleNamespace(text='assessment')) == 'turn-2' + try: + generator.send(SimpleNamespace(text='plan')) + except StopIteration as done: + output = done.value + else: + raise AssertionError('orchestrator did not finish') + assert len(calls) == 2 + assert all(payload['order'] == prepared and used_session is session + for payload, used_session in calls) + assert calls[1][0]['risk_assessment'] == 'assessment' + print(json.dumps(output)) + """) + assert result == { + "order_id": "D-2048", + "risk_assessment": "assessment", + "fulfillment_plan": "plan", } diff --git a/azurefunctions-agents-extensions-base/README.md b/azurefunctions-agents-extensions-base/README.md index 06f78e2..0349211 100644 --- a/azurefunctions-agents-extensions-base/README.md +++ b/azurefunctions-agents-extensions-base/README.md @@ -17,8 +17,9 @@ name is the provider ID. The factory returns an `AgentProvider` with a matching 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. +compiled recipe exposes `open_agent()` to create a fresh Agent context for each +invocation. Provider-specific adapters can use the same lifecycle for durable +entity execution. Applications import the app class supplied by a provider package. Each Agent Function App uses one provider, configured when the app is constructed. @@ -44,6 +45,11 @@ this package. If both locations exist, lookup fails as ambiguous. Absolute paths, separators, traversal components, and symlinks outside `app_root` are rejected. +`discover_agent_names()` enumerates `.agent.md` files directly in these two +directories, not nested directories. Provider apps can compile the discovered +names without constructing live clients. The Microsoft Agent Framework app uses +this discovery when `durable=True` is set. + ## Skills and MCP discovery The base package discovers immutable definitions from the shared app root: @@ -72,7 +78,13 @@ each invocation. 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 `AgentFunctionApp` provider. All file, client, Agent, model, and -tool I/O occurs in the activity, never in the orchestrator. +not import or require Durable Functions. The base package supplies discovery, +compilation, and lifecycle contracts, not an orchestration context wrapper or +hidden Agent activity. + +The Microsoft Agent Framework provider offers `durable=True` discovery and a +`durable_markdown_agent()` binding. It registers compiled markdown recipes with +DAFX and injects a durable proxy into generator orchestrators. Concrete clients +and tools are created and closed through `open_agent()` during entity execution, +not indexing or orchestration replay. See the +[provider documentation](../azurefunctions-agents-extensions-agent-framework/README.md#durable-agents). 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 49c0f7b..ae5e841 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py @@ -1,9 +1,6 @@ from __future__ import annotations -from collections.abc import Callable -from typing import TYPE_CHECKING, Any, TypeVar - -from .bindings import configure_app, markdown_agent +from .bindings import compile_agent, configure_app, discover_agent_names, markdown_agent from .capabilities import ( AgentCapabilities, MCPAuthConfig, @@ -19,37 +16,6 @@ load_provider, ) -if TYPE_CHECKING: - from .durable import _DurableApp - -_F = TypeVar("_F", bound=Callable[..., Any]) - - -def configure_durable_app(app: _DurableApp) -> None: - from .durable import configure_durable_app as configure - - configure(app) - - -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( - app, - sdk_decorator=sdk_decorator, - context_name=context_name, - orchestration=orchestration, - input_type=input_type, - ) - - __all__ = [ "AGENT_PROVIDER_ENTRY_POINT_GROUP", "AgentCapabilities", @@ -60,9 +26,9 @@ def durable_orchestration_trigger( "MCPHTTPConfig", "MCPServerDefinition", "SkillDefinition", + "compile_agent", "configure_app", - "configure_durable_app", - "durable_orchestration_trigger", + "discover_agent_names", "load_provider", "markdown_agent", ] 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..a6cae1e 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -28,8 +28,6 @@ class _AppState: provider_id: str provider: AgentProvider 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) @@ -110,31 +108,53 @@ def _configured_state(app: object) -> _AppState: return state -def _durable_agent( +def compile_agent( app: object, agent_name: str, ) -> CompiledAgent: + """Compile a named markdown definition using the app's provider defaults. + + This performs no client creation or network I/O. The caller owns caching + and invokes the returned recipe's open_agent() at execution time. + """ state = _configured_state(app) with state.lock: - compiled = state.durable_agents.get(agent_name) - if compiled is None: - _validate_provider_capabilities( - state.provider, - state.capabilities, - ) - 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, - capabilities=state.capabilities, - ) - state.durable_agents[agent_name] = compiled - return compiled + _validate_provider_capabilities(state.provider, state.capabilities) + return state.provider.compile_binding( + instructions=_resolve_instructions(state.app_root, agent_name), + agent_name=agent_name, + options=state.provider_defaults, + annotation=inspect.Signature.empty, + capabilities=state.capabilities, + ) + + +def discover_agent_names(app: object) -> list[str]: + """Find definitions directly under the app root and its agents directory. + + Validate all files before returning so ambiguous or escaping definitions + cannot partially publish a set of endpoints. + """ + root = _configured_state(app).app_root + names: dict[str, str] = {} + for directory in (root, root / "agents"): + if not directory.is_dir(): + continue + for source in sorted(directory.iterdir()): + if not source.name.endswith(".agent.md"): + continue + name = _validate_agent_name(source.name.removesuffix(".agent.md")) + if not source.is_file(): + raise ValueError(f"Agent definition {source.name!r} is not a file") + if name.casefold() in names: + raise ValueError(f"Ambiguous agent name {name!r}") + _resolve_instructions(root, name) + names[name.casefold()] = name + return sorted(names.values(), key=str.casefold) def _validate_agent_name(agent_name: str) -> str: - if not isinstance(agent_name, str) or not agent_name: + if not isinstance(agent_name, str) or not agent_name.strip(): raise ValueError("agent_name must be a non-empty string") if agent_name in {".", ".."}: raise ValueError("agent_name must be a filename component") diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py deleted file mode 100644 index 9069b7c..0000000 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py +++ /dev/null @@ -1,276 +0,0 @@ -from __future__ import annotations - -import functools -import inspect -import json -import math -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, TypedDict, cast - -import azure.functions as func - -from .bindings import _configured_state, _durable_agent -from .providers import InvocationMetadata - -if TYPE_CHECKING: - import azure.durable_functions as df - from durabletask.task import RetryPolicy, Task - - -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 - 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) -> _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(payload) != expected_fields: - raise ValueError( - "Markdown Agent activity input must contain exactly: " - + ", ".join(sorted(expected_fields)) - ) - 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 = 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 = 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" - ) - return { - "schema_version": 1, - "agent_name": agent_name, - "input": _canonicalize_json_value(payload["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 _DurableAgentContextMixin: - _context: _DurableContext - - def call_agent( - self, - agent_name: str, - input_: JSONValue, - *, - 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 = { - "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) - from durabletask.task import RetryPolicy - - 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, - payload, - ) - - -if TYPE_CHECKING: - - class DurableAgentContext( - _DurableAgentContextMixin, - df.DurableOrchestrationContext, - ): - def __init__(self, context: _DurableContext) -> None: - self._context = 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: - return - - @app.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, - ) - - state.durable_activity_registered = True - - -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]: - 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( - "AgentFunctionApp 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( - _DurableContext, - 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-agents-extensions-base/tests/test_durable.py b/azurefunctions-agents-extensions-base/tests/test_durable.py deleted file mode 100644 index b148075..0000000 --- a/azurefunctions-agents-extensions-base/tests/test_durable.py +++ /dev/null @@ -1,290 +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.agents.extensions.base import bindings, durable -from azurefunctions.agents.extensions.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-extensions-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_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")) - 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-extensions-base/tests/test_imports.py b/azurefunctions-agents-extensions-base/tests/test_imports.py index 0331915..4b4f9fa 100644 --- a/azurefunctions-agents-extensions-base/tests/test_imports.py +++ b/azurefunctions-agents-extensions-base/tests/test_imports.py @@ -2,7 +2,7 @@ import sys -def test_durable_module_import_does_not_require_durable(): +def test_base_import_does_not_require_durable(): result = subprocess.run( [ sys.executable, @@ -16,7 +16,8 @@ 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.agents.extensions.base.durable\n" + "import azurefunctions.agents.extensions.base as base\n" + "assert not hasattr(base, 'configure_durable_app')\n" "assert 'azure.durable_functions' not in sys.modules" ), ], From 164bd0e559cb003fc7812d19d8b4a437ec50e77b Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Wed, 9 Sep 2026 21:44:58 -0500 Subject: [PATCH 27/30] Discover YAML workflows and host them through DAFX --- .../README.md | 41 ++ .../extensions/agent_framework/_workflows.py | 185 ++++++++ .../agents/extensions/agent_framework/apps.py | 26 +- .../pyproject.toml | 4 + .../samples/README.md | 4 + .../samples/durable-yaml-workflow/README.md | 140 ++++++ .../durable-yaml-workflow/VALIDATION.md | 54 +++ .../agents/writer.agent.md | 3 + .../durable-yaml-workflow/function_app.py | 11 + .../samples/durable-yaml-workflow/host.json | 7 + .../local_chat_client.py | 46 ++ .../workflows/Approval.workflow.yaml | 10 + .../workflows/OrderReview.workflow.yaml | 21 + .../tests/_yaml_workflow_probe.py | 433 ++++++++++++++++++ .../tests/test_apps.py | 1 + .../tests/test_imports.py | 3 + .../tests/test_samples.py | 23 +- .../tests/test_yaml_workflows.py | 98 ++++ .../agents/extensions/base/__init__.py | 5 +- .../agents/extensions/base/bindings.py | 5 + eng/templates/official/jobs/unit-tests.yml | 3 + 21 files changed, 1119 insertions(+), 4 deletions(-) create mode 100644 azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/agents/writer.agent.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/host.json create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/local_chat_client.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/Approval.workflow.yaml create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/OrderReview.workflow.yaml create mode 100644 azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py create mode 100644 azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index a37f43d..415bb8d 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -197,3 +197,44 @@ and MCP endpoints are disabled. See the [endpoint-only local sample](samples/lazy-owned-dafx/README.md) and the [durable binding sample](samples/durable-markdown-binding/README.md) for setup and deterministic examples that do not need a model service. + +## YAML workflows + +YAML hosting is a separate opt-in and currently requires **Python 3.13**. Install +both optional extras. Python 3.14 is rejected for this feature because the +declarative runtime needs PowerFx. + +```text +pip install "azurefunctions-agents-extensions-agent-framework[durable,workflows]" +``` + +```python +app = AgentFunctionApp( + client_factory=create_chat_client, + durable=True, + workflows=True, +) +``` + +Only `*.workflow.yaml` and `*.workflow.yml` directly in the app root or its +`workflows/` directory are discovered, not arbitrary YAML or nested files. Each +definition needs `kind: Workflow` and an explicit `name` of 1–63 ASCII letters, +digits, hyphens, or underscores, starting with a letter. Names must be unique +ignoring case. + +The extension builds graphs with `WorkflowFactory` and supplies them to DAFX's +`workflows=` constructor. With the default route prefix, each graph gets +`POST /api/workflow/NAME/run`, `GET /api/workflow/NAME/status/{instanceId}`, and +`POST /api/workflow/NAME/respond/{instanceId}/{requestId}`. No custom +orchestration or handwritten HTTP handlers are needed. + +`InvokeAzureAgent` accepts static Markdown references, either `agent: writer` or +`agent: {name: writer}`. Inline top-level `agents` definitions, file-based YAML +agents, and dynamic agent names are rejected. Agent actions run as durable +activities using the existing `MarkdownDurableAgent` open/close lifecycle, not +through the agent entity in the same graph. `durable=True` still publishes all +discovered Markdown agents and their standalone HTTP endpoints. + +See the [local YAML sample](samples/durable-yaml-workflow/README.md) for shared +state, a Markdown agent call, and a separate question/response workflow. The +sample uses a deterministic client and does not provision a host or backend. diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py new file mode 100644 index 0000000..c14dbe7 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py @@ -0,0 +1,185 @@ +"""Opt-in loading of MAF YAML workflows for the DAFX Functions host.""" + +from __future__ import annotations + +import re +import sys +from collections.abc import Callable, Iterable, Iterator +from importlib import import_module +from pathlib import Path +from typing import Any + +from agent_framework import SupportsAgentRun, Workflow + +_SUFFIXES = (".workflow.yaml", ".workflow.yml") +_WORKFLOW_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,62}") + + +def _definition_paths(root: Path) -> list[Path]: + paths = [] + for directory in (root, root / "workflows"): + if not directory.is_dir(): + continue + for path in sorted(directory.iterdir()): + if not path.name.endswith(_SUFFIXES): + continue + if not path.is_file(): + raise ValueError(f"Workflow definition {path.name!r} is not a file.") + if not path.resolve().is_relative_to(root): + raise ValueError(f"Workflow definition {path.name!r} escapes app root.") + paths.append(path) + return paths + + +def _nodes( + value: Any, ancestors: frozenset[int] = frozenset(), +) -> Iterator[dict[str, Any]]: + """Walk definitions without recursively following a cyclic YAML alias.""" + if not isinstance(value, (dict, list)): + return + if id(value) in ancestors: + raise ValueError("Cyclic YAML aliases are not supported in workflows.") + ancestors = ancestors | {id(value)} + children: Iterable[Any] + if isinstance(value, dict): + yield value + children = value.values() + else: + children = value + for child in children: + yield from _nodes(child, ancestors) + + +def load_workflows( + root: Path, + resolve_agent: Callable[[str], SupportsAgentRun], +) -> list[Workflow]: + """Load selected definitions without instantiating live agents or clients. + + YAML agent actions reference markdown names. Inline/file-based YAML agent + construction and implicit HTTP/MCP action handlers are intentionally excluded. + The factory still validates the supported YAML action schemas. + """ + # MAF currently omits its PowerFx dependency on 3.14. Refuse this opt-in + # rather than silently treating expressions as literal strings. + if sys.version_info >= (3, 14): + raise RuntimeError("YAML workflows currently require Python 3.13 (PowerFx).") + paths = _definition_paths(root) + try: + import yaml + from agent_framework.declarative import WorkflowFactory + builder = import_module( + "agent_framework_declarative._workflows._declarative_builder" + ) + except ModuleNotFoundError as error: + if error.name not in {"yaml", "agent_framework_declarative"}: + raise + raise ImportError( + "YAML workflow support is not installed. Install " + "'azurefunctions-agents-extensions-agent-framework[durable,workflows]'." + ) from error + + class UniqueKeyLoader(yaml.SafeLoader): + def construct_mapping(self, node: Any, deep: bool = False) -> Any: + self.flatten_mapping(node) + keys: set[Any] = set() + for key_node, _ in node.value: + key = self.construct_object(key_node, deep=deep) + if not isinstance(key, (str, int, float, bool, type(None))): + raise ValueError("Workflow YAML keys must be scalar values.") + if key in keys: + raise ValueError(f"Duplicate YAML key {key!r}.") + keys.add(key) + return super().construct_mapping(node, deep=deep) + + definitions: list[tuple[Path, dict[str, Any], set[str]]] = [] + names: set[str] = set() + for path in paths: + definition = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) + if not isinstance(definition, dict): + raise ValueError(f"Workflow {path.name!r} must contain a YAML mapping.") + name = definition.get("name") + if not isinstance(name, str) or _WORKFLOW_NAME.fullmatch(name) is None: + raise ValueError( + f"Workflow {path.name!r} needs an explicit name of 1-63 ASCII " + "letters, digits, hyphens or underscores, starting with a letter." + ) + if name.casefold() in names: + raise ValueError(f"Duplicate workflow name {name!r}.") + names.add(name.casefold()) + if definition.get("agents"): + raise ValueError( + f"Workflow {name!r}: inline/file agent definitions are not supported; " + "reference a markdown agent by name in InvokeAzureAgent instead." + ) + # Force cycle validation before inspecting action schema fields. + list(_nodes(definition)) + references: set[str] = set() + # MAF logs and skips unknown actions. Reject them instead of publishing + # an incomplete graph. The registry is internal to the pinned loader; + # structural actions are handled separately by its graph builder. + action_kinds = set(builder.ALL_ACTION_EXECUTORS) | { + "If", "ConditionGroup", "Foreach", "GotoAction", + "BreakLoop", "ContinueLoop", + } + + def actions_in(container: dict[str, Any]) -> Iterator[dict[str, Any]]: + container_kind = container.get("kind") + if container_kind == "If": + if "elseActions" in container: + raise ValueError("If uses 'else', not 'elseActions'.") + if "then" in container and "actions" in container: + raise ValueError("If cannot define both 'then' and 'actions'.") + fields = ( + ("then", "else") if "then" in container else ("actions", "else") + ) + else: + fields = ("actions", "elseActions") + for field in fields: + actions = container.get(field) + if isinstance(actions, list): + for action in actions: + kind = action.get("kind") if isinstance(action, dict) else None + if not isinstance(kind, str) or kind not in action_kinds: + raise ValueError(f"Unknown workflow action kind {kind!r}.") + if kind in { + "InvokeFunctionTool", "HttpRequestAction", "InvokeMcpTool", + }: + raise ValueError( + f"{kind} requires a workflow handler that this loader " + "does not configure. Agent tools remain supported." + ) + yield action + yield from actions_in(action) + if kind == "ConditionGroup": + for condition in action.get("conditions", []): + if isinstance(condition, dict): + yield from actions_in(condition) + + if "actions" in definition and "trigger" in definition: + raise ValueError("Workflow cannot define both root actions and trigger.") + container = definition.get("trigger", definition) + if not isinstance(container, dict): + raise ValueError("Workflow trigger must be a mapping.") + for node in actions_in(container): + if node.get("kind") != "InvokeAzureAgent": + continue + agent = node.get("agent", node.get("agentName")) + if isinstance(agent, dict): + agent = agent.get("name") + if not isinstance(agent, str) or not agent or agent.startswith("="): + raise ValueError( + f"Workflow {name!r}: InvokeAzureAgent requires a static markdown " + "agent name (dynamic names are not supported)." + ) + references.add(agent) + definitions.append((path, definition, references)) + + workflows = [] + for path, definition, references in definitions: + agents = {name: resolve_agent(name) for name in sorted(references)} + factory = WorkflowFactory(agents=agents) + workflows.append(factory.create_workflow_from_definition( + definition, base_path=path.parent, + )) + return workflows 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 63065d3..6840332 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 @@ -8,13 +8,14 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast import azure.functions as func -from agent_framework import SupportsAgentRun, ToolTypes +from agent_framework import SupportsAgentRun, ToolTypes, Workflow from azure.functions.decorators.function_app import Function from azurefunctions.agents.extensions.base import ( compile_agent, configure_app, discover_agent_names, + get_app_root, ) from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent @@ -87,15 +88,21 @@ def __init__( ) = None, http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, durable: bool = False, + workflows: bool = False, ) -> None: if not isinstance(durable, bool): raise TypeError("durable must be a bool") + if not isinstance(workflows, bool): + raise TypeError("workflows must be a bool") + if workflows and not durable: + raise ValueError("workflows=True requires durable=True.") super().__init__( http_auth_level=http_auth_level, ) self._durable_app: DurableAgentFunctionApp | None = None self._functions_indexed = False self._markdown_agents: dict[str, str] = {} + self._hosted_workflows: list[Workflow] = [] configure_app( self, provider=AGENT_FRAMEWORK_PROVIDER_ID, @@ -112,6 +119,22 @@ def __init__( self._compile_durable_markdown(name) for name in discover_agent_names(self) ] + if workflows: + from ._durable import MarkdownDurableAgent + from ._workflows import load_workflows + + recipes = {binding.agent_name: binding for binding in bindings} + + def resolve_agent(name: str) -> SupportsAgentRun: + if name not in recipes: + # Use the same validation/error for missing and mis-cased + # references as a standalone markdown declaration. + recipes[name] = self._compile_durable_markdown(name) + return MarkdownDurableAgent(recipes[name]) + + self._hosted_workflows = load_workflows( + get_app_root(self), resolve_agent, + ) self._ensure_durable_app() for binding in bindings: self._register_durable_markdown(binding) @@ -244,6 +267,7 @@ def _ensure_durable_app(self) -> DurableAgentFunctionApp: ) from error self._durable_app = DurableAgentFunctionApp( + workflows=self._hosted_workflows, http_auth_level=self.auth_level, enable_health_check=False, enable_http_endpoints=True, diff --git a/azurefunctions-agents-extensions-agent-framework/pyproject.toml b/azurefunctions-agents-extensions-agent-framework/pyproject.toml index 6a115c1..28a2b4e 100644 --- a/azurefunctions-agents-extensions-agent-framework/pyproject.toml +++ b/azurefunctions-agents-extensions-agent-framework/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ ] [project.optional-dependencies] +workflows = [ + "agent-framework-declarative==1.0.3", +] mcp = [ "azure-identity>=1.25.3,<2", "httpx>=0.27,<1", @@ -47,6 +50,7 @@ dev = [ "coverage", "flake8", "mypy", + "types-PyYAML", "pre-commit", "pytest", "pytest-cov", diff --git a/azurefunctions-agents-extensions-agent-framework/samples/README.md b/azurefunctions-agents-extensions-agent-framework/samples/README.md index e7bc66e..65690fb 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/README.md @@ -34,6 +34,10 @@ examples use deterministic clients without model credentials. * [Durable markdown binding](durable-markdown-binding/README.md) injects a proxy into a generator orchestrator, runs two turns in one session, and includes an HTTP starter. It also uses a deterministic local client. +- [Durable YAML workflows](durable-yaml-workflow/README.md) enables + `durable=True, workflows=True` for shared state, a Markdown agent activity, + and a separate approval question. No handwritten handlers are needed. + Requires Python 3.13 and the `[durable,workflows]` extras. ## Prerequisites diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md new file mode 100644 index 0000000..0b4f9ad --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md @@ -0,0 +1,140 @@ +# Durable YAML workflows + +[function_app.py](function_app.py) enables `durable=True, workflows=True` with no +handwritten handlers or orchestrators. The extension loads YAML through MAF's +`WorkflowFactory` and passes the resulting graphs to DAFX's `workflows=` +constructor. DAFX supplies the orchestration, activities, and HTTP routes. + +- [OrderReview.workflow.yaml](workflows/OrderReview.workflow.yaml) copies the + request's `order` into shared state, builds a prompt, calls the Markdown + `writer`, and emits `Local.reply`. `resultProperty` belongs on the agent action; + `output.autoSend: false` leaves output to the final `SendActivity`. +- [Approval.workflow.yaml](workflows/Approval.workflow.yaml) is a separate + workflow. `Question` waits for input, saves it in `Local.answer`, then + `SendActivity` emits the answer. It is not an approval gate for `OrderReview`. + +[local_chat_client.py](local_chat_client.py) is copied from the +[durable binding sample](../durable-markdown-binding/README.md). It counts user +turns and echoes the prompt without model credentials or network calls. It does +not perform a real order review or interpret the writer's instructions. + +## Install and run + +Use **Python 3.13**. YAML workflows need PowerFx, and this prototype rejects the +YAML opt-in on Python 3.14. From the repository root, in a Python 3.13 virtual +environment, install the local packages and both optional extras. + +```powershell +python -m pip install -e ./azurefunctions-agents-extensions-base +python -m pip install -e './azurefunctions-agents-extensions-agent-framework[durable,workflows]' +``` + +The durable extra pins the prototype dependencies from +[DAFX PR #72](https://github.com/microsoft/agent-framework-durable-extension/pull/72). +Core Tools execution also needs an SDK 2-compatible Functions host/extension and +a configured Durable backend. This sample does not provision or verify either. +Set `FUNCTIONS_WORKER_RUNTIME=python` and `AzureWebJobsStorage` for your backend, +then start from the sample directory. + +```powershell +cd azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow +func start +``` + +## Invoke OrderReview + +In another PowerShell terminal, start a workflow with a nonempty string `order`. +The JSON body is the workflow input, not an agent `message` envelope. + +```powershell +$run = Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflow/OrderReview/run ` + -ContentType application/json -Body '{"order":"42"}' +Invoke-RestMethod -Uri $run.statusQueryGetUri +``` + +The start response is `202` with `instanceId` and `statusQueryGetUri`. Query the +status URL again until completion. The expected `output` for this input is +`["User turn 1: Review order 42."]`. + +## Invoke Approval + +```powershell +$approval = Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflow/Approval/run ` + -ContentType application/json -Body '{}' +$status = Invoke-RestMethod -Uri $approval.statusQueryGetUri +$status.pendingHumanInputRequests +``` + +Query the status URL again until `pendingHumanInputRequests` contains the +question. Use its `respondUrl`, not the YAML action ID. Send the response object +expected by `Question`. + +```powershell +$pending = $status.pendingHumanInputRequests[0] +Invoke-RestMethod -Method Post -Uri $pending.respondUrl ` + -ContentType application/json -Body '{"user_input":"approved"}' +Invoke-RestMethod -Uri $approval.statusQueryGetUri +``` + +Query status again until completion. The expected `output` is `["approved"]`. +This example accepts free text. It demonstrates pause/resume, not approval +validation, authorization, or a business side effect. + +## Routes and execution + +With the default `/api` prefix, each workflow name (`OrderReview` and `Approval`) +gets these generated routes. + +| Method | Route | +| --- | --- | +| POST | `/api/workflow/NAME/run` | +| GET | `/api/workflow/NAME/status/{instanceId}` | +| POST | `/api/workflow/NAME/respond/{instanceId}/{requestId}` | + +The pinned dependencies index 20 functions. Workflow suffixes below are appended +to the prefix with `-`; each prefix itself is the orchestrator function. + +| Prefix | Generated suffixes | +| --- | --- | +| `dafx-OrderReview` | `start`, `status`, `respond`, `_workflow_entry`, `capture_order`, `prepare_prompt`, `review_order`, `send_review` | +| `dafx-Approval` | `start`, `status`, `respond`, `_workflow_entry`, `request_approval`, `send_answer` | + +The other functions are `dafx-writer`, `http-writer`, `BuiltIn__HttpActivity`, +and `BuiltIn__HttpPollOrchestrator`. + +`durable=True` still discovers and publishes every Markdown agent directly in +the app root or `agents/`, including `POST /api/agents/writer/run`. Calling that +endpoint bypasses both workflows. Hosted requests need a function key, including +requests to returned status and response URLs. + +Inside a YAML graph, agent actions run as **durable activities** through the +`MarkdownDurableAgent` lifecycle. Each run opens and closes a fresh Agent and +client. These actions do not call the writer's durable entity or share that +endpoint's entity session. DAFX carries workflow shared state between actions. + +## Boundaries + +See [VALIDATION.md](VALIDATION.md) for measured results and test limitations. + +- `workflows=True` requires `durable=True` and the `[durable,workflows]` extras. +- Only `*.workflow.yaml` and `*.workflow.yml` directly in the app root or its + `workflows/` directory are discovered. Discovery is not recursive and does not + load arbitrary YAML files. +- Each definition uses `kind: Workflow` and an explicit `name` of 1–63 ASCII + letters, digits, `_`, or `-`, starting with a letter. Names must be unique + ignoring case. The YAML name, not the filename, determines the route. +- Agent references must be static Markdown names, such as `agent: writer` or + `agent: {name: writer}`, matching [writer.agent.md](agents/writer.agent.md). + Inline top-level `agents` definitions, file-based YAML agents, and dynamic + agent names are not supported. Workflow-level `InvokeFunctionTool`, + `HttpRequestAction`, and `InvokeMcpTool` are rejected because no handlers are + configured for them. Python/MCP tools on the referenced agents remain supported. +- Use either root `actions` or `trigger.actions`, not both. `If` supports `then` + (or `actions`) and `else`; `elseActions` belongs to `ConditionGroup`. +- The declarative loader is pinned to 1.0.3. Its internal action registry is used + to reject unknown actions rather than allowing the loader to warn and skip them. +- Keep action IDs stable across reloads. The samples supply explicit IDs. +- `OrderReview` expects the documented input shape and adds no request schema + validation. Local execution or indexing is not host/backend validation. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md new file mode 100644 index 0000000..e7605ef --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md @@ -0,0 +1,54 @@ +# YAML discovery verification + +Verified on Windows, Python 3.13.11, core 1.16.0, declarative 1.0.3, +Functions 2.3.0, Durable 2.0.0rc1 and DAFX PR #72 at `aa9529ec`. + +- Both agent-package suites passed 152 tests with the optional workflows package. +- Without YAML dependencies, 148 tests passed and four YAML-only tests skipped. +- The fresh non-durable environment passed five import tests with YAML, PowerFx, + DAFX and Durable imports blocked. +- Strict mypy passed on 12 source files. Flake8, whitespace, dependency consistency, + and both package wheel/source builds passed. + +The YAML subprocess suite contains nine replay cases: simple output, shared state, +ConditionGroup true/else branches, Foreach, an agent call, Question pause/resume, +and agent calls inside both If branches. Each reconstructs the outer app and YAML +graph before every orchestration activation and activity. The actual SDK protobuf +orchestration handler and registered DAFX activities execute; only storage and +dispatch are represented by in-memory history. Agent calls open/close a fresh +client and do not repeat during orchestration replay. + +The sample's real local client and documented order input produce +`["User turn 1: Review order 42."]`; Approval resumes with `["approved"]`. +Its complete 20-function index and generated endpoint metadata are checked. + +## Change analysis + +- Workflow loading precedes construction of the one owned DAFX app. Plain and + markdown-only paths do not import the declarative package. Existing binding + registration, HTTP auth and repeated indexing tests remain green. +- Discovery handles both suffixes and locations, validates explicit stable names, + duplicate keys/names, YAML cycles, malformed definitions, directories and + escaping symlinks. Arbitrary YAML elsewhere is not discovered. +- Registration keeps live resources out of indexing. Inline/file-based YAML agent + definitions and dynamic agent names fail rather than constructing hidden clients. +- The action walker follows the pinned loader's If and ConditionGroup structures, + not arbitrary literal dictionaries. Unknown actions fail instead of being skipped. + Conflicting root/trigger or If branch definitions are rejected. Workflow-level + tools without registered handlers fail; agent-level tools are unaffected. +- A read-only review found missing If branch discovery. A real replay reproduced + `Agent 'writer' invocation failed: not found in registry`; both branches pass + after correction. It also identified shadowed action lists and unconfigured + function tools, now covered by rejection tests. +- Removing workflow loading in an in-memory mutation makes the replay test fail + on missing `dafx-Simple`; restoring the implementation passes all workflow tests. + The earlier shared-state-loss mutation also failed the expected output assertion. +- Action registry knowledge comes from the pinned declarative loader. Structural + action traversal is tested with root/trigger forms, nested If, ConditionGroup, + Foreach, and literal data containing keys named `actions`. + +No live Functions host, storage backend, external model or MCP service was run. +Retries, parallel execution and nested workflows are not claimed as verified. +The subprocess helper exits after all assertions to isolate embedded PowerFx/CLR +shutdown behavior from pytest. It does not bypass application logic or assertions. +YAML support is limited to Python 3.13 until the PowerFx dependency supports 3.14. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/agents/writer.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/agents/writer.agent.md new file mode 100644 index 0000000..6e5c620 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/agents/writer.agent.md @@ -0,0 +1,3 @@ +You are an order review assistant. +Write a concise review of the supplied order and flag missing details. +Do not claim that an order was approved, charged, or shipped without confirmation. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py new file mode 100644 index 0000000..1c96335 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py @@ -0,0 +1,11 @@ +"""Discover Markdown agents and YAML workflows without handwritten handlers.""" + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + +from local_chat_client import LocalChatClient + +app = AgentFunctionApp( + client_factory=LocalChatClient, + durable=True, + workflows=True, +) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/host.json b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/host.json new file mode 100644 index 0000000..b7e5ad1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/local_chat_client.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/local_chat_client.py new file mode 100644 index 0000000..71fa95b --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/local_chat_client.py @@ -0,0 +1,46 @@ +"""A deterministic model substitute with no credentials or network resources.""" + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import Any + +from agent_framework import ( + BaseChatClient, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + ResponseStream, +) + + +class LocalChatClient(BaseChatClient): + """Count user messages in the supplied history and echo the latest prompt.""" + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ): + turns = sum(message.role == "user" for message in messages) + text = f"User turn {turns}: {messages[-1].text}" + created_at = datetime.now(timezone.utc).isoformat() + + async def updates(): + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text(text)], + created_at=created_at, + ) + + async def respond(): + return ChatResponse( + messages=[Message(role="assistant", contents=[text])], + created_at=created_at, + ) + + if stream: + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + return respond() diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/Approval.workflow.yaml b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/Approval.workflow.yaml new file mode 100644 index 0000000..4df85b3 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/Approval.workflow.yaml @@ -0,0 +1,10 @@ +kind: Workflow +name: Approval +actions: + - kind: Question + id: request_approval + question: Approve this order? Reply approved or rejected. + variable: Local.answer + - kind: SendActivity + id: send_answer + activity: =Local.answer diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/OrderReview.workflow.yaml b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/OrderReview.workflow.yaml new file mode 100644 index 0000000..79d4481 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/OrderReview.workflow.yaml @@ -0,0 +1,21 @@ +kind: Workflow +name: OrderReview +actions: + - kind: SetValue + id: capture_order + path: Local.order + value: =Workflow.Inputs.order + - kind: SetValue + id: prepare_prompt + path: Local.prompt + value: '="Review order " & Local.order & "."' + - kind: InvokeAzureAgent + id: review_order + agent: writer + input: =Local.prompt + resultProperty: Local.reply + output: + autoSend: false + - kind: SendActivity + id: send_review + activity: =Local.reply diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py new file mode 100644 index 0000000..8084e17 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py @@ -0,0 +1,433 @@ +"""Real YAML/DAFX probes isolated from pytest's embedded-CLR reporting hooks.""" +from __future__ import annotations + +import base64 +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import traceback + +import azure.functions as func +from agent_framework import ( + BaseChatClient, ChatResponse, ChatResponseUpdate, Content, Message, ResponseStream, +) +from agent_framework_durabletask import deserialize_workflow_output +from durabletask.internal import orchestrator_service_pb2 as pb +from google.protobuf.timestamp_pb2 import Timestamp +from google.protobuf.wrappers_pb2 import StringValue + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +class LocalClient(BaseChatClient): + instances = [] + calls = [] + + def __init__(self): + super().__init__() + self.entered = self.closed = False + self.instances.append(self) + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, *args): + self.closed = True + + def _inner_get_response(self, *, messages, stream, options, **kwargs): + self.calls.append([m.text for m in messages]) + + async def updates(): + assert self.entered and not self.closed + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text("ok")], + ) + + async def response(): + return ChatResponse(messages=[Message(role="assistant", contents=["ok"])]) + + return (ResponseStream(updates(), finalizer=ChatResponse.from_updates) + if stream else response()) + + +def write_workflow(root, content, filename="probe.workflow.yaml"): + path = root / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def make_app(root): + before = len(LocalClient.instances) + app = AgentFunctionApp( + client_factory=LocalClient, app_root=root, durable=True, workflows=True, + ) + assert len(LocalClient.instances) == before, "Live client created during loading" + return app + + +def index(root): + app = make_app(root) + before = len(LocalClient.instances) + functions = {fn.get_function_name(): fn for fn in app.get_functions()} + assert len(LocalClient.instances) == before, "Live client created during indexing" + return app, functions + + +def event(event_id=-1, **kwargs): + return pb.HistoryEvent( + eventId=event_id, timestamp=Timestamp(seconds=1_700_000_000), **kwargs, + ) + + +def run_workflow(root, name, input_data=None, human_response=None): + """Rebuild the outer app on every activation and activity, replay SDK history.""" + history = [] + new_events = [ + event(orchestratorStarted=pb.OrchestratorStartedEvent()), + event(0, executionStarted=pb.ExecutionStartedEvent( + name=f"dafx-{name}", input=StringValue(value=json.dumps(input_data or {})), + orchestrationInstance=pb.OrchestrationInstance( + instanceId="yaml-probe", executionId=StringValue(value="execution-1")), + )), + ] + activity_names = [] + answered = [] + for _ in range(60): + _, functions = index(root) + request = pb.OrchestratorRequest( + instanceId="yaml-probe", pastEvents=history, newEvents=new_events, + ) + encoded = functions[f"dafx-{name}"].get_user_function()( + func.OrchestrationContext(base64.b64encode(request.SerializeToString())) + ) + result = pb.OrchestratorResponse.FromString(base64.b64decode(encoded)) + status = ( + json.loads(result.customStatus.value) if result.customStatus.value else {} + ) + history.extend(new_events) + upcoming = [event(orchestratorStarted=pb.OrchestratorStartedEvent())] + for action in result.actions: + if action.HasField("completeOrchestration"): + done = action.completeOrchestration + assert done.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED, ( + done + ) + return (deserialize_workflow_output(json.loads(done.result.value)), + activity_names, answered) + assert action.HasField("scheduleTask"), action + scheduled = action.scheduleTask + history.append(event(action.id, taskScheduled=pb.TaskScheduledEvent( + name=scheduled.name, input=scheduled.input, + ))) + _, cold_functions = index(root) + handler = cold_functions[scheduled.name].get_user_function() + output = handler(json.loads(scheduled.input.value)) + activity_names.append(scheduled.name) + upcoming.append(event(1000 + len(activity_names), + taskCompleted=pb.TaskCompletedEvent( + taskScheduledId=action.id, result=StringValue(value=json.dumps(output)), + ))) + history.append(event(orchestratorCompleted=pb.OrchestratorCompletedEvent())) + if len(upcoming) == 1: + pending = status.get("pending_requests", {}) + assert len(pending) == 1 and human_response is not None, status + request_id = next(iter(pending)) + assert request_id not in answered + answered.append(request_id) + upcoming.append(event(2000 + len(answered), eventRaised=pb.EventRaisedEvent( + name=request_id, + input=StringValue(value=json.dumps({"user_input": human_response})), + ))) + new_events = upcoming + raise AssertionError("Workflow exceeded activation limit") + + +CASES = { + "simple": ("""name: Simple +actions: + - kind: SendActivity + id: greet + activity: Hello +""", "Simple", {}, ["Hello"]), + "state": ("""name: State +actions: + - kind: SetValue + id: set + path: Local.count + value: 41 + - kind: SetValue + id: increment + path: Local.count + value: =Local.count + 1 + - kind: SendActivity + id: output + activity: =Text(Local.count) +""", "State", {}, ["42"]), + "branch": ("""name: Branch +actions: + - kind: ConditionGroup + id: choose + conditions: + - condition: =Workflow.Inputs.color = "red" + actions: + - kind: SendActivity + id: red + activity: RED + elseActions: + - kind: SendActivity + id: other + activity: OTHER +""", "Branch", {"color": "red"}, ["RED"]), + "loop": ("""name: Loop +actions: + - kind: Foreach + id: loop + source: [apple, banana, cherry] + itemName: fruit + actions: + - kind: SendActivity + id: item + activity: =Local.fruit +""", "Loop", {}, ["apple", "banana", "cherry"]), + "agent": ("""name: Agent +actions: + - kind: InvokeAzureAgent + id: writer + agent: writer + input: hello + resultProperty: Local.reply + output: + autoSend: false + - kind: SendActivity + id: output + activity: =Local.reply +""", "Agent", {}, ["ok"]), + "human": ("""name: Human +actions: + - kind: Question + id: ask + question: Approve? + variable: Local.answer + - kind: SendActivity + id: output + activity: =Local.answer +""", "Human", {}, ["approved"]), +} +CASES["else"] = (CASES["branch"][0], "Branch", {"color": "blue"}, ["OTHER"]) +CASES["if-agent"] = (json.dumps({ + "name": "IfAgent", + "actions": [ + { + "kind": "If", "id": "choose", "condition": True, + "then": [{ + "kind": "InvokeAzureAgent", "id": "writer", "agent": "writer", + "input": "hello", "resultProperty": "Local.reply", + "output": {"autoSend": False}, + }], + "else": [{ + "kind": "SetValue", "id": "fallback", "path": "Local.reply", + "value": "wrong", + }], + }, + {"kind": "SendActivity", "id": "output", "activity": "=Local.reply"}, + ], +}), "IfAgent", {}, ["ok"]) +else_definition = json.loads(CASES["if-agent"][0]) +branch = else_definition["actions"][0] +branch["condition"] = False +branch["then"], branch["else"] = branch["else"], branch["then"] +CASES["if-else-agent"] = (json.dumps(else_definition), "IfAgent", {}, ["ok"]) + + +def execution_checks(): + results = {} + for label, (yaml, name, data, expected) in CASES.items(): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, yaml) + if label in {"agent", "if-agent", "if-else-agent"}: + (root / "writer.agent.md").write_text("Be helpful", encoding="utf-8") + LocalClient.instances.clear() + LocalClient.calls.clear() + output, activities, answered = run_workflow(root, name, data, "approved") + assert output == expected, (label, output) + if label in {"agent", "if-agent", "if-else-agent"}: + assert len(LocalClient.calls) == 1, LocalClient.calls + assert len(LocalClient.instances) == 1 + assert all(c.entered and c.closed for c in LocalClient.instances) + if label == "human": + assert len(answered) == 1 + results[label] = {"output": output, "activities": activities} + return results + + +def validation_checks(): + checks = [] + + def invalid(label, files, expected): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + for filename, content in files.items(): + write_workflow(root, content, filename) + try: + make_app(root) + except Exception as error: + assert expected.lower() in str(error).lower(), (label, str(error)) + else: + raise AssertionError(f"Accepted invalid definition: {label}") + checks.append(label) + + for label, content, error in [ + ("scalar", "hello", "mapping"), + ("malformed", "[", "parsing"), + ("unnamed", "actions: []", "explicit name"), + ("bad-name", "name: ../bad\nactions: []", "explicit name"), + ("duplicate-key", "name: First\nname: Second", "Duplicate YAML key"), + ("cyclic-alias", "name: Cycle\nactions: &a [*a]", "Cyclic"), + ("inline-agent", "name: Inline\nagents: {writer: {kind: Prompt}}", + "inline/file"), + ("file-agent", "name: File\nagents: {writer: {file: ../escape.yaml}}", + "inline/file"), + ("unknown-action", "name: Unknown\nactions: [{kind: Imaginary}]", "Unknown"), + ("missing-agent", CASES["agent"][0], "was not found"), + ("dynamic-agent", CASES["agent"][0].replace( + "agent: writer", "agent: =Local.name"), + "static markdown"), + ]: + invalid(label, {"probe.workflow.yaml": content}, error) + invalid("duplicate-name", { + "one.workflow.yaml": CASES["simple"][0], + "workflows/two.workflow.yml": CASES["simple"][0].replace("Simple", "simple"), + }, "Duplicate workflow name") + invalid("shadowed-root-actions", { + "probe.workflow.yaml": CASES["simple"][0] + "trigger: {actions: []}", + }, "both") + invalid("ignored-if-elseActions", { + "probe.workflow.yaml": CASES["if-agent"][0].replace( + '"else":', '"elseActions":'), + }, "elseActions") + invalid("unregistered-function-tool", { + "probe.workflow.yaml": "name: Tool\nactions:\n" + " - {kind: InvokeFunctionTool, id: tool, functionName: lookup}\n" + " - {kind: SendActivity, id: done, activity: DONE}\n", + }, "handler") + + invalid("unknown-nested-action", { + "probe.workflow.yaml": CASES["if-agent"][0].replace( + '"kind": "InvokeAzureAgent"', '"kind": "Imaginary"'), + }, "Unknown workflow action") + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + definition = json.loads(CASES["if-agent"][0]) + definition["trigger"] = {"actions": definition.pop("actions")} + write_workflow(root, json.dumps(definition)) + (root / "writer.agent.md").write_text("Be helpful", encoding="utf-8") + assert run_workflow(root, "IfAgent")[0] == ["ok"] + checks.append("trigger-nested-agent") + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, json.dumps({"name": "Literal", "actions": [ + {"kind": "SetValue", "id": "set", "path": "Local.data", + "value": {"actions": [{"kind": "NotAnAction"}]}}, + {"kind": "SendActivity", "id": "out", "activity": "done"}, + ]})) + make_app(root) + checks.append("literal-data-not-actions") + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, CASES["simple"][0], "first.workflow.yml") + write_workflow(root, CASES["state"][0], "workflows/second.workflow.yaml") + write_workflow(root, "not YAML", "ignored.yaml") + write_workflow(root, "not YAML", "nested/ignored.workflow.yaml") + app, functions = index(root) + assert set(app._durable_app.workflows) == {"Simple", "State"} + assert len(app._durable_app.agents) == 0 + assert [f.get_function_name() for f in app.get_functions()] == list(functions) + checks.append("both-suffixes-and-directories-only") + for workflow_name in ["Simple", "State"]: + assert f"dafx-{workflow_name}" in functions + routes = [ + b["route"] for f in functions.values() + for b in f.get_bindings_dict()["bindings"] if b["type"] == "httpTrigger" + ] + assert f"workflow/{workflow_name}/run" in routes + assert f"workflow/{workflow_name}/status/{{instanceId}}" in routes + respond_route = ( + f"workflow/{workflow_name}/respond/{{instanceId}}/{{requestId}}" + ) + assert respond_route in routes + checks.append("workflow-routes") + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "invalid.workflow.yaml").mkdir() + try: + make_app(root) + except ValueError as error: + assert "not a file" in str(error) + else: + raise AssertionError("Accepted directory") + checks.append("directory-rejected") + + return checks + + +def main(): + global LocalClient + mode = sys.argv[1] + if mode == "execution": + return execution_checks() + if mode == "validation": + return validation_checks() + if mode == "sample": + root = Path(__file__).parents[1] / "samples" / "durable-yaml-workflow" + spec = importlib.util.spec_from_file_location( + "yaml_sample_client", root / "local_chat_client.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + class SampleClient(module.LocalChatClient): + instances = [] + + def __init__(self): + super().__init__() + self.instances.append(self) + + LocalClient = SampleClient + return { + name: run_workflow(root, name, {"order": "42"}, "approved")[0] + for name in ["OrderReview", "Approval"] + } + if mode == "review": + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, CASES["if-agent"][0]) + (root / "writer.agent.md").write_text("Be helpful", encoding="utf-8") + output = run_workflow(root, "IfAgent")[0] + assert output == ["ok"], output + return output + if mode == "mutation": + from azurefunctions.agents.extensions.agent_framework import _workflows + _workflows.load_workflows = lambda *args: [] + return execution_checks() + raise ValueError(mode) + + +if __name__ == "__main__": + try: + print(json.dumps({"result": main()}), flush=True) + exit_code = 0 + except Exception: + traceback.print_exc() + exit_code = 1 + sys.stdout.flush() + sys.stderr.flush() + # The tests are complete; isolate PowerFx/CLR shutdown from the pytest host. + os._exit(exit_code) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py index f009531..b385d30 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py @@ -17,6 +17,7 @@ def test_typed_api_exposes_only_v1_options(): "tools", "http_auth_level", "durable", + "workflows", ] assert list(inspect.signature(AgentFunctionApp.markdown_agent).parameters) == [ "self", diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py index 106831f..b38da5b 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -48,6 +48,9 @@ def test_non_durable_binding_runs_with_all_durable_imports_blocked(tmp_path): import sys blocked = ( + 'agent_framework_declarative', + 'yaml', + 'powerfx', 'agent_framework_azurefunctions', 'agent_framework_durabletask', 'azure.durable_functions', diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py index 227c0d1..0154110 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -26,6 +26,17 @@ "orders", "start_orders", "dafx-orders", "http-orders", "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", }, + "durable-yaml-workflow": { + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + "dafx-writer", "http-writer", + "dafx-OrderReview", "dafx-OrderReview-start", "dafx-OrderReview-status", + "dafx-OrderReview-respond", "dafx-OrderReview-_workflow_entry", + "dafx-OrderReview-capture_order", "dafx-OrderReview-prepare_prompt", + "dafx-OrderReview-review_order", "dafx-OrderReview-send_review", + "dafx-Approval", "dafx-Approval-start", "dafx-Approval-status", + "dafx-Approval-respond", "dafx-Approval-_workflow_entry", + "dafx-Approval-request_approval", "dafx-Approval-send_answer", + }, } _LOCAL_SAMPLES = ("lazy-owned-dafx", "durable-markdown-binding") @@ -40,14 +51,15 @@ def _run_sample(sample_path, script): ]) ) completed = subprocess.run( - [sys.executable, "-c", textwrap.dedent(script)], + [sys.executable, "-X", "utf8", "-c", textwrap.dedent(script)], cwd=_SAMPLES_ROOT / sample_path, env=environment, capture_output=True, text=True, ) assert completed.returncode == 0, completed.stdout + completed.stderr - return json.loads(completed.stdout) + # PowerFx's loader may print initialization notices before the JSON result. + return json.loads(completed.stdout.strip().splitlines()[-1]) def test_index_cases_cover_every_sample_app(): @@ -59,6 +71,13 @@ def test_index_cases_cover_every_sample_app(): @pytest.mark.parametrize("sample_path", _SAMPLE_INDEXES) def test_sample_indexes_all_functions(sample_path): + if sample_path == "durable-yaml-workflow": + from importlib.util import find_spec + if ( + sys.version_info >= (3, 14) + or find_spec("agent_framework_declarative") is None + ): + pytest.skip("YAML sample requires Python 3.13 and the workflows extra") # Exact names were recorded from the SDK 2/DAFX PR #72 index. DAFX sanitizes # HTTP names (_build_function_name), but preserves hyphens in entity names. result = _run_sample(sample_path, """ diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py b/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py new file mode 100644 index 0000000..efd6c94 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import builtins +from importlib.util import find_spec +import json +from pathlib import Path +import subprocess +import sys +from unittest.mock import Mock + +import pytest + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.agent_framework import _workflows + + +@pytest.mark.parametrize("value", [None, 0, 1, "true", [], {}]) +def test_workflows_flag_requires_bool(tmp_path, value): + with pytest.raises(TypeError, match="workflows must be a bool"): + AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, workflows=value, + ) + + +def test_workflows_requires_explicit_durable_opt_in(tmp_path): + with pytest.raises(ValueError, match="requires durable=True"): + AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, workflows=True) + + +def test_workflow_files_are_ignored_without_workflow_opt_in(tmp_path): + (tmp_path / "bad.workflow.yaml").write_text("not valid: [", encoding="utf-8") + plain = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) + assert plain.get_functions() == [] + durable = AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, durable=True, + ) + assert durable._durable_app.workflows == {} + + +def test_unsupported_python_does_not_silently_ignore_expressions(tmp_path, monkeypatch): + monkeypatch.setattr(_workflows.sys, "version_info", (3, 14)) + with pytest.raises(RuntimeError, match="Python 3.13"): + _workflows.load_workflows(tmp_path, Mock()) + + +@pytest.mark.parametrize("missing", ["yaml", "agent_framework_declarative", "clr"]) +def test_missing_workflow_dependencies(tmp_path, monkeypatch, missing): + monkeypatch.setattr(_workflows.sys, "version_info", (3, 13)) + original = builtins.__import__ + + def blocked(name, *args, **kwargs): + if name == "yaml": + raise ModuleNotFoundError(name=missing) + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", blocked) + with pytest.raises(ImportError) as error: + _workflows.load_workflows(tmp_path, Mock()) + if missing == "clr": + assert error.value.name == "clr" + else: + assert "[durable,workflows]" in str(error.value) + + +def test_workflow_symlink_escape_is_rejected(tmp_path): + outside = tmp_path.parent / f"{tmp_path.name}-outside.yaml" + outside.write_text("name: Outside", encoding="utf-8") + try: + (tmp_path / "escape.workflow.yaml").symlink_to(outside) + except OSError as error: + pytest.skip(f"Symlinks unavailable: {error}") + with pytest.raises(ValueError, match="escapes app root"): + _workflows._definition_paths(tmp_path) + + +@pytest.mark.parametrize("mode", ["validation", "execution", "sample"]) +def test_real_yaml_workflow_probes(mode): + if sys.version_info >= (3, 14) or find_spec("agent_framework_declarative") is None: + pytest.skip("Requires Python 3.13 and workflows extra") + result = subprocess.run( + [sys.executable, "-X", "utf8", str(Path(__file__).with_name( + "_yaml_workflow_probe.py")), mode], + capture_output=True, text=True, encoding="utf-8", timeout=180, + ) + assert result.returncode == 0, result.stdout + result.stderr + data = json.loads(result.stdout.strip().splitlines()[-1])["result"] + if mode == "execution": + assert set(data) == { + "simple", "state", "branch", "else", "loop", "agent", "human", + "if-agent", "if-else-agent", + } + elif mode == "sample": + assert data == { + "OrderReview": ["User turn 1: Review order 42."], + "Approval": ["approved"], + } + else: + assert len(data) == 21 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 ae5e841..5ba3d62 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py @@ -1,6 +1,8 @@ from __future__ import annotations -from .bindings import compile_agent, configure_app, discover_agent_names, markdown_agent +from .bindings import ( + compile_agent, configure_app, discover_agent_names, get_app_root, markdown_agent, +) from .capabilities import ( AgentCapabilities, MCPAuthConfig, @@ -29,6 +31,7 @@ "compile_agent", "configure_app", "discover_agent_names", + "get_app_root", "load_provider", "markdown_agent", ] 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 a6cae1e..f03e866 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -108,6 +108,11 @@ def _configured_state(app: object) -> _AppState: return state +def get_app_root(app: object) -> Path: + """Return the configured definition root for provider-specific loaders.""" + return _configured_state(app).app_root + + def compile_agent( app: object, agent_name: str, diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index 55da262..deb0aa0 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -69,6 +69,9 @@ jobs: python -m pip install -e ./azurefunctions-agents-extensions-base cd azurefunctions-agents-extensions-agent-framework python -m pip install -U -e .[dev,durable,mcp] + if [ "$(PYTHON_VERSION)" = "3.13" ]; then + python -m pip install -e .[workflows] + fi displayName: 'Install Agents Framework Dependencies' - bash: | python -m pytest -q --instafail azurefunctions-agents-extensions-agent-framework/tests/ From 7ef11460ea8d31b8bcbec53f63d5c983d97e2d46 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Thu, 10 Sep 2026 10:55:03 -0500 Subject: [PATCH 28/30] Delegate YAML workflows to configurable native MAF factories --- .../README.md | 67 ++- .../extensions/agent_framework/_workflows.py | 173 ++---- .../agents/extensions/agent_framework/apps.py | 18 +- .../pyproject.toml | 3 +- .../samples/README.md | 7 +- .../configured-workflow-factory/README.md | 56 ++ .../function_app.py | 29 + .../configured-workflow-factory/host.json | 7 + .../workflows/ConfiguredTools.workflow.yaml | 15 + .../samples/durable-yaml-workflow/README.md | 69 ++- .../durable-yaml-workflow/VALIDATION.md | 119 ++-- .../tests/_native_workflow_probe.py | 508 ++++++++++++++++++ .../tests/_yaml_workflow_probe.py | 105 ++-- .../tests/test_apps.py | 1 + .../tests/test_samples.py | 11 +- .../tests/test_yaml_workflows.py | 85 ++- 16 files changed, 984 insertions(+), 289 deletions(-) create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/host.json create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/workflows/ConfiguredTools.workflow.yaml create mode 100644 azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index 415bb8d..b453d88 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -200,9 +200,8 @@ and deterministic examples that do not need a model service. ## YAML workflows -YAML hosting is a separate opt-in and currently requires **Python 3.13**. Install -both optional extras. Python 3.14 is rejected for this feature because the -declarative runtime needs PowerFx. +YAML hosting is a separate opt-in. Install both optional extras. The workflows +extra uses `agent-framework-declarative>=1.0.3,<2`. ```text pip install "azurefunctions-agents-extensions-agent-framework[durable,workflows]" @@ -216,25 +215,57 @@ app = AgentFunctionApp( ) ``` -Only `*.workflow.yaml` and `*.workflow.yml` directly in the app root or its -`workflows/` directory are discovered, not arbitrary YAML or nested files. Each -definition needs `kind: Workflow` and an explicit `name` of 1–63 ASCII letters, -digits, hyphens, or underscores, starting with a letter. Names must be unique -ignoring case. - -The extension builds graphs with `WorkflowFactory` and supplies them to DAFX's -`workflows=` constructor. With the default route prefix, each graph gets +`workflows=True` requires `durable=True`. Only `*.workflow.yaml` and +`*.workflow.yml` directly in the app root or its `workflows/` directory are +discovered, not arbitrary YAML or nested files. Discovered entry files must stay +within the app root. Each loaded result must be a MAF `Workflow` with a stable +name of 1–63 ASCII letters, digits, hyphens, or underscores, starting with a +letter. Names must be unique ignoring case. An explicit YAML `name` keeps routes +predictable, but naming and YAML parsing otherwise follow MAF. + +The extension calls the public `create_workflow_from_yaml_path(path)` method and +supplies the graphs to DAFX's `workflows=` constructor. With the default route +prefix, each graph gets `POST /api/workflow/NAME/run`, `GET /api/workflow/NAME/status/{instanceId}`, and `POST /api/workflow/NAME/respond/{instanceId}/{requestId}`. No custom orchestration or handwritten HTTP handlers are needed. -`InvokeAzureAgent` accepts static Markdown references, either `agent: writer` or -`agent: {name: writer}`. Inline top-level `agents` definitions, file-based YAML -agents, and dynamic agent names are rejected. Agent actions run as durable -activities using the existing `MarkdownDurableAgent` open/close lifecycle, not -through the agent entity in the same graph. `durable=True` still publishes all -discovered Markdown agents and their standalone HTTP endpoints. +By default, `WorkflowFactory(agents=...)` receives `MarkdownDurableAgent` +adapters for **all** discovered Markdown agents, including agents selected by +dynamic names. To configure MAF directly, pass a configured `WorkflowFactory` +object as `workflow_factory=` alongside `workflows=True`. That object is used +unchanged. Its agent registry is not automatically merged with discovered +Markdown agents. Configure its `agent_factory`, agents, registered tools, HTTP +or MCP handlers, and configuration through MAF's public APIs. + +The extension does not impose a separate YAML parser, action allowlist, or +restrictions on inline agents, file-based agents, dynamic agent references, or +workflow tool actions. These follow the installed MAF parser and builder, +including their warnings, errors, and required configuration. For example, +`InvokeFunctionTool` can use `WorkflowFactory.register_tool()`, while HTTP and +MCP actions need their MAF handlers. DAFX's hosting validations still apply. +This delegation is not a claim that every MAF feature has been execution-tested. + +Relative file references inside YAML use native MAF resolution from the workflow +file's directory. They are not sandboxed by the entry-file containment check. +Treat workflow files and their references as trusted deployment content. + +Agent actions execute as durable activities, not through the agent entity in the +same graph. Markdown adapters open and close fresh Agents, clients, and tools +per execution. Inline agents and agents supplied by a custom factory follow +MAF's or that factory's construction and resource lifecycle, which may construct +agents and clients during app initialization/indexing. The extension does not +wrap them in the Markdown lifecycle. `durable=True` still publishes all +discovered Markdown agents and their standalone HTTP endpoints, even with a +custom workflow factory. + +Python support follows the installed MAF dependencies, not an extension-level +Python 3.14 rejection. Expression execution has been verified on Python 3.13. +MAF declarative 1.0.3 excludes its PowerFx dependency on Python 3.14, so those +expression checks remain on 3.13. Python 3.14 execution is not claimed as verified. See the [local YAML sample](samples/durable-yaml-workflow/README.md) for shared state, a Markdown agent call, and a separate question/response workflow. The -sample uses a deterministic client and does not provision a host or backend. +[configured factory sample](samples/configured-workflow-factory/README.md) uses +a registered function tool and configuration without an agent client. Neither +sample provisions a host or backend. diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py index c14dbe7..0bb060f 100644 --- a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py @@ -3,11 +3,9 @@ from __future__ import annotations import re -import sys -from collections.abc import Callable, Iterable, Iterator -from importlib import import_module +from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import Protocol from agent_framework import SupportsAgentRun, Workflow @@ -15,6 +13,13 @@ _WORKFLOW_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,62}") +class WorkflowLoader(Protocol): + """Public loading surface implemented by MAF's WorkflowFactory.""" + + def create_workflow_from_yaml_path(self, yaml_path: str | Path) -> Workflow: + ... + + def _definition_paths(root: Path) -> list[Path]: paths = [] for directory in (root, root / "workflows"): @@ -31,155 +36,45 @@ def _definition_paths(root: Path) -> list[Path]: return paths -def _nodes( - value: Any, ancestors: frozenset[int] = frozenset(), -) -> Iterator[dict[str, Any]]: - """Walk definitions without recursively following a cyclic YAML alias.""" - if not isinstance(value, (dict, list)): - return - if id(value) in ancestors: - raise ValueError("Cyclic YAML aliases are not supported in workflows.") - ancestors = ancestors | {id(value)} - children: Iterable[Any] - if isinstance(value, dict): - yield value - children = value.values() - else: - children = value - for child in children: - yield from _nodes(child, ancestors) - - def load_workflows( root: Path, - resolve_agent: Callable[[str], SupportsAgentRun], + agents: Mapping[str, SupportsAgentRun], + factory: WorkflowLoader | None = None, ) -> list[Workflow]: - """Load selected definitions without instantiating live agents or clients. + """Discover files and hand them to MAF without interpreting its YAML schema. - YAML agent actions reference markdown names. Inline/file-based YAML agent - construction and implicit HTTP/MCP action handlers are intentionally excluded. - The factory still validates the supported YAML action schemas. + The default factory receives markdown recipes as a convenience registry. + A caller-supplied factory is used unchanged, including its agent registry, + agent factory, tools, handlers, configuration, and resource ownership. + MAF owns parsing, relative references, validation and agent construction. """ - # MAF currently omits its PowerFx dependency on 3.14. Refuse this opt-in - # rather than silently treating expressions as literal strings. - if sys.version_info >= (3, 14): - raise RuntimeError("YAML workflows currently require Python 3.13 (PowerFx).") paths = _definition_paths(root) - try: - import yaml - from agent_framework.declarative import WorkflowFactory - builder = import_module( - "agent_framework_declarative._workflows._declarative_builder" - ) - except ModuleNotFoundError as error: - if error.name not in {"yaml", "agent_framework_declarative"}: - raise - raise ImportError( - "YAML workflow support is not installed. Install " - "'azurefunctions-agents-extensions-agent-framework[durable,workflows]'." - ) from error - - class UniqueKeyLoader(yaml.SafeLoader): - def construct_mapping(self, node: Any, deep: bool = False) -> Any: - self.flatten_mapping(node) - keys: set[Any] = set() - for key_node, _ in node.value: - key = self.construct_object(key_node, deep=deep) - if not isinstance(key, (str, int, float, bool, type(None))): - raise ValueError("Workflow YAML keys must be scalar values.") - if key in keys: - raise ValueError(f"Duplicate YAML key {key!r}.") - keys.add(key) - return super().construct_mapping(node, deep=deep) + if factory is None: + try: + from agent_framework.declarative import WorkflowFactory + except ModuleNotFoundError as error: + if error.name != "agent_framework_declarative": + raise + raise ImportError( + "YAML workflow support is not installed. Install " + "'azurefunctions-agents-extensions-agent-framework[durable,workflows]'." + ) from error + factory = WorkflowFactory(agents=agents) - definitions: list[tuple[Path, dict[str, Any], set[str]]] = [] + workflows = [] names: set[str] = set() for path in paths: - definition = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) - if not isinstance(definition, dict): - raise ValueError(f"Workflow {path.name!r} must contain a YAML mapping.") - name = definition.get("name") + workflow = factory.create_workflow_from_yaml_path(path) + if not isinstance(workflow, Workflow): + raise TypeError("The workflow factory must return a MAF Workflow.") + name = workflow.name if not isinstance(name, str) or _WORKFLOW_NAME.fullmatch(name) is None: raise ValueError( - f"Workflow {path.name!r} needs an explicit name of 1-63 ASCII " + f"Workflow {path.name!r} needs a stable name of 1-63 ASCII " "letters, digits, hyphens or underscores, starting with a letter." ) if name.casefold() in names: raise ValueError(f"Duplicate workflow name {name!r}.") names.add(name.casefold()) - if definition.get("agents"): - raise ValueError( - f"Workflow {name!r}: inline/file agent definitions are not supported; " - "reference a markdown agent by name in InvokeAzureAgent instead." - ) - # Force cycle validation before inspecting action schema fields. - list(_nodes(definition)) - references: set[str] = set() - # MAF logs and skips unknown actions. Reject them instead of publishing - # an incomplete graph. The registry is internal to the pinned loader; - # structural actions are handled separately by its graph builder. - action_kinds = set(builder.ALL_ACTION_EXECUTORS) | { - "If", "ConditionGroup", "Foreach", "GotoAction", - "BreakLoop", "ContinueLoop", - } - - def actions_in(container: dict[str, Any]) -> Iterator[dict[str, Any]]: - container_kind = container.get("kind") - if container_kind == "If": - if "elseActions" in container: - raise ValueError("If uses 'else', not 'elseActions'.") - if "then" in container and "actions" in container: - raise ValueError("If cannot define both 'then' and 'actions'.") - fields = ( - ("then", "else") if "then" in container else ("actions", "else") - ) - else: - fields = ("actions", "elseActions") - for field in fields: - actions = container.get(field) - if isinstance(actions, list): - for action in actions: - kind = action.get("kind") if isinstance(action, dict) else None - if not isinstance(kind, str) or kind not in action_kinds: - raise ValueError(f"Unknown workflow action kind {kind!r}.") - if kind in { - "InvokeFunctionTool", "HttpRequestAction", "InvokeMcpTool", - }: - raise ValueError( - f"{kind} requires a workflow handler that this loader " - "does not configure. Agent tools remain supported." - ) - yield action - yield from actions_in(action) - if kind == "ConditionGroup": - for condition in action.get("conditions", []): - if isinstance(condition, dict): - yield from actions_in(condition) - - if "actions" in definition and "trigger" in definition: - raise ValueError("Workflow cannot define both root actions and trigger.") - container = definition.get("trigger", definition) - if not isinstance(container, dict): - raise ValueError("Workflow trigger must be a mapping.") - for node in actions_in(container): - if node.get("kind") != "InvokeAzureAgent": - continue - agent = node.get("agent", node.get("agentName")) - if isinstance(agent, dict): - agent = agent.get("name") - if not isinstance(agent, str) or not agent or agent.startswith("="): - raise ValueError( - f"Workflow {name!r}: InvokeAzureAgent requires a static markdown " - "agent name (dynamic names are not supported)." - ) - references.add(agent) - definitions.append((path, definition, references)) - - workflows = [] - for path, definition, references in definitions: - agents = {name: resolve_agent(name) for name in sorted(references)} - factory = WorkflowFactory(agents=agents) - workflows.append(factory.create_workflow_from_definition( - definition, base_path=path.parent, - )) + workflows.append(workflow) return workflows 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 6840332..5d7c3b6 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 @@ -20,6 +20,7 @@ from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent from .provider import AGENT_FRAMEWORK_PROVIDER_ID, AgentFrameworkBinding, ClientFactory +from ._workflows import WorkflowLoader if TYPE_CHECKING: from agent_framework_azurefunctions import ( @@ -89,6 +90,7 @@ def __init__( http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, durable: bool = False, workflows: bool = False, + workflow_factory: WorkflowLoader | None = None, ) -> None: if not isinstance(durable, bool): raise TypeError("durable must be a bool") @@ -96,6 +98,8 @@ def __init__( raise TypeError("workflows must be a bool") if workflows and not durable: raise ValueError("workflows=True requires durable=True.") + if workflow_factory is not None and not workflows: + raise ValueError("workflow_factory requires workflows=True.") super().__init__( http_auth_level=http_auth_level, ) @@ -123,17 +127,11 @@ def __init__( from ._durable import MarkdownDurableAgent from ._workflows import load_workflows - recipes = {binding.agent_name: binding for binding in bindings} - - def resolve_agent(name: str) -> SupportsAgentRun: - if name not in recipes: - # Use the same validation/error for missing and mis-cased - # references as a standalone markdown declaration. - recipes[name] = self._compile_durable_markdown(name) - return MarkdownDurableAgent(recipes[name]) - self._hosted_workflows = load_workflows( - get_app_root(self), resolve_agent, + get_app_root(self), + {binding.agent_name: MarkdownDurableAgent(binding) + for binding in bindings}, + factory=workflow_factory, ) self._ensure_durable_app() for binding in bindings: diff --git a/azurefunctions-agents-extensions-agent-framework/pyproject.toml b/azurefunctions-agents-extensions-agent-framework/pyproject.toml index 28a2b4e..da80a34 100644 --- a/azurefunctions-agents-extensions-agent-framework/pyproject.toml +++ b/azurefunctions-agents-extensions-agent-framework/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ [project.optional-dependencies] workflows = [ - "agent-framework-declarative==1.0.3", + "agent-framework-declarative>=1.0.3,<2", ] mcp = [ "azure-identity>=1.25.3,<2", @@ -50,7 +50,6 @@ dev = [ "coverage", "flake8", "mypy", - "types-PyYAML", "pre-commit", "pytest", "pytest-cov", diff --git a/azurefunctions-agents-extensions-agent-framework/samples/README.md b/azurefunctions-agents-extensions-agent-framework/samples/README.md index 65690fb..4adb2fb 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/README.md @@ -14,7 +14,7 @@ urlFragment: extension-agent-framework-samples # Azure Functions Microsoft Agent Framework Extension for Python samples These code samples show common scenarios for using Microsoft Agent Framework -Agents in Python Function Apps. All samples use raw `.agent.md` instructions. +Agents in Python Function Apps. Agent samples use raw `.agent.md` instructions. The first two use explicit Microsoft Foundry client factories, while the local examples use deterministic clients without model credentials. @@ -37,7 +37,10 @@ examples use deterministic clients without model credentials. - [Durable YAML workflows](durable-yaml-workflow/README.md) enables `durable=True, workflows=True` for shared state, a Markdown agent activity, and a separate approval question. No handwritten handlers are needed. - Requires Python 3.13 and the `[durable,workflows]` extras. + Uses the `[durable,workflows]` extras; expression execution is verified on 3.13. +- [Configured workflow factory](configured-workflow-factory/README.md) passes a + public MAF factory with a registered local function and environment configuration. + It needs no Markdown agent, model client, or custom HTTP handler. ## Prerequisites diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md new file mode 100644 index 0000000..3a38bed --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md @@ -0,0 +1,56 @@ +# Configured YAML workflow factory + +[function_app.py](function_app.py) configures MAF's public `WorkflowFactory`, +registers the local `format_order` function with `register_tool()`, and passes +that same object to `AgentFunctionApp(workflow_factory=...)`. The configuration +provides `ORDER_PREFIX` for `=Env.ORDER_PREFIX`. With +`restrict_env_to_configuration=True`, MAF does not consult process environment +variables for these workflow expressions. + +[ConfiguredTools.workflow.yaml](workflows/ConfiguredTools.workflow.yaml) calls +`InvokeFunctionTool`, stores its result in `Local.result`, then emits it with +`SendActivity`. The function only formats text. No Markdown definition, agent +client, model credentials, or external service is needed. `client_factory` is +still a required app argument, so `no_agent_client()` raises if it is called. +There are no handwritten HTTP handlers or orchestrators. + +## Run + +Follow the [YAML sample setup](../durable-yaml-workflow/README.md#install-and-run) +for the local packages, `[durable,workflows]` extras, and host/backend settings. +Use Python 3.13 to reproduce the verified PowerFx expression execution. The +extension does not reject Python 3.14, but support follows the installed MAF +dependencies and execution on 3.14 has not been verified. + +Start Core Tools from this sample directory rather than the Markdown sample. +The copied [host.json](host.json) does not provision or verify a compatible host +or Durable backend. + +With the default `/api` prefix, send `{"order":"42"}` to +`POST /api/workflow/ConfiguredTools/run`. Query the returned `statusQueryGetUri` +until completion. The expected `output` is `["Local order 42."]`. Hosted requests +need a function key. The sample expects this input shape and adds no request +schema validation. + +DAFX also generates `GET /api/workflow/ConfiguredTools/status/{instanceId}` and +`POST /api/workflow/ConfiguredTools/respond/{instanceId}/{requestId}`. This +workflow does not request human input. + +## Factory and lifecycle + +The extension calls `create_workflow_from_yaml_path()` on the supplied factory +unchanged, without merging discovered Markdown adapters into its agent registry. +This sample has no Markdown files or standalone agent endpoints. Adding Markdown +files would still publish their standalone endpoints because `durable=True`, but +would not add them to this factory's registry. + +MAF owns parsing and building, including warnings, errors, and native agent/tool +configuration. The extension does not impose a separate action allowlist. +Discovered entry files must stay within the app root, but nested file references +use native MAF resolution and are not sandboxed. Deploy only trusted files. + +If you add inline or custom agents, their construction and resource lifecycle +follow MAF or the supplied factory and may create clients during app +initialization/indexing. They do not automatically get the Markdown adapter's +fresh per-execution lifecycle. See the [verification scope](../durable-yaml-workflow/VALIDATION.md) +for the distinction between local replay checks and host/backend validation. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py new file mode 100644 index 0000000..44a0e84 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py @@ -0,0 +1,29 @@ +"""Host a tool-only YAML workflow with a configured public MAF factory.""" + +from typing import NoReturn + +from agent_framework.declarative import WorkflowFactory +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +def format_order(order: str, prefix: str) -> str: + """Format local input without a model or external service.""" + return f"{prefix} order {order}." + + +def no_agent_client() -> NoReturn: + raise AssertionError("This tool-only workflow must not create an agent client.") + + +workflow_factory = WorkflowFactory( + configuration={"ORDER_PREFIX": "Local"}, + restrict_env_to_configuration=True, +) +workflow_factory.register_tool("format_order", format_order) + +app = AgentFunctionApp( + client_factory=no_agent_client, + durable=True, + workflows=True, + workflow_factory=workflow_factory, +) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/host.json b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/host.json new file mode 100644 index 0000000..b7e5ad1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/workflows/ConfiguredTools.workflow.yaml b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/workflows/ConfiguredTools.workflow.yaml new file mode 100644 index 0000000..d456336 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/workflows/ConfiguredTools.workflow.yaml @@ -0,0 +1,15 @@ +kind: Workflow +name: ConfiguredTools +actions: + - kind: InvokeFunctionTool + id: format_order + functionName: format_order + arguments: + order: =Workflow.Inputs.order + prefix: =Env.ORDER_PREFIX + output: + result: Local.result + autoSend: false + - kind: SendActivity + id: send_result + activity: =Local.result diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md index 0b4f9ad..aa9351d 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md @@ -2,8 +2,10 @@ [function_app.py](function_app.py) enables `durable=True, workflows=True` with no handwritten handlers or orchestrators. The extension loads YAML through MAF's -`WorkflowFactory` and passes the resulting graphs to DAFX's `workflows=` -constructor. DAFX supplies the orchestration, activities, and HTTP routes. +public `WorkflowFactory.create_workflow_from_yaml_path()` and passes the +resulting graphs to DAFX's `workflows=` constructor. The default factory receives +adapters for all discovered Markdown agents. DAFX supplies the orchestration, +activities, and HTTP routes. - [OrderReview.workflow.yaml](workflows/OrderReview.workflow.yaml) copies the request's `order` into shared state, builds a prompt, calls the Markdown @@ -20,9 +22,11 @@ not perform a real order review or interpret the writer's instructions. ## Install and run -Use **Python 3.13**. YAML workflows need PowerFx, and this prototype rejects the -YAML opt-in on Python 3.14. From the repository root, in a Python 3.13 virtual -environment, install the local packages and both optional extras. +Use **Python 3.13** to reproduce the verified expression execution. Python +support follows MAF's dependencies, with no extension-level Python 3.14 rejection. +MAF declarative 1.0.3 excludes PowerFx on Python 3.14, and expression execution +has only been verified on 3.13. From the repository root, in a Python 3.13 +virtual environment, install the local packages and both optional extras. ```powershell python -m pip install -e ./azurefunctions-agents-extensions-base @@ -109,10 +113,29 @@ the app root or `agents/`, including `POST /api/agents/writer/run`. Calling that endpoint bypasses both workflows. Hosted requests need a function key, including requests to returned status and response URLs. -Inside a YAML graph, agent actions run as **durable activities** through the -`MarkdownDurableAgent` lifecycle. Each run opens and closes a fresh Agent and -client. These actions do not call the writer's durable entity or share that -endpoint's entity session. DAFX carries workflow shared state between actions. +Inside this sample's YAML graph, the writer action runs as a **durable activity** +through the `MarkdownDurableAgent` lifecycle. Each execution opens and closes a +fresh Agent, client, and tools. It does not call the writer's durable entity or +share that endpoint's entity session. DAFX carries workflow shared state between +actions. + +Inline YAML agents and custom-factory agents instead follow MAF's or the +factory's construction and resource lifecycle. Agents and clients may be created +during app initialization/indexing. The extension does not give them the +Markdown adapter's per-execution open/close lifecycle. + +## Configure the factory + +The default factory supports native MAF agent definitions and dynamic names as +well as Markdown references such as `agent: writer`. Supply `workflow_factory=` +to configure a public `WorkflowFactory` with an `agent_factory`, agents, tools, +HTTP or MCP handlers, or configuration. The supplied object is used unchanged, +without automatically merging discovered Markdown agents into its registry. +All discovered Markdown agents still get their standalone endpoints. + +See the [configured factory sample](../configured-workflow-factory/README.md) +for a tool-only workflow using `register_tool()` and `configuration`, with no +agent client or external service. ## Boundaries @@ -121,20 +144,20 @@ See [VALIDATION.md](VALIDATION.md) for measured results and test limitations. - `workflows=True` requires `durable=True` and the `[durable,workflows]` extras. - Only `*.workflow.yaml` and `*.workflow.yml` directly in the app root or its `workflows/` directory are discovered. Discovery is not recursive and does not - load arbitrary YAML files. -- Each definition uses `kind: Workflow` and an explicit `name` of 1–63 ASCII - letters, digits, `_`, or `-`, starting with a letter. Names must be unique - ignoring case. The YAML name, not the filename, determines the route. -- Agent references must be static Markdown names, such as `agent: writer` or - `agent: {name: writer}`, matching [writer.agent.md](agents/writer.agent.md). - Inline top-level `agents` definitions, file-based YAML agents, and dynamic - agent names are not supported. Workflow-level `InvokeFunctionTool`, - `HttpRequestAction`, and `InvokeMcpTool` are rejected because no handlers are - configured for them. Python/MCP tools on the referenced agents remain supported. -- Use either root `actions` or `trigger.actions`, not both. `If` supports `then` - (or `actions`) and `else`; `elseActions` belongs to `ConditionGroup`. -- The declarative loader is pinned to 1.0.3. Its internal action registry is used - to reject unknown actions rather than allowing the loader to warn and skip them. + load arbitrary YAML files. Discovered entry files must stay within the app root. +- Relative references inside YAML use MAF's native resolution from the workflow + file's directory, not an extension sandbox. Deploy only trusted workflow files + and references. +- The returned MAF `Workflow` needs a stable name of 1–63 ASCII letters, digits, + `_`, or `-`, starting with a letter, unique ignoring case. The resulting name, + not the filename, determines the route. These samples set explicit names. +- YAML parsing and graph construction follow the installed MAF loader, including + its warnings, errors, and handler requirements. There is no extension action + allowlist or separate inline/file/dynamic-agent or tool-action gate. DAFX + hosting validations still apply. This is not exhaustive execution coverage of + MAF features. +- The workflows extra accepts `agent-framework-declarative>=1.0.3,<2` and uses + its public factory API, not a private action registry. - Keep action IDs stable across reloads. The samples supply explicit IDs. - `OrderReview` expects the documented input shape and adds no request schema validation. Local execution or indexing is not host/backend validation. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md index e7605ef..20d1c81 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md @@ -1,54 +1,83 @@ # YAML discovery verification -Verified on Windows, Python 3.13.11, core 1.16.0, declarative 1.0.3, -Functions 2.3.0, Durable 2.0.0rc1 and DAFX PR #72 at `aa9529ec`. +## Results -- Both agent-package suites passed 152 tests with the optional workflows package. -- Without YAML dependencies, 148 tests passed and four YAML-only tests skipped. -- The fresh non-durable environment passed five import tests with YAML, PowerFx, - DAFX and Durable imports blocked. +Native factory delegation was verified on Windows/Python 3.13.11 with core 1.16.0, +declarative 1.0.3, Functions 2.3.0, Durable 2.0.0rc1 and DAFX PR #72 at `aa9529ec`. + +- Both agent-package suites passed 158 tests with workflow dependencies installed. +- Without YAML dependencies, 151 passed and seven YAML-only cases skipped. +- The clean non-durable installation passed five isolated import tests. - Strict mypy passed on 12 source files. Flake8, whitespace, dependency consistency, - and both package wheel/source builds passed. + documentation links, and both package wheel/source builds passed. +- The original nine YAML replay scenarios still pass. Thirteen additional native + cases cover previously blocked MAF configuration, plus the configured sample. + +An upstream `df_loads` deprecation warning remains visible. The tests do not hide +it. Python 3.14 execution is unverified rather than blocked by this extension. + +## Current integration contract -The YAML subprocess suite contains nine replay cases: simple output, shared state, -ConditionGroup true/else branches, Foreach, an agent call, Question pause/resume, -and agent calls inside both If branches. Each reconstructs the outer app and YAML -graph before every orchestration activation and activity. The actual SDK protobuf -orchestration handler and registered DAFX activities execute; only storage and -dispatch are represented by in-memory history. Agent calls open/close a fresh -client and do not repeat during orchestration replay. +- Discovery is limited to the two workflow suffixes directly in the app root or + `workflows/`, with entry-file containment checks. Loaded results must be MAF + `Workflow` objects with stable, valid names unique ignoring case. +- Loading calls public `create_workflow_from_yaml_path()`. MAF owns YAML parsing, + action handling, native warnings/errors, and nested file resolution. References + inside YAML are not sandboxed by the extension. Deploy only trusted files. +- The default factory receives all discovered Markdown adapters. A supplied + factory is used unchanged, with no automatic Markdown registry merge. DAFX + still validates and hosts the resulting graphs, and all discovered Markdown + agents still get standalone endpoints. +- Markdown adapters create fresh resources per execution. Inline and + custom-factory agents follow MAF's or the factory's lifecycle and may construct + agents and clients during app initialization/indexing. +- The workflows extra accepts declarative `>=1.0.3,<2`. There is no private action + registry dependency or extension-level Python 3.14 rejection. Python support + follows MAF's dependencies. Expression execution is verified on 3.13 only, + since declarative 1.0.3 excludes its PowerFx dependency on 3.14. -The sample's real local client and documented order input produce -`["User turn 1: Review order 42."]`; Approval resumes with `["approved"]`. -Its complete 20-function index and generated endpoint metadata are checked. +## Verification scope + +The replay probes reconstruct the app and YAML graphs before orchestration +activations and activities. They execute the actual SDK protobuf orchestration +handler and registered DAFX activities with in-memory storage/dispatch history. +Native-feature probes cover inline and relative-file agents, dynamic agent +selection with default and custom factories, sync/async function tools, local +HTTP/MCP handlers, configuration-only and environment-fallback expressions, and +the absence of automatic Markdown merging into a custom factory. This is not an +exhaustive claim of MAF feature parity. ## Change analysis -- Workflow loading precedes construction of the one owned DAFX app. Plain and - markdown-only paths do not import the declarative package. Existing binding - registration, HTTP auth and repeated indexing tests remain green. -- Discovery handles both suffixes and locations, validates explicit stable names, - duplicate keys/names, YAML cycles, malformed definitions, directories and - escaping symlinks. Arbitrary YAML elsewhere is not discovered. -- Registration keeps live resources out of indexing. Inline/file-based YAML agent - definitions and dynamic agent names fail rather than constructing hidden clients. -- The action walker follows the pinned loader's If and ConditionGroup structures, - not arbitrary literal dictionaries. Unknown actions fail instead of being skipped. - Conflicting root/trigger or If branch definitions are rejected. Workflow-level - tools without registered handlers fail; agent-level tools are unaffected. -- A read-only review found missing If branch discovery. A real replay reproduced - `Agent 'writer' invocation failed: not found in registry`; both branches pass - after correction. It also identified shadowed action lists and unconfigured - function tools, now covered by rejection tests. -- Removing workflow loading in an in-memory mutation makes the replay test fail - on missing `dafx-Simple`; restoring the implementation passes all workflow tests. - The earlier shared-state-loss mutation also failed the expected output assertion. -- Action registry knowledge comes from the pinned declarative loader. Structural - action traversal is tested with root/trigger forms, nested If, ConditionGroup, - Foreach, and literal data containing keys named `actions`. - -No live Functions host, storage backend, external model or MCP service was run. -Retries, parallel execution and nested workflows are not claimed as verified. -The subprocess helper exits after all assertions to isolate embedded PowerFx/CLR -shutdown behavior from pytest. It does not bypass application logic or assertions. -YAML support is limited to Python 3.13 until the PowerFx dependency supports 3.14. +- Removed custom parsing, action traversal/allowlists, the internal action registry + import, and the inline/file/dynamic/tool/Python version gates. Tests now compare + duplicate-key, unknown-action, root-precedence and trigger-name behavior with + the public MAF factory instead of enforcing a second YAML dialect. +- Discovery boundaries and returned Workflow/name checks remain. The exact supplied + factory instance receives both file paths without extra method calls or mutation. + Its exceptions propagate; it need not import the default declarative loader. +- Default factories receive all markdown adapters, allowing runtime selection. + Supplied factories receive no implicit merge. The negative missing-agent case + verifies a same-named discovered Markdown file cannot override a custom registry. +- Native agent creation is intentionally permitted during loading. Real AgentFactory + public methods parse/build inline and relative-file agents using a local client. + Local HTTP/MCP handlers and registered sync/async tools execute once across replay, + with complete expected arguments/state checked. No external network is involved. +- Review found a stale mutation stub after adding the factory keyword. Its signature + is corrected: removing workflow loading fails on missing `dafx-Simple`, not a + keyword error. Ignoring the supplied factory also fails the native construction + assertion. Restored workflow tests pass all 21 collected cases. +- The sample index enumerates every sample app. The configured factory sample uses + its actual `format_order` implementation and configuration through SDK replay. + Documentation and sample-index claims were checked together. + +The documented sample outputs are `["User turn 1: Review order 42."]` for +`OrderReview` and `["approved"]` after responding to `Approval`. The +[configured factory sample](../configured-workflow-factory/README.md) expects +`["Local order 42."]` for `ConfiguredTools`. + +No live Functions host, storage backend, external model, or external HTTP/MCP +service is part of these checks. Retries, parallel execution, and nested +workflows are not claimed as verified. The subprocess helpers exit after all +assertions to isolate embedded PowerFx/CLR shutdown from pytest reporting, not +to bypass application logic or assertions. diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py new file mode 100644 index 0000000..20782e3 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py @@ -0,0 +1,508 @@ +"""Native MAF YAML features through actual cold-rebuilt DAFX replay activities.""" + +from __future__ import annotations + +import ast +from copy import deepcopy +from dataclasses import asdict +import json +import os +from pathlib import Path +import sys +import tempfile +import traceback +from unittest.mock import patch + +from agent_framework import ( + Agent, ChatResponse, ChatResponseUpdate, Content, Message, ResponseStream, +) +from agent_framework.declarative import ( + AgentFactory, HttpRequestHandler, HttpRequestResult, MCPToolHandler, + MCPToolResult, WorkflowFactory, +) +from agent_framework.exceptions import AgentInvalidRequestException + +import _yaml_workflow_probe as harness + + +class NativeClient(harness.LocalClient): + """An in-memory client which does not require an async context manager.""" + + instances = [] + calls = [] + + def _inner_get_response(self, *, messages, stream, options, **kwargs): + messages = list(messages) + call = { + "instructions": options.get("instructions"), + "messages": [[str(message.role), message.text] for message in messages], + } + self.calls.append(call) + text = f"{call['instructions']}:{messages[-1].text}" + + async def response(): + return ChatResponse(messages=[Message(role="assistant", contents=[text])]) + + async def updates(): + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text(text)], + ) + + return (ResponseStream(updates(), finalizer=ChatResponse.from_updates) + if stream else response()) + + +class RecordingAgentFactory(AgentFactory): + """Observe public entry points, retaining native parsing and construction.""" + + def __init__(self): + super().__init__(client=NativeClient()) + self.definitions = [] + self.paths = [] + self.created = [] + + def create_agent_from_dict(self, agent_def): + self.definitions.append(deepcopy(agent_def)) + agent = super().create_agent_from_dict(agent_def) + assert isinstance(agent, Agent) + self.created.append(agent) + return agent + + def create_agent_from_yaml_path(self, yaml_path): + self.paths.append(Path(yaml_path)) + return super().create_agent_from_yaml_path(yaml_path) + + +class RecordingWorkflowFactory(WorkflowFactory): + """Record direct path delegation, without replacing YAML parsing/building.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.paths = [] + + def create_workflow_from_yaml_path(self, yaml_path): + self.paths.append(Path(yaml_path)) + return super().create_workflow_from_yaml_path(yaml_path) + + +class LocalHttpHandler: + def __init__(self, calls): + self.calls = calls + + async def send(self, info): + self.calls.append(asdict(info)) + return HttpRequestResult( + status_code=201, is_success_status_code=True, + body='{"order":"42","accepted":true,"items":[1,2]}', + headers={"x-probe": ["one", "two"], "content-type": ["application/json"]}, + ) + + +class LocalMcpHandler: + def __init__(self, calls): + self.calls = calls + + async def invoke_tool(self, invocation): + self.calls.append(asdict(invocation)) + return MCPToolResult(outputs=[Content.from_text( + '{"order":"42","approved":true,"tags":["local","mcp"]}', + )]) + + +INLINE_AGENT = { + "kind": "Prompt", "name": "writer", "description": "Native inline probe", + "instructions": "INLINE", +} +FILE_AGENT = { + "kind": "Prompt", "name": "file_writer", "description": "Native file probe", + "instructions": "FILE", +} + +INLINE = """name: NativeInline +agents: + writer: + kind: Prompt + name: writer + description: Native inline probe + instructions: INLINE +actions: + - kind: InvokeAzureAgent + id: invoke + agent: writer + input: hello + resultProperty: Local.reply + output: + autoSend: false + - kind: Question + id: approve + question: Approve native result? + variable: Local.approval + - kind: SendActivity + id: state + activity: '{Local}' +""" + +FILE = """name: NativeFile +agents: + writer: + file: definitions/writer.yaml +actions: + - kind: InvokeAzureAgent + id: invoke + agent: writer + input: from-file + resultProperty: Local.reply + output: + autoSend: false + - kind: SendActivity + id: state + activity: '{Local}' +""" + +DYNAMIC = """name: NativeDynamic +actions: + - kind: SetValue + id: select + path: Local.selected + value: =Workflow.Inputs.agent + - kind: InvokeAzureAgent + id: invoke + agent: =Local.selected + input: choose + resultProperty: Local.reply + output: + autoSend: false + - kind: SetValue + id: chosen + path: Local.actual + value: =Agent.name + - kind: SendActivity + id: state + activity: '{Local}' +""" + +FUNCTION = """name: NativeFunction +actions: + - kind: SetValue + id: seed + path: Local.amount + value: =Workflow.Inputs.amount + - kind: InvokeFunctionTool + id: lookup + functionName: lookup + arguments: + order: =Workflow.Inputs.order + amount: =Local.amount + 1 + output: + result: Local.result + autoSend: false + - kind: SendActivity + id: state + activity: '{Local}' +""" + +HTTP = """name: NativeHttp +actions: + - kind: HttpRequestAction + id: request + method: post + url: =Env.HTTP_URL + headers: + X-Probe: =Env.PROBE_HEADER + X-Empty: '' + queryParameters: + order: =Workflow.Inputs.order + enabled: true + omitted: null + body: + kind: json + content: =Workflow.Inputs + requestTimeoutInMilliseconds: 1234 + connection: + name: local-http + response: Local.response + responseHeaders: + path: Local.headers + - kind: SendActivity + id: state + activity: '{Local}' +""" + +MCP = """name: NativeMcp +actions: + - kind: InvokeMcpTool + id: tool + serverUrl: =Env.MCP_URL + serverLabel: local-server + toolName: =Env.MCP_TOOL + arguments: + order: =Workflow.Inputs.order + count: 2 + enabled: true + headers: + X-Probe: =Env.PROBE_HEADER + X-Empty: '' + connection: + name: local-mcp + output: + result: Local.result + autoSend: false + - kind: SendActivity + id: state + activity: '{Local}' +""" + +ENV = """name: NativeEnv +actions: + - kind: SetValue + id: configured + path: Local.configured + value: =Env.NATIVE_PROBE_VALUE + - kind: SetValue + id: fallback + path: Local.fallback + value: =Env.NATIVE_PROBE_FALLBACK + - kind: SendActivity + id: state + activity: '{Local}' +""" + + +def run_case(label, definition, name, expected, *, input_data=None, + agent_definitions=(), agent_paths=(), agents=(), files=None, + filename="probe.workflow.yaml", configure=None, + expected_client_calls=(), human_response=None, default_factory=False): + """Require native construction at index time and one call per replayed effect.""" + with tempfile.TemporaryDirectory() as temp: + root = Path(temp).resolve() + harness.write_workflow(root, definition, filename) + for relative, content in (files or {}).items(): + harness.write_workflow(root, content, relative) + NativeClient.instances.clear() + NativeClient.calls.clear() + harness.LocalClient.instances.clear() + harness.LocalClient.calls.clear() + factories = [] + agent_factories = [] + + def build(app_root): + assert Path(app_root) == root + agent_factory = RecordingAgentFactory() + agent_factories.append(agent_factory) + supplied_agents = { + key: Agent(client=NativeClient(), name=key, instructions=key.upper()) + for key in agents + } + factory = RecordingWorkflowFactory( + agent_factory=agent_factory, agents=supplied_agents, + **(configure() if configure else {}), + ) + factories.append(factory) + if label in {"function-sync", "function-async"}: + assert factory.register_tool("lookup", lookup) is factory + return factory + + tool_calls = [] + + def sync_lookup(order, amount): + tool_calls.append({"order": order, "amount": amount}) + return {"order": order, "amount": amount, "source": "registered"} + + async def async_lookup(order, amount): + return sync_lookup(order, amount) + + lookup = async_lookup if label == "function-async" else sync_lookup + + def check_construction(): + if default_factory: + assert not factories + return + assert factories, "Harness did not call WORKFLOW_FACTORY_BUILDER" + for factory, agent_factory in zip(factories, agent_factories, strict=True): + assert factory.paths == [root / filename], factory.paths + assert agent_factory.definitions == list(agent_definitions), ( + agent_factory.definitions + ) + assert agent_factory.paths == [root / p for p in agent_paths], ( + agent_factory.paths + ) + assert len(agent_factory.created) == len(agent_definitions) + + with patch.object(harness, "WORKFLOW_FACTORY_BUILDER", + None if default_factory else build): + harness.index(root) + check_construction() + assert not NativeClient.calls and not harness.LocalClient.calls + output, activities, answered = harness.run_workflow( + root, name, input_data, human_response, + ) + check_construction() + + assert len(output) == 1 and isinstance(output[0], str), (label, output) + state = ast.literal_eval(output[0]) + assert state == expected, (label, state, expected) + assert len(activities) >= 2, (label, activities) + assert len(answered) == (1 if human_response is not None else 0), answered + if not default_factory: + assert len(factories) > len(activities) + 1, (factories, activities) + assert NativeClient.calls == list(expected_client_calls), NativeClient.calls + assert not harness.LocalClient.calls, harness.LocalClient.calls + assert all(not c.entered and not c.closed for c in NativeClient.instances) + else: + assert not NativeClient.calls + assert harness.LocalClient.calls == [["choose"]], harness.LocalClient.calls + assert len(harness.LocalClient.instances) == 1 + assert all(c.entered and c.closed for c in harness.LocalClient.instances) + if label in {"function-sync", "function-async"}: + assert tool_calls == [{"order": "42", "amount": 42}], tool_calls + return { + "output": output, "state": state, "activities": activities, + "factory_builds": len(factories), "responses": len(answered), + "agent_calls": deepcopy(NativeClient.calls or harness.LocalClient.calls), + "function_calls": tool_calls, + } + + +def main(): + results = {} + results["inline-agent"] = run_case( + "inline-agent", INLINE, "NativeInline", + {"reply": "INLINE:hello", "approval": "approved"}, + agent_definitions=[INLINE_AGENT], human_response="approved", + files={"writer.agent.md": "A markdown name must not replace YAML agents."}, + expected_client_calls=[{ + "instructions": "INLINE", "messages": [["user", "hello"]], + }], + ) + results["relative-file-agent"] = run_case( + "relative-file-agent", FILE, "NativeFile", {"reply": "FILE:from-file"}, + filename="workflows/probe.workflow.yaml", + files={ + "workflows/definitions/writer.yaml": ( + "kind: Prompt\nname: file_writer\ndescription: Native file probe\n" + "instructions: FILE\n" + ), + "definitions/writer.yaml": "not the workflow-relative agent", + }, + agent_definitions=[FILE_AGENT], + agent_paths=["workflows/definitions/writer.yaml"], + expected_client_calls=[{ + "instructions": "FILE", "messages": [["user", "from-file"]], + }], + ) + for selected in ("first", "second"): + for default_factory in (False, True): + label = f"dynamic-{'default' if default_factory else 'custom'}-{selected}" + results[label] = run_case( + label, DYNAMIC, "NativeDynamic", + {"selected": selected, "actual": selected, + "reply": "ok" if default_factory else f"{selected.upper()}:choose"}, + input_data={"agent": selected}, agents=("first", "second"), + files={"first.agent.md": "First adapter", + "agents/second.agent.md": "Second adapter"}, + default_factory=default_factory, + expected_client_calls=[{ + "instructions": selected.upper(), "messages": [["user", "choose"]], + }], + ) + try: + run_case( + "custom-factory-no-markdown-merge", DYNAMIC, "NativeDynamic", {}, + input_data={"agent": "second"}, agents=("first",), + files={"agents/second.agent.md": "Not supplied to the custom factory"}, + ) + except AgentInvalidRequestException as error: + missing_agent = "Agent 'second' invocation failed: not found in registry" + assert missing_agent in str(error), str(error) + assert not NativeClient.calls and not harness.LocalClient.calls + results["custom-factory-no-markdown-merge"] = { + "rejected": "Agent 'second' invocation failed: not found in registry", + } + else: + raise AssertionError("Custom factory unexpectedly received a markdown agent") + for kind in ("sync", "async"): + label = f"function-{kind}" + results[label] = run_case( + label, FUNCTION, "NativeFunction", + {"amount": 41, "result": { + "order": "42", "amount": 42, "source": "registered", + }}, + input_data={"order": "42", "amount": 41}, + ) + http_calls = [] + + def http_config(): + handler = LocalHttpHandler(http_calls) + assert isinstance(handler, HttpRequestHandler) + return {"http_request_handler": handler, "configuration": { + "HTTP_URL": "https://native-probe.invalid/orders", "PROBE_HEADER": "local", + }} + + results["http-handler"] = run_case( + "http-handler", HTTP, "NativeHttp", + {"response": {"order": "42", "accepted": True, "items": [1, 2]}, + "headers": {"x-probe": "one,two", "content-type": "application/json"}}, + input_data={"order": "42"}, configure=http_config, + ) + assert len(http_calls) == 1, http_calls + http_call = dict(http_calls[0]) + http_call["body"] = json.loads(http_call["body"]) + assert http_call == { + "method": "POST", "url": "https://native-probe.invalid/orders", + "headers": {"X-Probe": "local"}, + "query_parameters": {"order": "42", "enabled": "true"}, + "body": {"order": "42"}, "body_content_type": "application/json", + "timeout_ms": 1234, "connection_name": "local-http", + }, http_call + results["http-handler"]["handler_calls"] = http_calls + mcp_calls = [] + + def mcp_config(): + handler = LocalMcpHandler(mcp_calls) + assert isinstance(handler, MCPToolHandler) + return {"mcp_tool_handler": handler, "configuration": { + "MCP_URL": "https://native-probe.invalid/mcp", "MCP_TOOL": "lookup", + "PROBE_HEADER": "local", + }} + + results["mcp-handler"] = run_case( + "mcp-handler", MCP, "NativeMcp", + {"result": [{"order": "42", "approved": True, "tags": ["local", "mcp"]}]}, + input_data={"order": "42"}, configure=mcp_config, + ) + assert mcp_calls == [{ + "server_url": "https://native-probe.invalid/mcp", "tool_name": "lookup", + "server_label": "local-server", + "arguments": {"order": "42", "count": 2, "enabled": True}, + "headers": {"X-Probe": "local"}, "connection_name": "local-mcp", + }], mcp_calls + results["mcp-handler"]["handler_calls"] = mcp_calls + with patch.dict(os.environ, { + "NATIVE_PROBE_VALUE": "ambient-wrong", + "NATIVE_PROBE_FALLBACK": "ambient-fallback", + }): + for restricted in (True, False): + label = f"env-{'restricted' if restricted else 'fallback'}" + results[label] = run_case( + label, ENV, "NativeEnv", + {"configured": "configured", + "fallback": None if restricted else "ambient-fallback"}, + configure=lambda: { + "configuration": {"NATIVE_PROBE_VALUE": "configured"}, + "restrict_env_to_configuration": restricted, + }, + ) + return results + + +if __name__ == "__main__": + try: + print(json.dumps({"result": main()}), flush=True) + exit_code = 0 + except Exception: + traceback.print_exc() + exit_code = 1 + sys.stdout.flush() + sys.stderr.flush() + # Do not let PowerFx/CLR teardown enter the parent pytest reporting process. + os._exit(exit_code) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py index 8084e17..e1c147d 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py @@ -21,6 +21,8 @@ from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +WORKFLOW_FACTORY_BUILDER = None + class LocalClient(BaseChatClient): instances = [] @@ -62,10 +64,15 @@ def write_workflow(root, content, filename="probe.workflow.yaml"): def make_app(root): before = len(LocalClient.instances) + factory = WORKFLOW_FACTORY_BUILDER(root) if WORKFLOW_FACTORY_BUILDER else None app = AgentFunctionApp( client_factory=LocalClient, app_root=root, durable=True, workflows=True, + workflow_factory=factory, ) - assert len(LocalClient.instances) == before, "Live client created during loading" + if factory is None: + assert len(LocalClient.instances) == before, ( + "Live client created during loading" + ) return app @@ -282,44 +289,44 @@ def invalid(label, files, expected): checks.append(label) for label, content, error in [ - ("scalar", "hello", "mapping"), + ("scalar", "hello", "dictionary"), ("malformed", "[", "parsing"), - ("unnamed", "actions: []", "explicit name"), - ("bad-name", "name: ../bad\nactions: []", "explicit name"), - ("duplicate-key", "name: First\nname: Second", "Duplicate YAML key"), - ("cyclic-alias", "name: Cycle\nactions: &a [*a]", "Cyclic"), - ("inline-agent", "name: Inline\nagents: {writer: {kind: Prompt}}", - "inline/file"), - ("file-agent", "name: File\nagents: {writer: {file: ../escape.yaml}}", - "inline/file"), - ("unknown-action", "name: Unknown\nactions: [{kind: Imaginary}]", "Unknown"), - ("missing-agent", CASES["agent"][0], "was not found"), - ("dynamic-agent", CASES["agent"][0].replace( - "agent: writer", "agent: =Local.name"), - "static markdown"), + ("bad-name", CASES["simple"][0].replace("Simple", "../bad"), "stable name"), ]: invalid(label, {"probe.workflow.yaml": content}, error) invalid("duplicate-name", { "one.workflow.yaml": CASES["simple"][0], "workflows/two.workflow.yml": CASES["simple"][0].replace("Simple", "simple"), }, "Duplicate workflow name") - invalid("shadowed-root-actions", { - "probe.workflow.yaml": CASES["simple"][0] + "trigger: {actions: []}", - }, "both") - invalid("ignored-if-elseActions", { - "probe.workflow.yaml": CASES["if-agent"][0].replace( - '"else":', '"elseActions":'), - }, "elseActions") - invalid("unregistered-function-tool", { - "probe.workflow.yaml": "name: Tool\nactions:\n" - " - {kind: InvokeFunctionTool, id: tool, functionName: lookup}\n" - " - {kind: SendActivity, id: done, activity: DONE}\n", - }, "handler") - - invalid("unknown-nested-action", { - "probe.workflow.yaml": CASES["if-agent"][0].replace( - '"kind": "InvokeAzureAgent"', '"kind": "Imaginary"'), - }, "Unknown workflow action") + # Compare parsing decisions with MAF itself instead of maintaining a second + # YAML schema. Unknown-action warnings, duplicate keys, and precedence are + # native loader behavior, not extension-specific rejections. + from agent_framework.declarative import WorkflowFactory + native_documents = { + "duplicate-key": "name: First\n" + CASES["simple"][0], + "root-precedence": CASES["simple"][0] + "trigger: {actions: []}\n", + "unknown-action": CASES["simple"][0] + " - kind: Imaginary\n", + "trigger-name": json.dumps({"trigger": {"id": "TriggerNamed", "actions": [ + {"kind": "SendActivity", "id": "greet", "activity": "Hello"}, + ]}}), + } + for label, document in native_documents.items(): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, document) + native = WorkflowFactory().create_workflow_from_yaml_path( + root / "probe.workflow.yaml" + ) + app = make_app(root) + loaded = app._hosted_workflows[0] + assert loaded.name == native.name + loaded_nodes = [ + (key, type(value)) for key, value in loaded.executors.items() + ] + assert loaded_nodes == [ + (key, type(executor)) for key, executor in native.executors.items() + ] + checks.append(label) with tempfile.TemporaryDirectory() as temp: root = Path(temp) definition = json.loads(CASES["if-agent"][0]) @@ -379,6 +386,7 @@ def invalid(label, files, expected): def main(): global LocalClient + global WORKFLOW_FACTORY_BUILDER mode = sys.argv[1] if mode == "execution": return execution_checks() @@ -413,9 +421,40 @@ def __init__(self): output = run_workflow(root, "IfAgent")[0] assert output == ["ok"], output return output + if mode == "configured-sample": + root = Path(__file__).parents[1] / "samples" / "configured-workflow-factory" + calls = [] + + def build(app_root): + spec = importlib.util.spec_from_file_location( + "configured_sample", root / "function_app.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + old_root = os.environ.get("AzureWebJobsScriptRoot") + os.environ["AzureWebJobsScriptRoot"] = str(root) + try: + spec.loader.exec_module(module) + finally: + if old_root is None: + os.environ.pop("AzureWebJobsScriptRoot", None) + else: + os.environ["AzureWebJobsScriptRoot"] = old_root + + def recorded(order, prefix): + calls.append((order, prefix)) + return module.format_order(order, prefix) + + module.workflow_factory.register_tool("format_order", recorded) + return module.workflow_factory + + WORKFLOW_FACTORY_BUILDER = build + output = run_workflow(root, "ConfiguredTools", {"order": "42"})[0] + assert calls == [("42", "Local")], calls + return output if mode == "mutation": from azurefunctions.agents.extensions.agent_framework import _workflows - _workflows.load_workflows = lambda *args: [] + _workflows.load_workflows = lambda *args, **kwargs: [] return execution_checks() raise ValueError(mode) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py index b385d30..f80a3e5 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py @@ -18,6 +18,7 @@ def test_typed_api_exposes_only_v1_options(): "http_auth_level", "durable", "workflows", + "workflow_factory", ] assert list(inspect.signature(AgentFunctionApp.markdown_agent).parameters) == [ "self", diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py index 0154110..607dd38 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -37,6 +37,13 @@ "dafx-Approval-respond", "dafx-Approval-_workflow_entry", "dafx-Approval-request_approval", "dafx-Approval-send_answer", }, + "configured-workflow-factory": { + "dafx-ConfiguredTools", "dafx-ConfiguredTools-start", + "dafx-ConfiguredTools-status", "dafx-ConfiguredTools-respond", + "dafx-ConfiguredTools-_workflow_entry", "dafx-ConfiguredTools-format_order", + "dafx-ConfiguredTools-send_result", "BuiltIn__HttpActivity", + "BuiltIn__HttpPollOrchestrator", + }, } _LOCAL_SAMPLES = ("lazy-owned-dafx", "durable-markdown-binding") @@ -71,13 +78,13 @@ def test_index_cases_cover_every_sample_app(): @pytest.mark.parametrize("sample_path", _SAMPLE_INDEXES) def test_sample_indexes_all_functions(sample_path): - if sample_path == "durable-yaml-workflow": + if sample_path in {"durable-yaml-workflow", "configured-workflow-factory"}: from importlib.util import find_spec if ( sys.version_info >= (3, 14) or find_spec("agent_framework_declarative") is None ): - pytest.skip("YAML sample requires Python 3.13 and the workflows extra") + pytest.skip("YAML expression samples tested on 3.13 with workflows extra") # Exact names were recorded from the SDK 2/DAFX PR #72 index. DAFX sanitizes # HTTP names (_build_function_name), but preserves hyphens in entity names. result = _run_sample(sample_path, """ diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py b/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py index efd6c94..c5d4455 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py @@ -6,7 +6,7 @@ from pathlib import Path import subprocess import sys -from unittest.mock import Mock +from unittest.mock import Mock, call import pytest @@ -37,29 +37,75 @@ def test_workflow_files_are_ignored_without_workflow_opt_in(tmp_path): assert durable._durable_app.workflows == {} -def test_unsupported_python_does_not_silently_ignore_expressions(tmp_path, monkeypatch): - monkeypatch.setattr(_workflows.sys, "version_info", (3, 14)) - with pytest.raises(RuntimeError, match="Python 3.13"): - _workflows.load_workflows(tmp_path, Mock()) +def test_factory_requires_workflow_opt_in(tmp_path): + with pytest.raises(ValueError, match="workflow_factory requires workflows=True"): + AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, + workflow_factory=Mock()) -@pytest.mark.parametrize("missing", ["yaml", "agent_framework_declarative", "clr"]) +@pytest.mark.parametrize("missing", ["agent_framework_declarative", "yaml", "clr"]) def test_missing_workflow_dependencies(tmp_path, monkeypatch, missing): - monkeypatch.setattr(_workflows.sys, "version_info", (3, 13)) original = builtins.__import__ def blocked(name, *args, **kwargs): - if name == "yaml": + if name == "agent_framework.declarative": raise ModuleNotFoundError(name=missing) return original(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", blocked) with pytest.raises(ImportError) as error: - _workflows.load_workflows(tmp_path, Mock()) - if missing == "clr": - assert error.value.name == "clr" - else: + _workflows.load_workflows(tmp_path, {}) + if missing == "agent_framework_declarative": assert "[durable,workflows]" in str(error.value) + else: + assert error.value.name == missing + + +def test_custom_factory_is_used_unchanged_without_declarative_import( + tmp_path, monkeypatch, +): + from agent_framework import Workflow + original = builtins.__import__ + + def no_declarative(name, *args, **kwargs): + if "declarative" in name or name in {"yaml", "powerfx"}: + raise AssertionError("Custom factory must not import the default loader") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_declarative) + paths = [tmp_path / "one.workflow.yaml", tmp_path / "workflows/two.workflow.yml"] + for path in paths: + path.parent.mkdir(exist_ok=True) + path.write_text("caller-defined format", encoding="utf-8") + outputs = [Mock(spec=Workflow, name="One"), Mock(spec=Workflow, name="Two")] + for output, name in zip(outputs, ["One", "Two"]): + output.name = name + factory = Mock() + factory.create_workflow_from_yaml_path.side_effect = outputs + assert _workflows.load_workflows(tmp_path, {"ignored": object()}, factory) == ( + outputs + ) + assert factory.method_calls == [ + call.create_workflow_from_yaml_path(path) for path in paths + ] + + +def test_custom_factory_errors_propagate(tmp_path): + (tmp_path / "bad.workflow.yaml").touch() + error = RuntimeError("custom factory rejected definition") + factory = Mock() + factory.create_workflow_from_yaml_path.side_effect = error + with pytest.raises(RuntimeError) as caught: + _workflows.load_workflows(tmp_path, {}, factory) + assert caught.value is error + + +def test_factory_must_return_a_workflow(tmp_path): + (tmp_path / "bad.workflow.yaml").touch() + factory = Mock() + factory.create_workflow_from_yaml_path.return_value = object() + with pytest.raises(TypeError, match="MAF Workflow"): + _workflows.load_workflows(tmp_path, {}, factory) def test_workflow_symlink_escape_is_rejected(tmp_path): @@ -73,13 +119,18 @@ def test_workflow_symlink_escape_is_rejected(tmp_path): _workflows._definition_paths(tmp_path) -@pytest.mark.parametrize("mode", ["validation", "execution", "sample"]) +@pytest.mark.parametrize("mode", [ + "validation", "execution", "sample", "native", "configured-sample", +]) def test_real_yaml_workflow_probes(mode): if sys.version_info >= (3, 14) or find_spec("agent_framework_declarative") is None: pytest.skip("Requires Python 3.13 and workflows extra") + filename = ( + "_native_workflow_probe.py" if mode == "native" else "_yaml_workflow_probe.py" + ) result = subprocess.run( [sys.executable, "-X", "utf8", str(Path(__file__).with_name( - "_yaml_workflow_probe.py")), mode], + filename)), mode], capture_output=True, text=True, encoding="utf-8", timeout=180, ) assert result.returncode == 0, result.stdout + result.stderr @@ -94,5 +145,9 @@ def test_real_yaml_workflow_probes(mode): "OrderReview": ["User turn 1: Review order 42."], "Approval": ["approved"], } + elif mode == "validation": + assert len(data) == 13 + elif mode == "configured-sample": + assert data == ["Local order 42."] else: - assert len(data) == 21 + assert len(data) == 13 From 9bd9937fbbf383d7ce334dbfe44549cd607acd48 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Thu, 10 Sep 2026 14:42:52 -0500 Subject: [PATCH 29/30] Separate discovery and HTTP exposure and add durable workflow bindings --- .../README.md | 100 ++++- .../extensions/agent_framework/_hosting.py | 28 ++ .../agent_framework/_workflow_client.py | 58 +++ .../extensions/agent_framework/_workflows.py | 38 +- .../agents/extensions/agent_framework/apps.py | 357 +++++++++++++----- .../samples/README.md | 15 +- .../README.md | 27 +- .../configured-workflow-factory/README.md | 13 +- .../function_app.py | 3 +- .../durable-markdown-binding/README.md | 16 +- .../durable-markdown-binding/function_app.py | 2 +- .../durable-workflow-binding/README.md | 74 ++++ .../durable-workflow-binding/VALIDATION.md | 45 +++ .../durable-workflow-binding/function_app.py | 31 ++ .../durable-workflow-binding/host.json | 7 + .../workflows/Child.workflow.yaml | 6 + .../samples/durable-yaml-workflow/README.md | 38 +- .../durable-yaml-workflow/VALIDATION.md | 23 +- .../durable-yaml-workflow/function_app.py | 5 +- .../samples/lazy-owned-dafx/README.md | 15 +- .../samples/lazy-owned-dafx/VALIDATION.md | 18 +- .../samples/lazy-owned-dafx/function_app.py | 2 +- .../tests/_native_workflow_probe.py | 8 +- .../tests/_registration_probe.py | 212 +++++++++++ .../tests/_yaml_workflow_probe.py | 6 +- .../tests/test_apps.py | 8 +- .../tests/test_dafx.py | 39 +- .../tests/test_durable_markdown.py | 48 ++- .../tests/test_imports.py | 7 +- .../tests/test_registration_api.py | 169 +++++++++ .../tests/test_samples.py | 120 +++++- .../tests/test_yaml_workflows.py | 43 ++- 32 files changed, 1345 insertions(+), 236 deletions(-) create mode 100644 azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_hosting.py create mode 100644 azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflow_client.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/README.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/function_app.py create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/host.json create mode 100644 azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/workflows/Child.workflow.yaml create mode 100644 azurefunctions-agents-extensions-agent-framework/tests/_registration_probe.py create mode 100644 azurefunctions-agents-extensions-agent-framework/tests/test_registration_api.py diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index b453d88..1778106 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -147,7 +147,7 @@ These Git dependencies are for local prototyping, not a PyPI release. pip install "azurefunctions-agents-extensions-agent-framework[durable]" ``` -Set `durable=True` to discover every `.agent.md` file directly in the app root +Set `discover_agents=True` to discover every `.agent.md` file directly in the app root or its `agents/` directory. Each discovered agent gets a DAFX entity and an automatic `POST /api/agents/{name}/run` endpoint with the default HTTP route prefix. No handwritten HTTP function or orchestrator is required. @@ -158,13 +158,14 @@ underscores. Ambiguous definitions and generated function-name collisions fail rather than silently selecting an agent. ```python -app = AgentFunctionApp(client_factory=create_chat_client, durable=True) +app = AgentFunctionApp(client_factory=create_chat_client, discover_agents=True) ``` For orchestration, place `durable_markdown_agent` below `orchestration_trigger` on a synchronous generator. The binding registers the selected markdown agent -and its HTTP endpoint even without `durable=True`. The injected object is a -DAFX proxy, not a live Agent. Yield its tasks and share a session across turns. +without bulk discovery. It is private by default (`expose_http_endpoint=False`). +The injected object is a DAFX proxy, not a live Agent. Yield its tasks and share +a session across turns. ```python app = AgentFunctionApp(client_factory=create_chat_client) @@ -188,11 +189,32 @@ restores conversation history from durable session state. Orchestrators keep the native SDK context. The old `context.call_agent()` activity path is replaced by the injected proxy. -Normal `markdown_agent()` remains invocation-scoped and unchanged. Without a -durable opt-in, it does not create an inner DAFX app. With durable agents, the -outer app indexes both registries, including the SDK's `BuiltIn__HttpActivity` -and `BuiltIn__HttpPollOrchestrator`. Agent HTTP endpoints are enabled; health -and MCP endpoints are disabled. +Normal `markdown_agent()` remains invocation-scoped and unchanged. Durable +declarations collect registrations first. The inner DAFX host is constructed +at `get_functions()` only when an agent or workflow is registered. The outer +app indexes both registries, including the SDK's `BuiltIn__HttpActivity` and +`BuiltIn__HttpPollOrchestrator`. Health and MCP endpoints are disabled. + +### Discovery, exposure, and policy + +`discover_agents=False` and `discover_workflows=False` are independent defaults. +The constructor's `expose_agent_endpoints=True` and +`expose_workflow_endpoints=True` apply only to their respective bulk discovery +paths. Set either exposure option to `False` to register those definitions +without publishing their standalone HTTP endpoints. + +Selective `durable_markdown_agent` and `durable_workflow` bindings instead use +their own `expose_http_endpoint=False` default. Set it to `True` on a binding +to publish that definition. Discovery and bindings share the same registry. +Repeated registration reuses the definition and combines exposure with logical +OR, so a private binding does not hide an endpoint already explicitly enabled. +Register all bindings before indexing. + +Endpoint exposure is not application policy. A generated endpoint invokes its +agent or workflow directly and bypasses any validation in a handwritten parent +or starter. Private here means no standalone generated HTTP route, not a +separate authorization or execution boundary. Hosted HTTP endpoints require +the configured auth level, which defaults to a function key. See the [endpoint-only local sample](samples/lazy-owned-dafx/README.md) and the [durable binding sample](samples/durable-markdown-binding/README.md) for setup @@ -210,12 +232,11 @@ pip install "azurefunctions-agents-extensions-agent-framework[durable,workflows] ```python app = AgentFunctionApp( client_factory=create_chat_client, - durable=True, - workflows=True, + discover_workflows=True, ) ``` -`workflows=True` requires `durable=True`. Only `*.workflow.yaml` and +Workflow discovery does not require agent discovery. Only `*.workflow.yaml` and `*.workflow.yml` directly in the app root or its `workflows/` directory are discovered, not arbitrary YAML or nested files. Discovered entry files must stay within the app root. Each loaded result must be a MAF `Workflow` with a stable @@ -232,9 +253,12 @@ orchestration or handwritten HTTP handlers are needed. By default, `WorkflowFactory(agents=...)` receives `MarkdownDurableAgent` adapters for **all** discovered Markdown agents, including agents selected by -dynamic names. To configure MAF directly, pass a configured `WorkflowFactory` -object as `workflow_factory=` alongside `workflows=True`. That object is used -unchanged. Its agent registry is not automatically merged with discovered +dynamic names. This does not register standalone agent entities or HTTP routes +unless `discover_agents=True` or a selective agent binding also registers them. +To configure MAF directly, pass a configured `WorkflowFactory` object as +`workflow_factory=`. It is allowed without discovery, including with a selective +workflow binding. That object is used unchanged. Its agent registry is not +automatically merged with discovered Markdown agents. Configure its `agent_factory`, agents, registered tools, HTTP or MCP handlers, and configuration through MAF's public APIs. @@ -255,9 +279,46 @@ same graph. Markdown adapters open and close fresh Agents, clients, and tools per execution. Inline agents and agents supplied by a custom factory follow MAF's or that factory's construction and resource lifecycle, which may construct agents and clients during app initialization/indexing. The extension does not -wrap them in the Markdown lifecycle. `durable=True` still publishes all -discovered Markdown agents and their standalone HTTP endpoints, even with a -custom workflow factory. +wrap them in the Markdown lifecycle. Supplying a factory neither enables agent +discovery nor publishes standalone agent endpoints. `client_factory` remains +required even for a tool-only workflow. Such apps can pass a `NoReturn` sentinel +that raises if called, as the configured factory sample does. + +### Bind a private child workflow + +Use `durable_workflow` below `orchestration_trigger` to select a YAML workflow +without bulk discovery. The default `context_name` is `"context"`. + +```python +app = AgentFunctionApp(client_factory=create_chat_client) + + +@app.orchestration_trigger(context_name="context") +@app.durable_workflow(arg_name="child", workflow_name="Child") +def parent(context, child): + outputs = yield child.run(context.get_input()) + return {"child_outputs": outputs} +``` + +Without `workflow_file`, a new binding matches `Child.workflow.yaml` or +`Child.workflow.yml` directly in the app root or `workflows/`, before parsing. +Unrelated YAML definitions are not loaded. An explicit app-root-relative +`workflow_file` can select another filename within the app root. In either case, +the loaded workflow name must equal `workflow_name`. When reusing an already +registered graph, omit `workflow_file`. + +`child.run(input_, instance_id=None)` returns a yieldable child-orchestration +task. It invokes `dafx-Child` through the native Durable context and returns +decoded workflow outputs, rather than calling `Workflow.run()` in-process. +Each invocation has its own workflow state. Binding alone creates no child run, +status, or response HTTP routes. Add `expose_http_endpoint=True` to the binding +only when standalone access is intended. + +Forwarded input uses the same reserved-marker sanitization as DAFX's workflow +HTTP entry point. Workflow results are decoded only from the trusted child result. +The generic parent binding does not aggregate child human-input requests into a +parent workflow status endpoint. For child HITL, expose the child's management +routes or implement application management using the child instance ID. Python support follows the installed MAF dependencies, not an extension-level Python 3.14 rejection. Expression execution has been verified on Python 3.13. @@ -269,3 +330,6 @@ state, a Markdown agent call, and a separate question/response workflow. The [configured factory sample](samples/configured-workflow-factory/README.md) uses a registered function tool and configuration without an agent client. Neither sample provisions a host or backend. +The [workflow binding sample](samples/durable-workflow-binding/README.md) calls a +private, agent-free YAML child from a parent generator and includes an HTTP +starter for the parent. diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_hosting.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_hosting.py new file mode 100644 index 0000000..bbbf212 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_hosting.py @@ -0,0 +1,28 @@ +"""Narrow endpoint policy adapter for the pinned DAFX Functions host.""" + +import azure.functions as func +from agent_framework import Workflow +from agent_framework_azurefunctions import AgentFunctionApp + + +class HostedAgentFunctionApp(AgentFunctionApp): + """Keep registration separate from generated HTTP exposure. + + DAFX currently registers workflow routes unconditionally. This single + override is coupled to the pinned SDK 2 migration, not its execution engine. + """ + + def __init__( + self, *, workflows: list[Workflow], exposed_workflows: set[str], + http_auth_level: func.AuthLevel, + ) -> None: + self._exposed_workflows = exposed_workflows + super().__init__( + workflows=workflows, http_auth_level=http_auth_level, + enable_health_check=False, enable_http_endpoints=False, + enable_mcp_tool_trigger=False, + ) + + def _register_workflow_routes(self, workflow: Workflow) -> None: + if workflow.name in self._exposed_workflows: + super()._register_workflow_routes(workflow) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflow_client.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflow_client.py new file mode 100644 index 0000000..f74b4e1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflow_client.py @@ -0,0 +1,58 @@ +"""Orchestration-only invocation of a registered DAFX workflow.""" + +from typing import Any + +from azure.durable_functions import DurableOrchestrationContext +from agent_framework_durabletask import deserialize_workflow_output +from agent_framework_durabletask._workflows.serialization import ( + strip_pickle_markers, strip_subworkflow_markers, +) +from durabletask.task import CompletableTask, CompositeTask, OrchestrationContext, Task + + +class WorkflowTask(CompositeTask[Any], CompletableTask[Any]): + """Decode only the trusted child orchestration result into MAF outputs.""" + + def on_child_completed(self, task: Task[Any]) -> None: + if self.is_complete: + return + if task.is_failed: + self.fail("Workflow child orchestration failed", task.get_exception()) + else: + try: + self.complete(deserialize_workflow_output(task.get_result())) + except Exception as error: + self.fail("Workflow output decoding failed", error) + + +class DurableWorkflow: + """A workflow handle bound to the calling durable orchestration context. + + run() returns a yieldable task. It never calls Workflow.run() in-process. + Each invocation starts a child workflow with its own workflow state. + """ + + def __init__( + self, context: OrchestrationContext | DurableOrchestrationContext, + workflow_name: str, + ) -> None: + self._context = context + self.name = workflow_name + + def run( + self, input_: Any = None, *, instance_id: str | None = None, + ) -> WorkflowTask: + # Match DAFX's public workflow-entry trust boundary. Parent input may + # originate in an HTTP request; it is not an internal checkpoint envelope. + input_ = strip_subworkflow_markers(strip_pickle_markers(input_)) + # The native SDK takes keyword-only input; the Functions compatibility + # context exposes input_ instead. Neither path runs a local Workflow. + if isinstance(self._context, DurableOrchestrationContext): + child = self._context.call_sub_orchestrator( + f"dafx-{self.name}", input_=input_, instance_id=instance_id, + ) + else: + child = self._context.call_sub_orchestrator( + f"dafx-{self.name}", input=input_, instance_id=instance_id, + ) + return WorkflowTask([child]) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py index 0bb060f..b9b34e6 100644 --- a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py @@ -40,6 +40,9 @@ def load_workflows( root: Path, agents: Mapping[str, SupportsAgentRun], factory: WorkflowLoader | None = None, + *, + workflow_name: str | None = None, + workflow_file: str | Path | None = None, ) -> list[Workflow]: """Discover files and hand them to MAF without interpreting its YAML schema. @@ -48,7 +51,36 @@ def load_workflows( agent factory, tools, handlers, configuration, and resource ownership. MAF owns parsing, relative references, validation and agent construction. """ - paths = _definition_paths(root) + if workflow_file is not None: + path = root / workflow_file + if not path.name.endswith(_SUFFIXES): + raise ValueError( + "workflow_file must end in .workflow.yaml or .workflow.yml" + ) + if not path.resolve().is_relative_to(root.resolve()): + raise ValueError("workflow_file escapes app root.") + if not path.is_file(): + raise FileNotFoundError(f"Workflow definition {workflow_file!s} not found.") + paths = [path] + elif workflow_name is not None: + # Select before parsing: unrelated definitions must not be loaded merely + # because a function declares a binding to one workflow. + paths = [ + path for directory in (root, root / "workflows") if directory.is_dir() + for path in directory.iterdir() + if any(path.name == workflow_name + suffix for suffix in _SUFFIXES) + ] + if not paths: + raise FileNotFoundError(f"Workflow definition {workflow_name!r} not found.") + if len(paths) != 1: + raise ValueError(f"Ambiguous workflow definition {workflow_name!r}.") + if ( + not paths[0].is_file() + or not paths[0].resolve().is_relative_to(root.resolve()) + ): + raise ValueError("Selected workflow must be a file inside app root.") + else: + paths = _definition_paths(root) if factory is None: try: from agent_framework.declarative import WorkflowFactory @@ -68,6 +100,10 @@ def load_workflows( if not isinstance(workflow, Workflow): raise TypeError("The workflow factory must return a MAF Workflow.") name = workflow.name + if workflow_name is not None and name != workflow_name: + raise ValueError( + f"Selected workflow name {name!r} does not match {workflow_name!r}." + ) if not isinstance(name, str) or _WORKFLOW_NAME.fullmatch(name) is None: raise ValueError( f"Workflow {path.name!r} needs a stable name of 1-63 ASCII " 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 5d7c3b6..25f91dd 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 @@ -5,6 +5,7 @@ import os import re from collections.abc import Callable, Sequence +from pathlib import Path from typing import TYPE_CHECKING, Any, TypeVar, cast import azure.functions as func @@ -23,12 +24,12 @@ from ._workflows import WorkflowLoader if TYPE_CHECKING: - from agent_framework_azurefunctions import ( - AgentFunctionApp as DurableAgentFunctionApp, - ) from agent_framework_durabletask import DurableAgentTask, DurableAIAgent from durabletask.task import OrchestrationContext + from ._durable import MarkdownDurableAgent + from ._hosting import HostedAgentFunctionApp + _F = TypeVar("_F", bound=Callable[..., Any]) @@ -88,25 +89,32 @@ def __init__( | None ) = None, http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, - durable: bool = False, - workflows: bool = False, + discover_agents: bool = False, + discover_workflows: bool = False, + expose_agent_endpoints: bool = True, + expose_workflow_endpoints: bool = True, workflow_factory: WorkflowLoader | None = None, ) -> None: - if not isinstance(durable, bool): - raise TypeError("durable must be a bool") - if not isinstance(workflows, bool): - raise TypeError("workflows must be a bool") - if workflows and not durable: - raise ValueError("workflows=True requires durable=True.") - if workflow_factory is not None and not workflows: - raise ValueError("workflow_factory requires workflows=True.") + for name, value in ( + ("discover_agents", discover_agents), + ("discover_workflows", discover_workflows), + ("expose_agent_endpoints", expose_agent_endpoints), + ("expose_workflow_endpoints", expose_workflow_endpoints), + ): + if not isinstance(value, bool): + raise TypeError(f"{name} must be a bool") super().__init__( http_auth_level=http_auth_level, ) - self._durable_app: DurableAgentFunctionApp | None = None + self._durable_app: HostedAgentFunctionApp | None = None self._functions_indexed = False - self._markdown_agents: dict[str, str] = {} - self._hosted_workflows: list[Workflow] = [] + self._durable_agents: dict[str, SupportsAgentRun] = {} + self._agent_http_endpoints: dict[str, bool] = {} + self._markdown_agents: dict[str, MarkdownDurableAgent] = {} + self._markdown_discovered = False + self._hosted_workflows: dict[str, Workflow] = {} + self._workflow_http_endpoints: dict[str, bool] = {} + self._workflow_factory = workflow_factory configure_app( self, provider=AGENT_FRAMEWORK_PROVIDER_ID, @@ -116,26 +124,46 @@ def __init__( tools=tools, ), ) - if durable: - # Validate/compile the complete discovery set before registering any - # endpoints. Compilation creates recipes, not clients or live agents. - bindings = [ - self._compile_durable_markdown(name) - for name in discover_agent_names(self) - ] - if workflows: - from ._durable import MarkdownDurableAgent - from ._workflows import load_workflows + if discover_agents or (discover_workflows and workflow_factory is None): + self._discover_markdown_agents() + if discover_agents: + for agent in self._markdown_agents.values(): + self.add_durable_agent( + agent, expose_http_endpoint=expose_agent_endpoints, + ) + if discover_workflows: + from ._workflows import load_workflows - self._hosted_workflows = load_workflows( - get_app_root(self), - {binding.agent_name: MarkdownDurableAgent(binding) - for binding in bindings}, - factory=workflow_factory, + for workflow in load_workflows( + get_app_root(self), self._markdown_agents, factory=workflow_factory, + ): + self._register_workflow( + workflow, expose_http_endpoint=expose_workflow_endpoints, ) - self._ensure_durable_app() - for binding in bindings: - self._register_durable_markdown(binding) + + def _check_registration_open(self) -> None: + # A failed combined-name validation may already have cached the host. + # Retrying indexing is safe, but changing that host's inputs is not. + if self._functions_indexed or self._durable_app is not None: + raise RuntimeError("Register durable bindings before function indexing.") + + def _discover_markdown_agents(self) -> None: + if not self._markdown_discovered: + for name in discover_agent_names(self): + self._get_markdown_agent(name) + self._markdown_discovered = True + + def _get_markdown_agent(self, name: str) -> MarkdownDurableAgent: + from ._durable import MarkdownDurableAgent + + for registered_name, agent in self._markdown_agents.items(): + if registered_name.casefold() == name.casefold(): + if registered_name != name: + raise ValueError(f"Ambiguous agent name {name!r}.") + return agent + agent = MarkdownDurableAgent(self._compile_durable_markdown(name)) + self._markdown_agents[name] = agent + return agent def _compile_durable_markdown(self, name: str) -> AgentFrameworkBinding: # The name is also used in an HTTP route and a Durable Entity ID, not @@ -150,42 +178,162 @@ def _compile_durable_markdown(self, name: str) -> AgentFrameworkBinding: raise TypeError("Durable markdown agents require the MAF provider.") return compiled - def _register_durable_markdown(self, binding: AgentFrameworkBinding) -> None: - from ._durable import MarkdownDurableAgent - - self.add_durable_agent(MarkdownDurableAgent(binding)) - self._markdown_agents[binding.agent_name.casefold()] = binding.agent_name - def durable_markdown_agent( self, *, arg_name: str, agent_name: str, context_name: str = "context", + expose_http_endpoint: bool = False, ) -> Callable[[_F], _F]: """Declare a durable markdown agent and inject its orchestration proxy. Apply below orchestration_trigger, above a synchronous generator. The - declaration also publishes DAFX's default agent HTTP endpoint. + agent is private unless HTTP exposure is explicitly requested here or + by bulk agent discovery. Repeated declarations reuse the same recipe. """ if not isinstance(agent_name, str) or not agent_name.strip(): raise ValueError("agent_name must be a non-empty string") + if not isinstance(expose_http_endpoint, bool): + raise TypeError("expose_http_endpoint must be a bool") + + def register() -> None: + self.add_durable_agent( + self._get_markdown_agent(agent_name), + expose_http_endpoint=expose_http_endpoint, + ) + + return self._durable_binding( + arg_name=arg_name, + context_name=context_name, + binding_name="durable_markdown_agent", + register=register, + get_proxy=lambda context: self.get_agent(context, agent_name), + ) + + def _register_workflow( + self, workflow: Workflow, *, expose_http_endpoint: bool, + ) -> None: + self._check_registration_open() + name = workflow.name + if not isinstance(name, str) or re.fullmatch( + r"[A-Za-z][A-Za-z0-9_-]{0,62}", name, + ) is None: + raise ValueError("A durable workflow must have a stable workflow name.") + for registered_name, registered in self._hosted_workflows.items(): + if registered_name.casefold() == name.casefold(): + if registered_name != name: + raise ValueError(f"Ambiguous workflow name {name!r}.") + if registered is not workflow: + raise ValueError(f"Workflow {name!r} is already registered.") + self._hosted_workflows[name] = workflow + self._workflow_http_endpoints[name] = ( + self._workflow_http_endpoints.get(name, False) or expose_http_endpoint + ) + + def durable_workflow( + self, + *, + arg_name: str, + workflow_name: str, + context_name: str = "context", + workflow_file: str | Path | None = None, + expose_http_endpoint: bool = False, + ) -> Callable[[_F], _F]: + """Inject a private-by-default workflow proxy into an orchestrator. + Reuse a registered graph, or selectively load a matching definition. + workflow_file selects an app-root-relative file only for a new name; + omit it when reusing a graph registered by discovery or another binding. + """ + if not isinstance(workflow_name, str) or re.fullmatch( + r"[A-Za-z][A-Za-z0-9_-]{0,62}", workflow_name, + ) is None: + raise ValueError( + "workflow_name must be 1-63 ASCII letters, digits, hyphens or " + "underscores, starting with a letter." + ) + if not isinstance(expose_http_endpoint, bool): + raise TypeError("expose_http_endpoint must be a bool") + if workflow_file is not None and not isinstance(workflow_file, (str, Path)): + raise TypeError("workflow_file must be a str or Path") + + def register() -> None: + for name in self._hosted_workflows: + if ( + name.casefold() == workflow_name.casefold() + and name != workflow_name + ): + raise ValueError(f"Ambiguous workflow name {workflow_name!r}.") + workflow = self._hosted_workflows.get(workflow_name) + if workflow is not None: + if workflow_file is not None: + raise ValueError( + f"Workflow {workflow_name!r} is already registered; omit " + "workflow_file to reuse the registered graph." + ) + else: + from ._workflows import load_workflows + + if self._workflow_factory is None: + self._discover_markdown_agents() + workflows = load_workflows( + get_app_root(self), self._markdown_agents, + factory=self._workflow_factory, + workflow_name=workflow_name, workflow_file=workflow_file, + ) + if len(workflows) != 1 or workflows[0].name != workflow_name: + raise ValueError( + f"The selected definition must return exactly one workflow " + f"named {workflow_name!r}." + ) + workflow = workflows[0] + self._register_workflow( + workflow, expose_http_endpoint=expose_http_endpoint, + ) + + def get_proxy(context: OrchestrationContext) -> Any: + from ._workflow_client import DurableWorkflow + + return DurableWorkflow(context, workflow_name) + + return self._durable_binding( + arg_name=arg_name, + context_name=context_name, + binding_name="durable_workflow", + register=register, + get_proxy=get_proxy, + ) + + def _durable_binding( + self, + *, + arg_name: str, + context_name: str, + binding_name: str, + register: Callable[[], None], + get_proxy: Callable[[OrchestrationContext], Any], + ) -> Callable[[_F], _F]: def decorate(handler: _F) -> _F: - if self._functions_indexed: - raise RuntimeError("Declare durable agents before function indexing.") + self._check_registration_open() if not inspect.isgeneratorfunction(handler): raise TypeError( - "durable_markdown_agent requires a synchronous generator " + f"{binding_name} requires a synchronous generator " "below orchestration_trigger." ) + pending = getattr(handler, "_durable_binding_args", ()) + if arg_name in pending: + raise TypeError(f"Duplicate injected parameter {arg_name!r}.") + existing_context = getattr(handler, "_durable_binding_context_name", None) + if existing_context is not None and existing_context != context_name: + raise TypeError("Durable bindings must use the same context_name.") signature = inspect.signature(handler) parameter = signature.parameters.get(arg_name) if parameter is None or parameter.kind not in { inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, }: - raise TypeError(f"Invalid injected agent parameter {arg_name!r}.") + raise TypeError(f"Invalid injected parameter {arg_name!r}.") context_parameter = signature.parameters.get(context_name) if arg_name == context_name or context_parameter is None: raise TypeError(f"Missing distinct context parameter {context_name!r}.") @@ -195,82 +343,90 @@ def decorate(handler: _F) -> _F: parameters = list(visible.parameters.values()) if ( not parameters or parameters[0].name != context_name - or len(parameters) > 2 - or any(p.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD - for p in parameters) + or context_parameter.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD + or any(p.kind not in { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + } for p in parameters) ): raise TypeError( "The orchestrator must accept context first and optionally input." ) - existing_context = getattr(handler, "_durable_agent_context_name", None) - if existing_context is not None and existing_context != context_name: - raise TypeError("Durable bindings must use the same context_name.") - - if agent_name.casefold() in self._markdown_agents: - if self._markdown_agents[agent_name.casefold()] != agent_name: - raise ValueError(f"Ambiguous agent name {agent_name!r}.") - else: - self._register_durable_markdown( - self._compile_durable_markdown(agent_name) - ) + # Other visible parameters may be consumed by stacked bindings. + # Only the outer trigger can validate the final native arity. + register() @functools.wraps(handler) def inject(*args: Any, **kwargs: Any) -> Any: bound = visible.bind(*args, **kwargs) bound.apply_defaults() - bound.arguments[arg_name] = self.get_agent( - bound.arguments[context_name], agent_name - ) + bound.arguments[arg_name] = get_proxy(bound.arguments[context_name]) call = inspect.BoundArguments(signature, bound.arguments) return (yield from handler(*call.args, **call.kwargs)) inject.__signature__ = visible # type: ignore[attr-defined] - setattr(inject, "_durable_agent_context_name", context_name) + setattr(inject, "_durable_binding_context_name", context_name) + setattr(inject, "_durable_binding_args", (*pending, arg_name)) return cast(_F, inject) return decorate - def add_durable_agent(self, agent: SupportsAgentRun) -> None: + def add_durable_agent( + self, agent: SupportsAgentRun, *, expose_http_endpoint: bool = False, + ) -> None: """Opt in to DAFX by registering an agent before function indexing. Unlike markdown bindings, this accepts a caller-owned agent instance. It does not construct or close the agent's clients or tools. """ - if self._functions_indexed: - raise RuntimeError("Register durable agents before function indexing.") + self._check_registration_open() + if not isinstance(expose_http_endpoint, bool): + raise TypeError("expose_http_endpoint must be a bool") name = getattr(agent, "name", None) if not isinstance(name, str) or not name.strip(): raise ValueError("A durable agent must have a non-empty string name.") - durable_app = self._ensure_durable_app() - for registered_name, registered_agent in durable_app.agents.items(): + for registered_name, registered_agent in self._durable_agents.items(): if registered_name.casefold() == name.casefold(): - if registered_agent is agent: - return - raise ValueError(f"Durable agent {name!r} is already registered.") - durable_app.add_agent(agent) + if registered_name != name or registered_agent is not agent: + raise ValueError(f"Durable agent {name!r} is already registered.") + self._durable_agents[name] = agent + self._agent_http_endpoints[name] = ( + self._agent_http_endpoints.get(name, False) or expose_http_endpoint + ) - def _ensure_durable_app(self) -> DurableAgentFunctionApp: + def _ensure_durable_app(self) -> HostedAgentFunctionApp: if self._durable_app is None: try: - from agent_framework_azurefunctions import ( - AgentFunctionApp as DurableAgentFunctionApp, - ) + from ._hosting import HostedAgentFunctionApp except ModuleNotFoundError as error: - if error.name != "agent_framework_azurefunctions": + if error.name not in { + "agent_framework_azurefunctions", "agent_framework_durabletask", + "azure.durable_functions", "durabletask", + }: raise raise ImportError( "DAFX support is not installed. Install " "'azurefunctions-agents-extensions-agent-framework[durable]'." ) from error - self._durable_app = DurableAgentFunctionApp( - workflows=self._hosted_workflows, + durable_app = HostedAgentFunctionApp( + workflows=list(self._hosted_workflows.values()), + exposed_workflows={ + name for name, exposed in self._workflow_http_endpoints.items() + if exposed + }, http_auth_level=self.auth_level, - enable_health_check=False, - enable_http_endpoints=True, - enable_mcp_tool_trigger=False, ) + for name, agent in self._durable_agents.items(): + if any(key.casefold() == name.casefold() for key in durable_app.agents): + raise ValueError( + f"Standalone agent {name!r} collides with a workflow agent." + ) + durable_app.add_agent( + agent, enable_http_endpoint=self._agent_http_endpoints[name], + ) + self._durable_app = durable_app return self._durable_app def get_agent( @@ -279,11 +435,13 @@ def get_agent( agent_name: str, ) -> DurableAIAgent[DurableAgentTask]: """Get a DAFX proxy without registering functions during execution.""" - if self._durable_app is None: - raise RuntimeError( - "Enable durable=True or declare a durable markdown agent." - ) - return self._durable_app.get_agent(context, agent_name) + if agent_name not in self._durable_agents: + raise ValueError(f"Agent {agent_name!r} is not registered with this app.") + from agent_framework_durabletask import ( + DurableAIAgent, OrchestrationAgentExecutor, + ) + + return DurableAIAgent(OrchestrationAgentExecutor(context), agent_name) def get_functions(self) -> list[Function]: """Expose both registries through the single worker-indexed app.""" @@ -291,9 +449,10 @@ def get_functions(self) -> list[Function]: # each pass fresh, including retries after an indexing error. self.functions_bindings = None functions: list[Function] = super().get_functions() - if self._durable_app is not None: - self._durable_app.functions_bindings = None - functions.extend(self._durable_app.get_functions()) + if self._durable_agents or self._hosted_workflows: + durable_app = self._ensure_durable_app() + durable_app.functions_bindings = None + functions.extend(durable_app.get_functions()) names: set[str] = set() for function in functions: @@ -327,9 +486,21 @@ def orchestration_trigger( decorator = sdk(**options) def decorate(handler: _F) -> Any: - declared_context = getattr(handler, "_durable_agent_context_name", None) - if declared_context is not None and declared_context != context_name: - raise TypeError("Binding and trigger context_name must match.") + declared_context = getattr(handler, "_durable_binding_context_name", None) + if declared_context is not None: + if declared_context != context_name: + raise TypeError("Binding and trigger context_name must match.") + parameters = list(inspect.signature(handler).parameters.values()) + if ( + not parameters or parameters[0].name != context_name + or len(parameters) > 2 + or any(p.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD + for p in parameters) + ): + raise TypeError( + "The orchestrator must accept context first " + "and optionally input." + ) return decorator(handler) return decorate diff --git a/azurefunctions-agents-extensions-agent-framework/samples/README.md b/azurefunctions-agents-extensions-agent-framework/samples/README.md index 4adb2fb..8dbb0b7 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/README.md @@ -18,29 +18,34 @@ Agents in Python Function Apps. Agent samples use raw `.agent.md` instructions. The first two use explicit Microsoft Foundry client factories, while the local examples use deterministic clients without model credentials. -* [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: +* [agent_samples_agent-framework](agent_samples_agent-framework/README.md) - 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: +* [agent_samples_agent-framework_durable](agent_samples_agent-framework_durable/README.md) - Examples for using Agents in Durable Functions: * Schedule Agent calls from a replay-safe orchestrator * Inject a durable markdown agent and run two turns in one shared session * Combine deterministic activity output with model-generated results + * Keep the selected agent private, with no automatic agent HTTP endpoint -* [Endpoint-only local agent](lazy-owned-dafx/README.md) uses `durable=True` +* [Endpoint-only local agent](lazy-owned-dafx/README.md) uses `discover_agents=True` discovery and the generated DAFX HTTP endpoint. No handwritten handlers or model credentials are needed. * [Durable markdown binding](durable-markdown-binding/README.md) injects a proxy into a generator orchestrator, runs two turns in one session, and includes an - HTTP starter. It also uses a deterministic local client. + HTTP starter. The agent stays private. It uses a deterministic local client. - [Durable YAML workflows](durable-yaml-workflow/README.md) enables - `durable=True, workflows=True` for shared state, a Markdown agent activity, + `discover_workflows=True` for shared state, a Markdown agent activity, and a separate approval question. No handwritten handlers are needed. + No standalone writer entity or agent HTTP endpoint is published. Uses the `[durable,workflows]` extras; expression execution is verified on 3.13. - [Configured workflow factory](configured-workflow-factory/README.md) passes a public MAF factory with a registered local function and environment configuration. It needs no Markdown agent, model client, or custom HTTP handler. +- [Durable workflow binding](durable-workflow-binding/README.md) selects a private + YAML child without discovery. A parent generator yields the child task and + returns its decoded outputs. Only the parent has an HTTP starter. ## Prerequisites 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 index aaa7c43..9c47525 100644 --- 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 @@ -53,10 +53,11 @@ The logical Agent name `order-fulfillment` resolves Foundry client and model configuration remain explicit in `create_chat_client()`. -The binding registers the selected markdown definition and enables its Agent -HTTP endpoint without `durable=True`. Clients are created and closed per entity +The binding registers only the selected markdown definition, without bulk +discovery or an automatic Agent HTTP endpoint. Clients are created and closed per entity execution through the compiled markdown binding, not during indexing or replay. No custom orchestration context wrapper or hidden Agent activity is used. +The inner DAFX host is created when the outer app indexes its functions. ## Project structure @@ -212,21 +213,17 @@ failure. closes a fresh Foundry client and Agent for each entity execution. - The output contains only the order ID, `assessment.text`, and `plan.text`. -### Direct Agent endpoint +### Private Agent registration -This sample's `host.json` removes the default `api` prefix. The binding also -publishes `POST /agents/order-fulfillment/run`: +This sample's `host.json` removes the default `api` prefix. The handwritten +`POST /orders/orchestrations` starter is the only application HTTP route. +The binding's `expose_http_endpoint=False` default keeps the agent private, so +there is no `POST /agents/order-fulfillment/run` route. -```bash -curl -X POST http://localhost:7071/agents/order-fulfillment/run \ - -H "Content-Type: application/json" \ - -d '{"message":"Describe the fulfillment review process.","session_id":"order-demo"}' -``` - -Reuse the `session_id` to continue that conversation. This direct route accepts -a message and bypasses the order-preparation activity. Use the orchestration -route above for the validated order flow. Include a function key when invoking -the Agent endpoint on a hosted app. +An explicit `expose_http_endpoint=True` on the binding would publish that direct +agent route. It would bypass `prepare_order_activity` and its order validation. +Do not enable it merely to access the agent from the orchestrator. HTTP exposure +and business policy are separate decisions. ## Troubleshooting diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md index 3a38bed..9f4c4d6 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md @@ -12,7 +12,8 @@ variables for these workflow expressions. `SendActivity`. The function only formats text. No Markdown definition, agent client, model credentials, or external service is needed. `client_factory` is still a required app argument, so `no_agent_client()` raises if it is called. -There are no handwritten HTTP handlers or orchestrators. +Its return annotation is `NoReturn`. The app enables only +`discover_workflows=True`. There are no handwritten HTTP handlers or orchestrators. ## Run @@ -41,8 +42,14 @@ workflow does not request human input. The extension calls `create_workflow_from_yaml_path()` on the supplied factory unchanged, without merging discovered Markdown adapters into its agent registry. This sample has no Markdown files or standalone agent endpoints. Adding Markdown -files would still publish their standalone endpoints because `durable=True`, but -would not add them to this factory's registry. +files would neither publish standalone endpoints nor add them to this factory's +registry. Agent discovery is a separate opt-in. + +The default `expose_workflow_endpoints=True` publishes the discovered workflow. +Set it to `False` to register the graph without standalone HTTP routes. That +constructor option controls bulk discovery only. A `workflow_factory` can also +be passed without discovery for a private `durable_workflow` binding. The inner +DAFX host is deferred until `app.get_functions()`. MAF owns parsing and building, including warnings, errors, and native agent/tool configuration. The extension does not impose a separate action allowlist. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py index 44a0e84..a5c0505 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py @@ -23,7 +23,6 @@ def no_agent_client() -> NoReturn: app = AgentFunctionApp( client_factory=no_agent_client, - durable=True, - workflows=True, + discover_workflows=True, workflow_factory=workflow_factory, ) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md index 7a2d83a..59d7cb1 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md @@ -2,8 +2,9 @@ This local example places `durable_markdown_agent` below `orchestration_trigger` on a synchronous generator. The binding selects `agents/orders.agent.md`, -registers its DAFX entity and HTTP endpoint, and injects an orchestration proxy. -It does not require `durable=True` or explicit agent instance registration. +registers its private DAFX entity, and injects an orchestration proxy. +It needs neither bulk discovery nor explicit agent instance registration. +The inner DAFX host is deferred until `app.get_functions()`. The orchestrator creates one session and yields two `agent.run()` tasks with that session. The deterministic client counts user messages in the restored @@ -39,9 +40,14 @@ The HTTP starter returns a check-status response. Follow its status URL to read the orchestration output. The starter uses fixed prompts and ignores the request body. Each orchestration creates a new session. -The binding also enables `POST /api/agents/orders/run`. Send JSON with `message` -and `session_id` to use the agent directly instead of starting the orchestration. -Add a function key when calling a hosted app. +The binding defaults to `expose_http_endpoint=False`, so there is no +`POST /api/agents/orders/run` route. Only the handwritten starter exposes this +flow. Add a function key when calling a hosted app. + +To deliberately expose the agent directly, add `expose_http_endpoint=True` to +the binding. That route bypasses the parent orchestration. The constructor's +`expose_agent_endpoints` controls bulk discovery only. Discovery and a binding +reuse one registration, with HTTP exposure enabled if either opts in. For automatic registration of all root and `agents/` markdown files without handwritten functions, see the [endpoint-only sample](../lazy-owned-dafx/README.md). \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py index a835ae7..be1fde1 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py @@ -6,7 +6,7 @@ from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp from local_chat_client import LocalChatClient -# The binding below opts in only the selected agent, without durable=True. +# The binding registers only the selected agent, without an agent HTTP endpoint. app = AgentFunctionApp(client_factory=LocalChatClient) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/README.md new file mode 100644 index 0000000..6818744 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/README.md @@ -0,0 +1,74 @@ +# Durable workflow binding + +[function_app.py](function_app.py) places `durable_workflow` below +`orchestration_trigger` on a synchronous generator. The binding selects +[Child.workflow.yaml](workflows/Child.workflow.yaml) without bulk discovery. +The child uses only `SendActivity`, with no agent, model credentials, or external +service. The required `client_factory` is a `NoReturn` sentinel that raises if +anything tries to create an agent client. + +The parent yields `child.run(context.get_input())`. This schedules the +`dafx-Child` sub-orchestration, not an in-process `Workflow.run()` call. The +yielded task returns decoded workflow outputs. The parent returns + +```json +{"child_outputs": ["Child workflow completed."]} +``` + +`child.run(input_, instance_id="child-instance-id")` can supply a child instance +ID. Otherwise the native Durable scheduler chooses it. Each invocation has its +own workflow state. + +## Registration and exposure + +Neither `discover_agents` nor `discover_workflows` is enabled. The binding loads +only the matching `Child.workflow.yaml` or `Child.workflow.yml` in the app root +or its `workflows/` directory. Its loaded workflow must be named `Child`. +For a differently named file, pass an app-root-relative `workflow_file`, such as +`workflow_file="workflows/review.workflow.yaml"`, while keeping the YAML name +equal to the requested workflow name. The selected entry must stay inside the +app root. + +The child is private by default. Indexing registers `parent`, `start_parent`, +`dafx-Child`, `dafx-Child-_workflow_entry`, `dafx-Child-send_result`, and the SDK's +`BuiltIn__HttpActivity` and `BuiltIn__HttpPollOrchestrator`. There are no generated +child HTTP routes or agent functions. The inner DAFX host is created at +`app.get_functions()`, after declarations have been collected. + +To also publish the child's run, status, and response endpoints, explicitly add +`expose_http_endpoint=True` to `@app.durable_workflow(...)`. That opts in to +`POST /api/workflow/Child/run`, `GET /api/workflow/Child/status/{instanceId}`, and +`POST /api/workflow/Child/respond/{instanceId}/{requestId}`. It is not needed to +call the child from the parent. Constructor exposure options govern bulk +discovery only and do not turn a private binding into a public endpoint. + +Input forwarding strips DAFX's reserved checkpoint/envelope markers, matching +the public workflow HTTP boundary. The generic parent does not aggregate child +human-input requests into a parent management endpoint. A child that requests +human input needs exposed child management routes or application management +using its instance ID. This sample's child does not request human input. + +## Run locally + +Follow the [YAML sample setup](../durable-yaml-workflow/README.md#install-and-run) +for Python 3.13, local packages, and the `[durable,workflows]` extras. Core Tools +also requires an SDK 2-compatible Functions host/extension and a configured +Durable backend. This sample does not provision or verify them. + +Set `FUNCTIONS_WORKER_RUNTIME=python` and `AzureWebJobsStorage`, then run +`func start` from this directory. With the default `/api` prefix, start the parent + +```bash +curl -X POST http://localhost:7071/api/parent/orchestrations +``` + +The handwritten starter ignores the body and returns a check-status response. +Follow its status URL to read the parent output. Hosted requests need a function +key. The starter can be omitted when another Durable caller starts `parent`. +This example adds no request schema or business policy validation. + +MAF owns YAML parsing and native factory lifecycle. A configured +`workflow_factory` can be supplied without enabling discovery. Native YAML file +references are trusted deployment content, not sandboxed by the entry-file +containment check. See the [package documentation](../../README.md#yaml-workflows) +for factory configuration and lifecycle boundaries. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md new file mode 100644 index 0000000..4afda35 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md @@ -0,0 +1,45 @@ +# Discovery and binding verification + +This revision rebases the prototype onto PR #185 at `2777aa3`. Its provider/MCP +fixes, renamed sample directories, malformed-input tests, and CI dependencies +are preserved. The rebased baseline passed 168 tests before the API revision. + +## Current results + +- 192 tests passed with YAML support installed, using Python 3.13.11, core 1.16.0, + declarative 1.0.3, Functions 2.3.0, Durable 2.0.0rc1 and pinned DAFX PR #72. +- Without YAML dependencies, 179 passed and 13 YAML-specific cases skipped. +- Five isolated non-durable import tests passed. +- Strict mypy passed on 14 source files; scoped Flake8 and whitespace checks passed. +- Both wheels/source distributions built and local documentation links resolved. + +## Change analysis + +- Replaced the ambiguous `durable`/`workflows` switches with independent discovery + flags and bulk endpoint controls. Bindings default private. Agent/workflow + exposure matrices and registration-reuse tests cover every Boolean combination. + Enabling exposure is monotonic and cannot be undone by a later private binding. + A mutation forcing every agent endpoint on fails the three private combinations + while the other five pass. Restored registration tests pass all 24 cases. +- One app-owned registry collects all declarations before the DAFX host is built. + Workflow-only discovery does not expose standalone agents. Native factories and + resource ownership remain unchanged, and a custom factory does not trigger + irrelevant Markdown compilation unless agent discovery is requested. +- Selective loading validates the entry path and expected workflow identity before + hosting. Unrelated YAML files remain unloaded. Binding stacking is validated at + the outer orchestration trigger after injected parameters have been removed. +- Actual SDK protobuf parent execution schedules a child orchestration, the child + runs through the existing real activity/replay harness, and parent replay receives + the result. Task tests cover native and compatibility contexts, typed output + reconstruction and child failure propagation. +- Independent review found reserved-envelope input forwarding and a collision + between a workflow-internal agent and a standalone agent. Both were reproduced + with safe failing tests and fixed. Forwarding now uses DAFX's own sanitizers; + a standalone/internal collision fails before returning an indexed app. +- Native YAML behavior remains delegated to the public MAF factory. The narrow + DAFX workflow-route override and input sanitizers rely on the pinned DAFX version, + not on a custom workflow execution engine. + +Tests use SDK handlers locally. No live Functions host/backend or external service +was exercised. The generic parent binding does not provide aggregated child HITL +management. Private means no generated HTTP routes, not a security principal. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/function_app.py new file mode 100644 index 0000000..f365921 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/function_app.py @@ -0,0 +1,31 @@ +"""Call a private YAML child workflow from a generator orchestrator.""" + +from typing import NoReturn + +import azure.durable_functions as df +import azure.functions as func +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +def no_agent_client() -> NoReturn: + raise AssertionError("This workflow must not create an agent client.") + + +app = AgentFunctionApp(client_factory=no_agent_client) + + +@app.orchestration_trigger(context_name="context") +@app.durable_workflow(arg_name="child", workflow_name="Child") +def parent(context: df.DurableOrchestrationContext, child): + outputs = yield child.run(context.get_input()) + return {"child_outputs": outputs} + + +@app.route(route="parent/orchestrations", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_parent( + req: func.HttpRequest, client: df.DurableFunctionsClient +) -> func.HttpResponse: + # The child emits fixed text, so this starter does not consume a request body. + instance_id = await client.start_new("parent", client_input={}) + return client.create_check_status_response(req, instance_id) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/host.json b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/host.json new file mode 100644 index 0000000..b7e5ad1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/workflows/Child.workflow.yaml b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/workflows/Child.workflow.yaml new file mode 100644 index 0000000..b0769bd --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/workflows/Child.workflow.yaml @@ -0,0 +1,6 @@ +kind: Workflow +name: Child +actions: + - kind: SendActivity + id: send_result + activity: Child workflow completed. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md index aa9351d..840bd49 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md @@ -1,6 +1,6 @@ # Durable YAML workflows -[function_app.py](function_app.py) enables `durable=True, workflows=True` with no +[function_app.py](function_app.py) enables `discover_workflows=True` with no handwritten handlers or orchestrators. The extension loads YAML through MAF's public `WorkflowFactory.create_workflow_from_yaml_path()` and passes the resulting graphs to DAFX's `workflows=` constructor. The default factory receives @@ -97,27 +97,29 @@ gets these generated routes. | GET | `/api/workflow/NAME/status/{instanceId}` | | POST | `/api/workflow/NAME/respond/{instanceId}/{requestId}` | -The pinned dependencies index 20 functions. Workflow suffixes below are appended -to the prefix with `-`; each prefix itself is the orchestrator function. +The sample indexes 18 functions. Workflow suffixes below are appended to the +prefix with `-`. Each prefix itself is the orchestrator function. | Prefix | Generated suffixes | | --- | --- | | `dafx-OrderReview` | `start`, `status`, `respond`, `_workflow_entry`, `capture_order`, `prepare_prompt`, `review_order`, `send_review` | | `dafx-Approval` | `start`, `status`, `respond`, `_workflow_entry`, `request_approval`, `send_answer` | -The other functions are `dafx-writer`, `http-writer`, `BuiltIn__HttpActivity`, -and `BuiltIn__HttpPollOrchestrator`. +The other functions are `BuiltIn__HttpActivity` and +`BuiltIn__HttpPollOrchestrator`. -`durable=True` still discovers and publishes every Markdown agent directly in -the app root or `agents/`, including `POST /api/agents/writer/run`. Calling that -endpoint bypasses both workflows. Hosted requests need a function key, including -requests to returned status and response URLs. +`discover_workflows=True` registers the two graphs and publishes their routes +with the default `expose_workflow_endpoints=True`. Agent discovery is disabled, +so there is no `dafx-writer` entity or `POST /api/agents/writer/run` route. The +default factory still receives the writer Markdown adapter for workflow actions. +The inner DAFX host is created at `app.get_functions()`, not app construction. +Hosted requests need a function key, including requests to returned status and +response URLs. Inside this sample's YAML graph, the writer action runs as a **durable activity** through the `MarkdownDurableAgent` lifecycle. Each execution opens and closes a fresh Agent, client, and tools. It does not call the writer's durable entity or -share that endpoint's entity session. DAFX carries workflow shared state between -actions. +use an entity session. DAFX carries workflow shared state between actions. Inline YAML agents and custom-factory agents instead follow MAF's or the factory's construction and resource lifecycle. Agents and clients may be created @@ -131,7 +133,9 @@ well as Markdown references such as `agent: writer`. Supply `workflow_factory=` to configure a public `WorkflowFactory` with an `agent_factory`, agents, tools, HTTP or MCP handlers, or configuration. The supplied object is used unchanged, without automatically merging discovered Markdown agents into its registry. -All discovered Markdown agents still get their standalone endpoints. +Supplying a factory does not enable agent discovery or standalone endpoints. +Factory configuration is also allowed without bulk discovery, for use with a +selective `durable_workflow` binding. See the [configured factory sample](../configured-workflow-factory/README.md) for a tool-only workflow using `register_tool()` and `configuration`, with no @@ -139,9 +143,15 @@ agent client or external service. ## Boundaries -See [VALIDATION.md](VALIDATION.md) for measured results and test limitations. +See [VALIDATION.md](VALIDATION.md) for historical results and test limitations. -- `workflows=True` requires `durable=True` and the `[durable,workflows]` extras. +- Workflow hosting needs the `[durable,workflows]` extras. `discover_workflows` + and `discover_agents` are independent switches, both disabled by default. +- Constructor exposure options apply only to bulk discovery. Selective bindings + are private unless their own `expose_http_endpoint=True` is set. See the + [child workflow sample](../durable-workflow-binding/README.md). +- Generated workflow routes bypass any handwritten parent policy. Disabling a + standalone route is not a separate authorization boundary. - Only `*.workflow.yaml` and `*.workflow.yml` directly in the app root or its `workflows/` directory are discovered. Discovery is not recursive and does not load arbitrary YAML files. Discovered entry files must stay within the app root. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md index 20d1c81..71e63b1 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md @@ -1,6 +1,13 @@ -# YAML discovery verification +# Historical YAML discovery verification -## Results +The totals and replay checks below predate the rebase onto PR #185 at `2777aa3` +and the separate discovery/exposure API. They are not current-revision totals. +The integration contract below describes the current API. See +[README.md](README.md) for this sample's routes. +Current revision results are recorded in the +[workflow binding validation](../durable-workflow-binding/VALIDATION.md). + +## Historical results Native factory delegation was verified on Windows/Python 3.13.11 with core 1.16.0, declarative 1.0.3, Functions 2.3.0, Durable 2.0.0rc1 and DAFX PR #72 at `aa9529ec`. @@ -26,8 +33,12 @@ it. Python 3.14 execution is unverified rather than blocked by this extension. inside YAML are not sandboxed by the extension. Deploy only trusted files. - The default factory receives all discovered Markdown adapters. A supplied factory is used unchanged, with no automatic Markdown registry merge. DAFX - still validates and hosts the resulting graphs, and all discovered Markdown - agents still get standalone endpoints. + still validates and hosts the resulting graphs. Workflow discovery alone + creates no standalone Markdown agent entities or endpoints. +- Agent and workflow discovery are independent and disabled by default. Their + constructor exposure switches apply only to bulk discovery. Selective bindings + default to no standalone HTTP routes, and exposure is combined with logical OR + when declarations share a registration. The inner DAFX host is built at indexing. - Markdown adapters create fresh resources per execution. Inline and custom-factory agents follow MAF's or the factory's lifecycle and may construct agents and clients during app initialization/indexing. @@ -36,7 +47,7 @@ it. Python 3.14 execution is unverified rather than blocked by this extension. follows MAF's dependencies. Expression execution is verified on 3.13 only, since declarative 1.0.3 excludes its PowerFx dependency on 3.14. -## Verification scope +## Historical verification scope The replay probes reconstruct the app and YAML graphs before orchestration activations and activities. They execute the actual SDK protobuf orchestration @@ -47,7 +58,7 @@ HTTP/MCP handlers, configuration-only and environment-fallback expressions, and the absence of automatic Markdown merging into a custom factory. This is not an exhaustive claim of MAF feature parity. -## Change analysis +## Historical change analysis - Removed custom parsing, action traversal/allowlists, the internal action registry import, and the inline/file/dynamic/tool/Python version gates. Tests now compare diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py index 1c96335..9420012 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py @@ -1,4 +1,4 @@ -"""Discover Markdown agents and YAML workflows without handwritten handlers.""" +"""Publish YAML workflows with a private Markdown adapter for agent actions.""" from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp @@ -6,6 +6,5 @@ app = AgentFunctionApp( client_factory=LocalChatClient, - durable=True, - workflows=True, + discover_workflows=True, ) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md index c534161..8222e6e 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md @@ -1,12 +1,13 @@ # Endpoint-only durable markdown agent -This sample sets `durable=True` on `AgentFunctionApp` and supplies +This sample sets `discover_agents=True` on `AgentFunctionApp` and supplies `orders.agent.md`. Discovery registers the agent's DAFX entity and `POST /api/agents/orders/run` endpoint. There are no handwritten HTTP functions, orchestrators, or agent instance registrations. Every `.agent.md` file directly in the app root or `agents/` is discovered. -Indexing compiles recipes without constructing clients. Each entity execution +Discovery compiles recipes without constructing clients. The inner DAFX host is +created at `get_functions()`. Each entity execution opens and closes a fresh Agent through the compiled binding's `open_agent()` lifecycle. DAFX stores conversation history separately in durable session state. @@ -41,7 +42,7 @@ python -m pytest -q azurefunctions-agents-extensions-agent-framework/tests/test_ The tests exercise indexing and local entity execution. They do not replace a deployed Functions host or storage integration test. See -[VALIDATION.md](VALIDATION.md) for the current verification results and limitations. +[VALIDATION.md](VALIDATION.md) for historical verification results and limitations. ## Run locally @@ -70,7 +71,11 @@ ID to start over. Add a function key when calling a hosted app. ASCII letters, digits, hyphens, and underscores. These names become routes and entity identifiers, not just filenames. - The outer app remains the only worker-indexed app and combines both registries. -- Agent HTTP endpoints are enabled. Health and MCP endpoints are disabled. The - SDK's built-in durable HTTP activity/orchestrator remain registered. +- `expose_agent_endpoints=True` publishes discovered agents by default. Set it + to `False` for registration without standalone HTTP endpoints. This constructor + option does not control selective bindings, which are private by default. +- Workflow discovery is independent and disabled here. Health and MCP endpoints + are disabled. The SDK's built-in durable HTTP activity/orchestrator remain + registered. - Normal `markdown_agent()` is unchanged. Durable orchestrators use the new binding and yield proxy tasks instead of calling `context.call_agent()`. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md index 9465cd2..d313b2f 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md @@ -1,11 +1,15 @@ -# Durable markdown prototype verification +# Historical durable markdown prototype verification + +These results predate the rebase onto PR #185 at `2777aa3` and the separate +discovery/exposure API. They are historical totals, not validation of the current +revision. Current behavior is documented in [README.md](README.md). Verified on Windows with Python 3.13.11 on 2026-09-09. This revision replaces the explicit-registration example at `5d77570`. Branch base is extensions PR #185 at `db2526586348513ff86ed2c61ffc685815a8d212`. Both DAFX packages remain pinned to PR #72 at `aa9529ec489e16ac64b73bd68d5adbb8e4945258`. -## Results +## Historical results | Configuration | Result | | --- | --- | @@ -22,14 +26,14 @@ It is not suppressed. Tests exercise the SDK's real orchestration and entity protobuf handlers, not a running Functions host or storage backend. Local clients substitute for the model service; external MCP servers were not contacted. -## Change analysis +## Historical change analysis - Normal markdown binding construction/invocation remains import-safe without DAFX. Durable discovery is explicit and creates recipes, not clients. The old context wrapper, hidden activity, exports, and activity-specific tests are removed. -- Both discovery and the binding publish entity and HTTP functions before indexing. - Binding and discovery share one registration. The SDK's built-in functions remain - in the combined index. Existing auth, collision, reindex, and isolation tests pass. +- The earlier revision published entity and HTTP functions for both discovery and + bindings. That exposure behavior is superseded. Bindings are now private by + default, and the inner DAFX host is deferred until indexing. - Raw instructions are preserved. Discovery rejects duplicate names across both directories, case collisions, directories masquerading as files, and symlinks escaping the app root. Durable markdown names are restricted to safe ASCII @@ -54,7 +58,7 @@ substitute for the model service; external MCP servers were not contacted. mutations disabling discovery fail two tests, substituting a non-agent proxy fails three, and dropping the final response fails one. The unmodified adapter suite then passes all 40 tests. No source files were mutated by the probes. -- Current docs and samples use discovery or binding declarations, not the deleted +- That revision's docs and samples used discovery or binding declarations, not the deleted custom activity API. `add_durable_agent()` remains a lower-level instance API but is not required by either markdown sample. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py index 60511bf..94274a9 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py @@ -3,4 +3,4 @@ from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp from local_chat_client import LocalChatClient -app = AgentFunctionApp(client_factory=LocalChatClient, durable=True) +app = AgentFunctionApp(client_factory=LocalChatClient, discover_agents=True) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py index 20782e3..cce6527 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py @@ -272,7 +272,7 @@ def run_case(label, definition, name, expected, *, input_data=None, agent_definitions=(), agent_paths=(), agents=(), files=None, filename="probe.workflow.yaml", configure=None, expected_client_calls=(), human_response=None, default_factory=False): - """Require native construction at index time and one call per replayed effect.""" + """Require native construction at app init and one call per replayed effect.""" with tempfile.TemporaryDirectory() as temp: root = Path(temp).resolve() harness.write_workflow(root, definition, filename) @@ -330,7 +330,11 @@ def check_construction(): with patch.object(harness, "WORKFLOW_FACTORY_BUILDER", None if default_factory else build): - harness.index(root) + app = harness.make_app(root) + check_construction() + assert app._durable_app is None + assert set(app._hosted_workflows) == {name} + app.get_functions() check_construction() assert not NativeClient.calls and not harness.LocalClient.calls output, activities, answered = harness.run_workflow( diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_registration_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_registration_probe.py new file mode 100644 index 0000000..7b94f38 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/_registration_probe.py @@ -0,0 +1,212 @@ +"""SDK-level workflow registration and child-orchestration probes.""" +from __future__ import annotations + +import base64 +from itertools import product +import json +import os +from pathlib import Path +import sys +import tempfile +import traceback + +import azure.functions as func +from agent_framework.declarative import WorkflowFactory +from durabletask.internal import orchestrator_service_pb2 as pb +from google.protobuf.wrappers_pb2 import StringValue + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from _yaml_workflow_probe import event, run_workflow + +YAML = """name: Child +actions: + - kind: SendActivity + id: output + activity: Hello +""" + + +def prepare(root): + (root / "Child.workflow.yaml").write_text(YAML, encoding="utf-8") + (root / "orders.agent.md").write_text("Handle orders", encoding="utf-8") + + +def routes(app): + return [b["route"] for f in app.get_functions() + for b in f.get_bindings_dict()["bindings"] if b["type"] == "httpTrigger"] + + +def bind(app, exposed=False, **kwargs): + @app.orchestration_trigger(context_name="context") + @app.durable_workflow( + arg_name="child", workflow_name="Child", expose_http_endpoint=exposed, **kwargs, + ) + def parent(context, child): + return (yield child.run({"greeting": "hello"})) + return parent + + +def matrix(root): + prepare(root) + for discovery, bulk_exposed, binding_exposed in product((False, True), repeat=3): + app = AgentFunctionApp( + client_factory=lambda: None, app_root=root, + discover_workflows=discovery, expose_workflow_endpoints=bulk_exposed, + ) + original = app._hosted_workflows.get("Child") + bind(app, binding_exposed) + assert not app._durable_agents, "Workflow discovery exposed a standalone agent" + if original: + assert app._hosted_workflows["Child"] is original + exposed = (discovery and bulk_exposed) or binding_exposed + assert len(routes(app)) == (3 if exposed else 0) + assert len([f for f in app.get_functions() + if f.get_function_name() == "dafx-Child"]) == 1 + + for agent_outer in (False, True): + app = AgentFunctionApp(client_factory=lambda: None, app_root=root) + + def parent(context, agent, child): + yield child.run() + yield agent.run("hello") + + decorators = [app.durable_workflow(arg_name="child", workflow_name="Child"), + app.durable_markdown_agent(arg_name="agent", agent_name="orders")] + if agent_outer: + decorators.reverse() + for decorate in decorators: + parent = decorate(parent) + app.orchestration_trigger(context_name="context")(parent) + assert routes(app) == [] + assert set(app._hosted_workflows) == {"Child"} + assert set(app._durable_agents) == {"orders"} + return {"exposure_combinations": 8, "stacked_orders": 2} + + +def selection(root): + prepare(root) + (root / "Broken.workflow.yaml").write_text("not YAML: [", encoding="utf-8") + app = AgentFunctionApp(client_factory=lambda: None, app_root=root) + bind(app) + assert set(app._hosted_workflows) == {"Child"} + assert routes(app) == [] + try: + bind(app) + except RuntimeError as error: + assert "before function indexing" in str(error) + else: + raise AssertionError("Late binding accepted") + + calls = [] + + class Factory(WorkflowFactory): + def create_workflow_from_yaml_path(self, yaml_path): + calls.append(Path(yaml_path)) + return super().create_workflow_from_yaml_path(yaml_path) + + app = AgentFunctionApp(client_factory=lambda: None, app_root=root, + workflow_factory=Factory()) + bind(app, workflow_file="Child.workflow.yaml") + assert calls == [root / "Child.workflow.yaml"] + (root / "sales.v2.agent.md").write_text("Non-durable only", encoding="utf-8") + # Custom workflow loading does not consume or publish unrelated markdown. + candidate = AgentFunctionApp( + client_factory=lambda: None, app_root=root, workflow_factory=Factory(), + ) + bind(candidate) + assert routes(candidate) == [] + assert not candidate._markdown_agents + (root / "Broken.workflow.yaml").unlink() + candidate = AgentFunctionApp( + client_factory=lambda: None, app_root=root, workflow_factory=Factory(), + discover_workflows=True, expose_workflow_endpoints=False, + ) + assert routes(candidate) == [] + assert not candidate._markdown_agents + (root / "sales.v2.agent.md").unlink() + for path in ("../outside.workflow.yaml", "Child.txt"): + candidate = AgentFunctionApp(client_factory=lambda: None, app_root=root) + try: + bind(candidate, workflow_file=path) + except ValueError: + pass + else: + raise AssertionError(path) + (root / "workflows").mkdir() + (root / "workflows/Child.workflow.yml").write_text(YAML, encoding="utf-8") + try: + bind(AgentFunctionApp(client_factory=lambda: None, app_root=root)) + except ValueError as error: + assert "Ambiguous" in str(error) + else: + raise AssertionError("Duplicate definition accepted") + return True + + +def transport(root): + prepare(root) + app = AgentFunctionApp(client_factory=lambda: None, app_root=root) + bind(app) + handler = next(f.get_user_function() for f in app.get_functions() + if f.get_function_name() == "parent") + start = [ + event(orchestratorStarted=pb.OrchestratorStartedEvent()), + event(0, executionStarted=pb.ExecutionStartedEvent( + name="parent", input=StringValue(value="{}"), + orchestrationInstance=pb.OrchestrationInstance(instanceId="parent-1"), + )), + ] + request = pb.OrchestratorRequest(instanceId="parent-1", newEvents=start) + encoded = handler(func.OrchestrationContext( + base64.b64encode(request.SerializeToString()), + )) + result = pb.OrchestratorResponse.FromString(base64.b64decode(encoded)) + assert len(result.actions) == 1, result + action = result.actions[0] + assert action.HasField("createSubOrchestration"), result + child = action.createSubOrchestration + assert child.name == "dafx-Child" + assert json.loads(child.input.value) == {"greeting": "hello"} + child_output = run_workflow(root, "Child")[0] + assert child_output == ["Hello"] + past = start + [event(action.id, subOrchestrationInstanceCreated=( + pb.SubOrchestrationInstanceCreatedEvent( + name=child.name, instanceId=child.instanceId, input=child.input, + ) + )), event(orchestratorCompleted=pb.OrchestratorCompletedEvent())] + new = [event(orchestratorStarted=pb.OrchestratorStartedEvent()), event( + 100, subOrchestrationInstanceCompleted=( + pb.SubOrchestrationInstanceCompletedEvent( + taskScheduledId=action.id, + result=StringValue(value=json.dumps(child_output)), + ) + ), + )] + request = pb.OrchestratorRequest( + instanceId="parent-1", pastEvents=past, newEvents=new, + ) + encoded = handler(func.OrchestrationContext( + base64.b64encode(request.SerializeToString()), + )) + result = pb.OrchestratorResponse.FromString(base64.b64decode(encoded)) + assert len(result.actions) == 1, result + done = result.actions[0].completeOrchestration + assert done.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED, result + assert json.loads(done.result.value) == ["Hello"] + return ["Hello"] + + +if __name__ == "__main__": + try: + with tempfile.TemporaryDirectory() as temp: + output = {"matrix": matrix, "selection": selection, "transport": transport}[ + sys.argv[1] + ](Path(temp).resolve()) + print(json.dumps({"result": output}), flush=True) + code = 0 + except Exception: + traceback.print_exc() + code = 1 + sys.stdout.flush() + sys.stderr.flush() + os._exit(code) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py index e1c147d..1a075a4 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py @@ -66,9 +66,11 @@ def make_app(root): before = len(LocalClient.instances) factory = WORKFLOW_FACTORY_BUILDER(root) if WORKFLOW_FACTORY_BUILDER else None app = AgentFunctionApp( - client_factory=LocalClient, app_root=root, durable=True, workflows=True, + client_factory=LocalClient, app_root=root, + discover_agents=True, discover_workflows=True, workflow_factory=factory, ) + assert app._durable_app is None, "Host created before function indexing" if factory is None: assert len(LocalClient.instances) == before, ( "Live client created during loading" @@ -318,7 +320,7 @@ def invalid(label, files, expected): root / "probe.workflow.yaml" ) app = make_app(root) - loaded = app._hosted_workflows[0] + loaded = app._hosted_workflows[native.name] assert loaded.name == native.name loaded_nodes = [ (key, type(value)) for key, value in loaded.executors.items() diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py index f80a3e5..7b367fe 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py @@ -9,15 +9,17 @@ from azurefunctions.agents.extensions.agent_framework import apps -def test_typed_api_exposes_only_v1_options(): +def test_typed_api_exposes_registration_options(): assert list(inspect.signature(AgentFunctionApp.__init__).parameters) == [ "self", "client_factory", "app_root", "tools", "http_auth_level", - "durable", - "workflows", + "discover_agents", + "discover_workflows", + "expose_agent_endpoints", + "expose_workflow_endpoints", "workflow_factory", ] assert list(inspect.signature(AgentFunctionApp.markdown_agent).parameters) == [ diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py b/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py index 931c385..8227368 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py @@ -52,23 +52,27 @@ def test_initialization_does_not_construct_dafx(app): assert app._durable_app is None -def test_registration_owns_one_real_dafx_app(app): +def test_indexing_constructs_one_real_dafx_app_from_registered_agents(app): from agent_framework_azurefunctions import AgentFunctionApp as DafxApp first = make_agent() + app.add_durable_agent(first, expose_http_endpoint=True) app.add_durable_agent(first) + app.add_durable_agent(make_agent("Shipping"), expose_http_endpoint=True) + + assert app._durable_app is None + assert set(app._durable_agents) == {"Orders", "Shipping"} + assert app._durable_agents["Orders"] is first + functions = app.get_functions() inner = app._durable_app assert isinstance(inner, DafxApp) - app.add_durable_agent(first) - app.add_durable_agent(make_agent("Shipping")) - - assert app._durable_app is inner assert set(inner.agents) == {"Orders", "Shipping"} assert not inner.enable_health_check - assert inner.enable_http_endpoints + assert not inner.enable_http_endpoints assert not inner.enable_mcp_tool_trigger assert inner.auth_level == app.auth_level - functions = app.get_functions() + assert app.get_functions() == functions + assert app._durable_app is inner entities = { function.get_function_name() for function in functions @@ -82,6 +86,7 @@ def test_registration_owns_one_real_dafx_app(app): def test_invalid_name_does_not_enable_dafx(app, name): with pytest.raises(ValueError, match="non-empty string name"): app.add_durable_agent(SimpleNamespace(name=name)) + assert app._durable_agents == {} assert app._durable_app is None @@ -90,19 +95,24 @@ def test_different_agent_with_duplicate_name_is_rejected(app, name): app.add_durable_agent(make_agent()) with pytest.raises(ValueError, match="already registered"): app.add_durable_agent(make_agent(name)) - assert len(app._durable_app.agents) == 1 + assert len(app._durable_agents) == 1 + assert app._durable_app is None def test_lookup_does_not_enable_dafx(app): - with pytest.raises(RuntimeError, match="durable=True"): + with pytest.raises(ValueError, match="not registered"): app.get_agent(object(), "Orders") assert app._durable_app is None -def test_unknown_agent_uses_dafx_validation(app): +def test_agent_lookup_validates_registry_without_constructing_host(app): + from agent_framework_durabletask import DurableAIAgent + app.add_durable_agent(make_agent()) + assert isinstance(app.get_agent(Mock(), "Orders"), DurableAIAgent) with pytest.raises(ValueError, match="not registered"): app.get_agent(object(), "Unknown") + assert app._durable_app is None def test_distinct_apps_do_not_share_durable_registries(app, tmp_path): @@ -110,6 +120,11 @@ def test_distinct_apps_do_not_share_durable_registries(app, tmp_path): app.add_durable_agent(make_agent("First")) assert other._durable_app is None other.add_durable_agent(make_agent("Second")) + assert app._durable_app is other._durable_app is None + assert set(app._durable_agents) == {"First"} + assert set(other._durable_agents) == {"Second"} + app.get_functions() + other.get_functions() assert app._durable_app is not other._durable_app assert set(app._durable_app.agents) == {"First"} assert set(other._durable_app.agents) == {"Second"} @@ -161,7 +176,7 @@ def test_combined_index_preserves_http_auth_and_is_repeatable(tmp_path, auth): def orders(req): return func.HttpResponse("ok") - app.add_durable_agent(make_agent()) + app.add_durable_agent(make_agent(), expose_http_endpoint=True) first = app.get_functions() second = app.get_functions() assert [fn.get_function_name() for fn in first] == [ @@ -195,7 +210,7 @@ def collision(req): def test_sdk_builtin_names_cannot_be_shadowed(app, tmp_path): app.add_durable_agent(make_agent()) # Derive the SDK-owned names rather than duplicating a hard-coded list. - sdk_functions = app._durable_app.get_functions() + sdk_functions = app.get_functions() builtins = [ fn for fn in sdk_functions if fn.get_function_name().startswith("BuiltIn__") diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py b/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py index 259f081..0bdad16 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py @@ -40,8 +40,12 @@ def test_discovery_is_opt_in_and_creates_recipes_not_live_agents(tmp_path): assert plain.get_functions() == [] assert plain._durable_app is None - app = AgentFunctionApp(client_factory=factory, app_root=tmp_path, durable=True) - assert set(app._durable_app.agents) == {"orders", "shipping"} + app = AgentFunctionApp( + client_factory=factory, app_root=tmp_path, discover_agents=True, + ) + assert app._durable_app is None + assert set(app._durable_agents) == {"orders", "shipping"} + assert app._durable_agents == app._markdown_agents indexed = app.get_functions() assert set(fn.get_function_name() for fn in indexed) == { "dafx-orders", "dafx-shipping", "http-orders", "http-shipping", @@ -53,9 +57,9 @@ def test_discovery_is_opt_in_and_creates_recipes_not_live_agents(tmp_path): @pytest.mark.parametrize("value", [None, 0, 1, "true", [], {}]) -def test_durable_flag_is_explicit_bool(tmp_path, value): - with pytest.raises(TypeError, match="durable must be a bool"): - make_app(tmp_path, durable=value) +def test_discover_agents_flag_is_explicit_bool(tmp_path, value): + with pytest.raises(TypeError, match="discover_agents must be a bool"): + make_app(tmp_path, discover_agents=value) @pytest.mark.parametrize("second", ["orders", "ORDERS"]) @@ -65,17 +69,20 @@ def test_discovery_rejects_ambiguous_names_before_registration( definition(tmp_path, "orders") definition(tmp_path / "agents", second) ensure = Mock(side_effect=AssertionError("partial durable registration")) + register = Mock(side_effect=AssertionError("partial agent registration")) monkeypatch.setattr(AgentFunctionApp, "_ensure_durable_app", ensure) + monkeypatch.setattr(AgentFunctionApp, "add_durable_agent", register) with pytest.raises(ValueError, match="[Aa]mbiguous"): - make_app(tmp_path, durable=True) + make_app(tmp_path, discover_agents=True) ensure.assert_not_called() + register.assert_not_called() def test_discovery_ignores_unrelated_files_and_rejects_definition_directories(tmp_path): (tmp_path / "readme.md").write_text("ignore", encoding="utf-8") (tmp_path / "orders.agent.md").mkdir() with pytest.raises(ValueError, match="not a file"): - make_app(tmp_path, durable=True) + make_app(tmp_path, discover_agents=True) def test_discovery_rejects_escaping_symlink(tmp_path): @@ -86,7 +93,7 @@ def test_discovery_rejects_escaping_symlink(tmp_path): except OSError as error: pytest.skip(f"Symlinks unavailable: {error}") with pytest.raises(ValueError, match="outside app root"): - make_app(tmp_path, durable=True) + make_app(tmp_path, discover_agents=True) def test_compile_preserves_raw_instructions(tmp_path): @@ -109,7 +116,9 @@ def first(context, *, agent): def second(context, agent): yield agent - assert len(app._durable_app.agents) == 1 + assert set(app._durable_agents) == {"orders"} + assert app._agent_http_endpoints == {"orders": False} + assert app._durable_app is None assert list(inspect.signature(first).parameters) == ["context"] proxy = object() app.get_agent = Mock(return_value=proxy) @@ -119,20 +128,25 @@ def second(context, agent): app.get_agent.assert_called_with(context, "orders") with pytest.raises(TypeError): next(first(context, agent=object())) + indexed = app.get_functions() + assert "dafx-orders" in {fn.get_function_name() for fn in indexed} + assert not any(fn.is_http_function() for fn in indexed) def test_binding_and_discovery_share_one_entity(tmp_path): definition(tmp_path) - app = make_app(tmp_path, durable=True) - original = app._durable_app.agents["orders"] + app = make_app(tmp_path, discover_agents=True) + original = app._durable_agents["orders"] @app.orchestration_trigger(context_name="context") @app.durable_markdown_agent(arg_name="agent", agent_name="orders") def workflow(context, agent): yield agent.run("hello") - assert app._durable_app.agents["orders"] is original + assert app._durable_agents["orders"] is original + assert app._durable_app is None assert len(app.get_functions()) == 5 + assert app._durable_app.agents["orders"] is original def test_binding_custom_context_and_native_input_are_forwarded(tmp_path): @@ -227,6 +241,8 @@ def workflow(context, agent): with pytest.raises(ValueError): app.durable_markdown_agent(arg_name="agent", agent_name=name)(workflow) + assert app._durable_agents == {} + assert app._markdown_agents == {} assert app._durable_app is None @@ -269,6 +285,8 @@ def workflow(context, agent): with pytest.raises(FileNotFoundError): app.durable_markdown_agent(arg_name="agent", agent_name="missing")(workflow) + assert app._durable_agents == {} + assert app._markdown_agents == {} assert app._durable_app is None @@ -299,6 +317,8 @@ def variadic(context, *agent): missing_context, wrong_order, variadic]: with pytest.raises(TypeError): bind(handler) + assert app._durable_agents == {} + assert app._markdown_agents == {} assert app._durable_app is None @@ -312,13 +332,15 @@ def workflow(context, agent): with pytest.raises(RuntimeError, match="before function indexing"): app.durable_markdown_agent(arg_name="agent", agent_name="orders")(workflow) + assert app._durable_agents == {} + assert app._markdown_agents == {} assert app._durable_app is None def test_generated_http_function_name_collision_is_not_silent(tmp_path): definition(tmp_path, "order-one") definition(tmp_path, "order_one") - app = make_app(tmp_path, durable=True) + app = make_app(tmp_path, discover_agents=True) with pytest.raises(ValueError, match="unique function name"): app.get_functions() diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py index b38da5b..0e387a6 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -109,13 +109,16 @@ def test_dafx_import_errors_are_actionable_without_hiding_broken_installs( original_import = builtins.__import__ def fail_dafx_import(name, *args, **kwargs): - if name == "agent_framework_azurefunctions": + if name == "_hosting": raise ModuleNotFoundError(f"No module named {missing!r}", name=missing) return original_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", fail_dafx_import) + app.add_durable_agent(SimpleNamespace(name="Orders")) + assert set(app._durable_agents) == {"Orders"} + assert app._durable_app is None with pytest.raises(ImportError) as caught: - app.add_durable_agent(SimpleNamespace(name="Orders")) + app.get_functions() if missing == "agent_framework_azurefunctions": assert "[durable]" in str(caught.value) assert isinstance(caught.value.__cause__, ModuleNotFoundError) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_registration_api.py b/azurefunctions-agents-extensions-agent-framework/tests/test_registration_api.py new file mode 100644 index 0000000..a674383 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_registration_api.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from itertools import product +import json +from pathlib import Path +import subprocess +import sys +from unittest.mock import Mock + +import pytest +from agent_framework import AgentResponse, Message +from azure.durable_functions import DurableOrchestrationContext +from durabletask.task import CompletableTask, OrchestrationContext + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.agent_framework._workflow_client import ( + DurableWorkflow, +) + + +def define_agent(root): + (root / "orders.agent.md").write_text("Handle orders", encoding="utf-8") + + +def agent_binding(app, exposed=False): + @app.orchestration_trigger(context_name="context") + @app.durable_markdown_agent( + arg_name="agent", agent_name="orders", expose_http_endpoint=exposed, + ) + def orchestrator(context, agent): + yield agent.run("hello") + return orchestrator + + +def http_routes(app): + return [binding["route"] for fn in app.get_functions() + for binding in fn.get_bindings_dict()["bindings"] + if binding["type"] == "httpTrigger"] + + +@pytest.mark.parametrize("discover,bulk_exposed,binding_exposed", list(product( + (False, True), repeat=3, +))) +def test_agent_discovery_and_binding_exposure_matrix( + tmp_path, discover, bulk_exposed, binding_exposed, +): + define_agent(tmp_path) + app = AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, + discover_agents=discover, expose_agent_endpoints=bulk_exposed, + ) + before = app._durable_agents.get("orders") + agent_binding(app, binding_exposed) + if before: + assert app._durable_agents["orders"] is before + assert app._durable_app is None + exposed = (discover and bulk_exposed) or binding_exposed + expected = ["agents/orders/run"] if exposed else [] + assert http_routes(app) == expected + functions = app.get_functions() + assert sum(fn.get_function_name() == "dafx-orders" for fn in functions) == 1 + + +@pytest.mark.parametrize("flags", [ + {}, {"expose_agent_endpoints": True}, {"expose_workflow_endpoints": True}, + {"discover_agents": True}, +]) +def test_empty_configuration_does_not_load_dafx(tmp_path, flags): + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, **flags) + assert app.get_functions() == [] + assert app._durable_app is None + + +@pytest.mark.parametrize("first,second", [(False, True), (True, False)]) +def test_repeated_agent_exposure_is_order_independent(tmp_path, first, second): + define_agent(tmp_path) + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) + + def flow(context, agent): + yield agent + + for expose in [first, second]: + app.durable_markdown_agent( + arg_name="agent", agent_name="orders", expose_http_endpoint=expose, + )(flow) + assert len(app._durable_agents) == 1 + assert http_routes(app) == ["agents/orders/run"] + + +@pytest.mark.parametrize("legacy", ["durable", "workflows"]) +def test_ambiguous_legacy_flags_are_not_silently_accepted(tmp_path, legacy): + with pytest.raises(TypeError, match=legacy): + AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, **{legacy: True}, + ) + + +@pytest.mark.parametrize("compatibility_context", [False, True]) +def test_workflow_proxy_schedules_child_and_decodes_output(compatibility_context): + from agent_framework_durabletask._workflows.serialization import serialize_value + + context = Mock(spec=OrchestrationContext) + pending = CompletableTask() + context.call_sub_orchestrator.return_value = pending + wrapped = DurableOrchestrationContext(context) if compatibility_context else context + proxy = DurableWorkflow(wrapped, "Child") + task = proxy.run({"message": "hello"}, instance_id="child-1") + context.call_sub_orchestrator.assert_called_once_with( + "dafx-Child", input={"message": "hello"}, instance_id="child-1", + ) + assert not task.is_complete + response = AgentResponse(messages=[Message(role="assistant", contents=["done"])]) + pending.complete([serialize_value(response)]) + output = task.get_result() + assert isinstance(output[0], AgentResponse) + assert output[0].text == "done" + + +def test_child_failure_propagates(): + context = Mock(spec=OrchestrationContext) + pending = CompletableTask() + context.call_sub_orchestrator.return_value = pending + task = DurableWorkflow(context, "Child").run() + pending.fail("child failed", ValueError("bad input")) + assert task.is_failed + with pytest.raises(Exception, match="bad input|failed"): + task.get_result() + + +def test_child_input_cannot_impersonate_internal_checkpoint_envelope(): + context = Mock(spec=OrchestrationContext) + context.call_sub_orchestrator.return_value = CompletableTask() + payload = { + "__subworkflow_input__": {"__pickled__": "not-a-pickle"}, + "__subworkflow_address__": {"root_instance_id": "wrong"}, + "message": "hello", "nested": {"__type__": "untrusted"}, + } + DurableWorkflow(context, "Child").run(payload) + assert context.call_sub_orchestrator.call_args.kwargs["input"] == { + "message": "hello", "nested": None, + } + assert "__subworkflow_input__" in payload # Sanitization must not mutate input. + + +def test_internal_workflow_agent_cannot_shadow_standalone_agent(tmp_path): + from agent_framework import Agent, AgentExecutor, WorkflowBuilder + + internal = Agent(client=Mock(), name="inner") + node = AgentExecutor(internal, id="reviewer") + workflow = WorkflowBuilder(start_executor=node, name="Review").build() + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) + app._register_workflow(workflow, expose_http_endpoint=False) + app.add_durable_agent(Agent(client=Mock(), name="Review-reviewer")) + with pytest.raises(ValueError, match="collides"): + app.get_functions() + + +@pytest.mark.parametrize("mode", ["matrix", "selection", "transport"]) +def test_workflow_registration_probes(mode): + from importlib.util import find_spec + if sys.version_info >= (3, 14) or find_spec("agent_framework_declarative") is None: + pytest.skip("YAML execution probes need the workflows extra and Python 3.13") + result = subprocess.run( + [sys.executable, "-X", "utf8", str(Path(__file__).with_name( + "_registration_probe.py")), mode], + capture_output=True, text=True, encoding="utf-8", timeout=180, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(result.stdout.strip().splitlines()[-1])["result"] diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py index 607dd38..cce4a7f 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -15,7 +15,7 @@ "agent_samples_agent-framework": {"process_order", "process_order_event"}, "agent_samples_agent-framework_durable": { "order_orchestrator", "prepare_order_activity", "start_order_orchestration", - "dafx-order-fulfillment", "http-order_fulfillment", + "dafx-order-fulfillment", "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", }, "lazy-owned-dafx": { @@ -23,12 +23,11 @@ "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", }, "durable-markdown-binding": { - "orders", "start_orders", "dafx-orders", "http-orders", + "orders", "start_orders", "dafx-orders", "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", }, "durable-yaml-workflow": { "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", - "dafx-writer", "http-writer", "dafx-OrderReview", "dafx-OrderReview-start", "dafx-OrderReview-status", "dafx-OrderReview-respond", "dafx-OrderReview-_workflow_entry", "dafx-OrderReview-capture_order", "dafx-OrderReview-prepare_prompt", @@ -44,8 +43,23 @@ "dafx-ConfiguredTools-send_result", "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", }, + "durable-workflow-binding": { + "parent", "start_parent", "dafx-Child", "dafx-Child-_workflow_entry", + "dafx-Child-send_result", "BuiltIn__HttpActivity", + "BuiltIn__HttpPollOrchestrator", + }, } _LOCAL_SAMPLES = ("lazy-owned-dafx", "durable-markdown-binding") +_LOCAL_ENDPOINT_SAMPLES = tuple( + sample for sample in _LOCAL_SAMPLES if "http-orders" in _SAMPLE_INDEXES[sample] +) + + +def _require_workflows(): + from importlib.util import find_spec + + if sys.version_info >= (3, 14) or find_spec("agent_framework_declarative") is None: + pytest.skip("YAML samples tested on 3.13 with workflows extra") def _run_sample(sample_path, script): @@ -78,13 +92,8 @@ def test_index_cases_cover_every_sample_app(): @pytest.mark.parametrize("sample_path", _SAMPLE_INDEXES) def test_sample_indexes_all_functions(sample_path): - if sample_path in {"durable-yaml-workflow", "configured-workflow-factory"}: - from importlib.util import find_spec - if ( - sys.version_info >= (3, 14) - or find_spec("agent_framework_declarative") is None - ): - pytest.skip("YAML expression samples tested on 3.13 with workflows extra") + if any((_SAMPLES_ROOT / sample_path).rglob("*.workflow.y*ml")): + _require_workflows() # Exact names were recorded from the SDK 2/DAFX PR #72 index. DAFX sanitizes # HTTP names (_build_function_name), but preserves hyphens in entity names. result = _run_sample(sample_path, """ @@ -96,6 +105,7 @@ def test_sample_indexes_all_functions(sample_path): with patch.object(AgentFrameworkBinding, '_create_agent', side_effect=AssertionError('live agent during indexing')): import function_app + assert function_app.app._durable_app is None, 'host built before indexing' first = function_app.app.get_functions() second = function_app.app.get_functions() names = [fn.get_function_name() for fn in first] @@ -319,7 +329,7 @@ def submit(prompt): ] -@pytest.mark.parametrize("sample_path", _LOCAL_SAMPLES) +@pytest.mark.parametrize("sample_path", _LOCAL_ENDPOINT_SAMPLES) @pytest.mark.parametrize("body", ["{not json", "{}", '{"message":""}']) def test_local_agent_endpoint_rejects_invalid_input(sample_path, body): result = _run_sample(sample_path, f""" @@ -341,6 +351,94 @@ def test_local_agent_endpoint_rejects_invalid_input(sample_path, body): assert result == 400 +def test_workflow_binding_sample_starts_parent(): + _require_workflows() + result = _run_sample("durable-workflow-binding", """ + import asyncio, json + import azure.functions as func + import function_app + class FakeClient: + async def start_new(self, name, *, client_input): + assert name == 'parent' and client_input == {} + return 'parent-42' + def create_check_status_response(self, request, instance_id): + assert request is not None and instance_id == 'parent-42' + return func.HttpResponse(status_code=202) + request = func.HttpRequest(method='POST', url='https://example.test', body=b'') + handler = function_app.start_parent._function.get_user_function().__wrapped__ + response = asyncio.run(handler(request, FakeClient())) + print(json.dumps(response.status_code)) + """) + assert result == 202 + + +def test_workflow_binding_sample_yields_child_and_returns_decoded_outputs(): + _require_workflows() + probe_path = str(_PACKAGE_ROOT / "tests" / "_yaml_workflow_probe.py") + result = _run_sample("durable-workflow-binding", f""" + import importlib, importlib.util, json, os, sys + from pathlib import Path + from unittest.mock import Mock, patch + from azure.durable_functions import DurableOrchestrationContext + from durabletask.task import CompletableTask + from azurefunctions.agents.extensions.agent_framework.provider import ( + AgentFrameworkBinding, + ) + import function_app + + spec = importlib.util.spec_from_file_location('sample_probe', {probe_path!r}) + probe = importlib.util.module_from_spec(spec) + spec.loader.exec_module(probe) + def index_sample(root): + module = importlib.reload(function_app) + assert module.app._durable_app is None + functions = {{f.get_function_name(): f for f in module.app.get_functions()}} + assert not any(name.startswith('http-') for name in functions) + assert not any(name.endswith(('-start', '-status', '-respond')) + for name in functions) + return module.app, functions + probe.index = index_sample + raw_outputs = [] + decode = probe.deserialize_workflow_output + def record_output(value): + raw_outputs.append(value) + return decode(value) + probe.deserialize_workflow_output = record_output + with patch.object(AgentFrameworkBinding, '_create_agent', + side_effect=AssertionError('unexpected agent')): + outputs, activities, answered = probe.run_workflow( + Path.cwd(), 'Child', {{'order': '42'}}) + assert outputs == ['Child workflow completed.'], outputs + assert activities == ['dafx-Child-_workflow_entry', 'dafx-Child-send_result'] + assert answered == [] + + pending = CompletableTask() + scheduler = Mock() + scheduler.call_sub_orchestrator.return_value = pending + context = DurableOrchestrationContext(scheduler, {{'order': '42'}}) + handler = function_app.parent._function.get_user_function() + generator = handler.orchestrator_function(context) + task = next(generator) + scheduler.call_sub_orchestrator.assert_called_once_with( + 'dafx-Child', input={{'order': '42'}}, instance_id=None) + assert not task.is_complete + assert len(raw_outputs) == 1 + pending.complete(raw_outputs[0]) + assert task.is_complete and task.get_result() == outputs + try: + generator.send(task.get_result()) + except StopIteration as done: + result = done.value + else: + raise AssertionError('parent did not finish') + print(json.dumps(result), flush=True) + sys.stdout.flush() + sys.stderr.flush() + os._exit(0) # Isolate embedded CLR shutdown after all assertions. + """) + assert result == {"child_outputs": ["Child workflow completed."]} + + def test_agent_framework_durable_sample_uses_prepared_order_and_shared_session(): result = _run_sample("agent_samples_agent-framework_durable", """ import json diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py b/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py index c5d4455..7d90d2c 100644 --- a/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py @@ -15,16 +15,26 @@ @pytest.mark.parametrize("value", [None, 0, 1, "true", [], {}]) -def test_workflows_flag_requires_bool(tmp_path, value): - with pytest.raises(TypeError, match="workflows must be a bool"): +def test_discover_workflows_flag_requires_bool(tmp_path, value): + with pytest.raises(TypeError, match="discover_workflows must be a bool"): AgentFunctionApp( - client_factory=lambda: None, app_root=tmp_path, workflows=value, + client_factory=lambda: None, app_root=tmp_path, discover_workflows=value, ) -def test_workflows_requires_explicit_durable_opt_in(tmp_path): - with pytest.raises(ValueError, match="requires durable=True"): - AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, workflows=True) +def test_workflow_discovery_does_not_register_standalone_agents(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("Handle orders.", encoding="utf-8") + load = Mock(return_value=[]) + monkeypatch.setattr(_workflows, "load_workflows", load) + app = AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, discover_workflows=True, + ) + assert set(app._markdown_agents) == {"orders"} + load.assert_called_once_with(tmp_path, app._markdown_agents, factory=None) + assert app._durable_agents == {} + assert app._hosted_workflows == {} + assert app.get_functions() == [] + assert app._durable_app is None def test_workflow_files_are_ignored_without_workflow_opt_in(tmp_path): @@ -32,19 +42,28 @@ def test_workflow_files_are_ignored_without_workflow_opt_in(tmp_path): plain = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) assert plain.get_functions() == [] durable = AgentFunctionApp( - client_factory=lambda: None, app_root=tmp_path, durable=True, + client_factory=lambda: None, app_root=tmp_path, discover_agents=True, ) - assert durable._durable_app.workflows == {} + assert durable._hosted_workflows == {} + assert durable.get_functions() == [] + assert durable._durable_app is None -def test_factory_requires_workflow_opt_in(tmp_path): - with pytest.raises(ValueError, match="workflow_factory requires workflows=True"): - AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, - workflow_factory=Mock()) +def test_factory_can_be_configured_without_workflow_discovery(tmp_path): + (tmp_path / "bad.workflow.yaml").write_text("not valid: [", encoding="utf-8") + factory = Mock() + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, + workflow_factory=factory) + assert app._workflow_factory is factory + assert factory.mock_calls == [] + assert app._hosted_workflows == {} + assert app.get_functions() == [] + assert app._durable_app is None @pytest.mark.parametrize("missing", ["agent_framework_declarative", "yaml", "clr"]) def test_missing_workflow_dependencies(tmp_path, monkeypatch, missing): + (tmp_path / "orders.workflow.yaml").write_text("name: Orders", encoding="utf-8") original = builtins.__import__ def blocked(name, *args, **kwargs): From 2695a4694e04614e81b4eef45f61f3ff5b314fdc Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Thu, 10 Sep 2026 15:23:49 -0500 Subject: [PATCH 30/30] Record final rebase validation --- .../samples/durable-workflow-binding/VALIDATION.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md index 4afda35..3ac69cf 100644 --- a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md @@ -1,8 +1,11 @@ # Discovery and binding verification -This revision rebases the prototype onto PR #185 at `2777aa3`. Its provider/MCP +This revision rebases the prototype onto PR #185 at `edb9d0a`. Its provider/MCP fixes, renamed sample directories, malformed-input tests, and CI dependencies -are preserved. The rebased baseline passed 168 tests before the API revision. +are preserved. The baseline at `2777aa3` passed 168 tests before the API revision. +The final rebase keeps the native SDK context instead of the new upstream export +of the deleted custom context. All 192 tests and five non-durable import tests +passed again after that rebase, along with typing and lint. ## Current results @@ -36,6 +39,9 @@ are preserved. The rebased baseline passed 168 tests before the API revision. between a workflow-internal agent and a standalone agent. Both were reproduced with safe failing tests and fixed. Forwarding now uses DAFX's own sanitizers; a standalone/internal collision fails before returning an indexed app. +- Final rebase review found no runtime-source changes from the approved revision. + The upstream custom-context export failed the import test before reconciliation; + the resolved export test verifies that no custom context is exposed. - Native YAML behavior remains delegated to the public MAF factory. The narrow DAFX workflow-route override and input sanitizers rely on the pinned DAFX version, not on a custom workflow execution engine.