diff --git a/README.md b/README.md index efc6c3f4..7f8c926c 100644 --- a/README.md +++ b/README.md @@ -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-agents-extension-agent-framework`, then use +`FunctionApp.markdown_agent(provider=...)`, `AgentFunctionApp`, or `AgentDFApp`. 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; `AgentFunctionApp` supplies a default, which all Durable Agent calls +use. #### Get Started diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index e267d450..cbc96a31 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -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 (AgentDFApp, AgentFunctionApp, FunctionApp, Function, Blueprint, DecoratorApi, DataType, AuthLevel, Cardinality, AccessRights, HttpMethod, AsgiFunctionApp, WsgiFunctionApp, @@ -94,6 +94,8 @@ # PyStein implementation 'FunctionApp', + 'AgentFunctionApp', + 'AgentDFApp', 'Function', 'FunctionRegister', 'DecoratorApi', diff --git a/azure/functions/decorators/__init__.py b/azure/functions/decorators/__init__.py index beaf7ff6..2ac45204 100644 --- a/azure/functions/decorators/__init__.py +++ b/azure/functions/decorators/__init__.py @@ -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 AgentDFApp, AgentFunctionApp, FunctionApp, Function, DecoratorApi, DataType, \ AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \ WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \ SettingsApi, BlobSource, McpPropertyType @@ -10,6 +10,8 @@ __all__ = [ 'FunctionApp', + 'AgentFunctionApp', + 'AgentDFApp', 'Function', 'FunctionRegister', 'DecoratorApi', diff --git a/azure/functions/decorators/_agents.py b/azure/functions/decorators/_agents.py new file mode 100644 index 00000000..f07b9faf --- /dev/null +++ b/azure/functions/decorators/_agents.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import importlib + +_AGENTS_BASE_MODULE = 'azurefunctions.extensions.agents.base' +_agents_base = None + + +def _agent_provider_distribution(provider: str) -> str: + normalized = provider.replace('_', '-') + return f'azurefunctions-agents-extension-{normalized}' + + +def _import_agents_base(): + global _agents_base + if _agents_base is None: + _agents_base = importlib.import_module(_AGENTS_BASE_MODULE) + return _agents_base + + +def _load_agents_base(provider: str): + try: + return _import_agents_base() + except ImportError as exc: + distribution = _agent_provider_distribution(provider) + raise ImportError( + f"Agent provider {provider!r} is not installed. " + f"Install {distribution!r}." + ) from exc diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index fbe241b4..e562315a 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -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 @@ -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): @@ -4540,6 +4541,67 @@ 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 AgentFunctionApp(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, **kwargs: Any) -> Callable[..., Any]: + return super().markdown_agent(provider=self._agent_provider, **kwargs) + + +class AgentDFApp(AgentFunctionApp): + """AgentFunctionApp 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 ImportError 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): """ diff --git a/docs/ProgModelSpec.pyi b/docs/ProgModelSpec.pyi index b9c1d60a..56ebc325 100644 --- a/docs/ProgModelSpec.pyi +++ b/docs/ProgModelSpec.pyi @@ -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, \ @@ -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 AgentFunctionApp(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 AgentDFApp(AgentFunctionApp): + """AgentFunctionApp 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 diff --git a/tests/decorators/test_agents.py b/tests/decorators/test_agents.py new file mode 100644 index 00000000..130a2129 --- /dev/null +++ b/tests/decorators/test_agents.py @@ -0,0 +1,200 @@ +# 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 import _agents +from azure.functions.decorators.function_app import ( + AgentDFApp, + AgentFunctionApp, + FunctionApp, +) + + +class TestAgentApps(unittest.TestCase): + + @patch('azure.functions.decorators._agents.importlib.import_module') + def test_agents_base_import_is_cached_across_providers(self, import_module): + agents_base = object() + import_module.return_value = agents_base + + with patch.object(_agents, '_agents_base', None): + self.assertIs( + _agents._load_agents_base('agent_framework'), agents_base) + self.assertIs( + _agents._load_agents_base('other_provider'), agents_base) + + import_module.assert_called_once_with( + 'azurefunctions.extensions.agents.base') + + @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_agent_function_app_configures_provider_and_defaults( + self, load_agents_base): + agents_base = load_agents_base.return_value + + app = AgentFunctionApp( + 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_agent_function_app_markdown_agent_uses_configured_provider( + self, load_agents_base): + agents_base = load_agents_base.return_value + app = AgentFunctionApp(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_agent_function_app_markdown_agent_rejects_provider_override( + self, load_agents_base): + agents_base = load_agents_base.return_value + app = AgentFunctionApp(provider='agent_framework') + agents_base.reset_mock() + + with self.assertRaisesRegex(TypeError, "multiple values for keyword"): + app.markdown_agent( + provider='langgraph', + arg_name='agent', + agent_name='researcher', + ) + + agents_base.markdown_agent.assert_not_called() + + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_agent_df_app_configures_durable_support( + self, load_agents_base): + agents_base = load_agents_base.return_value + + app = AgentDFApp(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 = AgentDFApp(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.AgentFunctionApp, AgentFunctionApp) + self.assertIs(func.AgentDFApp, AgentDFApp) + + @patch('azure.functions.decorators._agents.importlib.import_module') + def test_missing_provider_reports_extension_install(self, import_module): + import_module.side_effect = ImportError( + "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-agents-extension-agent-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 = ImportError( + "No module named 'azurefunctions'", + name='azurefunctions', + ) + expected_distribution = ( + 'azurefunctions-agents-extension-agent-framework' + ) + + with self.assertRaisesRegex(ImportError, expected_distribution): + 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 = ImportError( + "No module named 'provider_dependency'", + name='provider_dependency', + ) + expected_distribution = ( + 'azurefunctions-agents-extension-agent-framework' + ) + + with self.assertRaisesRegex(ImportError, expected_distribution) 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 = ImportError( + "No module named 'azure.durable_functions'", + name='azure.durable_functions', + ) + expected_distribution = ( + r'azurefunctions-agents-extension-agent-framework\[durable\]' + ) + + with self.assertRaisesRegex(ImportError, expected_distribution): + AgentDFApp(provider='agent_framework') + + +if __name__ == '__main__': + unittest.main()