diff --git a/README.md b/README.md index 6c901d2..09d7070 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-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-extensions-agent-framework/LICENSE b/azurefunctions-agents-extensions-agent-framework/LICENSE new file mode 100644 index 0000000..22aed37 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-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-agents-extensions-agent-framework/MANIFEST.in b/azurefunctions-agents-extensions-agent-framework/MANIFEST.in new file mode 100644 index 0000000..4c501a0 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include azurefunctions *.py *.pyi +recursive-include tests *.py +include LICENSE README.md diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md new file mode 100644 index 0000000..eb41a60 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -0,0 +1,157 @@ +# 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-extensions-agent-framework +``` + +Install Durable Functions support with the durable 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]" +``` + +## 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. + +```python +import azure.functions as func +from agent_framework import Agent +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +def create_chat_client(): + from agent_framework.openai import OpenAIChatClient + + return OpenAIChatClient() + + +app = AgentFunctionApp(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 +``` + +`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 +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. 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 +discovered from the app root: + +```python +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + +app = AgentFunctionApp(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 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-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. diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/__init__.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) 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 new file mode 100644 index 0000000..e1eefcd --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py @@ -0,0 +1,13 @@ +from azurefunctions.agents.extensions.base.durable import DurableAgentContext + +from .apps import AgentFunctionApp +from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory + +__all__ = [ + "AGENT_FRAMEWORK_PROVIDER_ID", + "AgentFunctionApp", + "ClientFactory", + "DurableAgentContext", +] + +__version__ = '1.0.0b1' 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 new file mode 100644 index 0000000..07fd8bc --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import os +from collections.abc import Callable, Sequence +from typing import Any, TypeVar + +import azure.functions as func +from agent_framework import ToolTypes + +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 + +_F = TypeVar("_F", bound=Callable[..., Any]) + + +def _provider_options( + *, + client_factory: ClientFactory | None = None, + tools: ( + ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None + ) = None, +) -> dict[str, object]: + options: dict[str, object] = {} + if client_factory is not None: + options["client_factory"] = client_factory + if tools is not None: + options["tools"] = tools + return options + + +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 AgentFunctionApp( + _AgentFrameworkAppMixin, + func.FunctionApp, +): + """Azure Functions app configured for Microsoft Agent Framework Agents.""" + + def __init__( + self, + *, + client_factory: ClientFactory, + app_root: str | os.PathLike[str] | None = None, + tools: ( + ToolTypes + | Callable[..., Any] + | Sequence[ToolTypes | Callable[..., Any]] + | None + ) = None, + http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, + ) -> None: + super().__init__( + http_auth_level=http_auth_level, + ) + configure_app( + self, + provider=AGENT_FRAMEWORK_PROVIDER_ID, + app_root=app_root, + provider_options=_provider_options( + client_factory=client_factory, + tools=tools, + ), + ) + + def orchestration_trigger( + self, + 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, + ) 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 new file mode 100644 index 0000000..2f8ac36 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/provider.py @@ -0,0 +1,327 @@ +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 ipaddress import ip_address +from typing import TYPE_CHECKING, Any, AsyncIterator, TypedDict, cast, get_origin +from urllib.parse import urlsplit + +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, +) + +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_]*)%" +) + +_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(CompiledAgent): + instructions: str + agent_name: str + options: _AgentFrameworkOptions + capabilities: AgentCapabilities + + def _create_agent( + self, + 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] + tools = list(self.options.tools) + if mcp_tools: + options["tools"] = [*tools, *mcp_tools] + elif tools: + options["tools"] = tools + return Agent( + client=client, + 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(AgentProvider): + provider_id = AGENT_FRAMEWORK_PROVIDER_ID + distribution_name = "azurefunctions-agents-extensions-agent-framework" + supported_capabilities = frozenset({"skills", "mcp"}) + + def compile_binding( + self, + *, + instructions: str, + agent_name: str, + options: Mapping[str, object], + annotation: object, + capabilities: AgentCapabilities, + ) -> AgentFrameworkBinding: + unknown = sorted(set(options) - _SUPPORTED_OPTIONS) + if unknown: + raise TypeError( + "Unsupported Microsoft Agent Framework option(s): " + ", ".join(unknown) + ) + 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): + 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" + ) + + return AgentFrameworkBinding( + instructions=instructions, + agent_name=agent_name, + options=_AgentFrameworkOptions( + client_factory=cast(ClientFactory, client_factory), + tools=_normalize_tools(options.get("tools")), + ), + capabilities=capabilities, + ) + + +def _normalize_tools(value: object) -> tuple[AgentTool, ...]: + if value is None: + return () + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return tuple(cast(Sequence[AgentTool], 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 + + +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, +) -> AsyncIterator[AgentTool]: + 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-extensions-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 + ) + 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 + 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-extensions-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: Request) -> 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, + tool_name_prefix=definition.name, + 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 cast(AgentTool, tool) + + +def create_provider() -> AgentFrameworkProvider: + return AgentFrameworkProvider() diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/py.typed b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/py.typed new file mode 100644 index 0000000..5fcb852 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/py.typed @@ -0,0 +1 @@ +partial \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/pyproject.toml b/azurefunctions-agents-extensions-agent-framework/pyproject.toml new file mode 100644 index 0000000..dc3d198 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-agents-extensions-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,<2", + "azurefunctions-agents-extensions-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-extensions-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.agents.extensions.providers"] +agent_framework = "azurefunctions.agents.extensions.agent_framework.provider:create_provider" + +[tool.setuptools.dynamic] +version = { attr = "azurefunctions.agents.extensions.agent_framework.__version__" } + +[tool.setuptools.packages.find] +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/samples/README.md b/azurefunctions-agents-extensions-agent-framework/samples/README.md new file mode 100644 index 0000000..8eb2699 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/README.md @@ -0,0 +1,73 @@ +--- +page_type: sample +languages: + - python +products: + - azure + - azure-functions + - azure-functions-extensions + - microsoft-foundry + - azurefunctions-agents-extensions-agent-framework +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. + +* [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/agent_samples_agent-framework/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/function_app.py new file mode 100644 index 0000000..c3df005 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/function_app.py @@ -0,0 +1,75 @@ +import json +import os + +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 +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 = AgentFunctionApp(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"] + 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, + mimetype="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}), + mimetype="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-agents-extensions-agent-framework/samples/agent_samples_agent-framework/host.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/host.json new file mode 100644 index 0000000..bab9278 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/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-agents-extensions-agent-framework/samples/agent_samples_agent-framework/local.settings.template.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/local.settings.template.json new file mode 100644 index 0000000..cd85a88 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/local.settings.template.json @@ -0,0 +1,10 @@ +{ + "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", + "INVENTORY_MCP_URL": "https://inventory.example.com/mcp" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/mcp.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/mcp.json new file mode 100644 index 0000000..941670f --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/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-agents-extensions-agent-framework/samples/agent_samples_agent-framework/order-fulfillment.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/order-fulfillment.agent.md new file mode 100644 index 0000000..2be89bb --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/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-agents-extensions-agent-framework/samples/agent_samples_agent-framework/order_processing.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/order_processing.py new file mode 100644 index 0000000..5ca775a --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/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-agents-extensions-agent-framework/samples/agent_samples_agent-framework/requirements.txt b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/requirements.txt new file mode 100644 index 0000000..8c57ab0 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/requirements.txt @@ -0,0 +1,4 @@ +-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/agent_samples_agent-framework/skills/order-policy/SKILL.md b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/skills/order-policy/SKILL.md new file mode 100644 index 0000000..56bf99b --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework/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. \ 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/agent_samples_agent-framework_durable/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py new file mode 100644 index 0000000..f3913f1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py @@ -0,0 +1,94 @@ +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 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 = AgentFunctionApp(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: 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=order, + ) + management = client.create_http_management_payload(req, instance_id) + return func.HttpResponse( + body=json.dumps(management), + status_code=202, + mimetype="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: DurableAgentContext): + prepared_order = yield context.call_activity( + "prepare_order_activity", + context.get_input(), + ) + + 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.RetryPolicy( + first_retry_interval=timedelta(seconds=5), + max_number_of_attempts=3, + ), + ) + return { + "order_id": prepared_order["order_id"], + "risk_assessment": assessment, + "fulfillment_plan": plan, + } diff --git a/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/host.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/host.json new file mode 100644 index 0000000..bab9278 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/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-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/local.settings.template.json b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/local.settings.template.json new file mode 100644 index 0000000..361120f --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/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-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/order-fulfillment.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/order-fulfillment.agent.md new file mode 100644 index 0000000..2be89bb --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/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-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/order_processing.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/order_processing.py new file mode 100644 index 0000000..5ca775a --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/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-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/requirements.txt b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/requirements.txt new file mode 100644 index 0000000..efceb96 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/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-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-extensions-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py new file mode 100644 index 0000000..66ea183 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -0,0 +1,38 @@ +import subprocess +import sys + + +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") + + +def test_framework_import_does_not_import_durable(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "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.agents.extensions.agent_framework\n" + "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-agents-extensions-agent-framework/tests/test_provider.py b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py new file mode 100644 index 0000000..6e7aff9 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import asyncio +import inspect +from contextlib import AsyncExitStack, asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import Mock + +import pytest +from agent_framework import Agent + +from azurefunctions.agents.extensions.base import ( + AgentCapabilities, + InvocationMetadata, + MCPAuthConfig, + MCPHTTPConfig, + MCPServerDefinition, + SkillDefinition, +) +from azurefunctions.agents.extensions.agent_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(*, capabilities=AgentCapabilities(), **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, + capabilities=capabilities, + ) + + +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, + capabilities=AgentCapabilities(), + ) + + +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, + capabilities=AgentCapabilities(), + ) + + 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, + capabilities=AgentCapabilities(), + ) + + +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_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) + + +@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") + + 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 + + +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_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) + 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()) + + +@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 + + 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-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py new file mode 100644 index 0000000..5976d99 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -0,0 +1,170 @@ +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"), + [ + ( + "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): + 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, + env=environment, + check=True, + capture_output=True, + text=True, + ) + + assert set(json.loads(completed.stdout)) == expected_names + + +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) + 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" + " return 'instance-42'\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" + "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 / "agent_samples_agent-framework_durable", + 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", + } + + +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" + "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 / "agent_samples_agent-framework_durable", + 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."} + + +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/azurefunctions-agents-extensions-base/LICENSE b/azurefunctions-agents-extensions-base/LICENSE new file mode 100644 index 0000000..22aed37 --- /dev/null +++ b/azurefunctions-agents-extensions-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-agents-extensions-base/MANIFEST.in b/azurefunctions-agents-extensions-base/MANIFEST.in new file mode 100644 index 0000000..4c501a0 --- /dev/null +++ b/azurefunctions-agents-extensions-base/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include azurefunctions *.py *.pyi +recursive-include tests *.py +include LICENSE README.md diff --git a/azurefunctions-agents-extensions-base/README.md b/azurefunctions-agents-extensions-base/README.md new file mode 100644 index 0000000..06f78e2 --- /dev/null +++ b/azurefunctions-agents-extensions-base/README.md @@ -0,0 +1,78 @@ +# 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-extensions-agent-framework`. + +## Provider contract + +Provider packages register a zero-argument factory in the +`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. + +`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 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 when the provider app is +constructed, or inferred from `AzureWebJobsScriptRoot` and then the current +directory; 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 `AgentFunctionApp` provider. All file, client, Agent, model, and +tool I/O occurs in the activity, never in the orchestrator. diff --git a/azurefunctions-agents-extensions-base/azurefunctions/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) 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-extensions-base/azurefunctions/agents/extensions/base/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py new file mode 100644 index 0000000..49c0f7b --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py @@ -0,0 +1,70 @@ +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 ( + AgentCapabilities, + MCPAuthConfig, + MCPHTTPConfig, + MCPServerDefinition, + SkillDefinition, +) +from .providers import ( + AGENT_PROVIDER_ENTRY_POINT_GROUP, + AgentProvider, + CompiledAgent, + InvocationMetadata, + 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", + "AgentProvider", + "CompiledAgent", + "InvocationMetadata", + "MCPAuthConfig", + "MCPHTTPConfig", + "MCPServerDefinition", + "SkillDefinition", + "configure_app", + "configure_durable_app", + "durable_orchestration_trigger", + "load_provider", + "markdown_agent", +] + +__version__ = '1.0.0b1' diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py new file mode 100644 index 0000000..ec892b8 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import functools +import inspect +import logging +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 .capabilities import AgentCapabilities +from .discovery import discover_capabilities +from .providers import AgentProvider, CompiledAgent, InvocationMetadata, load_provider + +_F = TypeVar("_F", bound=Callable[..., Any]) +_INVALID_FILENAME_CHARACTERS = frozenset('<>:"/\\|?*') +_logger = logging.getLogger("azure.functions.AgentExtension") + + +def _log_agent_usage(provider: str, agent_name: str) -> None: + _logger.info( + "Agent extension invoked with provider %r and agent %r", + provider, + agent_name, + extra={"provider": provider, "agent_name": agent_name}, + ) + + +@dataclass +class _AppState: + app_root: Path + capabilities: AgentCapabilities + 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) + + +_APP_STATES: weakref.WeakKeyDictionary[object, _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: object, + *, + provider: str, + app_root: str | os.PathLike[str] | None = None, + provider_defaults: Mapping[str, object] | 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 + if app_root is not None and state.app_root != resolved_root: + raise ValueError( + 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"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( + "Agent app provider defaults are already configured" + ) + return state + + +def configure_app( + app: object, + *, + provider: str, + app_root: str | os.PathLike[str] | None = None, + provider_options: Mapping[str, object] | None = None, +) -> None: + _state_for( + app, + provider=provider, + app_root=app_root, + provider_defaults=provider_options, + ) + + +def _configured_state(app: object) -> _AppState: + with _APP_STATES_LOCK: + state = _APP_STATES.get(app) + if state is None: + raise RuntimeError("Agent app is not configured with a provider") + return state + + +def _durable_agent( + app: object, + agent_name: str, +) -> CompiledAgent: + 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 + + +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}" + ) + with source.open("r", encoding="utf-8", newline="") as handle: + return handle.read() + + +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: object, +) -> 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 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, ())) + 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 _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, ...], + 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: object, + *, + provider: str, + arg_name: str, + agent_name: str, + **provider_options: object, +) -> Callable[[_F], _F]: + if "app_root" in provider_options: + raise TypeError( + "markdown_agent app_root is app-scoped; configure it on AgentFunctionApp" + ) + state = _state_for(app, provider=provider) + + 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) + _validate_provider_capabilities( + state.provider, + state.capabilities, + ) + compiled = state.provider.compile_binding( + instructions=instructions, + agent_name=agent_name, + options=options, + annotation=annotation, + capabilities=state.capabilities, + ) + + @functools.wraps(handler) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + invocation = _invocation_metadata(visible_signature, args, kwargs) + _log_agent_usage(state.provider_id, agent_name) + async with compiled.open_agent(invocation) as agent: + return await _source_call( + handler, + 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-agents-extensions-base/azurefunctions/agents/extensions/base/capabilities.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/capabilities.py new file mode 100644 index 0000000..d6637c7 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/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-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/__init__.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/__init__.py new file mode 100644 index 0000000..c205f4b --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/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-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py new file mode 100644 index 0000000..0fdf133 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/mcp.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import 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, 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") + result[key] = value + return result + + +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(): + raise ValueError(f"MCP {field} must be a non-empty string") + return value.strip() + + +def _allowed_tools(value: object) -> tuple[str, ...] | None: + if value is None: + return None + if not isinstance(value, list): + raise ValueError("MCP tools must be a list of non-empty strings") + 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: + if len(tools) != 1: + raise ValueError("MCP '*' tool selection cannot be combined") + return None + return tools + + +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 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 + headers.append((header_name, header_text)) + return tuple(sorted(headers)) + + +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") + 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(auth.get("scope"), field="auth scope") + client_id = _string( + auth.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: 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, 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") + 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: + 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(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, server_definitions[name]) + for name in sorted(server_definitions) + ) diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/skills.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/discovery/skills.py new file mode 100644 index 0000000..201a317 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/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-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py new file mode 100644 index 0000000..1b24c39 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/durable.py @@ -0,0 +1,277 @@ +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, _log_agent_usage +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"], + ) + _log_agent_usage(state.provider_id, parsed["agent_name"]) + 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/azurefunctions/agents/extensions/base/providers.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py new file mode 100644 index 0000000..00afecd --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/providers.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from functools import lru_cache +from importlib import metadata +from typing import Callable, Mapping, Protocol, cast + +from .capabilities import AgentCapabilities + +AGENT_PROVIDER_ENTRY_POINT_GROUP = "azurefunctions.agents.extensions.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[object]: + pass + + async def run_agent( + self, + prompt: str, + invocation: InvocationMetadata, + ) -> str: + pass + + +class AgentProvider(Protocol): + provider_id: str + distribution_name: str + supported_capabilities: frozenset[str] + + def compile_binding( + self, + *, + instructions: str, + agent_name: str, + options: Mapping[str, object], + annotation: object, + capabilities: AgentCapabilities, + ) -> CompiledAgent: + pass + + +def _provider_distribution_name(provider_id: str) -> str: + normalized = provider_id.replace("_", "-") + return f"azurefunctions-agents-extensions-{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") + 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 cast(AgentProvider, provider) + + +@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: 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(cast(Callable[[], object], factory)(), provider_id) diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/py.typed b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/py.typed new file mode 100644 index 0000000..5fcb852 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/py.typed @@ -0,0 +1 @@ +partial \ No newline at end of file diff --git a/azurefunctions-agents-extensions-base/pyproject.toml b/azurefunctions-agents-extensions-base/pyproject.toml new file mode 100644 index 0000000..f0053a6 --- /dev/null +++ b/azurefunctions-agents-extensions-base/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-agents-extensions-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.3.0,<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.agents.extensions.base.__version__" } + +[tool.setuptools.packages.find] +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 + +[[tool.mypy.overrides]] +module = ["azure.durable_functions", "azure.durable_functions.*"] +follow_untyped_imports = true diff --git a/azurefunctions-agents-extensions-base/tests/test_bindings.py b/azurefunctions-agents-extensions-base/tests/test_bindings.py new file mode 100644 index 0000000..188df21 --- /dev/null +++ b/azurefunctions-agents-extensions-base/tests/test_bindings.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +import asyncio +import gc +import inspect +import logging +import weakref +from contextlib import asynccontextmanager + +import azure.functions as func +import pytest + +from azurefunctions.agents.extensions.base import AgentCapabilities +from azurefunctions.agents.extensions.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-extensions-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_logs_provider_usage(tmp_path, provider, caplog): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @bindings.markdown_agent( + app, + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + pass + + with caplog.at_level(logging.INFO, logger="azure.functions.AgentExtension"): + asyncio.run(handler()) + + records = [ + record + for record in caplog.records + if record.name == "azure.functions.AgentExtension" + ] + assert [record.getMessage() for record in records] == [ + "Agent extension invoked with provider 'agent_framework' and agent 'orders'" + ] + assert records[0].provider == "agent_framework" + assert records[0].agent_name == "orders" + + +def test_markdown_agent_preserves_variadic_handler_arguments(tmp_path, provider): + (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") + app = func.FunctionApp() + + @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 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 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-agents-extensions-base/tests/test_capability_discovery.py b/azurefunctions-agents-extensions-base/tests/test_capability_discovery.py new file mode 100644 index 0000000..881508f --- /dev/null +++ b/azurefunctions-agents-extensions-base/tests/test_capability_discovery.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json + +import pytest + +from azurefunctions.agents.extensions.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-agents-extensions-base/tests/test_durable.py b/azurefunctions-agents-extensions-base/tests/test_durable.py new file mode 100644 index 0000000..1d67650 --- /dev/null +++ b/azurefunctions-agents-extensions-base/tests/test_durable.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import asyncio +import logging +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, + caplog, +): + instructions = "---\nthis remains: raw\n---\nHandle orders.\n" + (tmp_path / "orders.agent.md").write_bytes(instructions.encode("utf-8")) + app, provider = _configured_app(tmp_path, monkeypatch) + durable.configure_durable_app(app) + activity = _hidden_activity(app) + context = SimpleNamespace( + function_name="activity", + invocation_id="invocation-1", + ) + + with caplog.at_level(logging.INFO, logger="azure.functions.AgentExtension"): + result = asyncio.run( + activity( + { + "schema_version": 1, + "agent_name": "orders", + "input": {"z": 1, "a": 2}, + "durable_instance_id": "instance-1", + }, + context, + ) + ) + + assert result == 'response:{"a":2,"z":1}' + records = [ + record + for record in caplog.records + if record.name == "azure.functions.AgentExtension" + ] + assert [record.getMessage() for record in records] == [ + "Agent extension invoked with provider 'agent_framework' and agent 'orders'" + ] + assert records[0].provider == "agent_framework" + assert records[0].agent_name == "orders" + assert provider.compile_calls[0]["instructions"] == instructions + assert provider.compile_calls[0]["capabilities"].skills == () + assert provider.compiled.calls[0][0] == '{"a":2,"z":1}' + 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 new file mode 100644 index 0000000..0331915 --- /dev/null +++ b/azurefunctions-agents-extensions-base/tests/test_imports.py @@ -0,0 +1,28 @@ +import subprocess +import sys + + +def test_durable_module_import_does_not_require_durable(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "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.agents.extensions.base.durable\n" + "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-agents-extensions-base/tests/test_providers.py b/azurefunctions-agents-extensions-base/tests/test_providers.py new file mode 100644 index 0000000..9187d45 --- /dev/null +++ b/azurefunctions-agents-extensions-base/tests/test_providers.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from azurefunctions.agents.extensions.base import providers + + +class _Provider: + provider_id = "agent_framework" + distribution_name = "azurefunctions-agents-extensions-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-extensions-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-extensions-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-extensions-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/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml index 4425295..c78ebf9 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-agents-extensions-base' + EXTENSION_NAME: 'Agents Base' + agents_framework_extension: + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-agent-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..0053ace 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-agents-extensions-base' + EXTENSION_NAME: 'Agents Base' + agents_framework_extension: + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-agent-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..569a580 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -17,6 +17,63 @@ 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' + condition: always() + 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-agents-extensions-base + python -m pip install -U -e .[dev] + displayName: 'Install Agents Base Dependencies' + - bash: | + python -m pytest -q --instafail azurefunctions-agents-extensions-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' + condition: always() + 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-agents-extensions-base + cd azurefunctions-agents-extensions-agent-framework + 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/ + displayName: "Run Agents Framework Tests for Python $(PYTHON_VERSION)" + - job: "BaseTests" displayName: "Base Extension Tests" dependsOn: []