Skip to content
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ _What's available?_
- Triggers / Bindings : Blob, Cosmos DB, Event Grid, Event Hub, HTTP, Kafka, MySQL, Queue, ServiceBus, SQL, Timer, and Warmup
- Create a Python Function on Linux using a custom docker image
- Triggers / Bindings : Custom binding support
- Pluggable markdown Agent injection through provider extension packages

Agent APIs are provider-neutral and add no binding metadata. Install a provider
package such as `azurefunctions-extensions-agents-framework`, then use
`FunctionApp.markdown_agent(provider=...)`, `AiApp`, or `DurableAiApp`. Durable
support is installed through the provider package's `[durable]` extra and is
not imported by the core SDK. Each Agent binding may select a different
installed provider; `AiApp` supplies a default, which all Durable Agent calls
use.

#### Get Started

Expand Down
4 changes: 3 additions & 1 deletion azure/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from ._eventgrid import CloudEvent, EventGridEvent, EventGridOutputEvent
from ._cosmosdb import Document, DocumentList
from ._http import HttpRequest, HttpResponse
from .decorators import (FunctionApp, Function, Blueprint,
from .decorators import (AiApp, DurableAiApp, FunctionApp, Function, Blueprint,
DecoratorApi, DataType, AuthLevel,
Cardinality, AccessRights, HttpMethod,
AsgiFunctionApp, WsgiFunctionApp,
Expand Down Expand Up @@ -94,6 +94,8 @@

# PyStein implementation
'FunctionApp',
'AiApp',
'DurableAiApp',
'Function',
'FunctionRegister',
'DecoratorApi',
Expand Down
4 changes: 3 additions & 1 deletion azure/functions/decorators/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
from .core import Cardinality, AccessRights, CosmosDBChangeFeedMode
from .function_app import FunctionApp, Function, DecoratorApi, DataType, \
from .function_app import AiApp, DurableAiApp, FunctionApp, Function, DecoratorApi, DataType, \
AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \
WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \
SettingsApi, BlobSource, McpPropertyType
Expand All @@ -10,6 +10,8 @@

__all__ = [
'FunctionApp',
'AiApp',
'DurableAiApp',
'Function',
'FunctionRegister',
'DecoratorApi',
Expand Down
23 changes: 23 additions & 0 deletions azure/functions/decorators/_agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import importlib

_AGENTS_BASE_MODULE = 'azurefunctions.extensions.agents.base'


def _agent_provider_distribution(provider: str) -> str:
normalized = provider.replace('_', '-')
if normalized.startswith('agent-'):
normalized = normalized.removeprefix('agent-')
return f'azurefunctions-extensions-agents-{normalized}'


def _load_agents_base(provider: str):
try:
return importlib.import_module(_AGENTS_BASE_MODULE)
except ModuleNotFoundError as exc:
distribution = _agent_provider_distribution(provider)
raise ImportError(
f"Agent provider {provider!r} is not installed. "
f"Install {distribution!r}."
) from exc
66 changes: 65 additions & 1 deletion azure/functions/decorators/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from abc import ABC
from datetime import time
from typing import Any, Callable, Dict, List, Optional, Union, \
from typing import Any, Callable, cast, Dict, List, Optional, Union, \
Iterable

from azure.functions.decorators.blob import BlobTrigger, BlobInput, BlobOutput
Expand Down Expand Up @@ -62,6 +62,7 @@
from .._http_wsgi import WsgiMiddleware, Context
from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \
MySqlTrigger
from ._agents import _agent_provider_distribution, _load_agents_base


class Function(object):
Expand Down Expand Up @@ -4540,6 +4541,69 @@ def __init__(self,
"""
super().__init__(auth_level=http_auth_level)

def markdown_agent(self, *, provider: str,
**kwargs: Any) -> Callable[..., Any]:
"""Inject a provider Agent built from a markdown definition."""
agents_base = _load_agents_base(provider)
return cast(Callable[..., Any], agents_base.markdown_agent(
self, provider=provider, **kwargs))


class AiApp(FunctionApp):
"""FunctionApp configured for one pluggable Agent provider."""

def __init__(self,
http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION,
*, provider: str, app_root=None, **provider_options):
super().__init__(http_auth_level=http_auth_level)
self._agent_provider = provider
agents_base = _load_agents_base(provider)
agents_base.configure_app(
self,
provider=provider,
app_root=app_root,
provider_options=provider_options,
)

def markdown_agent(self, *, provider: Optional[str] = None,
**kwargs: Any) -> Callable[..., Any]:
selected_provider = self._agent_provider if provider is None else provider
return super().markdown_agent(provider=selected_provider, **kwargs)


class DurableAiApp(AiApp):
"""AiApp with optional replay-safe Durable Agent orchestration."""

def __init__(self,
http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION,
*, provider: str, app_root=None, **provider_options):
super().__init__(
http_auth_level=http_auth_level,
provider=provider,
app_root=app_root,
**provider_options,
)
try:
_load_agents_base(provider).configure_durable_app(self)
except ModuleNotFoundError as exc:
distribution = _agent_provider_distribution(provider)
raise ImportError(
f"Durable Agent support is not installed. "
f"Install {distribution + '[durable]'!r}."
) from exc

def orchestration_trigger(self, context_name: str,
orchestration: Optional[str] = None,
input_type: Optional[type] = None):
agents_base = _load_agents_base(self._agent_provider)
return agents_base.durable_orchestration_trigger(
self,
sdk_decorator=super().orchestration_trigger,
context_name=context_name,
orchestration=orchestration,
input_type=input_type,
)


class Blueprint(TriggerApi, BindingApi, SettingsApi):
"""
Expand Down
37 changes: 36 additions & 1 deletion docs/ProgModelSpec.pyi
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
from abc import ABC
from typing import Callable, Dict, List, Optional, Union, Iterable
from typing import Any, Callable, Dict, List, Optional, Union, Iterable

from azure.functions import AsgiMiddleware, WsgiMiddleware
from azure.functions.decorators.core import Binding, BlobSource, Trigger, DataType, \
Expand Down Expand Up @@ -1329,6 +1329,41 @@ class FunctionApp(FunctionRegister, TriggerApi, BindingApi):
"""
pass

def markdown_agent(self, *, provider: str, **kwargs: Any) -> Callable:
"""Inject an Agent supplied by a provider extension.

:param provider: Registered Agent provider ID.
:param kwargs: Provider and markdown binding options.
:return: Decorator function.
"""
pass


class AiApp(FunctionApp):
"""FunctionApp configured for one Agent provider."""

def __init__(self,
http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION,
*, provider: str, app_root=None,
**provider_options: Any):
"""Configure an app with a provider and immutable defaults."""
pass

def markdown_agent(self, *, provider: Optional[str] = None,
**kwargs: Any) -> Callable:
"""Inject an Agent using the configured provider by default."""
pass


class DurableAiApp(AiApp):
"""AiApp with optional replay-safe Durable Agent orchestration."""

def orchestration_trigger(self, context_name: str,
orchestration: Optional[str] = None,
input_type: Optional[type] = None) -> Callable:
"""Register an orchestrator with a Durable Agent context."""
pass


class BluePrint(TriggerApi, BindingApi, SettingsApi):
"""Functions container class where all the functions
Expand Down
185 changes: 185 additions & 0 deletions tests/decorators/test_agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import unittest
from unittest.mock import patch

import azure.functions as func
from azure.functions.decorators.function_app import (
AiApp,
DurableAiApp,
FunctionApp,
)


class TestAgentApps(unittest.TestCase):

@patch('azure.functions.decorators.function_app._load_agents_base')
def test_function_app_markdown_agent_delegates_exact_arguments(
self, load_agents_base):
agents_base = load_agents_base.return_value
decorator = object()
agents_base.markdown_agent.return_value = decorator
app = FunctionApp()

result = app.markdown_agent(
provider='agent_framework',
arg_name='agent',
agent_name='orders',
client_factory='factory',
)

self.assertIs(result, decorator)
agents_base.markdown_agent.assert_called_once_with(
app,
provider='agent_framework',
arg_name='agent',
agent_name='orders',
client_factory='factory',
)

@patch('azure.functions.decorators.function_app._load_agents_base')
def test_ai_app_configures_provider_and_defaults(self, load_agents_base):
agents_base = load_agents_base.return_value

app = AiApp(
provider='agent_framework',
app_root='app',
client_factory='factory',
)

agents_base.configure_app.assert_called_once_with(
app,
provider='agent_framework',
app_root='app',
provider_options={'client_factory': 'factory'},
)

@patch('azure.functions.decorators.function_app._load_agents_base')
def test_ai_app_markdown_agent_uses_configured_provider(
self, load_agents_base):
agents_base = load_agents_base.return_value
app = AiApp(provider='agent_framework')
agents_base.reset_mock()

app.markdown_agent(arg_name='agent', agent_name='orders')

agents_base.markdown_agent.assert_called_once_with(
app,
provider='agent_framework',
arg_name='agent',
agent_name='orders',
)

@patch('azure.functions.decorators.function_app._load_agents_base')
def test_ai_app_markdown_agent_can_override_configured_provider(
self, load_agents_base):
agents_base = load_agents_base.return_value
app = AiApp(provider='agent_framework')
agents_base.reset_mock()

app.markdown_agent(
provider='langgraph',
arg_name='agent',
agent_name='researcher',
)

agents_base.markdown_agent.assert_called_once_with(
app,
provider='langgraph',
arg_name='agent',
agent_name='researcher',
)

@patch('azure.functions.decorators.function_app._load_agents_base')
def test_durable_ai_app_configures_durable_support(
self, load_agents_base):
agents_base = load_agents_base.return_value

app = DurableAiApp(provider='agent_framework')

agents_base.configure_durable_app.assert_called_once_with(app)

@patch('azure.functions.decorators.function_app._load_agents_base')
def test_durable_orchestration_delegates_to_base(
self, load_agents_base):
agents_base = load_agents_base.return_value
sentinel = object()
agents_base.durable_orchestration_trigger.return_value = sentinel
app = DurableAiApp(provider='agent_framework')

result = app.orchestration_trigger(
context_name='context',
orchestration='orders',
)

self.assertIs(result, sentinel)
call = agents_base.durable_orchestration_trigger.call_args
self.assertIs(call.args[0], app)
self.assertEqual(call.kwargs['context_name'], 'context')
self.assertEqual(call.kwargs['orchestration'], 'orders')
self.assertIsNone(call.kwargs['input_type'])
self.assertTrue(callable(call.kwargs['sdk_decorator']))

def test_agent_apps_are_public(self):
self.assertIs(func.AiApp, AiApp)
self.assertIs(func.DurableAiApp, DurableAiApp)

@patch('azure.functions.decorators._agents.importlib.import_module')
def test_missing_provider_reports_extension_install(self, import_module):
import_module.side_effect = ModuleNotFoundError(
"No module named 'azurefunctions.extensions.agents.base'",
name='azurefunctions.extensions.agents.base',
)

with self.assertRaises(ImportError) as raised:
FunctionApp().markdown_agent(provider='agent_framework')

self.assertEqual(
str(raised.exception),
"Agent provider 'agent_framework' is not installed. "
"Install 'azurefunctions-extensions-agents-framework'.",
)
self.assertIs(raised.exception.__cause__, import_module.side_effect)

@patch('azure.functions.decorators._agents.importlib.import_module')
def test_missing_base_parent_reports_provider_install(self, import_module):
import_module.side_effect = ModuleNotFoundError(
"No module named 'azurefunctions'",
name='azurefunctions',
)

with self.assertRaisesRegex(
ImportError,
'azurefunctions-extensions-agents-framework'):
FunctionApp().markdown_agent(provider='agent_framework')

@patch('azure.functions.decorators._agents.importlib.import_module')
def test_provider_import_error_reports_extension_install(self, import_module):
import_module.side_effect = ModuleNotFoundError(
"No module named 'provider_dependency'",
name='provider_dependency',
)

with self.assertRaisesRegex(
ImportError,
'azurefunctions-extensions-agents-framework') as raised:
FunctionApp().markdown_agent(provider='agent_framework')

self.assertIs(raised.exception.__cause__, import_module.side_effect)

@patch('azure.functions.decorators.function_app._load_agents_base')
def test_missing_durable_reports_provider_extra(self, load_agents_base):
agents_base = load_agents_base.return_value
agents_base.configure_durable_app.side_effect = ModuleNotFoundError(
"No module named 'azure.durable_functions'",
name='azure.durable_functions',
)

with self.assertRaisesRegex(
ImportError,
r'azurefunctions-extensions-agents-framework\[durable\]'):
DurableAiApp(provider='agent_framework')


if __name__ == '__main__':
unittest.main()
Loading