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..1778106 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -0,0 +1,335 @@ +# 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 normal markdown binding accepts `client_factory` and explicit Python +`tools` overrides. The extension owns the Agent client, name, instructions, and +discovered Skills/MCP integration. Configure `app_root` only when constructing +`AgentFunctionApp`; decorators do not override it. + +## Durable Agents + +Durable support is optional. This prototype pins both DAFX packages to +[DAFX PR #72](https://github.com/microsoft/agent-framework-durable-extension/pull/72) +at `aa9529ec489e16ac64b73bd68d5adbb8e4945258` for SDK 2 compatibility. +These Git dependencies are for local prototyping, not a PyPI release. + +```text +pip install "azurefunctions-agents-extensions-agent-framework[durable]" +``` + +Set `discover_agents=True` to discover every `.agent.md` file directly in the app root +or its `agents/` directory. Each discovered agent gets a DAFX entity and an +automatic `POST /api/agents/{name}/run` endpoint with the default HTTP route +prefix. No handwritten HTTP function or orchestrator is required. + +This explicitly publishes every discovered definition. Durable names must start +with an ASCII letter or digit and contain only ASCII letters, digits, hyphens, and +underscores. Ambiguous definitions and generated function-name collisions fail +rather than silently selecting an agent. + +```python +app = AgentFunctionApp(client_factory=create_chat_client, discover_agents=True) +``` + +For orchestration, place `durable_markdown_agent` below `orchestration_trigger` +on a synchronous generator. The binding registers the selected markdown agent +without bulk discovery. It is private by default (`expose_http_endpoint=False`). +The injected object is a DAFX proxy, not a live Agent. Yield its tasks and share +a session across turns. + +```python +app = AgentFunctionApp(client_factory=create_chat_client) + + +@app.orchestration_trigger(context_name="context") +@app.durable_markdown_agent( + arg_name="agent", agent_name="orders", context_name="context" +) +def orders(context, agent): + session = agent.create_session() + assessment = yield agent.run("Assess the order.", session=session) + plan = yield agent.run("Make a fulfillment plan.", session=session) + return {"assessment": assessment.text, "plan": plan.text} +``` + +Registration compiles recipes without constructing clients. At entity execution, +the lifecycle adapter enters the compiled binding's `open_agent()` context and +closes it after the run. Each execution creates fresh clients and tools; DAFX +restores conversation history from durable session state. Orchestrators keep the +native SDK context. The old `context.call_agent()` activity path is replaced by +the injected proxy. + +Normal `markdown_agent()` remains invocation-scoped and unchanged. Durable +declarations collect registrations first. The inner DAFX host is constructed +at `get_functions()` only when an agent or workflow is registered. The outer +app indexes both registries, including the SDK's `BuiltIn__HttpActivity` and +`BuiltIn__HttpPollOrchestrator`. Health and MCP endpoints are disabled. + +### Discovery, exposure, and policy + +`discover_agents=False` and `discover_workflows=False` are independent defaults. +The constructor's `expose_agent_endpoints=True` and +`expose_workflow_endpoints=True` apply only to their respective bulk discovery +paths. Set either exposure option to `False` to register those definitions +without publishing their standalone HTTP endpoints. + +Selective `durable_markdown_agent` and `durable_workflow` bindings instead use +their own `expose_http_endpoint=False` default. Set it to `True` on a binding +to publish that definition. Discovery and bindings share the same registry. +Repeated registration reuses the definition and combines exposure with logical +OR, so a private binding does not hide an endpoint already explicitly enabled. +Register all bindings before indexing. + +Endpoint exposure is not application policy. A generated endpoint invokes its +agent or workflow directly and bypasses any validation in a handwritten parent +or starter. Private here means no standalone generated HTTP route, not a +separate authorization or execution boundary. Hosted HTTP endpoints require +the configured auth level, which defaults to a function key. + +See the [endpoint-only local sample](samples/lazy-owned-dafx/README.md) and the +[durable binding sample](samples/durable-markdown-binding/README.md) for setup +and deterministic examples that do not need a model service. + +## YAML workflows + +YAML hosting is a separate opt-in. Install both optional extras. The workflows +extra uses `agent-framework-declarative>=1.0.3,<2`. + +```text +pip install "azurefunctions-agents-extensions-agent-framework[durable,workflows]" +``` + +```python +app = AgentFunctionApp( + client_factory=create_chat_client, + discover_workflows=True, +) +``` + +Workflow discovery does not require agent discovery. Only `*.workflow.yaml` and +`*.workflow.yml` directly in the app root or its `workflows/` directory are +discovered, not arbitrary YAML or nested files. Discovered entry files must stay +within the app root. Each loaded result must be a MAF `Workflow` with a stable +name of 1–63 ASCII letters, digits, hyphens, or underscores, starting with a +letter. Names must be unique ignoring case. An explicit YAML `name` keeps routes +predictable, but naming and YAML parsing otherwise follow MAF. + +The extension calls the public `create_workflow_from_yaml_path(path)` method and +supplies the graphs to DAFX's `workflows=` constructor. With the default route +prefix, each graph gets +`POST /api/workflow/NAME/run`, `GET /api/workflow/NAME/status/{instanceId}`, and +`POST /api/workflow/NAME/respond/{instanceId}/{requestId}`. No custom +orchestration or handwritten HTTP handlers are needed. + +By default, `WorkflowFactory(agents=...)` receives `MarkdownDurableAgent` +adapters for **all** discovered Markdown agents, including agents selected by +dynamic names. This does not register standalone agent entities or HTTP routes +unless `discover_agents=True` or a selective agent binding also registers them. +To configure MAF directly, pass a configured `WorkflowFactory` object as +`workflow_factory=`. It is allowed without discovery, including with a selective +workflow binding. That object is used unchanged. Its agent registry is not +automatically merged with discovered +Markdown agents. Configure its `agent_factory`, agents, registered tools, HTTP +or MCP handlers, and configuration through MAF's public APIs. + +The extension does not impose a separate YAML parser, action allowlist, or +restrictions on inline agents, file-based agents, dynamic agent references, or +workflow tool actions. These follow the installed MAF parser and builder, +including their warnings, errors, and required configuration. For example, +`InvokeFunctionTool` can use `WorkflowFactory.register_tool()`, while HTTP and +MCP actions need their MAF handlers. DAFX's hosting validations still apply. +This delegation is not a claim that every MAF feature has been execution-tested. + +Relative file references inside YAML use native MAF resolution from the workflow +file's directory. They are not sandboxed by the entry-file containment check. +Treat workflow files and their references as trusted deployment content. + +Agent actions execute as durable activities, not through the agent entity in the +same graph. Markdown adapters open and close fresh Agents, clients, and tools +per execution. Inline agents and agents supplied by a custom factory follow +MAF's or that factory's construction and resource lifecycle, which may construct +agents and clients during app initialization/indexing. The extension does not +wrap them in the Markdown lifecycle. Supplying a factory neither enables agent +discovery nor publishes standalone agent endpoints. `client_factory` remains +required even for a tool-only workflow. Such apps can pass a `NoReturn` sentinel +that raises if called, as the configured factory sample does. + +### Bind a private child workflow + +Use `durable_workflow` below `orchestration_trigger` to select a YAML workflow +without bulk discovery. The default `context_name` is `"context"`. + +```python +app = AgentFunctionApp(client_factory=create_chat_client) + + +@app.orchestration_trigger(context_name="context") +@app.durable_workflow(arg_name="child", workflow_name="Child") +def parent(context, child): + outputs = yield child.run(context.get_input()) + return {"child_outputs": outputs} +``` + +Without `workflow_file`, a new binding matches `Child.workflow.yaml` or +`Child.workflow.yml` directly in the app root or `workflows/`, before parsing. +Unrelated YAML definitions are not loaded. An explicit app-root-relative +`workflow_file` can select another filename within the app root. In either case, +the loaded workflow name must equal `workflow_name`. When reusing an already +registered graph, omit `workflow_file`. + +`child.run(input_, instance_id=None)` returns a yieldable child-orchestration +task. It invokes `dafx-Child` through the native Durable context and returns +decoded workflow outputs, rather than calling `Workflow.run()` in-process. +Each invocation has its own workflow state. Binding alone creates no child run, +status, or response HTTP routes. Add `expose_http_endpoint=True` to the binding +only when standalone access is intended. + +Forwarded input uses the same reserved-marker sanitization as DAFX's workflow +HTTP entry point. Workflow results are decoded only from the trusted child result. +The generic parent binding does not aggregate child human-input requests into a +parent workflow status endpoint. For child HITL, expose the child's management +routes or implement application management using the child instance ID. + +Python support follows the installed MAF dependencies, not an extension-level +Python 3.14 rejection. Expression execution has been verified on Python 3.13. +MAF declarative 1.0.3 excludes its PowerFx dependency on Python 3.14, so those +expression checks remain on 3.13. Python 3.14 execution is not claimed as verified. + +See the [local YAML sample](samples/durable-yaml-workflow/README.md) for shared +state, a Markdown agent call, and a separate question/response workflow. The +[configured factory sample](samples/configured-workflow-factory/README.md) uses +a registered function tool and configuration without an agent client. Neither +sample provisions a host or backend. +The [workflow binding sample](samples/durable-workflow-binding/README.md) calls a +private, agent-free YAML child from a parent generator and includes an HTTP +starter for the parent. diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/__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..7a608cf --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/__init__.py @@ -0,0 +1,10 @@ +from .apps import AgentFunctionApp +from .provider import AGENT_FRAMEWORK_PROVIDER_ID, ClientFactory + +__all__ = [ + "AGENT_FRAMEWORK_PROVIDER_ID", + "AgentFunctionApp", + "ClientFactory", +] + +__version__ = '1.0.0b1' diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_durable.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_durable.py new file mode 100644 index 0000000..eb0d511 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_durable.py @@ -0,0 +1,95 @@ +"""Execution-time adapter between markdown recipes and DAFX agent entities.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Awaitable, Sequence +from typing import Any, Literal, overload + +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + AgentSession, + ResponseStream, +) + +from azurefunctions.agents.extensions.base import InvocationMetadata + +from .provider import AgentFrameworkBinding + + +class MarkdownDurableAgent: + """Register a recipe, not a live client, with DAFX. + + Every entity run opens a fresh agent and resources on the execution loop. + No clients, MCP connections, or per-run session objects are cached here. + """ + + def __init__(self, binding: AgentFrameworkBinding) -> None: + self._binding = binding + self.name: str | None = binding.agent_name + self.id = f"markdown:{self.name}" + self.description: str | None = None + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + return AgentSession(session_id=session_id) + + def get_session( + self, service_session_id: Any, *, session_id: str | None = None + ) -> AgentSession: + return AgentSession( + service_session_id=service_session_id, session_id=session_id + ) + + @overload + def run( + self, messages: Any = None, *, stream: Literal[False] = False, + session: AgentSession | None = None, **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: + ... + + @overload + def run( + self, messages: Any = None, *, stream: Literal[True], + session: AgentSession | None = None, **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + ... + + def run( + self, messages: Any = None, *, stream: bool = False, + session: AgentSession | None = None, **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[ + AgentResponseUpdate, AgentResponse[Any] + ]: + invocation = InvocationMetadata(function_name=f"dafx-{self.name}") + + async def invoke() -> AgentResponse[Any]: + async with self._binding.open_agent(invocation) as agent: + response = await agent.run(messages, session=session, **kwargs) + if not isinstance(response, AgentResponse): + raise TypeError("A durable agent must return AgentResponse.") + return response + + if not stream: + return invoke() + + final: AgentResponse[Any] | None = None + + async def updates() -> AsyncGenerator[AgentResponseUpdate, None]: + nonlocal final + async with self._binding.open_agent(invocation) as agent: + inner = agent.run(messages, stream=True, session=session, **kwargs) + async for update in inner: + yield update + # Finalization can run provider hooks. Keep resources alive until + # it completes and preserve the complete response, not just text. + final = await inner.get_final_response() + + def finalize(_: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: + if final is None: + raise RuntimeError("The durable agent stream did not complete.") + return final + + iterator = updates() + return ResponseStream( + iterator, finalizer=finalize, cleanup_hooks=[iterator.aclose] + ) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_hosting.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_hosting.py new file mode 100644 index 0000000..bbbf212 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_hosting.py @@ -0,0 +1,28 @@ +"""Narrow endpoint policy adapter for the pinned DAFX Functions host.""" + +import azure.functions as func +from agent_framework import Workflow +from agent_framework_azurefunctions import AgentFunctionApp + + +class HostedAgentFunctionApp(AgentFunctionApp): + """Keep registration separate from generated HTTP exposure. + + DAFX currently registers workflow routes unconditionally. This single + override is coupled to the pinned SDK 2 migration, not its execution engine. + """ + + def __init__( + self, *, workflows: list[Workflow], exposed_workflows: set[str], + http_auth_level: func.AuthLevel, + ) -> None: + self._exposed_workflows = exposed_workflows + super().__init__( + workflows=workflows, http_auth_level=http_auth_level, + enable_health_check=False, enable_http_endpoints=False, + enable_mcp_tool_trigger=False, + ) + + def _register_workflow_routes(self, workflow: Workflow) -> None: + if workflow.name in self._exposed_workflows: + super()._register_workflow_routes(workflow) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflow_client.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflow_client.py new file mode 100644 index 0000000..f74b4e1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflow_client.py @@ -0,0 +1,58 @@ +"""Orchestration-only invocation of a registered DAFX workflow.""" + +from typing import Any + +from azure.durable_functions import DurableOrchestrationContext +from agent_framework_durabletask import deserialize_workflow_output +from agent_framework_durabletask._workflows.serialization import ( + strip_pickle_markers, strip_subworkflow_markers, +) +from durabletask.task import CompletableTask, CompositeTask, OrchestrationContext, Task + + +class WorkflowTask(CompositeTask[Any], CompletableTask[Any]): + """Decode only the trusted child orchestration result into MAF outputs.""" + + def on_child_completed(self, task: Task[Any]) -> None: + if self.is_complete: + return + if task.is_failed: + self.fail("Workflow child orchestration failed", task.get_exception()) + else: + try: + self.complete(deserialize_workflow_output(task.get_result())) + except Exception as error: + self.fail("Workflow output decoding failed", error) + + +class DurableWorkflow: + """A workflow handle bound to the calling durable orchestration context. + + run() returns a yieldable task. It never calls Workflow.run() in-process. + Each invocation starts a child workflow with its own workflow state. + """ + + def __init__( + self, context: OrchestrationContext | DurableOrchestrationContext, + workflow_name: str, + ) -> None: + self._context = context + self.name = workflow_name + + def run( + self, input_: Any = None, *, instance_id: str | None = None, + ) -> WorkflowTask: + # Match DAFX's public workflow-entry trust boundary. Parent input may + # originate in an HTTP request; it is not an internal checkpoint envelope. + input_ = strip_subworkflow_markers(strip_pickle_markers(input_)) + # The native SDK takes keyword-only input; the Functions compatibility + # context exposes input_ instead. Neither path runs a local Workflow. + if isinstance(self._context, DurableOrchestrationContext): + child = self._context.call_sub_orchestrator( + f"dafx-{self.name}", input_=input_, instance_id=instance_id, + ) + else: + child = self._context.call_sub_orchestrator( + f"dafx-{self.name}", input=input_, instance_id=instance_id, + ) + return WorkflowTask([child]) diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py new file mode 100644 index 0000000..b9b34e6 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/_workflows.py @@ -0,0 +1,116 @@ +"""Opt-in loading of MAF YAML workflows for the DAFX Functions host.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from pathlib import Path +from typing import Protocol + +from agent_framework import SupportsAgentRun, Workflow + +_SUFFIXES = (".workflow.yaml", ".workflow.yml") +_WORKFLOW_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,62}") + + +class WorkflowLoader(Protocol): + """Public loading surface implemented by MAF's WorkflowFactory.""" + + def create_workflow_from_yaml_path(self, yaml_path: str | Path) -> Workflow: + ... + + +def _definition_paths(root: Path) -> list[Path]: + paths = [] + for directory in (root, root / "workflows"): + if not directory.is_dir(): + continue + for path in sorted(directory.iterdir()): + if not path.name.endswith(_SUFFIXES): + continue + if not path.is_file(): + raise ValueError(f"Workflow definition {path.name!r} is not a file.") + if not path.resolve().is_relative_to(root): + raise ValueError(f"Workflow definition {path.name!r} escapes app root.") + paths.append(path) + return paths + + +def load_workflows( + root: Path, + agents: Mapping[str, SupportsAgentRun], + factory: WorkflowLoader | None = None, + *, + workflow_name: str | None = None, + workflow_file: str | Path | None = None, +) -> list[Workflow]: + """Discover files and hand them to MAF without interpreting its YAML schema. + + The default factory receives markdown recipes as a convenience registry. + A caller-supplied factory is used unchanged, including its agent registry, + agent factory, tools, handlers, configuration, and resource ownership. + MAF owns parsing, relative references, validation and agent construction. + """ + if workflow_file is not None: + path = root / workflow_file + if not path.name.endswith(_SUFFIXES): + raise ValueError( + "workflow_file must end in .workflow.yaml or .workflow.yml" + ) + if not path.resolve().is_relative_to(root.resolve()): + raise ValueError("workflow_file escapes app root.") + if not path.is_file(): + raise FileNotFoundError(f"Workflow definition {workflow_file!s} not found.") + paths = [path] + elif workflow_name is not None: + # Select before parsing: unrelated definitions must not be loaded merely + # because a function declares a binding to one workflow. + paths = [ + path for directory in (root, root / "workflows") if directory.is_dir() + for path in directory.iterdir() + if any(path.name == workflow_name + suffix for suffix in _SUFFIXES) + ] + if not paths: + raise FileNotFoundError(f"Workflow definition {workflow_name!r} not found.") + if len(paths) != 1: + raise ValueError(f"Ambiguous workflow definition {workflow_name!r}.") + if ( + not paths[0].is_file() + or not paths[0].resolve().is_relative_to(root.resolve()) + ): + raise ValueError("Selected workflow must be a file inside app root.") + else: + paths = _definition_paths(root) + if factory is None: + try: + from agent_framework.declarative import WorkflowFactory + except ModuleNotFoundError as error: + if error.name != "agent_framework_declarative": + raise + raise ImportError( + "YAML workflow support is not installed. Install " + "'azurefunctions-agents-extensions-agent-framework[durable,workflows]'." + ) from error + factory = WorkflowFactory(agents=agents) + + workflows = [] + names: set[str] = set() + for path in paths: + workflow = factory.create_workflow_from_yaml_path(path) + if not isinstance(workflow, Workflow): + raise TypeError("The workflow factory must return a MAF Workflow.") + name = workflow.name + if workflow_name is not None and name != workflow_name: + raise ValueError( + f"Selected workflow name {name!r} does not match {workflow_name!r}." + ) + if not isinstance(name, str) or _WORKFLOW_NAME.fullmatch(name) is None: + raise ValueError( + f"Workflow {path.name!r} needs a stable name of 1-63 ASCII " + "letters, digits, hyphens or underscores, starting with a letter." + ) + if name.casefold() in names: + raise ValueError(f"Duplicate workflow name {name!r}.") + names.add(name.casefold()) + workflows.append(workflow) + return workflows diff --git a/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py new file mode 100644 index 0000000..25f91dd --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/azurefunctions/agents/extensions/agent_framework/apps.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +import functools +import inspect +import os +import re +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar, cast + +import azure.functions as func +from agent_framework import SupportsAgentRun, ToolTypes, Workflow +from azure.functions.decorators.function_app import Function + +from azurefunctions.agents.extensions.base import ( + compile_agent, + configure_app, + discover_agent_names, + get_app_root, +) +from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent + +from .provider import AGENT_FRAMEWORK_PROVIDER_ID, AgentFrameworkBinding, ClientFactory +from ._workflows import WorkflowLoader + +if TYPE_CHECKING: + from agent_framework_durabletask import DurableAgentTask, DurableAIAgent + from durabletask.task import OrchestrationContext + + from ._durable import MarkdownDurableAgent + from ._hosting import HostedAgentFunctionApp + +_F = TypeVar("_F", bound=Callable[..., Any]) + + +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, + discover_agents: bool = False, + discover_workflows: bool = False, + expose_agent_endpoints: bool = True, + expose_workflow_endpoints: bool = True, + workflow_factory: WorkflowLoader | None = None, + ) -> None: + for name, value in ( + ("discover_agents", discover_agents), + ("discover_workflows", discover_workflows), + ("expose_agent_endpoints", expose_agent_endpoints), + ("expose_workflow_endpoints", expose_workflow_endpoints), + ): + if not isinstance(value, bool): + raise TypeError(f"{name} must be a bool") + super().__init__( + http_auth_level=http_auth_level, + ) + self._durable_app: HostedAgentFunctionApp | None = None + self._functions_indexed = False + self._durable_agents: dict[str, SupportsAgentRun] = {} + self._agent_http_endpoints: dict[str, bool] = {} + self._markdown_agents: dict[str, MarkdownDurableAgent] = {} + self._markdown_discovered = False + self._hosted_workflows: dict[str, Workflow] = {} + self._workflow_http_endpoints: dict[str, bool] = {} + self._workflow_factory = workflow_factory + configure_app( + self, + provider=AGENT_FRAMEWORK_PROVIDER_ID, + app_root=app_root, + provider_options=_provider_options( + client_factory=client_factory, + tools=tools, + ), + ) + if discover_agents or (discover_workflows and workflow_factory is None): + self._discover_markdown_agents() + if discover_agents: + for agent in self._markdown_agents.values(): + self.add_durable_agent( + agent, expose_http_endpoint=expose_agent_endpoints, + ) + if discover_workflows: + from ._workflows import load_workflows + + for workflow in load_workflows( + get_app_root(self), self._markdown_agents, factory=workflow_factory, + ): + self._register_workflow( + workflow, expose_http_endpoint=expose_workflow_endpoints, + ) + + def _check_registration_open(self) -> None: + # A failed combined-name validation may already have cached the host. + # Retrying indexing is safe, but changing that host's inputs is not. + if self._functions_indexed or self._durable_app is not None: + raise RuntimeError("Register durable bindings before function indexing.") + + def _discover_markdown_agents(self) -> None: + if not self._markdown_discovered: + for name in discover_agent_names(self): + self._get_markdown_agent(name) + self._markdown_discovered = True + + def _get_markdown_agent(self, name: str) -> MarkdownDurableAgent: + from ._durable import MarkdownDurableAgent + + for registered_name, agent in self._markdown_agents.items(): + if registered_name.casefold() == name.casefold(): + if registered_name != name: + raise ValueError(f"Ambiguous agent name {name!r}.") + return agent + agent = MarkdownDurableAgent(self._compile_durable_markdown(name)) + self._markdown_agents[name] = agent + return agent + + def _compile_durable_markdown(self, name: str) -> AgentFrameworkBinding: + # The name is also used in an HTTP route and a Durable Entity ID, not + # only a filename. Reject route placeholders and entity-ID separators. + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", name) is None: + raise ValueError( + "Durable agent names must start with a letter or digit and " + "contain only ASCII letters, digits, hyphens, and underscores." + ) + compiled = compile_agent(self, name) + if not isinstance(compiled, AgentFrameworkBinding): + raise TypeError("Durable markdown agents require the MAF provider.") + return compiled + + def durable_markdown_agent( + self, + *, + arg_name: str, + agent_name: str, + context_name: str = "context", + expose_http_endpoint: bool = False, + ) -> Callable[[_F], _F]: + """Declare a durable markdown agent and inject its orchestration proxy. + + Apply below orchestration_trigger, above a synchronous generator. The + agent is private unless HTTP exposure is explicitly requested here or + by bulk agent discovery. Repeated declarations reuse the same recipe. + """ + if not isinstance(agent_name, str) or not agent_name.strip(): + raise ValueError("agent_name must be a non-empty string") + if not isinstance(expose_http_endpoint, bool): + raise TypeError("expose_http_endpoint must be a bool") + + def register() -> None: + self.add_durable_agent( + self._get_markdown_agent(agent_name), + expose_http_endpoint=expose_http_endpoint, + ) + + return self._durable_binding( + arg_name=arg_name, + context_name=context_name, + binding_name="durable_markdown_agent", + register=register, + get_proxy=lambda context: self.get_agent(context, agent_name), + ) + + def _register_workflow( + self, workflow: Workflow, *, expose_http_endpoint: bool, + ) -> None: + self._check_registration_open() + name = workflow.name + if not isinstance(name, str) or re.fullmatch( + r"[A-Za-z][A-Za-z0-9_-]{0,62}", name, + ) is None: + raise ValueError("A durable workflow must have a stable workflow name.") + for registered_name, registered in self._hosted_workflows.items(): + if registered_name.casefold() == name.casefold(): + if registered_name != name: + raise ValueError(f"Ambiguous workflow name {name!r}.") + if registered is not workflow: + raise ValueError(f"Workflow {name!r} is already registered.") + self._hosted_workflows[name] = workflow + self._workflow_http_endpoints[name] = ( + self._workflow_http_endpoints.get(name, False) or expose_http_endpoint + ) + + def durable_workflow( + self, + *, + arg_name: str, + workflow_name: str, + context_name: str = "context", + workflow_file: str | Path | None = None, + expose_http_endpoint: bool = False, + ) -> Callable[[_F], _F]: + """Inject a private-by-default workflow proxy into an orchestrator. + + Reuse a registered graph, or selectively load a matching definition. + workflow_file selects an app-root-relative file only for a new name; + omit it when reusing a graph registered by discovery or another binding. + """ + if not isinstance(workflow_name, str) or re.fullmatch( + r"[A-Za-z][A-Za-z0-9_-]{0,62}", workflow_name, + ) is None: + raise ValueError( + "workflow_name must be 1-63 ASCII letters, digits, hyphens or " + "underscores, starting with a letter." + ) + if not isinstance(expose_http_endpoint, bool): + raise TypeError("expose_http_endpoint must be a bool") + if workflow_file is not None and not isinstance(workflow_file, (str, Path)): + raise TypeError("workflow_file must be a str or Path") + + def register() -> None: + for name in self._hosted_workflows: + if ( + name.casefold() == workflow_name.casefold() + and name != workflow_name + ): + raise ValueError(f"Ambiguous workflow name {workflow_name!r}.") + workflow = self._hosted_workflows.get(workflow_name) + if workflow is not None: + if workflow_file is not None: + raise ValueError( + f"Workflow {workflow_name!r} is already registered; omit " + "workflow_file to reuse the registered graph." + ) + else: + from ._workflows import load_workflows + + if self._workflow_factory is None: + self._discover_markdown_agents() + workflows = load_workflows( + get_app_root(self), self._markdown_agents, + factory=self._workflow_factory, + workflow_name=workflow_name, workflow_file=workflow_file, + ) + if len(workflows) != 1 or workflows[0].name != workflow_name: + raise ValueError( + f"The selected definition must return exactly one workflow " + f"named {workflow_name!r}." + ) + workflow = workflows[0] + self._register_workflow( + workflow, expose_http_endpoint=expose_http_endpoint, + ) + + def get_proxy(context: OrchestrationContext) -> Any: + from ._workflow_client import DurableWorkflow + + return DurableWorkflow(context, workflow_name) + + return self._durable_binding( + arg_name=arg_name, + context_name=context_name, + binding_name="durable_workflow", + register=register, + get_proxy=get_proxy, + ) + + def _durable_binding( + self, + *, + arg_name: str, + context_name: str, + binding_name: str, + register: Callable[[], None], + get_proxy: Callable[[OrchestrationContext], Any], + ) -> Callable[[_F], _F]: + def decorate(handler: _F) -> _F: + self._check_registration_open() + if not inspect.isgeneratorfunction(handler): + raise TypeError( + f"{binding_name} requires a synchronous generator " + "below orchestration_trigger." + ) + pending = getattr(handler, "_durable_binding_args", ()) + if arg_name in pending: + raise TypeError(f"Duplicate injected parameter {arg_name!r}.") + existing_context = getattr(handler, "_durable_binding_context_name", None) + if existing_context is not None and existing_context != context_name: + raise TypeError("Durable bindings must use the same context_name.") + signature = inspect.signature(handler) + parameter = signature.parameters.get(arg_name) + if parameter is None or parameter.kind not in { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + }: + raise TypeError(f"Invalid injected parameter {arg_name!r}.") + context_parameter = signature.parameters.get(context_name) + if arg_name == context_name or context_parameter is None: + raise TypeError(f"Missing distinct context parameter {context_name!r}.") + visible = signature.replace(parameters=[ + item for name, item in signature.parameters.items() if name != arg_name + ]) + parameters = list(visible.parameters.values()) + if ( + not parameters or parameters[0].name != context_name + or context_parameter.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD + or any(p.kind not in { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + } for p in parameters) + ): + raise TypeError( + "The orchestrator must accept context first and optionally input." + ) + # Other visible parameters may be consumed by stacked bindings. + # Only the outer trigger can validate the final native arity. + register() + + @functools.wraps(handler) + def inject(*args: Any, **kwargs: Any) -> Any: + bound = visible.bind(*args, **kwargs) + bound.apply_defaults() + bound.arguments[arg_name] = get_proxy(bound.arguments[context_name]) + call = inspect.BoundArguments(signature, bound.arguments) + return (yield from handler(*call.args, **call.kwargs)) + + inject.__signature__ = visible # type: ignore[attr-defined] + setattr(inject, "_durable_binding_context_name", context_name) + setattr(inject, "_durable_binding_args", (*pending, arg_name)) + return cast(_F, inject) + + return decorate + + def add_durable_agent( + self, agent: SupportsAgentRun, *, expose_http_endpoint: bool = False, + ) -> None: + """Opt in to DAFX by registering an agent before function indexing. + + Unlike markdown bindings, this accepts a caller-owned agent instance. + It does not construct or close the agent's clients or tools. + """ + self._check_registration_open() + if not isinstance(expose_http_endpoint, bool): + raise TypeError("expose_http_endpoint must be a bool") + name = getattr(agent, "name", None) + if not isinstance(name, str) or not name.strip(): + raise ValueError("A durable agent must have a non-empty string name.") + + for registered_name, registered_agent in self._durable_agents.items(): + if registered_name.casefold() == name.casefold(): + if registered_name != name or registered_agent is not agent: + raise ValueError(f"Durable agent {name!r} is already registered.") + self._durable_agents[name] = agent + self._agent_http_endpoints[name] = ( + self._agent_http_endpoints.get(name, False) or expose_http_endpoint + ) + + def _ensure_durable_app(self) -> HostedAgentFunctionApp: + if self._durable_app is None: + try: + from ._hosting import HostedAgentFunctionApp + except ModuleNotFoundError as error: + if error.name not in { + "agent_framework_azurefunctions", "agent_framework_durabletask", + "azure.durable_functions", "durabletask", + }: + raise + raise ImportError( + "DAFX support is not installed. Install " + "'azurefunctions-agents-extensions-agent-framework[durable]'." + ) from error + + durable_app = HostedAgentFunctionApp( + workflows=list(self._hosted_workflows.values()), + exposed_workflows={ + name for name, exposed in self._workflow_http_endpoints.items() + if exposed + }, + http_auth_level=self.auth_level, + ) + for name, agent in self._durable_agents.items(): + if any(key.casefold() == name.casefold() for key in durable_app.agents): + raise ValueError( + f"Standalone agent {name!r} collides with a workflow agent." + ) + durable_app.add_agent( + agent, enable_http_endpoint=self._agent_http_endpoints[name], + ) + self._durable_app = durable_app + return self._durable_app + + def get_agent( + self, + context: OrchestrationContext, + agent_name: str, + ) -> DurableAIAgent[DurableAgentTask]: + """Get a DAFX proxy without registering functions during execution.""" + if agent_name not in self._durable_agents: + raise ValueError(f"Agent {agent_name!r} is not registered with this app.") + from agent_framework_durabletask import ( + DurableAIAgent, OrchestrationAgentExecutor, + ) + + return DurableAIAgent(OrchestrationAgentExecutor(context), agent_name) + + def get_functions(self) -> list[Function]: + """Expose both registries through the single worker-indexed app.""" + # The SDK retains name-validation state between indexing calls. Start + # each pass fresh, including retries after an indexing error. + self.functions_bindings = None + functions: list[Function] = super().get_functions() + if self._durable_agents or self._hosted_workflows: + durable_app = self._ensure_durable_app() + durable_app.functions_bindings = None + functions.extend(durable_app.get_functions()) + + names: set[str] = set() + for function in functions: + name = function.get_function_name() + if not name: + raise ValueError("An indexed function must have a name.") + if name.casefold() in names: + raise ValueError( + f"Duplicate function name across app registries: {name}" + ) + names.add(name.casefold()) + self._functions_indexed = True + return functions + + def orchestration_trigger( + self, + context_name: str, + orchestration: str | None = None, + input_type: type | None = None, + ) -> Callable[..., Any]: + # Keep the native SDK context and task semantics; no hidden activity or + # custom call_agent context wrapper is installed. + sdk = super().orchestration_trigger + options: dict[str, Any] = { + "context_name": context_name, "orchestration": orchestration, + } + if input_type is not None: + if "input_type" not in inspect.signature(sdk).parameters: + raise TypeError("The installed SDK does not support input_type.") + options["input_type"] = input_type + decorator = sdk(**options) + + def decorate(handler: _F) -> Any: + declared_context = getattr(handler, "_durable_binding_context_name", None) + if declared_context is not None: + if declared_context != context_name: + raise TypeError("Binding and trigger context_name must match.") + parameters = list(inspect.signature(handler).parameters.values()) + if ( + not parameters or parameters[0].name != context_name + or len(parameters) > 2 + or any(p.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD + for p in parameters) + ): + raise TypeError( + "The orchestrator must accept context first " + "and optionally input." + ) + return decorator(handler) + + return decorate diff --git a/azurefunctions-agents-extensions-agent-framework/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..da80a34 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/pyproject.toml @@ -0,0 +1,76 @@ +[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] +workflows = [ + "agent-framework-declarative>=1.0.3,<2", +] +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", + # Prototype only. PR #72 supplies DAFX's SDK 2 support. Pin both packages + # to the same revision until compatible distributions are published. + "agent-framework-azurefunctions @ git+https://github.com/microsoft/agent-framework-durable-extension.git@aa9529ec489e16ac64b73bd68d5adbb8e4945258#subdirectory=python/packages/azurefunctions", + "agent-framework-durabletask @ git+https://github.com/microsoft/agent-framework-durable-extension.git@aa9529ec489e16ac64b73bd68d5adbb8e4945258#subdirectory=python/packages/durabletask", +] +dev = [ + "azure-functions-durable>=2.0.0b2", + "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..8dbb0b7 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/README.md @@ -0,0 +1,100 @@ +--- +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. Agent samples use raw `.agent.md` instructions. +The first two use explicit Microsoft Foundry client factories, while the local +examples use deterministic clients without model credentials. + +* [agent_samples_agent-framework](agent_samples_agent-framework/README.md) - Examples for adding an Agent to an existing Function App: + * Inject a fresh Agent into HTTP and queue-triggered Functions + * Discover app-wide Skills and MCP servers + * Keep validation and deterministic processing in application code + +* [agent_samples_agent-framework_durable](agent_samples_agent-framework_durable/README.md) - Examples for using Agents in Durable Functions: + * Schedule Agent calls from a replay-safe orchestrator + * Inject a durable markdown agent and run two turns in one shared session + * Combine deterministic activity output with model-generated results + * Keep the selected agent private, with no automatic agent HTTP endpoint + +* [Endpoint-only local agent](lazy-owned-dafx/README.md) uses `discover_agents=True` + discovery and the generated DAFX HTTP endpoint. No handwritten handlers or + model credentials are needed. +* [Durable markdown binding](durable-markdown-binding/README.md) injects a proxy + into a generator orchestrator, runs two turns in one session, and includes an + HTTP starter. The agent stays private. It uses a deterministic local client. +- [Durable YAML workflows](durable-yaml-workflow/README.md) enables + `discover_workflows=True` for shared state, a Markdown agent activity, + and a separate approval question. No handwritten handlers are needed. + No standalone writer entity or agent HTTP endpoint is published. + Uses the `[durable,workflows]` extras; expression execution is verified on 3.13. +- [Configured workflow factory](configured-workflow-factory/README.md) passes a + public MAF factory with a registered local function and environment configuration. + It needs no Markdown agent, model client, or custom HTTP handler. +- [Durable workflow binding](durable-workflow-binding/README.md) selects a private + YAML child without discovery. A parent generator yields the child task and + returns its decoded outputs. Only the parent has an HTTP starter. + +## Prerequisites + +* 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). +* The Foundry samples require an [Azure subscription](https://azure.microsoft.com/free/), a Microsoft Foundry project, and a deployed model. +* You must have [Azurite](https://learn.microsoft.com/azure/storage/common/storage-use-azurite) or an Azure Storage account for Functions host storage, queue triggers, and Durable Functions state. +* The non-Durable sample also requires a trusted streamable-HTTP MCP endpoint. +* Durable samples require the prototype's SDK 2-compatible DAFX dependencies + and a compatible Functions host/backend to run under Core Tools. They do not + depend on the Azure Functions Agents runtime. Follow the + [prototype setup steps](lazy-owned-dafx/README.md#install-and-verify) first. + +## Setup + +1. Install [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local?tabs=windows%2Cisolated-process%2Cnode-v4%2Cpython-v2%2Chttp-trigger%2Ccontainer-apps&pivots=programming-language-python). +2. Clone or download this sample repository. +3. Open the sample folder in Visual Studio Code or your IDE of choice. +4. For a Foundry sample, sign in with an identity authorized to use your Microsoft Foundry project. For example: + +```bash +az login +``` + +## Running the samples + +The following steps apply to the Foundry samples. For the local-client examples, +follow the linked README for its installation steps, settings, and HTTP requests. + +1. Open a terminal window and `cd` to the directory containing the sample you want to run. +2. Create `local.settings.json` from `local.settings.template.json` and replace the placeholders with your Foundry project and model settings. +3. Create and activate a virtual environment. +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/). 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..9c47525 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/README.md @@ -0,0 +1,252 @@ +--- +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. An ordinary activity prepares the order, and +DAFX entities execute the Agent calls. + +The sample demonstrates + +- starting an orchestration from an HTTP-triggered Function +- validating and minimizing an order in an ordinary Durable activity +- injecting a DAFX proxy with `durable_markdown_agent` below + `orchestration_trigger` +- yielding `agent.run()` tasks from a synchronous generator orchestrator +- sharing one durable session between assessment and planning +- passing the prepared order as JSON and returning the responses' text +- polling the standard Durable management endpoint for status and output. + +## How the sample works + +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. The injected Agent proxy creates a session. The first `agent.run()` schedules + an assessment of the prepared order through the DAFX entity. +4. A second `agent.run()` requests a fulfillment plan using the same session and + the assessment's text. +5. The orchestration output combines the deterministic order ID with the two + model-generated results. + +The orchestrator never opens files, creates credentials or clients, connects to +a model, or performs network I/O. During replay it recreates the activity and +Agent task schedule from recorded inputs and results. + +The logical Agent name `order-fulfillment` resolves +`order-fulfillment.agent.md`. The file contains raw Agent instructions; +Foundry client and model configuration remain explicit in +`create_chat_client()`. + +The binding registers only the selected markdown definition, without bulk +discovery or an automatic Agent HTTP endpoint. Clients are created and closed per entity +execution through the compiled markdown binding, not during indexing or replay. +No custom orchestration context wrapper or hidden Agent activity is used. +The inner DAFX host is created when the outer app indexes its functions. + +## Project structure + +| 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 turns. | +| `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, activity work items, and entity state. +- The prototype's SDK 2-compatible DAFX packages and a compatible Functions + host/extension and Durable backend. These are not provisioned by this sample. +- An Azure subscription and a Microsoft Foundry project with a deployed model. +- A local identity authorized to use the Foundry project. For example, sign in + with `az login` before running the sample. + +## 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: + + First follow the [prototype setup steps](../lazy-owned-dafx/README.md#install-and-verify) + to install both local extension packages and the pinned SDK 2-compatible DAFX + dependencies in the same virtual environment. Then, from this sample directory, + install the Foundry and order-validation dependencies: + + ```bash + python -m pip install -r requirements.txt + ``` + + The editable dependency in `requirements.txt` installs the Agent Framework + extension from this repository with its `[durable]` extra. Use the prototype + dependencies rather than substituting the published SDK 1.x DAFX packages. + +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 + +- `@app.durable_markdown_agent` sits below `@app.orchestration_trigger` and + injects a `DurableAIAgent[DurableAgentTask]` proxy into the generator. +- `agent.create_session()` creates one session that both `agent.run()` calls + reuse. DAFX stores conversation history in durable session state. +- Each Agent call receives a JSON string containing the trusted prepared order. + The planning request also includes `assessment.text`. +- Agent execution and all related I/O occur in the DAFX entity, never in the + orchestrator. +- The extension compiles the Agent recipe during registration, then creates and + closes a fresh Foundry client and Agent for each entity execution. +- The output contains only the order ID, `assessment.text`, and `plan.text`. + +### Private Agent registration + +This sample's `host.json` removes the default `api` prefix. The handwritten +`POST /orders/orchestrations` starter is the only application HTTP route. +The binding's `expose_http_endpoint=False` default keeps the agent private, so +there is no `POST /agents/order-fulfillment/run` route. + +An explicit `expose_http_endpoint=True` on the binding would publish that direct +agent route. It would bypass `prepare_order_activity` and its order validation. +Do not enable it merely to access the agent from the orchestrator. HTTP exposure +and business policy are separate decisions. + +## Troubleshooting + +- **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, the SDK 2-compatible host/extension is available, and the extension + bundle in `host.json` can be downloaded. +- **Orchestration remains Pending:** verify Azurite is running and + `AzureWebJobsStorage` points to the same storage service used by the host. +- **Orchestration fails in `prepare_order_activity`:** confirm the request has an + `order_id`, customer, two-letter shipping country, supported shipping method, + and at least one item with a positive integer quantity. +- **Agent execution 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. +- Try the [durable markdown binding sample](../durable-markdown-binding/README.md) + for the same shared-session pattern with a deterministic local client. +- Learn more about [Durable Functions for Python](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview?tabs=python). \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/agent_samples_agent-framework_durable/function_app.py new file mode 100644 index 0000000..98ef8b7 --- /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 + +import azure.durable_functions as df +import azure.functions as func +from agent_framework_durabletask import DurableAgentTask, DurableAIAgent +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from 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") +@app.durable_markdown_agent( + arg_name="agent", agent_name="order-fulfillment", context_name="context" +) +def order_orchestrator( + context: df.DurableOrchestrationContext, + agent: DurableAIAgent[DurableAgentTask], +): + prepared_order = yield context.call_activity( + "prepare_order_activity", + context.get_input(), + ) + + session = agent.create_session() + assessment = yield agent.run( + json.dumps({ + "order": prepared_order, + "task": "assess fulfillment risk using the trusted calculated fields", + }), + session=session, + ) + plan = yield agent.run( + json.dumps({ + "order": prepared_order, + "risk_assessment": assessment.text, + "task": "create a fulfillment plan with prioritized human-review actions", + }), + session=session, + ) + return { + "order_id": prepared_order["order_id"], + "risk_assessment": assessment.text, + "fulfillment_plan": plan.text, + } 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/samples/configured-workflow-factory/README.md b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md new file mode 100644 index 0000000..9f4c4d6 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/README.md @@ -0,0 +1,63 @@ +# Configured YAML workflow factory + +[function_app.py](function_app.py) configures MAF's public `WorkflowFactory`, +registers the local `format_order` function with `register_tool()`, and passes +that same object to `AgentFunctionApp(workflow_factory=...)`. The configuration +provides `ORDER_PREFIX` for `=Env.ORDER_PREFIX`. With +`restrict_env_to_configuration=True`, MAF does not consult process environment +variables for these workflow expressions. + +[ConfiguredTools.workflow.yaml](workflows/ConfiguredTools.workflow.yaml) calls +`InvokeFunctionTool`, stores its result in `Local.result`, then emits it with +`SendActivity`. The function only formats text. No Markdown definition, agent +client, model credentials, or external service is needed. `client_factory` is +still a required app argument, so `no_agent_client()` raises if it is called. +Its return annotation is `NoReturn`. The app enables only +`discover_workflows=True`. There are no handwritten HTTP handlers or orchestrators. + +## Run + +Follow the [YAML sample setup](../durable-yaml-workflow/README.md#install-and-run) +for the local packages, `[durable,workflows]` extras, and host/backend settings. +Use Python 3.13 to reproduce the verified PowerFx expression execution. The +extension does not reject Python 3.14, but support follows the installed MAF +dependencies and execution on 3.14 has not been verified. + +Start Core Tools from this sample directory rather than the Markdown sample. +The copied [host.json](host.json) does not provision or verify a compatible host +or Durable backend. + +With the default `/api` prefix, send `{"order":"42"}` to +`POST /api/workflow/ConfiguredTools/run`. Query the returned `statusQueryGetUri` +until completion. The expected `output` is `["Local order 42."]`. Hosted requests +need a function key. The sample expects this input shape and adds no request +schema validation. + +DAFX also generates `GET /api/workflow/ConfiguredTools/status/{instanceId}` and +`POST /api/workflow/ConfiguredTools/respond/{instanceId}/{requestId}`. This +workflow does not request human input. + +## Factory and lifecycle + +The extension calls `create_workflow_from_yaml_path()` on the supplied factory +unchanged, without merging discovered Markdown adapters into its agent registry. +This sample has no Markdown files or standalone agent endpoints. Adding Markdown +files would neither publish standalone endpoints nor add them to this factory's +registry. Agent discovery is a separate opt-in. + +The default `expose_workflow_endpoints=True` publishes the discovered workflow. +Set it to `False` to register the graph without standalone HTTP routes. That +constructor option controls bulk discovery only. A `workflow_factory` can also +be passed without discovery for a private `durable_workflow` binding. The inner +DAFX host is deferred until `app.get_functions()`. + +MAF owns parsing and building, including warnings, errors, and native agent/tool +configuration. The extension does not impose a separate action allowlist. +Discovered entry files must stay within the app root, but nested file references +use native MAF resolution and are not sandboxed. Deploy only trusted files. + +If you add inline or custom agents, their construction and resource lifecycle +follow MAF or the supplied factory and may create clients during app +initialization/indexing. They do not automatically get the Markdown adapter's +fresh per-execution lifecycle. See the [verification scope](../durable-yaml-workflow/VALIDATION.md) +for the distinction between local replay checks and host/backend validation. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py new file mode 100644 index 0000000..a5c0505 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/function_app.py @@ -0,0 +1,28 @@ +"""Host a tool-only YAML workflow with a configured public MAF factory.""" + +from typing import NoReturn + +from agent_framework.declarative import WorkflowFactory +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +def format_order(order: str, prefix: str) -> str: + """Format local input without a model or external service.""" + return f"{prefix} order {order}." + + +def no_agent_client() -> NoReturn: + raise AssertionError("This tool-only workflow must not create an agent client.") + + +workflow_factory = WorkflowFactory( + configuration={"ORDER_PREFIX": "Local"}, + restrict_env_to_configuration=True, +) +workflow_factory.register_tool("format_order", format_order) + +app = AgentFunctionApp( + client_factory=no_agent_client, + discover_workflows=True, + workflow_factory=workflow_factory, +) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/host.json b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/host.json new file mode 100644 index 0000000..b7e5ad1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/workflows/ConfiguredTools.workflow.yaml b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/workflows/ConfiguredTools.workflow.yaml new file mode 100644 index 0000000..d456336 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/configured-workflow-factory/workflows/ConfiguredTools.workflow.yaml @@ -0,0 +1,15 @@ +kind: Workflow +name: ConfiguredTools +actions: + - kind: InvokeFunctionTool + id: format_order + functionName: format_order + arguments: + order: =Workflow.Inputs.order + prefix: =Env.ORDER_PREFIX + output: + result: Local.result + autoSend: false + - kind: SendActivity + id: send_result + activity: =Local.result diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md new file mode 100644 index 0000000..59d7cb1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/README.md @@ -0,0 +1,53 @@ +# Durable markdown binding + +This local example places `durable_markdown_agent` below `orchestration_trigger` +on a synchronous generator. The binding selects `agents/orders.agent.md`, +registers its private DAFX entity, and injects an orchestration proxy. +It needs neither bulk discovery nor explicit agent instance registration. +The inner DAFX host is deferred until `app.get_functions()`. + +The orchestrator creates one session and yields two `agent.run()` tasks with +that session. The deterministic client counts user messages in the restored +history, so the output is: + +```json +{ + "assessment": "User turn 1: Assess the order.", + "plan": "User turn 2: Make a fulfillment plan." +} +``` + +The same small `LocalChatClient` helper is included in each local sample so +either directory can be run on its own. There are no model credentials or +network calls in the client. Registration compiles recipes; each entity run +opens and closes a fresh Agent through `open_agent()`. Session history belongs +to DAFX, not the client instance or orchestrator process. + +## Run locally + +Follow the [endpoint-only sample's installation steps](../lazy-owned-dafx/README.md#install-and-verify). +Both samples use the optional dependencies pinned to DAFX PR #72. Running under +Core Tools requires an SDK 2-compatible Functions host/extension and configured +Durable backend, which this sample does not provision. Set +`FUNCTIONS_WORKER_RUNTIME=python` and `AzureWebJobsStorage`, then run `func start` +from this directory. + +```bash +curl -X POST http://localhost:7071/api/orders/orchestrations +``` + +The HTTP starter returns a check-status response. Follow its status URL to read +the orchestration output. The starter uses fixed prompts and ignores the request +body. Each orchestration creates a new session. + +The binding defaults to `expose_http_endpoint=False`, so there is no +`POST /api/agents/orders/run` route. Only the handwritten starter exposes this +flow. Add a function key when calling a hosted app. + +To deliberately expose the agent directly, add `expose_http_endpoint=True` to +the binding. That route bypasses the parent orchestration. The constructor's +`expose_agent_endpoints` controls bulk discovery only. Discovery and a binding +reuse one registration, with HTTP exposure enabled if either opts in. + +For automatic registration of all root and `agents/` markdown files without +handwritten functions, see the [endpoint-only sample](../lazy-owned-dafx/README.md). \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/agents/orders.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/agents/orders.agent.md new file mode 100644 index 0000000..ea7a90a --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/agents/orders.agent.md @@ -0,0 +1,3 @@ +You are an order fulfillment assistant. +Assess the order, then propose a concise fulfillment plan. +Never claim that an external action completed without a confirming tool result. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py new file mode 100644 index 0000000..be1fde1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/function_app.py @@ -0,0 +1,34 @@ +"""Inject a durable markdown agent into a two-turn generator orchestrator.""" + +import azure.durable_functions as df +import azure.functions as func +from agent_framework_durabletask import DurableAgentTask, DurableAIAgent +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from local_chat_client import LocalChatClient + +# The binding registers only the selected agent, without an agent HTTP endpoint. +app = AgentFunctionApp(client_factory=LocalChatClient) + + +@app.orchestration_trigger(context_name="context") +@app.durable_markdown_agent( + arg_name="agent", agent_name="orders", context_name="context" +) +def orders( + context: df.DurableOrchestrationContext, + agent: DurableAIAgent[DurableAgentTask], +): + session = agent.create_session() + first = yield agent.run("Assess the order.", session=session) + second = yield agent.run("Make a fulfillment plan.", session=session) + return {"assessment": first.text, "plan": second.text} + + +@app.route(route="orders/orchestrations", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_orders( + req: func.HttpRequest, client: df.DurableFunctionsClient +) -> func.HttpResponse: + # Fixed prompts keep this example focused on durable session continuity. + instance_id = await client.start_new("orders", client_input={}) + return client.create_check_status_response(req, instance_id) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/host.json b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/host.json new file mode 100644 index 0000000..55d1642 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/local_chat_client.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/local_chat_client.py new file mode 100644 index 0000000..71fa95b --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-markdown-binding/local_chat_client.py @@ -0,0 +1,46 @@ +"""A deterministic model substitute with no credentials or network resources.""" + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import Any + +from agent_framework import ( + BaseChatClient, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + ResponseStream, +) + + +class LocalChatClient(BaseChatClient): + """Count user messages in the supplied history and echo the latest prompt.""" + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ): + turns = sum(message.role == "user" for message in messages) + text = f"User turn {turns}: {messages[-1].text}" + created_at = datetime.now(timezone.utc).isoformat() + + async def updates(): + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text(text)], + created_at=created_at, + ) + + async def respond(): + return ChatResponse( + messages=[Message(role="assistant", contents=[text])], + created_at=created_at, + ) + + if stream: + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + return respond() diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/README.md new file mode 100644 index 0000000..6818744 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/README.md @@ -0,0 +1,74 @@ +# Durable workflow binding + +[function_app.py](function_app.py) places `durable_workflow` below +`orchestration_trigger` on a synchronous generator. The binding selects +[Child.workflow.yaml](workflows/Child.workflow.yaml) without bulk discovery. +The child uses only `SendActivity`, with no agent, model credentials, or external +service. The required `client_factory` is a `NoReturn` sentinel that raises if +anything tries to create an agent client. + +The parent yields `child.run(context.get_input())`. This schedules the +`dafx-Child` sub-orchestration, not an in-process `Workflow.run()` call. The +yielded task returns decoded workflow outputs. The parent returns + +```json +{"child_outputs": ["Child workflow completed."]} +``` + +`child.run(input_, instance_id="child-instance-id")` can supply a child instance +ID. Otherwise the native Durable scheduler chooses it. Each invocation has its +own workflow state. + +## Registration and exposure + +Neither `discover_agents` nor `discover_workflows` is enabled. The binding loads +only the matching `Child.workflow.yaml` or `Child.workflow.yml` in the app root +or its `workflows/` directory. Its loaded workflow must be named `Child`. +For a differently named file, pass an app-root-relative `workflow_file`, such as +`workflow_file="workflows/review.workflow.yaml"`, while keeping the YAML name +equal to the requested workflow name. The selected entry must stay inside the +app root. + +The child is private by default. Indexing registers `parent`, `start_parent`, +`dafx-Child`, `dafx-Child-_workflow_entry`, `dafx-Child-send_result`, and the SDK's +`BuiltIn__HttpActivity` and `BuiltIn__HttpPollOrchestrator`. There are no generated +child HTTP routes or agent functions. The inner DAFX host is created at +`app.get_functions()`, after declarations have been collected. + +To also publish the child's run, status, and response endpoints, explicitly add +`expose_http_endpoint=True` to `@app.durable_workflow(...)`. That opts in to +`POST /api/workflow/Child/run`, `GET /api/workflow/Child/status/{instanceId}`, and +`POST /api/workflow/Child/respond/{instanceId}/{requestId}`. It is not needed to +call the child from the parent. Constructor exposure options govern bulk +discovery only and do not turn a private binding into a public endpoint. + +Input forwarding strips DAFX's reserved checkpoint/envelope markers, matching +the public workflow HTTP boundary. The generic parent does not aggregate child +human-input requests into a parent management endpoint. A child that requests +human input needs exposed child management routes or application management +using its instance ID. This sample's child does not request human input. + +## Run locally + +Follow the [YAML sample setup](../durable-yaml-workflow/README.md#install-and-run) +for Python 3.13, local packages, and the `[durable,workflows]` extras. Core Tools +also requires an SDK 2-compatible Functions host/extension and a configured +Durable backend. This sample does not provision or verify them. + +Set `FUNCTIONS_WORKER_RUNTIME=python` and `AzureWebJobsStorage`, then run +`func start` from this directory. With the default `/api` prefix, start the parent + +```bash +curl -X POST http://localhost:7071/api/parent/orchestrations +``` + +The handwritten starter ignores the body and returns a check-status response. +Follow its status URL to read the parent output. Hosted requests need a function +key. The starter can be omitted when another Durable caller starts `parent`. +This example adds no request schema or business policy validation. + +MAF owns YAML parsing and native factory lifecycle. A configured +`workflow_factory` can be supplied without enabling discovery. Native YAML file +references are trusted deployment content, not sandboxed by the entry-file +containment check. See the [package documentation](../../README.md#yaml-workflows) +for factory configuration and lifecycle boundaries. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md new file mode 100644 index 0000000..3ac69cf --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/VALIDATION.md @@ -0,0 +1,51 @@ +# Discovery and binding verification + +This revision rebases the prototype onto PR #185 at `edb9d0a`. Its provider/MCP +fixes, renamed sample directories, malformed-input tests, and CI dependencies +are preserved. The baseline at `2777aa3` passed 168 tests before the API revision. +The final rebase keeps the native SDK context instead of the new upstream export +of the deleted custom context. All 192 tests and five non-durable import tests +passed again after that rebase, along with typing and lint. + +## Current results + +- 192 tests passed with YAML support installed, using Python 3.13.11, core 1.16.0, + declarative 1.0.3, Functions 2.3.0, Durable 2.0.0rc1 and pinned DAFX PR #72. +- Without YAML dependencies, 179 passed and 13 YAML-specific cases skipped. +- Five isolated non-durable import tests passed. +- Strict mypy passed on 14 source files; scoped Flake8 and whitespace checks passed. +- Both wheels/source distributions built and local documentation links resolved. + +## Change analysis + +- Replaced the ambiguous `durable`/`workflows` switches with independent discovery + flags and bulk endpoint controls. Bindings default private. Agent/workflow + exposure matrices and registration-reuse tests cover every Boolean combination. + Enabling exposure is monotonic and cannot be undone by a later private binding. + A mutation forcing every agent endpoint on fails the three private combinations + while the other five pass. Restored registration tests pass all 24 cases. +- One app-owned registry collects all declarations before the DAFX host is built. + Workflow-only discovery does not expose standalone agents. Native factories and + resource ownership remain unchanged, and a custom factory does not trigger + irrelevant Markdown compilation unless agent discovery is requested. +- Selective loading validates the entry path and expected workflow identity before + hosting. Unrelated YAML files remain unloaded. Binding stacking is validated at + the outer orchestration trigger after injected parameters have been removed. +- Actual SDK protobuf parent execution schedules a child orchestration, the child + runs through the existing real activity/replay harness, and parent replay receives + the result. Task tests cover native and compatibility contexts, typed output + reconstruction and child failure propagation. +- Independent review found reserved-envelope input forwarding and a collision + between a workflow-internal agent and a standalone agent. Both were reproduced + with safe failing tests and fixed. Forwarding now uses DAFX's own sanitizers; + a standalone/internal collision fails before returning an indexed app. +- Final rebase review found no runtime-source changes from the approved revision. + The upstream custom-context export failed the import test before reconciliation; + the resolved export test verifies that no custom context is exposed. +- Native YAML behavior remains delegated to the public MAF factory. The narrow + DAFX workflow-route override and input sanitizers rely on the pinned DAFX version, + not on a custom workflow execution engine. + +Tests use SDK handlers locally. No live Functions host/backend or external service +was exercised. The generic parent binding does not provide aggregated child HITL +management. Private means no generated HTTP routes, not a security principal. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/function_app.py new file mode 100644 index 0000000..f365921 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/function_app.py @@ -0,0 +1,31 @@ +"""Call a private YAML child workflow from a generator orchestrator.""" + +from typing import NoReturn + +import azure.durable_functions as df +import azure.functions as func +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +def no_agent_client() -> NoReturn: + raise AssertionError("This workflow must not create an agent client.") + + +app = AgentFunctionApp(client_factory=no_agent_client) + + +@app.orchestration_trigger(context_name="context") +@app.durable_workflow(arg_name="child", workflow_name="Child") +def parent(context: df.DurableOrchestrationContext, child): + outputs = yield child.run(context.get_input()) + return {"child_outputs": outputs} + + +@app.route(route="parent/orchestrations", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_parent( + req: func.HttpRequest, client: df.DurableFunctionsClient +) -> func.HttpResponse: + # The child emits fixed text, so this starter does not consume a request body. + instance_id = await client.start_new("parent", client_input={}) + return client.create_check_status_response(req, instance_id) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/host.json b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/host.json new file mode 100644 index 0000000..b7e5ad1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/workflows/Child.workflow.yaml b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/workflows/Child.workflow.yaml new file mode 100644 index 0000000..b0769bd --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-workflow-binding/workflows/Child.workflow.yaml @@ -0,0 +1,6 @@ +kind: Workflow +name: Child +actions: + - kind: SendActivity + id: send_result + activity: Child workflow completed. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md new file mode 100644 index 0000000..840bd49 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/README.md @@ -0,0 +1,173 @@ +# Durable YAML workflows + +[function_app.py](function_app.py) enables `discover_workflows=True` with no +handwritten handlers or orchestrators. The extension loads YAML through MAF's +public `WorkflowFactory.create_workflow_from_yaml_path()` and passes the +resulting graphs to DAFX's `workflows=` constructor. The default factory receives +adapters for all discovered Markdown agents. DAFX supplies the orchestration, +activities, and HTTP routes. + +- [OrderReview.workflow.yaml](workflows/OrderReview.workflow.yaml) copies the + request's `order` into shared state, builds a prompt, calls the Markdown + `writer`, and emits `Local.reply`. `resultProperty` belongs on the agent action; + `output.autoSend: false` leaves output to the final `SendActivity`. +- [Approval.workflow.yaml](workflows/Approval.workflow.yaml) is a separate + workflow. `Question` waits for input, saves it in `Local.answer`, then + `SendActivity` emits the answer. It is not an approval gate for `OrderReview`. + +[local_chat_client.py](local_chat_client.py) is copied from the +[durable binding sample](../durable-markdown-binding/README.md). It counts user +turns and echoes the prompt without model credentials or network calls. It does +not perform a real order review or interpret the writer's instructions. + +## Install and run + +Use **Python 3.13** to reproduce the verified expression execution. Python +support follows MAF's dependencies, with no extension-level Python 3.14 rejection. +MAF declarative 1.0.3 excludes PowerFx on Python 3.14, and expression execution +has only been verified on 3.13. From the repository root, in a Python 3.13 +virtual environment, install the local packages and both optional extras. + +```powershell +python -m pip install -e ./azurefunctions-agents-extensions-base +python -m pip install -e './azurefunctions-agents-extensions-agent-framework[durable,workflows]' +``` + +The durable extra pins the prototype dependencies from +[DAFX PR #72](https://github.com/microsoft/agent-framework-durable-extension/pull/72). +Core Tools execution also needs an SDK 2-compatible Functions host/extension and +a configured Durable backend. This sample does not provision or verify either. +Set `FUNCTIONS_WORKER_RUNTIME=python` and `AzureWebJobsStorage` for your backend, +then start from the sample directory. + +```powershell +cd azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow +func start +``` + +## Invoke OrderReview + +In another PowerShell terminal, start a workflow with a nonempty string `order`. +The JSON body is the workflow input, not an agent `message` envelope. + +```powershell +$run = Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflow/OrderReview/run ` + -ContentType application/json -Body '{"order":"42"}' +Invoke-RestMethod -Uri $run.statusQueryGetUri +``` + +The start response is `202` with `instanceId` and `statusQueryGetUri`. Query the +status URL again until completion. The expected `output` for this input is +`["User turn 1: Review order 42."]`. + +## Invoke Approval + +```powershell +$approval = Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflow/Approval/run ` + -ContentType application/json -Body '{}' +$status = Invoke-RestMethod -Uri $approval.statusQueryGetUri +$status.pendingHumanInputRequests +``` + +Query the status URL again until `pendingHumanInputRequests` contains the +question. Use its `respondUrl`, not the YAML action ID. Send the response object +expected by `Question`. + +```powershell +$pending = $status.pendingHumanInputRequests[0] +Invoke-RestMethod -Method Post -Uri $pending.respondUrl ` + -ContentType application/json -Body '{"user_input":"approved"}' +Invoke-RestMethod -Uri $approval.statusQueryGetUri +``` + +Query status again until completion. The expected `output` is `["approved"]`. +This example accepts free text. It demonstrates pause/resume, not approval +validation, authorization, or a business side effect. + +## Routes and execution + +With the default `/api` prefix, each workflow name (`OrderReview` and `Approval`) +gets these generated routes. + +| Method | Route | +| --- | --- | +| POST | `/api/workflow/NAME/run` | +| GET | `/api/workflow/NAME/status/{instanceId}` | +| POST | `/api/workflow/NAME/respond/{instanceId}/{requestId}` | + +The sample indexes 18 functions. Workflow suffixes below are appended to the +prefix with `-`. Each prefix itself is the orchestrator function. + +| Prefix | Generated suffixes | +| --- | --- | +| `dafx-OrderReview` | `start`, `status`, `respond`, `_workflow_entry`, `capture_order`, `prepare_prompt`, `review_order`, `send_review` | +| `dafx-Approval` | `start`, `status`, `respond`, `_workflow_entry`, `request_approval`, `send_answer` | + +The other functions are `BuiltIn__HttpActivity` and +`BuiltIn__HttpPollOrchestrator`. + +`discover_workflows=True` registers the two graphs and publishes their routes +with the default `expose_workflow_endpoints=True`. Agent discovery is disabled, +so there is no `dafx-writer` entity or `POST /api/agents/writer/run` route. The +default factory still receives the writer Markdown adapter for workflow actions. +The inner DAFX host is created at `app.get_functions()`, not app construction. +Hosted requests need a function key, including requests to returned status and +response URLs. + +Inside this sample's YAML graph, the writer action runs as a **durable activity** +through the `MarkdownDurableAgent` lifecycle. Each execution opens and closes a +fresh Agent, client, and tools. It does not call the writer's durable entity or +use an entity session. DAFX carries workflow shared state between actions. + +Inline YAML agents and custom-factory agents instead follow MAF's or the +factory's construction and resource lifecycle. Agents and clients may be created +during app initialization/indexing. The extension does not give them the +Markdown adapter's per-execution open/close lifecycle. + +## Configure the factory + +The default factory supports native MAF agent definitions and dynamic names as +well as Markdown references such as `agent: writer`. Supply `workflow_factory=` +to configure a public `WorkflowFactory` with an `agent_factory`, agents, tools, +HTTP or MCP handlers, or configuration. The supplied object is used unchanged, +without automatically merging discovered Markdown agents into its registry. +Supplying a factory does not enable agent discovery or standalone endpoints. +Factory configuration is also allowed without bulk discovery, for use with a +selective `durable_workflow` binding. + +See the [configured factory sample](../configured-workflow-factory/README.md) +for a tool-only workflow using `register_tool()` and `configuration`, with no +agent client or external service. + +## Boundaries + +See [VALIDATION.md](VALIDATION.md) for historical results and test limitations. + +- Workflow hosting needs the `[durable,workflows]` extras. `discover_workflows` + and `discover_agents` are independent switches, both disabled by default. +- Constructor exposure options apply only to bulk discovery. Selective bindings + are private unless their own `expose_http_endpoint=True` is set. See the + [child workflow sample](../durable-workflow-binding/README.md). +- Generated workflow routes bypass any handwritten parent policy. Disabling a + standalone route is not a separate authorization boundary. +- Only `*.workflow.yaml` and `*.workflow.yml` directly in the app root or its + `workflows/` directory are discovered. Discovery is not recursive and does not + load arbitrary YAML files. Discovered entry files must stay within the app root. +- Relative references inside YAML use MAF's native resolution from the workflow + file's directory, not an extension sandbox. Deploy only trusted workflow files + and references. +- The returned MAF `Workflow` needs a stable name of 1–63 ASCII letters, digits, + `_`, or `-`, starting with a letter, unique ignoring case. The resulting name, + not the filename, determines the route. These samples set explicit names. +- YAML parsing and graph construction follow the installed MAF loader, including + its warnings, errors, and handler requirements. There is no extension action + allowlist or separate inline/file/dynamic-agent or tool-action gate. DAFX + hosting validations still apply. This is not exhaustive execution coverage of + MAF features. +- The workflows extra accepts `agent-framework-declarative>=1.0.3,<2` and uses + its public factory API, not a private action registry. +- Keep action IDs stable across reloads. The samples supply explicit IDs. +- `OrderReview` expects the documented input shape and adds no request schema + validation. Local execution or indexing is not host/backend validation. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md new file mode 100644 index 0000000..71e63b1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/VALIDATION.md @@ -0,0 +1,94 @@ +# Historical YAML discovery verification + +The totals and replay checks below predate the rebase onto PR #185 at `2777aa3` +and the separate discovery/exposure API. They are not current-revision totals. +The integration contract below describes the current API. See +[README.md](README.md) for this sample's routes. +Current revision results are recorded in the +[workflow binding validation](../durable-workflow-binding/VALIDATION.md). + +## Historical results + +Native factory delegation was verified on Windows/Python 3.13.11 with core 1.16.0, +declarative 1.0.3, Functions 2.3.0, Durable 2.0.0rc1 and DAFX PR #72 at `aa9529ec`. + +- Both agent-package suites passed 158 tests with workflow dependencies installed. +- Without YAML dependencies, 151 passed and seven YAML-only cases skipped. +- The clean non-durable installation passed five isolated import tests. +- Strict mypy passed on 12 source files. Flake8, whitespace, dependency consistency, + documentation links, and both package wheel/source builds passed. +- The original nine YAML replay scenarios still pass. Thirteen additional native + cases cover previously blocked MAF configuration, plus the configured sample. + +An upstream `df_loads` deprecation warning remains visible. The tests do not hide +it. Python 3.14 execution is unverified rather than blocked by this extension. + +## Current integration contract + +- Discovery is limited to the two workflow suffixes directly in the app root or + `workflows/`, with entry-file containment checks. Loaded results must be MAF + `Workflow` objects with stable, valid names unique ignoring case. +- Loading calls public `create_workflow_from_yaml_path()`. MAF owns YAML parsing, + action handling, native warnings/errors, and nested file resolution. References + inside YAML are not sandboxed by the extension. Deploy only trusted files. +- The default factory receives all discovered Markdown adapters. A supplied + factory is used unchanged, with no automatic Markdown registry merge. DAFX + still validates and hosts the resulting graphs. Workflow discovery alone + creates no standalone Markdown agent entities or endpoints. +- Agent and workflow discovery are independent and disabled by default. Their + constructor exposure switches apply only to bulk discovery. Selective bindings + default to no standalone HTTP routes, and exposure is combined with logical OR + when declarations share a registration. The inner DAFX host is built at indexing. +- Markdown adapters create fresh resources per execution. Inline and + custom-factory agents follow MAF's or the factory's lifecycle and may construct + agents and clients during app initialization/indexing. +- The workflows extra accepts declarative `>=1.0.3,<2`. There is no private action + registry dependency or extension-level Python 3.14 rejection. Python support + follows MAF's dependencies. Expression execution is verified on 3.13 only, + since declarative 1.0.3 excludes its PowerFx dependency on 3.14. + +## Historical verification scope + +The replay probes reconstruct the app and YAML graphs before orchestration +activations and activities. They execute the actual SDK protobuf orchestration +handler and registered DAFX activities with in-memory storage/dispatch history. +Native-feature probes cover inline and relative-file agents, dynamic agent +selection with default and custom factories, sync/async function tools, local +HTTP/MCP handlers, configuration-only and environment-fallback expressions, and +the absence of automatic Markdown merging into a custom factory. This is not an +exhaustive claim of MAF feature parity. + +## Historical change analysis + +- Removed custom parsing, action traversal/allowlists, the internal action registry + import, and the inline/file/dynamic/tool/Python version gates. Tests now compare + duplicate-key, unknown-action, root-precedence and trigger-name behavior with + the public MAF factory instead of enforcing a second YAML dialect. +- Discovery boundaries and returned Workflow/name checks remain. The exact supplied + factory instance receives both file paths without extra method calls or mutation. + Its exceptions propagate; it need not import the default declarative loader. +- Default factories receive all markdown adapters, allowing runtime selection. + Supplied factories receive no implicit merge. The negative missing-agent case + verifies a same-named discovered Markdown file cannot override a custom registry. +- Native agent creation is intentionally permitted during loading. Real AgentFactory + public methods parse/build inline and relative-file agents using a local client. + Local HTTP/MCP handlers and registered sync/async tools execute once across replay, + with complete expected arguments/state checked. No external network is involved. +- Review found a stale mutation stub after adding the factory keyword. Its signature + is corrected: removing workflow loading fails on missing `dafx-Simple`, not a + keyword error. Ignoring the supplied factory also fails the native construction + assertion. Restored workflow tests pass all 21 collected cases. +- The sample index enumerates every sample app. The configured factory sample uses + its actual `format_order` implementation and configuration through SDK replay. + Documentation and sample-index claims were checked together. + +The documented sample outputs are `["User turn 1: Review order 42."]` for +`OrderReview` and `["approved"]` after responding to `Approval`. The +[configured factory sample](../configured-workflow-factory/README.md) expects +`["Local order 42."]` for `ConfiguredTools`. + +No live Functions host, storage backend, external model, or external HTTP/MCP +service is part of these checks. Retries, parallel execution, and nested +workflows are not claimed as verified. The subprocess helpers exit after all +assertions to isolate embedded PowerFx/CLR shutdown from pytest reporting, not +to bypass application logic or assertions. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/agents/writer.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/agents/writer.agent.md new file mode 100644 index 0000000..6e5c620 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/agents/writer.agent.md @@ -0,0 +1,3 @@ +You are an order review assistant. +Write a concise review of the supplied order and flag missing details. +Do not claim that an order was approved, charged, or shipped without confirmation. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py new file mode 100644 index 0000000..9420012 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/function_app.py @@ -0,0 +1,10 @@ +"""Publish YAML workflows with a private Markdown adapter for agent actions.""" + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + +from local_chat_client import LocalChatClient + +app = AgentFunctionApp( + client_factory=LocalChatClient, + discover_workflows=True, +) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/host.json b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/host.json new file mode 100644 index 0000000..b7e5ad1 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/local_chat_client.py b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/local_chat_client.py new file mode 100644 index 0000000..71fa95b --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/local_chat_client.py @@ -0,0 +1,46 @@ +"""A deterministic model substitute with no credentials or network resources.""" + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import Any + +from agent_framework import ( + BaseChatClient, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + ResponseStream, +) + + +class LocalChatClient(BaseChatClient): + """Count user messages in the supplied history and echo the latest prompt.""" + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ): + turns = sum(message.role == "user" for message in messages) + text = f"User turn {turns}: {messages[-1].text}" + created_at = datetime.now(timezone.utc).isoformat() + + async def updates(): + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text(text)], + created_at=created_at, + ) + + async def respond(): + return ChatResponse( + messages=[Message(role="assistant", contents=[text])], + created_at=created_at, + ) + + if stream: + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + return respond() diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/Approval.workflow.yaml b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/Approval.workflow.yaml new file mode 100644 index 0000000..4df85b3 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/Approval.workflow.yaml @@ -0,0 +1,10 @@ +kind: Workflow +name: Approval +actions: + - kind: Question + id: request_approval + question: Approve this order? Reply approved or rejected. + variable: Local.answer + - kind: SendActivity + id: send_answer + activity: =Local.answer diff --git a/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/OrderReview.workflow.yaml b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/OrderReview.workflow.yaml new file mode 100644 index 0000000..79d4481 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/durable-yaml-workflow/workflows/OrderReview.workflow.yaml @@ -0,0 +1,21 @@ +kind: Workflow +name: OrderReview +actions: + - kind: SetValue + id: capture_order + path: Local.order + value: =Workflow.Inputs.order + - kind: SetValue + id: prepare_prompt + path: Local.prompt + value: '="Review order " & Local.order & "."' + - kind: InvokeAzureAgent + id: review_order + agent: writer + input: =Local.prompt + resultProperty: Local.reply + output: + autoSend: false + - kind: SendActivity + id: send_review + activity: =Local.reply diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md new file mode 100644 index 0000000..8222e6e --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/README.md @@ -0,0 +1,81 @@ +# Endpoint-only durable markdown agent + +This sample sets `discover_agents=True` on `AgentFunctionApp` and supplies +`orders.agent.md`. Discovery registers the agent's DAFX entity and +`POST /api/agents/orders/run` endpoint. There are no handwritten HTTP functions, +orchestrators, or agent instance registrations. + +Every `.agent.md` file directly in the app root or `agents/` is discovered. +Discovery compiles recipes without constructing clients. The inner DAFX host is +created at `get_functions()`. Each entity execution +opens and closes a fresh Agent through the compiled binding's `open_agent()` +lifecycle. DAFX stores conversation history separately in durable session state. + +`LocalChatClient` counts user messages and echoes the latest prompt. It makes +no model calls and needs no model credentials. It is kept in a standalone module +so tests can import it without indexing the app. For a generator orchestrator +with an injected proxy, see the +[durable binding sample](../durable-markdown-binding/README.md). + +## Install and verify + +Use Python 3.13 or later. From the repository root, in a fresh virtual environment: + +```powershell +python -m pip install -e ./azurefunctions-agents-extensions-base +python -m pip install -e './azurefunctions-agents-extensions-agent-framework[dev,durable]' +python -m pytest -q --import-mode=importlib azurefunctions-agents-extensions-base/tests azurefunctions-agents-extensions-agent-framework/tests +``` + +The optional extra pins both DAFX packages to commit +`aa9529ec489e16ac64b73bd68d5adbb8e4945258` from +[DAFX PR #72](https://github.com/microsoft/agent-framework-durable-extension/pull/72). +The published DAFX packages currently require SDK 1.x and cannot satisfy this +PR's SDK 2.x requirements. These Git dependencies are for local prototyping, not +for publishing this package to PyPI. Normal installs do not install DAFX. + +Run only the sample tests with: + +```powershell +python -m pytest -q azurefunctions-agents-extensions-agent-framework/tests/test_samples.py +``` + +The tests exercise indexing and local entity execution. They do not replace a +deployed Functions host or storage integration test. See +[VALIDATION.md](VALIDATION.md) for historical verification results and limitations. + +## Run locally + +Running the example under Core Tools additionally requires the Python SDK 2 +compatible Functions host/extension and a configured Durable backend. Those are +not provisioned by this sample. Set `FUNCTIONS_WORKER_RUNTIME=python` and +`AzureWebJobsStorage` for your backend, then run `func start` from this directory. + +```bash +curl -X POST http://localhost:7071/api/agents/orders/run \ + -H "Content-Type: application/json" \ + -d '{"message":"Assess the order.","session_id":"orders-demo"}' +``` + +Send another request with the same `session_id` to continue the conversation. +The local client responds with `User turn 1: Assess the order.` on the first +turn and counts subsequent turns from the restored history. Use a new session +ID to start over. Add a function key when calling a hosted app. + +## Boundaries + +- No DAFX import, inner app, or entity registration on the non-durable path. +- Declare durable agents before indexing. Ambiguous markdown names fail instead + of silently selecting a file. +- Durable markdown names start with an ASCII letter or digit and contain only + ASCII letters, digits, hyphens, and underscores. These names become routes and + entity identifiers, not just filenames. +- The outer app remains the only worker-indexed app and combines both registries. +- `expose_agent_endpoints=True` publishes discovered agents by default. Set it + to `False` for registration without standalone HTTP endpoints. This constructor + option does not control selective bindings, which are private by default. +- Workflow discovery is independent and disabled here. Health and MCP endpoints + are disabled. The SDK's built-in durable HTTP activity/orchestrator remain + registered. +- Normal `markdown_agent()` is unchanged. Durable orchestrators use the new + binding and yield proxy tasks instead of calling `context.call_agent()`. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md new file mode 100644 index 0000000..d313b2f --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/VALIDATION.md @@ -0,0 +1,66 @@ +# Historical durable markdown prototype verification + +These results predate the rebase onto PR #185 at `2777aa3` and the separate +discovery/exposure API. They are historical totals, not validation of the current +revision. Current behavior is documented in [README.md](README.md). + +Verified on Windows with Python 3.13.11 on 2026-09-09. This revision replaces +the explicit-registration example at `5d77570`. Branch base is extensions +PR #185 at `db2526586348513ff86ed2c61ffc685815a8d212`. Both DAFX packages remain +pinned to PR #72 at `aa9529ec489e16ac64b73bd68d5adbb8e4945258`. + +## Historical results + +| Configuration | Result | +| --- | --- | +| Functions 2.3.0, Durable 2.0.0rc1, core 1.16.0 | 135 passed | +| Functions 2.3.0, Durable 2.0.0b2, core 1.13.0 | 135 passed | +| Normal install without Durable/DAFX packages | 5 import tests passed | +| Strict mypy, both agent packages | Passed, 11 source files | +| Flake8, both package sources, framework tests and local samples | Passed | +| Both wheels and source distributions | Built | +| Wheel contents | New adapter included, removed base durable module absent | + +The SDK emits one deprecation warning about `df_loads` without `expected_type`. +It is not suppressed. Tests exercise the SDK's real orchestration and entity +protobuf handlers, not a running Functions host or storage backend. Local clients +substitute for the model service; external MCP servers were not contacted. + +## Historical change analysis + +- Normal markdown binding construction/invocation remains import-safe without + DAFX. Durable discovery is explicit and creates recipes, not clients. The old + context wrapper, hidden activity, exports, and activity-specific tests are removed. +- The earlier revision published entity and HTTP functions for both discovery and + bindings. That exposure behavior is superseded. Bindings are now private by + default, and the inner DAFX host is deferred until indexing. +- Raw instructions are preserved. Discovery rejects duplicate names across both + directories, case collisions, directories masquerading as files, and symlinks + escaping the app root. Durable markdown names are restricted to safe ASCII + route/entity identifiers. Colliding generated HTTP function names fail indexing. +- The binding validates generator shape, hides the injected parameter, forwards + context and optional native input, and rejects mismatched context names and late + declarations. Both one- and two-argument forms run through the real SDK dispatcher. +- A replay probe schedules the same entity, correlation ID, and input without + constructing a client. Pinned DAFX supplies a wall-clock `created_at` on each + request, so this is not a claim of byte-identical replay payloads. That upstream + timestamp behavior is unchanged by this prototype. +- Endpoint and binding samples execute two turns through indexed entity handlers + with serialized state carried between operations. Clients are newly created, + entered, and closed for each turn. Session identity and history remain stable. +- Adapter tests cover finalization before cleanup, full response identity/value, + fresh resources, stream/run failures, and cancellation during an active pull. + An abandoned stream that is neither consumed nor cancelled is not covered. +- All sample apps are enumerated into index tests. Every SDK-owned function name + is independently checked for collisions. A read-only adversarial review found + no concrete additional defect; it is not a substitute for these runtime checks. +- Previous-commit probes fail on the new constructor/decorator APIs. In-memory + mutations disabling discovery fail two tests, substituting a non-agent proxy + fails three, and dropping the final response fails one. The unmodified adapter + suite then passes all 40 tests. No source files were mutated by the probes. +- That revision's docs and samples used discovery or binding declarations, not the deleted + custom activity API. `add_durable_agent()` remains a lower-level instance API but + is not required by either markdown sample. + +The Git-pinned SDK 2 migration is still an open PR. These dependencies and tests +support local exploration, not a PyPI release or deployed-host compatibility claim. diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py new file mode 100644 index 0000000..94274a9 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/function_app.py @@ -0,0 +1,6 @@ +"""Discover markdown agents and expose their DAFX endpoints without handlers.""" + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from local_chat_client import LocalChatClient + +app = AgentFunctionApp(client_factory=LocalChatClient, discover_agents=True) diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/host.json b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/host.json new file mode 100644 index 0000000..55d1642 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/local_chat_client.py b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/local_chat_client.py new file mode 100644 index 0000000..71fa95b --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/local_chat_client.py @@ -0,0 +1,46 @@ +"""A deterministic model substitute with no credentials or network resources.""" + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import Any + +from agent_framework import ( + BaseChatClient, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + ResponseStream, +) + + +class LocalChatClient(BaseChatClient): + """Count user messages in the supplied history and echo the latest prompt.""" + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ): + turns = sum(message.role == "user" for message in messages) + text = f"User turn {turns}: {messages[-1].text}" + created_at = datetime.now(timezone.utc).isoformat() + + async def updates(): + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text(text)], + created_at=created_at, + ) + + async def respond(): + return ChatResponse( + messages=[Message(role="assistant", contents=[text])], + created_at=created_at, + ) + + if stream: + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + return respond() diff --git a/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/orders.agent.md b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/orders.agent.md new file mode 100644 index 0000000..ea7a90a --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/samples/lazy-owned-dafx/orders.agent.md @@ -0,0 +1,3 @@ +You are an order fulfillment assistant. +Assess the order, then propose a concise fulfillment plan. +Never claim that an external action completed without a confirming tool result. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py new file mode 100644 index 0000000..cce6527 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/_native_workflow_probe.py @@ -0,0 +1,512 @@ +"""Native MAF YAML features through actual cold-rebuilt DAFX replay activities.""" + +from __future__ import annotations + +import ast +from copy import deepcopy +from dataclasses import asdict +import json +import os +from pathlib import Path +import sys +import tempfile +import traceback +from unittest.mock import patch + +from agent_framework import ( + Agent, ChatResponse, ChatResponseUpdate, Content, Message, ResponseStream, +) +from agent_framework.declarative import ( + AgentFactory, HttpRequestHandler, HttpRequestResult, MCPToolHandler, + MCPToolResult, WorkflowFactory, +) +from agent_framework.exceptions import AgentInvalidRequestException + +import _yaml_workflow_probe as harness + + +class NativeClient(harness.LocalClient): + """An in-memory client which does not require an async context manager.""" + + instances = [] + calls = [] + + def _inner_get_response(self, *, messages, stream, options, **kwargs): + messages = list(messages) + call = { + "instructions": options.get("instructions"), + "messages": [[str(message.role), message.text] for message in messages], + } + self.calls.append(call) + text = f"{call['instructions']}:{messages[-1].text}" + + async def response(): + return ChatResponse(messages=[Message(role="assistant", contents=[text])]) + + async def updates(): + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text(text)], + ) + + return (ResponseStream(updates(), finalizer=ChatResponse.from_updates) + if stream else response()) + + +class RecordingAgentFactory(AgentFactory): + """Observe public entry points, retaining native parsing and construction.""" + + def __init__(self): + super().__init__(client=NativeClient()) + self.definitions = [] + self.paths = [] + self.created = [] + + def create_agent_from_dict(self, agent_def): + self.definitions.append(deepcopy(agent_def)) + agent = super().create_agent_from_dict(agent_def) + assert isinstance(agent, Agent) + self.created.append(agent) + return agent + + def create_agent_from_yaml_path(self, yaml_path): + self.paths.append(Path(yaml_path)) + return super().create_agent_from_yaml_path(yaml_path) + + +class RecordingWorkflowFactory(WorkflowFactory): + """Record direct path delegation, without replacing YAML parsing/building.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.paths = [] + + def create_workflow_from_yaml_path(self, yaml_path): + self.paths.append(Path(yaml_path)) + return super().create_workflow_from_yaml_path(yaml_path) + + +class LocalHttpHandler: + def __init__(self, calls): + self.calls = calls + + async def send(self, info): + self.calls.append(asdict(info)) + return HttpRequestResult( + status_code=201, is_success_status_code=True, + body='{"order":"42","accepted":true,"items":[1,2]}', + headers={"x-probe": ["one", "two"], "content-type": ["application/json"]}, + ) + + +class LocalMcpHandler: + def __init__(self, calls): + self.calls = calls + + async def invoke_tool(self, invocation): + self.calls.append(asdict(invocation)) + return MCPToolResult(outputs=[Content.from_text( + '{"order":"42","approved":true,"tags":["local","mcp"]}', + )]) + + +INLINE_AGENT = { + "kind": "Prompt", "name": "writer", "description": "Native inline probe", + "instructions": "INLINE", +} +FILE_AGENT = { + "kind": "Prompt", "name": "file_writer", "description": "Native file probe", + "instructions": "FILE", +} + +INLINE = """name: NativeInline +agents: + writer: + kind: Prompt + name: writer + description: Native inline probe + instructions: INLINE +actions: + - kind: InvokeAzureAgent + id: invoke + agent: writer + input: hello + resultProperty: Local.reply + output: + autoSend: false + - kind: Question + id: approve + question: Approve native result? + variable: Local.approval + - kind: SendActivity + id: state + activity: '{Local}' +""" + +FILE = """name: NativeFile +agents: + writer: + file: definitions/writer.yaml +actions: + - kind: InvokeAzureAgent + id: invoke + agent: writer + input: from-file + resultProperty: Local.reply + output: + autoSend: false + - kind: SendActivity + id: state + activity: '{Local}' +""" + +DYNAMIC = """name: NativeDynamic +actions: + - kind: SetValue + id: select + path: Local.selected + value: =Workflow.Inputs.agent + - kind: InvokeAzureAgent + id: invoke + agent: =Local.selected + input: choose + resultProperty: Local.reply + output: + autoSend: false + - kind: SetValue + id: chosen + path: Local.actual + value: =Agent.name + - kind: SendActivity + id: state + activity: '{Local}' +""" + +FUNCTION = """name: NativeFunction +actions: + - kind: SetValue + id: seed + path: Local.amount + value: =Workflow.Inputs.amount + - kind: InvokeFunctionTool + id: lookup + functionName: lookup + arguments: + order: =Workflow.Inputs.order + amount: =Local.amount + 1 + output: + result: Local.result + autoSend: false + - kind: SendActivity + id: state + activity: '{Local}' +""" + +HTTP = """name: NativeHttp +actions: + - kind: HttpRequestAction + id: request + method: post + url: =Env.HTTP_URL + headers: + X-Probe: =Env.PROBE_HEADER + X-Empty: '' + queryParameters: + order: =Workflow.Inputs.order + enabled: true + omitted: null + body: + kind: json + content: =Workflow.Inputs + requestTimeoutInMilliseconds: 1234 + connection: + name: local-http + response: Local.response + responseHeaders: + path: Local.headers + - kind: SendActivity + id: state + activity: '{Local}' +""" + +MCP = """name: NativeMcp +actions: + - kind: InvokeMcpTool + id: tool + serverUrl: =Env.MCP_URL + serverLabel: local-server + toolName: =Env.MCP_TOOL + arguments: + order: =Workflow.Inputs.order + count: 2 + enabled: true + headers: + X-Probe: =Env.PROBE_HEADER + X-Empty: '' + connection: + name: local-mcp + output: + result: Local.result + autoSend: false + - kind: SendActivity + id: state + activity: '{Local}' +""" + +ENV = """name: NativeEnv +actions: + - kind: SetValue + id: configured + path: Local.configured + value: =Env.NATIVE_PROBE_VALUE + - kind: SetValue + id: fallback + path: Local.fallback + value: =Env.NATIVE_PROBE_FALLBACK + - kind: SendActivity + id: state + activity: '{Local}' +""" + + +def run_case(label, definition, name, expected, *, input_data=None, + agent_definitions=(), agent_paths=(), agents=(), files=None, + filename="probe.workflow.yaml", configure=None, + expected_client_calls=(), human_response=None, default_factory=False): + """Require native construction at app init and one call per replayed effect.""" + with tempfile.TemporaryDirectory() as temp: + root = Path(temp).resolve() + harness.write_workflow(root, definition, filename) + for relative, content in (files or {}).items(): + harness.write_workflow(root, content, relative) + NativeClient.instances.clear() + NativeClient.calls.clear() + harness.LocalClient.instances.clear() + harness.LocalClient.calls.clear() + factories = [] + agent_factories = [] + + def build(app_root): + assert Path(app_root) == root + agent_factory = RecordingAgentFactory() + agent_factories.append(agent_factory) + supplied_agents = { + key: Agent(client=NativeClient(), name=key, instructions=key.upper()) + for key in agents + } + factory = RecordingWorkflowFactory( + agent_factory=agent_factory, agents=supplied_agents, + **(configure() if configure else {}), + ) + factories.append(factory) + if label in {"function-sync", "function-async"}: + assert factory.register_tool("lookup", lookup) is factory + return factory + + tool_calls = [] + + def sync_lookup(order, amount): + tool_calls.append({"order": order, "amount": amount}) + return {"order": order, "amount": amount, "source": "registered"} + + async def async_lookup(order, amount): + return sync_lookup(order, amount) + + lookup = async_lookup if label == "function-async" else sync_lookup + + def check_construction(): + if default_factory: + assert not factories + return + assert factories, "Harness did not call WORKFLOW_FACTORY_BUILDER" + for factory, agent_factory in zip(factories, agent_factories, strict=True): + assert factory.paths == [root / filename], factory.paths + assert agent_factory.definitions == list(agent_definitions), ( + agent_factory.definitions + ) + assert agent_factory.paths == [root / p for p in agent_paths], ( + agent_factory.paths + ) + assert len(agent_factory.created) == len(agent_definitions) + + with patch.object(harness, "WORKFLOW_FACTORY_BUILDER", + None if default_factory else build): + app = harness.make_app(root) + check_construction() + assert app._durable_app is None + assert set(app._hosted_workflows) == {name} + app.get_functions() + check_construction() + assert not NativeClient.calls and not harness.LocalClient.calls + output, activities, answered = harness.run_workflow( + root, name, input_data, human_response, + ) + check_construction() + + assert len(output) == 1 and isinstance(output[0], str), (label, output) + state = ast.literal_eval(output[0]) + assert state == expected, (label, state, expected) + assert len(activities) >= 2, (label, activities) + assert len(answered) == (1 if human_response is not None else 0), answered + if not default_factory: + assert len(factories) > len(activities) + 1, (factories, activities) + assert NativeClient.calls == list(expected_client_calls), NativeClient.calls + assert not harness.LocalClient.calls, harness.LocalClient.calls + assert all(not c.entered and not c.closed for c in NativeClient.instances) + else: + assert not NativeClient.calls + assert harness.LocalClient.calls == [["choose"]], harness.LocalClient.calls + assert len(harness.LocalClient.instances) == 1 + assert all(c.entered and c.closed for c in harness.LocalClient.instances) + if label in {"function-sync", "function-async"}: + assert tool_calls == [{"order": "42", "amount": 42}], tool_calls + return { + "output": output, "state": state, "activities": activities, + "factory_builds": len(factories), "responses": len(answered), + "agent_calls": deepcopy(NativeClient.calls or harness.LocalClient.calls), + "function_calls": tool_calls, + } + + +def main(): + results = {} + results["inline-agent"] = run_case( + "inline-agent", INLINE, "NativeInline", + {"reply": "INLINE:hello", "approval": "approved"}, + agent_definitions=[INLINE_AGENT], human_response="approved", + files={"writer.agent.md": "A markdown name must not replace YAML agents."}, + expected_client_calls=[{ + "instructions": "INLINE", "messages": [["user", "hello"]], + }], + ) + results["relative-file-agent"] = run_case( + "relative-file-agent", FILE, "NativeFile", {"reply": "FILE:from-file"}, + filename="workflows/probe.workflow.yaml", + files={ + "workflows/definitions/writer.yaml": ( + "kind: Prompt\nname: file_writer\ndescription: Native file probe\n" + "instructions: FILE\n" + ), + "definitions/writer.yaml": "not the workflow-relative agent", + }, + agent_definitions=[FILE_AGENT], + agent_paths=["workflows/definitions/writer.yaml"], + expected_client_calls=[{ + "instructions": "FILE", "messages": [["user", "from-file"]], + }], + ) + for selected in ("first", "second"): + for default_factory in (False, True): + label = f"dynamic-{'default' if default_factory else 'custom'}-{selected}" + results[label] = run_case( + label, DYNAMIC, "NativeDynamic", + {"selected": selected, "actual": selected, + "reply": "ok" if default_factory else f"{selected.upper()}:choose"}, + input_data={"agent": selected}, agents=("first", "second"), + files={"first.agent.md": "First adapter", + "agents/second.agent.md": "Second adapter"}, + default_factory=default_factory, + expected_client_calls=[{ + "instructions": selected.upper(), "messages": [["user", "choose"]], + }], + ) + try: + run_case( + "custom-factory-no-markdown-merge", DYNAMIC, "NativeDynamic", {}, + input_data={"agent": "second"}, agents=("first",), + files={"agents/second.agent.md": "Not supplied to the custom factory"}, + ) + except AgentInvalidRequestException as error: + missing_agent = "Agent 'second' invocation failed: not found in registry" + assert missing_agent in str(error), str(error) + assert not NativeClient.calls and not harness.LocalClient.calls + results["custom-factory-no-markdown-merge"] = { + "rejected": "Agent 'second' invocation failed: not found in registry", + } + else: + raise AssertionError("Custom factory unexpectedly received a markdown agent") + for kind in ("sync", "async"): + label = f"function-{kind}" + results[label] = run_case( + label, FUNCTION, "NativeFunction", + {"amount": 41, "result": { + "order": "42", "amount": 42, "source": "registered", + }}, + input_data={"order": "42", "amount": 41}, + ) + http_calls = [] + + def http_config(): + handler = LocalHttpHandler(http_calls) + assert isinstance(handler, HttpRequestHandler) + return {"http_request_handler": handler, "configuration": { + "HTTP_URL": "https://native-probe.invalid/orders", "PROBE_HEADER": "local", + }} + + results["http-handler"] = run_case( + "http-handler", HTTP, "NativeHttp", + {"response": {"order": "42", "accepted": True, "items": [1, 2]}, + "headers": {"x-probe": "one,two", "content-type": "application/json"}}, + input_data={"order": "42"}, configure=http_config, + ) + assert len(http_calls) == 1, http_calls + http_call = dict(http_calls[0]) + http_call["body"] = json.loads(http_call["body"]) + assert http_call == { + "method": "POST", "url": "https://native-probe.invalid/orders", + "headers": {"X-Probe": "local"}, + "query_parameters": {"order": "42", "enabled": "true"}, + "body": {"order": "42"}, "body_content_type": "application/json", + "timeout_ms": 1234, "connection_name": "local-http", + }, http_call + results["http-handler"]["handler_calls"] = http_calls + mcp_calls = [] + + def mcp_config(): + handler = LocalMcpHandler(mcp_calls) + assert isinstance(handler, MCPToolHandler) + return {"mcp_tool_handler": handler, "configuration": { + "MCP_URL": "https://native-probe.invalid/mcp", "MCP_TOOL": "lookup", + "PROBE_HEADER": "local", + }} + + results["mcp-handler"] = run_case( + "mcp-handler", MCP, "NativeMcp", + {"result": [{"order": "42", "approved": True, "tags": ["local", "mcp"]}]}, + input_data={"order": "42"}, configure=mcp_config, + ) + assert mcp_calls == [{ + "server_url": "https://native-probe.invalid/mcp", "tool_name": "lookup", + "server_label": "local-server", + "arguments": {"order": "42", "count": 2, "enabled": True}, + "headers": {"X-Probe": "local"}, "connection_name": "local-mcp", + }], mcp_calls + results["mcp-handler"]["handler_calls"] = mcp_calls + with patch.dict(os.environ, { + "NATIVE_PROBE_VALUE": "ambient-wrong", + "NATIVE_PROBE_FALLBACK": "ambient-fallback", + }): + for restricted in (True, False): + label = f"env-{'restricted' if restricted else 'fallback'}" + results[label] = run_case( + label, ENV, "NativeEnv", + {"configured": "configured", + "fallback": None if restricted else "ambient-fallback"}, + configure=lambda: { + "configuration": {"NATIVE_PROBE_VALUE": "configured"}, + "restrict_env_to_configuration": restricted, + }, + ) + return results + + +if __name__ == "__main__": + try: + print(json.dumps({"result": main()}), flush=True) + exit_code = 0 + except Exception: + traceback.print_exc() + exit_code = 1 + sys.stdout.flush() + sys.stderr.flush() + # Do not let PowerFx/CLR teardown enter the parent pytest reporting process. + os._exit(exit_code) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_registration_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_registration_probe.py new file mode 100644 index 0000000..7b94f38 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/_registration_probe.py @@ -0,0 +1,212 @@ +"""SDK-level workflow registration and child-orchestration probes.""" +from __future__ import annotations + +import base64 +from itertools import product +import json +import os +from pathlib import Path +import sys +import tempfile +import traceback + +import azure.functions as func +from agent_framework.declarative import WorkflowFactory +from durabletask.internal import orchestrator_service_pb2 as pb +from google.protobuf.wrappers_pb2 import StringValue + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from _yaml_workflow_probe import event, run_workflow + +YAML = """name: Child +actions: + - kind: SendActivity + id: output + activity: Hello +""" + + +def prepare(root): + (root / "Child.workflow.yaml").write_text(YAML, encoding="utf-8") + (root / "orders.agent.md").write_text("Handle orders", encoding="utf-8") + + +def routes(app): + return [b["route"] for f in app.get_functions() + for b in f.get_bindings_dict()["bindings"] if b["type"] == "httpTrigger"] + + +def bind(app, exposed=False, **kwargs): + @app.orchestration_trigger(context_name="context") + @app.durable_workflow( + arg_name="child", workflow_name="Child", expose_http_endpoint=exposed, **kwargs, + ) + def parent(context, child): + return (yield child.run({"greeting": "hello"})) + return parent + + +def matrix(root): + prepare(root) + for discovery, bulk_exposed, binding_exposed in product((False, True), repeat=3): + app = AgentFunctionApp( + client_factory=lambda: None, app_root=root, + discover_workflows=discovery, expose_workflow_endpoints=bulk_exposed, + ) + original = app._hosted_workflows.get("Child") + bind(app, binding_exposed) + assert not app._durable_agents, "Workflow discovery exposed a standalone agent" + if original: + assert app._hosted_workflows["Child"] is original + exposed = (discovery and bulk_exposed) or binding_exposed + assert len(routes(app)) == (3 if exposed else 0) + assert len([f for f in app.get_functions() + if f.get_function_name() == "dafx-Child"]) == 1 + + for agent_outer in (False, True): + app = AgentFunctionApp(client_factory=lambda: None, app_root=root) + + def parent(context, agent, child): + yield child.run() + yield agent.run("hello") + + decorators = [app.durable_workflow(arg_name="child", workflow_name="Child"), + app.durable_markdown_agent(arg_name="agent", agent_name="orders")] + if agent_outer: + decorators.reverse() + for decorate in decorators: + parent = decorate(parent) + app.orchestration_trigger(context_name="context")(parent) + assert routes(app) == [] + assert set(app._hosted_workflows) == {"Child"} + assert set(app._durable_agents) == {"orders"} + return {"exposure_combinations": 8, "stacked_orders": 2} + + +def selection(root): + prepare(root) + (root / "Broken.workflow.yaml").write_text("not YAML: [", encoding="utf-8") + app = AgentFunctionApp(client_factory=lambda: None, app_root=root) + bind(app) + assert set(app._hosted_workflows) == {"Child"} + assert routes(app) == [] + try: + bind(app) + except RuntimeError as error: + assert "before function indexing" in str(error) + else: + raise AssertionError("Late binding accepted") + + calls = [] + + class Factory(WorkflowFactory): + def create_workflow_from_yaml_path(self, yaml_path): + calls.append(Path(yaml_path)) + return super().create_workflow_from_yaml_path(yaml_path) + + app = AgentFunctionApp(client_factory=lambda: None, app_root=root, + workflow_factory=Factory()) + bind(app, workflow_file="Child.workflow.yaml") + assert calls == [root / "Child.workflow.yaml"] + (root / "sales.v2.agent.md").write_text("Non-durable only", encoding="utf-8") + # Custom workflow loading does not consume or publish unrelated markdown. + candidate = AgentFunctionApp( + client_factory=lambda: None, app_root=root, workflow_factory=Factory(), + ) + bind(candidate) + assert routes(candidate) == [] + assert not candidate._markdown_agents + (root / "Broken.workflow.yaml").unlink() + candidate = AgentFunctionApp( + client_factory=lambda: None, app_root=root, workflow_factory=Factory(), + discover_workflows=True, expose_workflow_endpoints=False, + ) + assert routes(candidate) == [] + assert not candidate._markdown_agents + (root / "sales.v2.agent.md").unlink() + for path in ("../outside.workflow.yaml", "Child.txt"): + candidate = AgentFunctionApp(client_factory=lambda: None, app_root=root) + try: + bind(candidate, workflow_file=path) + except ValueError: + pass + else: + raise AssertionError(path) + (root / "workflows").mkdir() + (root / "workflows/Child.workflow.yml").write_text(YAML, encoding="utf-8") + try: + bind(AgentFunctionApp(client_factory=lambda: None, app_root=root)) + except ValueError as error: + assert "Ambiguous" in str(error) + else: + raise AssertionError("Duplicate definition accepted") + return True + + +def transport(root): + prepare(root) + app = AgentFunctionApp(client_factory=lambda: None, app_root=root) + bind(app) + handler = next(f.get_user_function() for f in app.get_functions() + if f.get_function_name() == "parent") + start = [ + event(orchestratorStarted=pb.OrchestratorStartedEvent()), + event(0, executionStarted=pb.ExecutionStartedEvent( + name="parent", input=StringValue(value="{}"), + orchestrationInstance=pb.OrchestrationInstance(instanceId="parent-1"), + )), + ] + request = pb.OrchestratorRequest(instanceId="parent-1", newEvents=start) + encoded = handler(func.OrchestrationContext( + base64.b64encode(request.SerializeToString()), + )) + result = pb.OrchestratorResponse.FromString(base64.b64decode(encoded)) + assert len(result.actions) == 1, result + action = result.actions[0] + assert action.HasField("createSubOrchestration"), result + child = action.createSubOrchestration + assert child.name == "dafx-Child" + assert json.loads(child.input.value) == {"greeting": "hello"} + child_output = run_workflow(root, "Child")[0] + assert child_output == ["Hello"] + past = start + [event(action.id, subOrchestrationInstanceCreated=( + pb.SubOrchestrationInstanceCreatedEvent( + name=child.name, instanceId=child.instanceId, input=child.input, + ) + )), event(orchestratorCompleted=pb.OrchestratorCompletedEvent())] + new = [event(orchestratorStarted=pb.OrchestratorStartedEvent()), event( + 100, subOrchestrationInstanceCompleted=( + pb.SubOrchestrationInstanceCompletedEvent( + taskScheduledId=action.id, + result=StringValue(value=json.dumps(child_output)), + ) + ), + )] + request = pb.OrchestratorRequest( + instanceId="parent-1", pastEvents=past, newEvents=new, + ) + encoded = handler(func.OrchestrationContext( + base64.b64encode(request.SerializeToString()), + )) + result = pb.OrchestratorResponse.FromString(base64.b64decode(encoded)) + assert len(result.actions) == 1, result + done = result.actions[0].completeOrchestration + assert done.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED, result + assert json.loads(done.result.value) == ["Hello"] + return ["Hello"] + + +if __name__ == "__main__": + try: + with tempfile.TemporaryDirectory() as temp: + output = {"matrix": matrix, "selection": selection, "transport": transport}[ + sys.argv[1] + ](Path(temp).resolve()) + print(json.dumps({"result": output}), flush=True) + code = 0 + except Exception: + traceback.print_exc() + code = 1 + sys.stdout.flush() + sys.stderr.flush() + os._exit(code) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py b/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py new file mode 100644 index 0000000..1a075a4 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/_yaml_workflow_probe.py @@ -0,0 +1,474 @@ +"""Real YAML/DAFX probes isolated from pytest's embedded-CLR reporting hooks.""" +from __future__ import annotations + +import base64 +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import traceback + +import azure.functions as func +from agent_framework import ( + BaseChatClient, ChatResponse, ChatResponseUpdate, Content, Message, ResponseStream, +) +from agent_framework_durabletask import deserialize_workflow_output +from durabletask.internal import orchestrator_service_pb2 as pb +from google.protobuf.timestamp_pb2 import Timestamp +from google.protobuf.wrappers_pb2 import StringValue + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + +WORKFLOW_FACTORY_BUILDER = None + + +class LocalClient(BaseChatClient): + instances = [] + calls = [] + + def __init__(self): + super().__init__() + self.entered = self.closed = False + self.instances.append(self) + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, *args): + self.closed = True + + def _inner_get_response(self, *, messages, stream, options, **kwargs): + self.calls.append([m.text for m in messages]) + + async def updates(): + assert self.entered and not self.closed + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_text("ok")], + ) + + async def response(): + return ChatResponse(messages=[Message(role="assistant", contents=["ok"])]) + + return (ResponseStream(updates(), finalizer=ChatResponse.from_updates) + if stream else response()) + + +def write_workflow(root, content, filename="probe.workflow.yaml"): + path = root / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def make_app(root): + before = len(LocalClient.instances) + factory = WORKFLOW_FACTORY_BUILDER(root) if WORKFLOW_FACTORY_BUILDER else None + app = AgentFunctionApp( + client_factory=LocalClient, app_root=root, + discover_agents=True, discover_workflows=True, + workflow_factory=factory, + ) + assert app._durable_app is None, "Host created before function indexing" + if factory is None: + assert len(LocalClient.instances) == before, ( + "Live client created during loading" + ) + return app + + +def index(root): + app = make_app(root) + before = len(LocalClient.instances) + functions = {fn.get_function_name(): fn for fn in app.get_functions()} + assert len(LocalClient.instances) == before, "Live client created during indexing" + return app, functions + + +def event(event_id=-1, **kwargs): + return pb.HistoryEvent( + eventId=event_id, timestamp=Timestamp(seconds=1_700_000_000), **kwargs, + ) + + +def run_workflow(root, name, input_data=None, human_response=None): + """Rebuild the outer app on every activation and activity, replay SDK history.""" + history = [] + new_events = [ + event(orchestratorStarted=pb.OrchestratorStartedEvent()), + event(0, executionStarted=pb.ExecutionStartedEvent( + name=f"dafx-{name}", input=StringValue(value=json.dumps(input_data or {})), + orchestrationInstance=pb.OrchestrationInstance( + instanceId="yaml-probe", executionId=StringValue(value="execution-1")), + )), + ] + activity_names = [] + answered = [] + for _ in range(60): + _, functions = index(root) + request = pb.OrchestratorRequest( + instanceId="yaml-probe", pastEvents=history, newEvents=new_events, + ) + encoded = functions[f"dafx-{name}"].get_user_function()( + func.OrchestrationContext(base64.b64encode(request.SerializeToString())) + ) + result = pb.OrchestratorResponse.FromString(base64.b64decode(encoded)) + status = ( + json.loads(result.customStatus.value) if result.customStatus.value else {} + ) + history.extend(new_events) + upcoming = [event(orchestratorStarted=pb.OrchestratorStartedEvent())] + for action in result.actions: + if action.HasField("completeOrchestration"): + done = action.completeOrchestration + assert done.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED, ( + done + ) + return (deserialize_workflow_output(json.loads(done.result.value)), + activity_names, answered) + assert action.HasField("scheduleTask"), action + scheduled = action.scheduleTask + history.append(event(action.id, taskScheduled=pb.TaskScheduledEvent( + name=scheduled.name, input=scheduled.input, + ))) + _, cold_functions = index(root) + handler = cold_functions[scheduled.name].get_user_function() + output = handler(json.loads(scheduled.input.value)) + activity_names.append(scheduled.name) + upcoming.append(event(1000 + len(activity_names), + taskCompleted=pb.TaskCompletedEvent( + taskScheduledId=action.id, result=StringValue(value=json.dumps(output)), + ))) + history.append(event(orchestratorCompleted=pb.OrchestratorCompletedEvent())) + if len(upcoming) == 1: + pending = status.get("pending_requests", {}) + assert len(pending) == 1 and human_response is not None, status + request_id = next(iter(pending)) + assert request_id not in answered + answered.append(request_id) + upcoming.append(event(2000 + len(answered), eventRaised=pb.EventRaisedEvent( + name=request_id, + input=StringValue(value=json.dumps({"user_input": human_response})), + ))) + new_events = upcoming + raise AssertionError("Workflow exceeded activation limit") + + +CASES = { + "simple": ("""name: Simple +actions: + - kind: SendActivity + id: greet + activity: Hello +""", "Simple", {}, ["Hello"]), + "state": ("""name: State +actions: + - kind: SetValue + id: set + path: Local.count + value: 41 + - kind: SetValue + id: increment + path: Local.count + value: =Local.count + 1 + - kind: SendActivity + id: output + activity: =Text(Local.count) +""", "State", {}, ["42"]), + "branch": ("""name: Branch +actions: + - kind: ConditionGroup + id: choose + conditions: + - condition: =Workflow.Inputs.color = "red" + actions: + - kind: SendActivity + id: red + activity: RED + elseActions: + - kind: SendActivity + id: other + activity: OTHER +""", "Branch", {"color": "red"}, ["RED"]), + "loop": ("""name: Loop +actions: + - kind: Foreach + id: loop + source: [apple, banana, cherry] + itemName: fruit + actions: + - kind: SendActivity + id: item + activity: =Local.fruit +""", "Loop", {}, ["apple", "banana", "cherry"]), + "agent": ("""name: Agent +actions: + - kind: InvokeAzureAgent + id: writer + agent: writer + input: hello + resultProperty: Local.reply + output: + autoSend: false + - kind: SendActivity + id: output + activity: =Local.reply +""", "Agent", {}, ["ok"]), + "human": ("""name: Human +actions: + - kind: Question + id: ask + question: Approve? + variable: Local.answer + - kind: SendActivity + id: output + activity: =Local.answer +""", "Human", {}, ["approved"]), +} +CASES["else"] = (CASES["branch"][0], "Branch", {"color": "blue"}, ["OTHER"]) +CASES["if-agent"] = (json.dumps({ + "name": "IfAgent", + "actions": [ + { + "kind": "If", "id": "choose", "condition": True, + "then": [{ + "kind": "InvokeAzureAgent", "id": "writer", "agent": "writer", + "input": "hello", "resultProperty": "Local.reply", + "output": {"autoSend": False}, + }], + "else": [{ + "kind": "SetValue", "id": "fallback", "path": "Local.reply", + "value": "wrong", + }], + }, + {"kind": "SendActivity", "id": "output", "activity": "=Local.reply"}, + ], +}), "IfAgent", {}, ["ok"]) +else_definition = json.loads(CASES["if-agent"][0]) +branch = else_definition["actions"][0] +branch["condition"] = False +branch["then"], branch["else"] = branch["else"], branch["then"] +CASES["if-else-agent"] = (json.dumps(else_definition), "IfAgent", {}, ["ok"]) + + +def execution_checks(): + results = {} + for label, (yaml, name, data, expected) in CASES.items(): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, yaml) + if label in {"agent", "if-agent", "if-else-agent"}: + (root / "writer.agent.md").write_text("Be helpful", encoding="utf-8") + LocalClient.instances.clear() + LocalClient.calls.clear() + output, activities, answered = run_workflow(root, name, data, "approved") + assert output == expected, (label, output) + if label in {"agent", "if-agent", "if-else-agent"}: + assert len(LocalClient.calls) == 1, LocalClient.calls + assert len(LocalClient.instances) == 1 + assert all(c.entered and c.closed for c in LocalClient.instances) + if label == "human": + assert len(answered) == 1 + results[label] = {"output": output, "activities": activities} + return results + + +def validation_checks(): + checks = [] + + def invalid(label, files, expected): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + for filename, content in files.items(): + write_workflow(root, content, filename) + try: + make_app(root) + except Exception as error: + assert expected.lower() in str(error).lower(), (label, str(error)) + else: + raise AssertionError(f"Accepted invalid definition: {label}") + checks.append(label) + + for label, content, error in [ + ("scalar", "hello", "dictionary"), + ("malformed", "[", "parsing"), + ("bad-name", CASES["simple"][0].replace("Simple", "../bad"), "stable name"), + ]: + invalid(label, {"probe.workflow.yaml": content}, error) + invalid("duplicate-name", { + "one.workflow.yaml": CASES["simple"][0], + "workflows/two.workflow.yml": CASES["simple"][0].replace("Simple", "simple"), + }, "Duplicate workflow name") + # Compare parsing decisions with MAF itself instead of maintaining a second + # YAML schema. Unknown-action warnings, duplicate keys, and precedence are + # native loader behavior, not extension-specific rejections. + from agent_framework.declarative import WorkflowFactory + native_documents = { + "duplicate-key": "name: First\n" + CASES["simple"][0], + "root-precedence": CASES["simple"][0] + "trigger: {actions: []}\n", + "unknown-action": CASES["simple"][0] + " - kind: Imaginary\n", + "trigger-name": json.dumps({"trigger": {"id": "TriggerNamed", "actions": [ + {"kind": "SendActivity", "id": "greet", "activity": "Hello"}, + ]}}), + } + for label, document in native_documents.items(): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, document) + native = WorkflowFactory().create_workflow_from_yaml_path( + root / "probe.workflow.yaml" + ) + app = make_app(root) + loaded = app._hosted_workflows[native.name] + assert loaded.name == native.name + loaded_nodes = [ + (key, type(value)) for key, value in loaded.executors.items() + ] + assert loaded_nodes == [ + (key, type(executor)) for key, executor in native.executors.items() + ] + checks.append(label) + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + definition = json.loads(CASES["if-agent"][0]) + definition["trigger"] = {"actions": definition.pop("actions")} + write_workflow(root, json.dumps(definition)) + (root / "writer.agent.md").write_text("Be helpful", encoding="utf-8") + assert run_workflow(root, "IfAgent")[0] == ["ok"] + checks.append("trigger-nested-agent") + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, json.dumps({"name": "Literal", "actions": [ + {"kind": "SetValue", "id": "set", "path": "Local.data", + "value": {"actions": [{"kind": "NotAnAction"}]}}, + {"kind": "SendActivity", "id": "out", "activity": "done"}, + ]})) + make_app(root) + checks.append("literal-data-not-actions") + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, CASES["simple"][0], "first.workflow.yml") + write_workflow(root, CASES["state"][0], "workflows/second.workflow.yaml") + write_workflow(root, "not YAML", "ignored.yaml") + write_workflow(root, "not YAML", "nested/ignored.workflow.yaml") + app, functions = index(root) + assert set(app._durable_app.workflows) == {"Simple", "State"} + assert len(app._durable_app.agents) == 0 + assert [f.get_function_name() for f in app.get_functions()] == list(functions) + checks.append("both-suffixes-and-directories-only") + for workflow_name in ["Simple", "State"]: + assert f"dafx-{workflow_name}" in functions + routes = [ + b["route"] for f in functions.values() + for b in f.get_bindings_dict()["bindings"] if b["type"] == "httpTrigger" + ] + assert f"workflow/{workflow_name}/run" in routes + assert f"workflow/{workflow_name}/status/{{instanceId}}" in routes + respond_route = ( + f"workflow/{workflow_name}/respond/{{instanceId}}/{{requestId}}" + ) + assert respond_route in routes + checks.append("workflow-routes") + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "invalid.workflow.yaml").mkdir() + try: + make_app(root) + except ValueError as error: + assert "not a file" in str(error) + else: + raise AssertionError("Accepted directory") + checks.append("directory-rejected") + + return checks + + +def main(): + global LocalClient + global WORKFLOW_FACTORY_BUILDER + mode = sys.argv[1] + if mode == "execution": + return execution_checks() + if mode == "validation": + return validation_checks() + if mode == "sample": + root = Path(__file__).parents[1] / "samples" / "durable-yaml-workflow" + spec = importlib.util.spec_from_file_location( + "yaml_sample_client", root / "local_chat_client.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + class SampleClient(module.LocalChatClient): + instances = [] + + def __init__(self): + super().__init__() + self.instances.append(self) + + LocalClient = SampleClient + return { + name: run_workflow(root, name, {"order": "42"}, "approved")[0] + for name in ["OrderReview", "Approval"] + } + if mode == "review": + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + write_workflow(root, CASES["if-agent"][0]) + (root / "writer.agent.md").write_text("Be helpful", encoding="utf-8") + output = run_workflow(root, "IfAgent")[0] + assert output == ["ok"], output + return output + if mode == "configured-sample": + root = Path(__file__).parents[1] / "samples" / "configured-workflow-factory" + calls = [] + + def build(app_root): + spec = importlib.util.spec_from_file_location( + "configured_sample", root / "function_app.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + old_root = os.environ.get("AzureWebJobsScriptRoot") + os.environ["AzureWebJobsScriptRoot"] = str(root) + try: + spec.loader.exec_module(module) + finally: + if old_root is None: + os.environ.pop("AzureWebJobsScriptRoot", None) + else: + os.environ["AzureWebJobsScriptRoot"] = old_root + + def recorded(order, prefix): + calls.append((order, prefix)) + return module.format_order(order, prefix) + + module.workflow_factory.register_tool("format_order", recorded) + return module.workflow_factory + + WORKFLOW_FACTORY_BUILDER = build + output = run_workflow(root, "ConfiguredTools", {"order": "42"})[0] + assert calls == [("42", "Local")], calls + return output + if mode == "mutation": + from azurefunctions.agents.extensions.agent_framework import _workflows + _workflows.load_workflows = lambda *args, **kwargs: [] + return execution_checks() + raise ValueError(mode) + + +if __name__ == "__main__": + try: + print(json.dumps({"result": main()}), flush=True) + exit_code = 0 + except Exception: + traceback.print_exc() + exit_code = 1 + sys.stdout.flush() + sys.stderr.flush() + # The tests are complete; isolate PowerFx/CLR shutdown from the pytest host. + os._exit(exit_code) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py new file mode 100644 index 0000000..7b367fe --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_apps.py @@ -0,0 +1,118 @@ +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_registration_options(): + assert list(inspect.signature(AgentFunctionApp.__init__).parameters) == [ + "self", + "client_factory", + "app_root", + "tools", + "http_auth_level", + "discover_agents", + "discover_workflows", + "expose_agent_endpoints", + "expose_workflow_endpoints", + "workflow_factory", + ] + 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_keeps_native_context(monkeypatch): + parent_decorator = Mock(return_value=object()) + + def sdk(context_name, orchestration=None, input_type=None): + assert (context_name, orchestration, input_type) == ("context", "orders", dict) + return parent_decorator + + monkeypatch.setattr( + func.FunctionApp, + "orchestration_trigger", + staticmethod(sdk), + ) + app = object.__new__(AgentFunctionApp) + + result = app.orchestration_trigger( + context_name="context", + orchestration="orders", + input_type=dict, + ) + + def handler(context): + yield context + + assert result(handler) is parent_decorator.return_value + parent_decorator.assert_called_once_with(handler) diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py b/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py new file mode 100644 index 0000000..8227368 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_dafx.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import base64 +import json +import uuid +from types import SimpleNamespace +from unittest.mock import Mock + +import azure.functions as func +import pytest +from agent_framework import Agent, AgentResponse, BaseChatClient, ChatResponse, Message +from azure.durable_functions import DurableOrchestrationContext +from durabletask.internal import orchestrator_service_pb2 as pb +from durabletask.task import CompletableTask +from google.protobuf.wrappers_pb2 import StringValue + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + +class RecordingClient(BaseChatClient): + """A local model substitute. DAFX and the Functions SDK are not mocked.""" + + def __init__(self): + super().__init__() + self.inputs = [] + + def _inner_get_response(self, *, messages, stream, options, **kwargs): + if stream: + raise TypeError("streaming is not supported by this test client") + + async def respond(): + self.inputs.append([message.text for message in messages]) + return ChatResponse(messages=[Message( + role="assistant", contents=[f"reply-{len(self.inputs)}"] + )]) + + return respond() + + +@pytest.fixture +def app(tmp_path): + return AgentFunctionApp(client_factory=RecordingClient, app_root=tmp_path) + + +def make_agent(name="Orders", client=None): + return Agent(client=client or RecordingClient(), name=name) + + +def test_initialization_does_not_construct_dafx(app): + assert app._durable_app is None + assert app.get_functions() == [] + assert app._durable_app is None + + +def test_indexing_constructs_one_real_dafx_app_from_registered_agents(app): + from agent_framework_azurefunctions import AgentFunctionApp as DafxApp + + first = make_agent() + app.add_durable_agent(first, expose_http_endpoint=True) + app.add_durable_agent(first) + app.add_durable_agent(make_agent("Shipping"), expose_http_endpoint=True) + + assert app._durable_app is None + assert set(app._durable_agents) == {"Orders", "Shipping"} + assert app._durable_agents["Orders"] is first + functions = app.get_functions() + inner = app._durable_app + assert isinstance(inner, DafxApp) + assert set(inner.agents) == {"Orders", "Shipping"} + assert not inner.enable_health_check + assert not inner.enable_http_endpoints + assert not inner.enable_mcp_tool_trigger + assert inner.auth_level == app.auth_level + assert app.get_functions() == functions + assert app._durable_app is inner + entities = { + function.get_function_name() + for function in functions + if function.get_bindings_dict()["bindings"][0]["type"] == "entityTrigger" + } + assert entities == {"dafx-Orders", "dafx-Shipping"} + assert sum(function.is_http_function() for function in functions) == 2 + + +@pytest.mark.parametrize("name", [None, "", " ", 42]) +def test_invalid_name_does_not_enable_dafx(app, name): + with pytest.raises(ValueError, match="non-empty string name"): + app.add_durable_agent(SimpleNamespace(name=name)) + assert app._durable_agents == {} + assert app._durable_app is None + + +@pytest.mark.parametrize("name", ["Orders", "orders", "ORDERS"]) +def test_different_agent_with_duplicate_name_is_rejected(app, name): + app.add_durable_agent(make_agent()) + with pytest.raises(ValueError, match="already registered"): + app.add_durable_agent(make_agent(name)) + assert len(app._durable_agents) == 1 + assert app._durable_app is None + + +def test_lookup_does_not_enable_dafx(app): + with pytest.raises(ValueError, match="not registered"): + app.get_agent(object(), "Orders") + assert app._durable_app is None + + +def test_agent_lookup_validates_registry_without_constructing_host(app): + from agent_framework_durabletask import DurableAIAgent + + app.add_durable_agent(make_agent()) + assert isinstance(app.get_agent(Mock(), "Orders"), DurableAIAgent) + with pytest.raises(ValueError, match="not registered"): + app.get_agent(object(), "Unknown") + assert app._durable_app is None + + +def test_distinct_apps_do_not_share_durable_registries(app, tmp_path): + other = AgentFunctionApp(client_factory=RecordingClient, app_root=tmp_path) + app.add_durable_agent(make_agent("First")) + assert other._durable_app is None + other.add_durable_agent(make_agent("Second")) + assert app._durable_app is other._durable_app is None + assert set(app._durable_agents) == {"First"} + assert set(other._durable_agents) == {"Second"} + app.get_functions() + other.get_functions() + assert app._durable_app is not other._durable_app + assert set(app._durable_app.agents) == {"First"} + assert set(other._durable_app.agents) == {"Second"} + + +def test_indexing_recovers_after_collision_is_removed(app): + @app.function_name(name="dafx-Orders") + @app.route(route="collision") + def collision(req): + return func.HttpResponse("ok") + + app.add_durable_agent(make_agent()) + with pytest.raises(ValueError, match="Duplicate function name"): + app.get_functions() + # Test-only removal simulates correcting the conflicting declaration. + app._function_builders.remove(collision) + names = [fn.get_function_name() for fn in app.get_functions()] + assert names.count("dafx-Orders") == 1 + assert app._functions_indexed + + +def test_duplicate_outer_names_are_still_validated_on_each_index(app): + for route in ["first", "second"]: + @app.route(route=route) + def duplicate(req): + return func.HttpResponse("ok") + + for _ in range(2): + with pytest.raises(ValueError, match="unique function name"): + app.get_functions() + + +@pytest.mark.parametrize("enable_dafx", [False, True]) +def test_registration_after_indexing_is_rejected(app, enable_dafx): + if enable_dafx: + app.add_durable_agent(make_agent()) + app.get_functions() + with pytest.raises(RuntimeError, match="before function indexing"): + app.add_durable_agent(make_agent("Late")) + + +@pytest.mark.parametrize("auth", [func.AuthLevel.ANONYMOUS, func.AuthLevel.FUNCTION]) +def test_combined_index_preserves_http_auth_and_is_repeatable(tmp_path, auth): + app = AgentFunctionApp( + client_factory=RecordingClient, app_root=tmp_path, http_auth_level=auth + ) + + @app.route(route="orders") + def orders(req): + return func.HttpResponse("ok") + + app.add_durable_agent(make_agent(), expose_http_endpoint=True) + first = app.get_functions() + second = app.get_functions() + assert [fn.get_function_name() for fn in first] == [ + fn.get_function_name() for fn in second + ] + assert len(first) == 5 # Two HTTP routes + entity + SDK built-ins. + http = next(fn for fn in first if fn.get_function_name() == "orders") + trigger = next( + binding for binding in http.get_bindings_dict()["bindings"] + if binding["type"] == "httpTrigger" + ) + assert trigger["authLevel"] == auth + assert http.get_user_function()(None).get_body() == b"ok" + assert app._durable_app.auth_level == auth + + +@pytest.mark.parametrize("name", ["dafx-Orders", "DAFX-ORDERS"]) +def test_cross_registry_collision_is_rejected_on_every_index(app, name): + @app.function_name(name=name) + @app.route(route="collision") + def collision(req): + return func.HttpResponse("ok") + + app.add_durable_agent(make_agent()) + for _ in range(2): + with pytest.raises(ValueError, match="Duplicate function name"): + app.get_functions() + assert not app._functions_indexed + + +def test_sdk_builtin_names_cannot_be_shadowed(app, tmp_path): + app.add_durable_agent(make_agent()) + # Derive the SDK-owned names rather than duplicating a hard-coded list. + sdk_functions = app.get_functions() + builtins = [ + fn for fn in sdk_functions + if fn.get_function_name().startswith("BuiltIn__") + ] + assert builtins + for index, builtin in enumerate(builtins): + candidate = AgentFunctionApp( + client_factory=RecordingClient, app_root=tmp_path + ) + candidate.add_durable_agent(make_agent()) + + @candidate.function_name(name=builtin.get_function_name()) + @candidate.route(route=f"collision/{index}") + def collision(req): + return func.HttpResponse("ok") + + with pytest.raises(ValueError, match="Duplicate function name"): + candidate.get_functions() + + +def test_native_orchestration_does_not_add_hidden_activity(app): + @app.orchestration_trigger(context_name="context") + def orchestrator(context): + assert not hasattr(context, "call_agent") + yield context.call_activity("orders", "hello") + + names = {fn.get_function_name() for fn in app.get_functions()} + assert names == {"orchestrator"} + assert app._durable_app is None + + +def _execute_entity(handler, entity_id, request, state): + """Invoke the indexed SDK handler using the host's protobuf wire format.""" + batch = pb.EntityBatchRequest( + instanceId=str(entity_id), + operations=[pb.OperationRequest( + operation="run", input=StringValue(value=json.dumps(request)) + )], + ) + if state is not None: + batch.entityState.CopyFrom(StringValue(value=state)) + transport = func.EntityContext(base64.b64encode(batch.SerializeToString())) + encoded_result = handler(transport) + result = pb.EntityBatchResult.FromString(base64.b64decode(encoded_result)) + assert not result.HasField("failureDetails"), result + assert len(result.results) == 1 + operation = result.results[0] + assert operation.HasField("success"), operation + return json.loads(operation.success.result.value), result.entityState.value + + +def test_proxy_runs_indexed_entity_and_restores_session_between_turns(app): + client = RecordingClient() + app.add_durable_agent(make_agent(client=client)) + entity = next( + fn for fn in app.get_functions() if fn.get_function_name() == "dafx-Orders" + ) + handler = entity.get_user_function() + scheduled = [] + + def call_entity(entity_id, operation, input_=None): + assert operation == "run" + task = CompletableTask() + scheduled.append((entity_id, input_, task)) + return task + + # Only the scheduler is replaced. Use the SDK context, real DAFX tasks, + # and the real indexed entity handler. + scheduler = Mock(instance_id="workflow-1") + scheduler.new_uuid.side_effect = [str(uuid.UUID(int=n)) for n in range(1, 5)] + scheduler.call_entity.side_effect = call_entity + context = DurableOrchestrationContext(scheduler) + agent = app.get_agent(context, "Orders") + session = agent.create_session() + state = None + + for turn, prompt in enumerate(["first", "second"], start=1): + task = agent.run(prompt, session=session) + assert not task.is_complete + entity_id, request, pending = scheduled[-1] + response, state = _execute_entity(handler, entity_id, request, state) + pending.complete(response) + result = task.get_result() + assert isinstance(result, AgentResponse) + assert result.text == f"reply-{turn}" + + assert scheduled[0][0] == scheduled[1][0] + assert client.inputs == [["first"], ["first", "reply-1", "second"]] + assert state diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py b/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py new file mode 100644 index 0000000..0bdad16 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_durable_markdown.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import asyncio +import base64 +import inspect +import json +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import azure.functions as func +from agent_framework import ( + AgentResponse, AgentResponseUpdate, AgentSession, Content, Message, ResponseStream, + SupportsAgentRun, +) + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.agent_framework._durable import ( + MarkdownDurableAgent, +) +from azurefunctions.agents.extensions.base import compile_agent, discover_agent_names + + +def make_app(tmp_path, **kwargs): + return AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, **kwargs) + + +def definition(root, name="orders", text="Instructions"): + root.mkdir(parents=True, exist_ok=True) + (root / f"{name}.agent.md").write_text(text, encoding="utf-8") + + +def test_discovery_is_opt_in_and_creates_recipes_not_live_agents(tmp_path): + definition(tmp_path) + definition(tmp_path / "agents", "shipping") + definition(tmp_path / "nested", "ignored") + factory = Mock(side_effect=AssertionError("client created during indexing")) + plain = AgentFunctionApp(client_factory=factory, app_root=tmp_path) + assert plain.get_functions() == [] + assert plain._durable_app is None + + app = AgentFunctionApp( + client_factory=factory, app_root=tmp_path, discover_agents=True, + ) + assert app._durable_app is None + assert set(app._durable_agents) == {"orders", "shipping"} + assert app._durable_agents == app._markdown_agents + indexed = app.get_functions() + assert set(fn.get_function_name() for fn in indexed) == { + "dafx-orders", "dafx-shipping", "http-orders", "http-shipping", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + } + factory.assert_not_called() + assert all(isinstance(agent, SupportsAgentRun) + for agent in app._durable_app.agents.values()) + + +@pytest.mark.parametrize("value", [None, 0, 1, "true", [], {}]) +def test_discover_agents_flag_is_explicit_bool(tmp_path, value): + with pytest.raises(TypeError, match="discover_agents must be a bool"): + make_app(tmp_path, discover_agents=value) + + +@pytest.mark.parametrize("second", ["orders", "ORDERS"]) +def test_discovery_rejects_ambiguous_names_before_registration( + tmp_path, monkeypatch, second +): + definition(tmp_path, "orders") + definition(tmp_path / "agents", second) + ensure = Mock(side_effect=AssertionError("partial durable registration")) + register = Mock(side_effect=AssertionError("partial agent registration")) + monkeypatch.setattr(AgentFunctionApp, "_ensure_durable_app", ensure) + monkeypatch.setattr(AgentFunctionApp, "add_durable_agent", register) + with pytest.raises(ValueError, match="[Aa]mbiguous"): + make_app(tmp_path, discover_agents=True) + ensure.assert_not_called() + register.assert_not_called() + + +def test_discovery_ignores_unrelated_files_and_rejects_definition_directories(tmp_path): + (tmp_path / "readme.md").write_text("ignore", encoding="utf-8") + (tmp_path / "orders.agent.md").mkdir() + with pytest.raises(ValueError, match="not a file"): + make_app(tmp_path, discover_agents=True) + + +def test_discovery_rejects_escaping_symlink(tmp_path): + outside = tmp_path.parent / f"{tmp_path.name}-outside.agent.md" + outside.write_text("Outside", encoding="utf-8") + try: + (tmp_path / "orders.agent.md").symlink_to(outside) + except OSError as error: + pytest.skip(f"Symlinks unavailable: {error}") + with pytest.raises(ValueError, match="outside app root"): + make_app(tmp_path, discover_agents=True) + + +def test_compile_preserves_raw_instructions(tmp_path): + path = tmp_path / "orders.agent.md" + path.write_bytes(b"---\r\nname: ignored\r\n---\r\nRaw instructions\r\n") + app = make_app(tmp_path) + assert discover_agent_names(app) == ["orders"] + assert compile_agent(app, "orders").instructions == path.read_bytes().decode() + + +def test_binding_registers_once_and_hides_injected_parameter(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def first(context, *, agent): + yield agent + + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def second(context, agent): + yield agent + + assert set(app._durable_agents) == {"orders"} + assert app._agent_http_endpoints == {"orders": False} + assert app._durable_app is None + assert list(inspect.signature(first).parameters) == ["context"] + proxy = object() + app.get_agent = Mock(return_value=proxy) + context = object() + assert next(first(context)) is proxy + assert next(second(context=context)) is proxy + app.get_agent.assert_called_with(context, "orders") + with pytest.raises(TypeError): + next(first(context, agent=object())) + indexed = app.get_functions() + assert "dafx-orders" in {fn.get_function_name() for fn in indexed} + assert not any(fn.is_http_function() for fn in indexed) + + +def test_binding_and_discovery_share_one_entity(tmp_path): + definition(tmp_path) + app = make_app(tmp_path, discover_agents=True) + original = app._durable_agents["orders"] + + @app.orchestration_trigger(context_name="context") + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def workflow(context, agent): + yield agent.run("hello") + + assert app._durable_agents["orders"] is original + assert app._durable_app is None + assert len(app.get_functions()) == 5 + assert app._durable_app.agents["orders"] is original + + +def test_binding_custom_context_and_native_input_are_forwarded(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + + @app.orchestration_trigger(context_name="ctx") + @app.durable_markdown_agent( + arg_name="agent", agent_name="orders", context_name="ctx" + ) + def workflow(ctx, input, agent): + yield (ctx, input, agent) + + proxy = object() + app.get_agent = Mock(return_value=proxy) + context = object() + original = workflow._function.get_user_function().orchestrator_function + assert next(original(context, {"message": "hello"})) == ( + context, {"message": "hello"}, proxy + ) + + +@pytest.mark.parametrize("native_input", [False, True]) +def test_indexed_orchestrator_transport_schedules_entity(tmp_path, native_input): + from durabletask.internal import orchestrator_service_pb2 as pb + from google.protobuf.timestamp_pb2 import Timestamp + from google.protobuf.wrappers_pb2 import StringValue + + definition(tmp_path) + factory = Mock(side_effect=AssertionError("live agent opened by orchestrator")) + app = AgentFunctionApp(client_factory=factory, app_root=tmp_path) + + if native_input: + def workflow(context, input, agent): + yield agent.run(input["message"]) + else: + def workflow(context, agent): + yield agent.run(context.get_input()["message"]) + + workflow = app.durable_markdown_agent( + arg_name="agent", agent_name="orders" + )(workflow) + app.orchestration_trigger(context_name="context")(workflow) + handler = next(fn.get_user_function() for fn in app.get_functions() + if fn.get_function_name() == "workflow") + timestamp = Timestamp(seconds=1_700_000_000) + request = pb.OrchestratorRequest(instanceId="test-workflow", newEvents=[ + pb.HistoryEvent(eventId=-1, timestamp=timestamp, + orchestratorStarted=pb.OrchestratorStartedEvent()), + pb.HistoryEvent(eventId=0, timestamp=timestamp, + executionStarted=pb.ExecutionStartedEvent( + name="workflow", + input=StringValue(value=json.dumps({"message": "hello"})), + orchestrationInstance=pb.OrchestrationInstance( + instanceId="test-workflow", + executionId=StringValue(value="execution-1"), + ), + )), + ]) + output = handler(func.OrchestrationContext( + base64.b64encode(request.SerializeToString()) + )) + result = pb.OrchestratorResponse.FromString(base64.b64decode(output)) + assert len(result.actions) == 1, result + action = result.actions[0] + assert action.HasField("sendEntityMessage"), result + assert "dafx-orders" in str(action), result + factory.assert_not_called() + + +def test_binding_trigger_context_mismatch_is_rejected(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def workflow(context, agent): + yield agent + + with pytest.raises(TypeError, match="context_name must match"): + app.orchestration_trigger(context_name="wrong")(workflow) + + +@pytest.mark.parametrize("name", [ + None, "", " ", 1, "../orders", "/orders", "{order}", "a@b", "a b", "a#b", +]) +def test_binding_invalid_or_escaping_names_fail_without_dafx(tmp_path, name): + definition(tmp_path) + app = make_app(tmp_path) + + def workflow(context, agent): + yield agent + + with pytest.raises(ValueError): + app.durable_markdown_agent(arg_name="agent", agent_name=name)(workflow) + assert app._durable_agents == {} + assert app._markdown_agents == {} + assert app._durable_app is None + + +def test_replay_only_schedules_tasks_without_opening_agents(tmp_path): + from azure.durable_functions import DurableOrchestrationContext + from durabletask.task import CompletableTask + + definition(tmp_path) + factory = Mock(side_effect=AssertionError("agent opened in orchestration")) + app = AgentFunctionApp(client_factory=factory, app_root=tmp_path) + + @app.orchestration_trigger(context_name="context") + @app.durable_markdown_agent(arg_name="agent", agent_name="orders") + def workflow(context, agent): + session = agent.create_session() + yield agent.run("hello", session=session) + + requests = [] + for replay in [False, True]: + scheduler = Mock(instance_id="workflow-1", is_replaying=replay) + scheduler.new_uuid.side_effect = ["session-key", "correlation-id"] + scheduler.call_entity.return_value = CompletableTask() + context = DurableOrchestrationContext(scheduler) + handler = workflow._function.get_user_function().orchestrator_function + next(handler(context)) + entity, operation, payload = scheduler.call_entity.call_args.args + # Pinned DAFX's RunRequest supplies a wall-clock created_at by default. + # Check stable routing/identity/input here, not byte-identical payloads. + assert payload.pop("created_at") + requests.append((str(entity), operation, payload)) + assert requests[0] == requests[1] + factory.assert_not_called() + + +def test_binding_missing_file_fails_before_registration(tmp_path): + app = make_app(tmp_path) + + def workflow(context, agent): + yield agent + + with pytest.raises(FileNotFoundError): + app.durable_markdown_agent(arg_name="agent", agent_name="missing")(workflow) + assert app._durable_agents == {} + assert app._markdown_agents == {} + assert app._durable_app is None + + +def test_binding_invalid_handler_shapes_do_not_register(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + bind = app.durable_markdown_agent(arg_name="agent", agent_name="orders") + + async def async_handler(context, agent): + return agent + + def nongenerator(context, agent): + return agent + + def missing_agent(context): + yield context + + def missing_context(agent): + yield agent + + def wrong_order(input, context, agent): + yield agent + + def variadic(context, *agent): + yield agent + + for handler in [async_handler, nongenerator, missing_agent, + missing_context, wrong_order, variadic]: + with pytest.raises(TypeError): + bind(handler) + assert app._durable_agents == {} + assert app._markdown_agents == {} + assert app._durable_app is None + + +def test_binding_late_declaration_fails(tmp_path): + definition(tmp_path) + app = make_app(tmp_path) + app.get_functions() + + def workflow(context, agent): + yield agent + + with pytest.raises(RuntimeError, match="before function indexing"): + app.durable_markdown_agent(arg_name="agent", agent_name="orders")(workflow) + assert app._durable_agents == {} + assert app._markdown_agents == {} + assert app._durable_app is None + + +def test_generated_http_function_name_collision_is_not_silent(tmp_path): + definition(tmp_path, "order-one") + definition(tmp_path, "order_one") + app = make_app(tmp_path, discover_agents=True) + with pytest.raises(ValueError, match="unique function name"): + app.get_functions() + + +def lifecycle_adapter(*, failure=None): + """Exercise adapter lifetimes separately from the real SDK sample tests.""" + events = [] + calls = [] + response = AgentResponse( + messages=[Message(role="assistant", contents=["done"])], value={"answer": 42} + ) + + @asynccontextmanager + async def open_agent(invocation): + instance = len(calls) + events.append((instance, "open")) + if failure == "open": + raise RuntimeError("open failed") + + def run(messages, *, stream=False, session=None, **kwargs): + calls.append((messages, session, kwargs)) + + async def updates(): + events.append((instance, "pull")) + yield AgentResponseUpdate(contents=[Content.from_text("done")]) + if failure == "stream": + raise RuntimeError("stream failed") + if failure == "cancel": + await asyncio.Event().wait() + + def finalize(_): + assert (instance, "close") not in events + events.append((instance, "finalize")) + if failure == "finalize": + raise RuntimeError("finalize failed") + return response + + async def invoke(): + if failure == "run": + raise RuntimeError("run failed") + return response + + return ResponseStream(updates(), finalizer=finalize) if stream else invoke() + + try: + yield SimpleNamespace(run=run) + finally: + events.append((instance, "close")) + + recipe = SimpleNamespace(agent_name="orders", open_agent=open_agent) + return MarkdownDurableAgent(recipe), events, calls, response + + +def test_adapter_stream_keeps_resources_alive_through_finalization(): + adapter, events, calls, expected = lifecycle_adapter() + session = AgentSession() + + async def invoke(): + for _ in range(2): + stream = adapter.run( + "hello", stream=True, session=session, options={"x": 1} + ) + # Constructing and awaiting the stream must not create live resources. + assert len(events) == 4 * len(calls) + await stream + result = await stream.get_final_response() + assert result is expected + assert result.value == {"answer": 42} + + asyncio.run(invoke()) + assert events == [(i, event) for i in range(2) + for event in ["open", "pull", "finalize", "close"]] + assert calls == [("hello", session, {"options": {"x": 1}})] * 2 + + +@pytest.mark.parametrize("failure", ["stream", "finalize"]) +def test_adapter_closes_resources_on_stream_failure(failure): + adapter, events, _, _ = lifecycle_adapter(failure=failure) + + async def invoke(): + with pytest.raises(RuntimeError, match=failure): + await adapter.run("hello", stream=True).get_final_response() + + asyncio.run(invoke()) + assert events[-1] == (0, "close") + + +def test_adapter_closes_resources_on_cancelled_pull(): + adapter, events, _, _ = lifecycle_adapter(failure="cancel") + + async def invoke(): + stream = adapter.run("hello", stream=True) + await anext(stream) + started = asyncio.Event() + + async def pull(): + started.set() + return await anext(stream) + + task = asyncio.create_task(pull()) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(invoke()) + assert events[-1] == (0, "close") + + +@pytest.mark.parametrize("failure", [None, "run", "open"]) +def test_adapter_nonstream_response_and_cleanup(failure): + adapter, events, _, expected = lifecycle_adapter(failure=failure) + + async def invoke(): + if failure: + with pytest.raises(RuntimeError, match=failure): + await adapter.run("hello") + else: + assert await adapter.run("hello") is expected + + asyncio.run(invoke()) + if failure != "open": + assert events[-1] == (0, "close") diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py new file mode 100644 index 0000000..0e387a6 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_imports.py @@ -0,0 +1,128 @@ +import subprocess +import sys +import textwrap + +import pytest + + +def test_framework_exports_supported_api(): + import azurefunctions.agents.extensions.agent_framework as framework + + assert framework.AgentFunctionApp is not None + assert not hasattr(framework, "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 + + +def test_non_durable_binding_runs_with_all_durable_imports_blocked(tmp_path): + (tmp_path / "orders.agent.md").write_text("Handle orders.", encoding="utf-8") + script = textwrap.dedent("""\ + import asyncio + import importlib.abc + import sys + + blocked = ( + 'agent_framework_declarative', + 'yaml', + 'powerfx', + 'agent_framework_azurefunctions', + 'agent_framework_durabletask', + 'azure.durable_functions', + 'durabletask', + ) + + class BlockDurable(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if any(fullname == name or fullname.startswith(name + '.') + for name in blocked): + raise ModuleNotFoundError(name=fullname) + + sys.meta_path.insert(0, BlockDurable()) + import azure.functions as func + from agent_framework import Agent, BaseChatClient + from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + class LocalClient(BaseChatClient): + def _inner_get_response(self, **kwargs): + raise AssertionError('No model calls are expected') + + app = AgentFunctionApp(client_factory=LocalClient, app_root=sys.argv[1]) + + @app.route(route='orders') + @app.markdown_agent(arg_name='agent', agent_name='orders') + async def orders(req: func.HttpRequest, agent: Agent): + return func.HttpResponse(agent.name) + + indexed = app.get_functions() + assert [f.get_function_name() for f in indexed] == ['orders'] + response = asyncio.run(indexed[0].get_user_function()( + func.HttpRequest(method='GET', url='http://localhost/orders', body=b'') + )) + assert response.get_body() == b'orders' + assert app._durable_app is None + assert not any(module == name or module.startswith(name + '.') + for module in sys.modules for name in blocked) + """) + result = subprocess.run( + [sys.executable, "-c", script, str(tmp_path)], + check=False, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("missing", ["agent_framework_azurefunctions", "grpc"]) +def test_dafx_import_errors_are_actionable_without_hiding_broken_installs( + monkeypatch, tmp_path, missing +): + import builtins + from types import SimpleNamespace + + from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp + + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) + original_import = builtins.__import__ + + def fail_dafx_import(name, *args, **kwargs): + if name == "_hosting": + raise ModuleNotFoundError(f"No module named {missing!r}", name=missing) + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fail_dafx_import) + app.add_durable_agent(SimpleNamespace(name="Orders")) + assert set(app._durable_agents) == {"Orders"} + assert app._durable_app is None + with pytest.raises(ImportError) as caught: + app.get_functions() + if missing == "agent_framework_azurefunctions": + assert "[durable]" in str(caught.value) + assert isinstance(caught.value.__cause__, ModuleNotFoundError) + else: + assert isinstance(caught.value, ModuleNotFoundError) + assert caught.value.name == "grpc" + assert app._durable_app is None diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py b/azurefunctions-agents-extensions-agent-framework/tests/test_provider.py new file mode 100644 index 0000000..0c30e51 --- /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_compiled_recipe(): + 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_registration_api.py b/azurefunctions-agents-extensions-agent-framework/tests/test_registration_api.py new file mode 100644 index 0000000..a674383 --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_registration_api.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from itertools import product +import json +from pathlib import Path +import subprocess +import sys +from unittest.mock import Mock + +import pytest +from agent_framework import AgentResponse, Message +from azure.durable_functions import DurableOrchestrationContext +from durabletask.task import CompletableTask, OrchestrationContext + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.agent_framework._workflow_client import ( + DurableWorkflow, +) + + +def define_agent(root): + (root / "orders.agent.md").write_text("Handle orders", encoding="utf-8") + + +def agent_binding(app, exposed=False): + @app.orchestration_trigger(context_name="context") + @app.durable_markdown_agent( + arg_name="agent", agent_name="orders", expose_http_endpoint=exposed, + ) + def orchestrator(context, agent): + yield agent.run("hello") + return orchestrator + + +def http_routes(app): + return [binding["route"] for fn in app.get_functions() + for binding in fn.get_bindings_dict()["bindings"] + if binding["type"] == "httpTrigger"] + + +@pytest.mark.parametrize("discover,bulk_exposed,binding_exposed", list(product( + (False, True), repeat=3, +))) +def test_agent_discovery_and_binding_exposure_matrix( + tmp_path, discover, bulk_exposed, binding_exposed, +): + define_agent(tmp_path) + app = AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, + discover_agents=discover, expose_agent_endpoints=bulk_exposed, + ) + before = app._durable_agents.get("orders") + agent_binding(app, binding_exposed) + if before: + assert app._durable_agents["orders"] is before + assert app._durable_app is None + exposed = (discover and bulk_exposed) or binding_exposed + expected = ["agents/orders/run"] if exposed else [] + assert http_routes(app) == expected + functions = app.get_functions() + assert sum(fn.get_function_name() == "dafx-orders" for fn in functions) == 1 + + +@pytest.mark.parametrize("flags", [ + {}, {"expose_agent_endpoints": True}, {"expose_workflow_endpoints": True}, + {"discover_agents": True}, +]) +def test_empty_configuration_does_not_load_dafx(tmp_path, flags): + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, **flags) + assert app.get_functions() == [] + assert app._durable_app is None + + +@pytest.mark.parametrize("first,second", [(False, True), (True, False)]) +def test_repeated_agent_exposure_is_order_independent(tmp_path, first, second): + define_agent(tmp_path) + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) + + def flow(context, agent): + yield agent + + for expose in [first, second]: + app.durable_markdown_agent( + arg_name="agent", agent_name="orders", expose_http_endpoint=expose, + )(flow) + assert len(app._durable_agents) == 1 + assert http_routes(app) == ["agents/orders/run"] + + +@pytest.mark.parametrize("legacy", ["durable", "workflows"]) +def test_ambiguous_legacy_flags_are_not_silently_accepted(tmp_path, legacy): + with pytest.raises(TypeError, match=legacy): + AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, **{legacy: True}, + ) + + +@pytest.mark.parametrize("compatibility_context", [False, True]) +def test_workflow_proxy_schedules_child_and_decodes_output(compatibility_context): + from agent_framework_durabletask._workflows.serialization import serialize_value + + context = Mock(spec=OrchestrationContext) + pending = CompletableTask() + context.call_sub_orchestrator.return_value = pending + wrapped = DurableOrchestrationContext(context) if compatibility_context else context + proxy = DurableWorkflow(wrapped, "Child") + task = proxy.run({"message": "hello"}, instance_id="child-1") + context.call_sub_orchestrator.assert_called_once_with( + "dafx-Child", input={"message": "hello"}, instance_id="child-1", + ) + assert not task.is_complete + response = AgentResponse(messages=[Message(role="assistant", contents=["done"])]) + pending.complete([serialize_value(response)]) + output = task.get_result() + assert isinstance(output[0], AgentResponse) + assert output[0].text == "done" + + +def test_child_failure_propagates(): + context = Mock(spec=OrchestrationContext) + pending = CompletableTask() + context.call_sub_orchestrator.return_value = pending + task = DurableWorkflow(context, "Child").run() + pending.fail("child failed", ValueError("bad input")) + assert task.is_failed + with pytest.raises(Exception, match="bad input|failed"): + task.get_result() + + +def test_child_input_cannot_impersonate_internal_checkpoint_envelope(): + context = Mock(spec=OrchestrationContext) + context.call_sub_orchestrator.return_value = CompletableTask() + payload = { + "__subworkflow_input__": {"__pickled__": "not-a-pickle"}, + "__subworkflow_address__": {"root_instance_id": "wrong"}, + "message": "hello", "nested": {"__type__": "untrusted"}, + } + DurableWorkflow(context, "Child").run(payload) + assert context.call_sub_orchestrator.call_args.kwargs["input"] == { + "message": "hello", "nested": None, + } + assert "__subworkflow_input__" in payload # Sanitization must not mutate input. + + +def test_internal_workflow_agent_cannot_shadow_standalone_agent(tmp_path): + from agent_framework import Agent, AgentExecutor, WorkflowBuilder + + internal = Agent(client=Mock(), name="inner") + node = AgentExecutor(internal, id="reviewer") + workflow = WorkflowBuilder(start_executor=node, name="Review").build() + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) + app._register_workflow(workflow, expose_http_endpoint=False) + app.add_durable_agent(Agent(client=Mock(), name="Review-reviewer")) + with pytest.raises(ValueError, match="collides"): + app.get_functions() + + +@pytest.mark.parametrize("mode", ["matrix", "selection", "transport"]) +def test_workflow_registration_probes(mode): + from importlib.util import find_spec + if sys.version_info >= (3, 14) or find_spec("agent_framework_declarative") is None: + pytest.skip("YAML execution probes need the workflows extra and Python 3.13") + result = subprocess.run( + [sys.executable, "-X", "utf8", str(Path(__file__).with_name( + "_registration_probe.py")), mode], + capture_output=True, text=True, encoding="utf-8", timeout=180, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(result.stdout.strip().splitlines()[-1])["result"] diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py new file mode 100644 index 0000000..cce4a7f --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_samples.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +_PACKAGE_ROOT = Path(__file__).parents[1] +_SAMPLES_ROOT = _PACKAGE_ROOT / "samples" +_SAMPLE_INDEXES = { + "agent_samples_agent-framework": {"process_order", "process_order_event"}, + "agent_samples_agent-framework_durable": { + "order_orchestrator", "prepare_order_activity", "start_order_orchestration", + "dafx-order-fulfillment", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + }, + "lazy-owned-dafx": { + "dafx-orders", "http-orders", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + }, + "durable-markdown-binding": { + "orders", "start_orders", "dafx-orders", + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + }, + "durable-yaml-workflow": { + "BuiltIn__HttpActivity", "BuiltIn__HttpPollOrchestrator", + "dafx-OrderReview", "dafx-OrderReview-start", "dafx-OrderReview-status", + "dafx-OrderReview-respond", "dafx-OrderReview-_workflow_entry", + "dafx-OrderReview-capture_order", "dafx-OrderReview-prepare_prompt", + "dafx-OrderReview-review_order", "dafx-OrderReview-send_review", + "dafx-Approval", "dafx-Approval-start", "dafx-Approval-status", + "dafx-Approval-respond", "dafx-Approval-_workflow_entry", + "dafx-Approval-request_approval", "dafx-Approval-send_answer", + }, + "configured-workflow-factory": { + "dafx-ConfiguredTools", "dafx-ConfiguredTools-start", + "dafx-ConfiguredTools-status", "dafx-ConfiguredTools-respond", + "dafx-ConfiguredTools-_workflow_entry", "dafx-ConfiguredTools-format_order", + "dafx-ConfiguredTools-send_result", "BuiltIn__HttpActivity", + "BuiltIn__HttpPollOrchestrator", + }, + "durable-workflow-binding": { + "parent", "start_parent", "dafx-Child", "dafx-Child-_workflow_entry", + "dafx-Child-send_result", "BuiltIn__HttpActivity", + "BuiltIn__HttpPollOrchestrator", + }, +} +_LOCAL_SAMPLES = ("lazy-owned-dafx", "durable-markdown-binding") +_LOCAL_ENDPOINT_SAMPLES = tuple( + sample for sample in _LOCAL_SAMPLES if "http-orders" in _SAMPLE_INDEXES[sample] +) + + +def _require_workflows(): + from importlib.util import find_spec + + if sys.version_info >= (3, 14) or find_spec("agent_framework_declarative") is None: + pytest.skip("YAML samples tested on 3.13 with workflows extra") + + +def _run_sample(sample_path, script): + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [ + str(_PACKAGE_ROOT), + str(_PACKAGE_ROOT.parent / "azurefunctions-agents-extensions-base"), + environment.get("PYTHONPATH"), + ]) + ) + completed = subprocess.run( + [sys.executable, "-X", "utf8", "-c", textwrap.dedent(script)], + cwd=_SAMPLES_ROOT / sample_path, + env=environment, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + # PowerFx's loader may print initialization notices before the JSON result. + return json.loads(completed.stdout.strip().splitlines()[-1]) + + +def test_index_cases_cover_every_sample_app(): + assert set(_SAMPLE_INDEXES) == { + path.parent.relative_to(_SAMPLES_ROOT).as_posix() + for path in _SAMPLES_ROOT.rglob("function_app.py") + } + + +@pytest.mark.parametrize("sample_path", _SAMPLE_INDEXES) +def test_sample_indexes_all_functions(sample_path): + if any((_SAMPLES_ROOT / sample_path).rglob("*.workflow.y*ml")): + _require_workflows() + # Exact names were recorded from the SDK 2/DAFX PR #72 index. DAFX sanitizes + # HTTP names (_build_function_name), but preserves hyphens in entity names. + result = _run_sample(sample_path, """ + import json + from unittest.mock import patch + from azurefunctions.agents.extensions.agent_framework.provider import ( + AgentFrameworkBinding, + ) + with patch.object(AgentFrameworkBinding, '_create_agent', + side_effect=AssertionError('live agent during indexing')): + import function_app + assert function_app.app._durable_app is None, 'host built before indexing' + first = function_app.app.get_functions() + second = function_app.app.get_functions() + names = [fn.get_function_name() for fn in first] + assert names == [fn.get_function_name() for fn in second] + assert len(names) == len(set(names)) + for fn in first: + if fn.get_function_name().startswith('http-'): + trigger = next(b for b in fn.get_bindings_dict()['bindings'] + if b['type'] == 'httpTrigger') + agent = next(iter(function_app.app._durable_app.agents)) + assert trigger['route'] == f'agents/{agent}/run' + assert [method.value for method in trigger['methods']] == ['POST'] + assert trigger['authLevel'].value == 'function' + print(json.dumps(names)) + """) + assert set(result) == _SAMPLE_INDEXES[sample_path] + + +def test_agent_framework_sample_rejects_malformed_json(): + result = _run_sample("agent_samples_agent-framework", """ + import asyncio, json + import azure.functions as func + import function_app + request = func.HttpRequest(method='POST', url='https://example.test', + body=b'{not json', route_params={'orderId': '42'}) + handler = function_app.process_order._function.get_user_function().__wrapped__ + response = asyncio.run(handler(request, object())) + print(json.dumps({'status_code': response.status_code, + 'body': response.get_body().decode()})) + """) + assert result["status_code"] == 400 + assert json.loads(result["body"]) == {"error": "Order failed validation."} + + +def test_agent_framework_durable_sample_starts_orchestration(): + script = ( + "import asyncio, json\n" + "import azure.functions as func\n" + "import function_app\n" + "class FakeClient:\n" + " async def start_new(self, name, *, client_input):\n" + " assert name == 'order_orchestrator' and client_input == {}\n" + " return 'instance-42'\n" + " def create_http_management_payload(self, request, instance_id):\n" + " assert request is not None\n" + " 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" + ) + assert _run_sample("agent_samples_agent-framework_durable", script) == { + "status_code": 202, + "mimetype": "application/json", + "location": "https://example.test/status/42", + } + + +def test_agent_framework_durable_sample_rejects_malformed_json(): + 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" + ) + result = _run_sample("agent_samples_agent-framework_durable", script) + 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() + + +def test_binding_sample_starts_orchestration(): + result = _run_sample("durable-markdown-binding", """ + import asyncio, json + import azure.functions as func + import function_app + class FakeClient: + async def start_new(self, name, *, client_input): + assert name == 'orders' and client_input == {} + return 'instance-42' + def create_check_status_response(self, request, instance_id): + assert request is not None and instance_id == 'instance-42' + return func.HttpResponse(status_code=202) + request = func.HttpRequest(method='POST', url='https://example.test', body=b'') + handler = function_app.start_orders._function.get_user_function().__wrapped__ + response = asyncio.run(handler(request, FakeClient())) + print(json.dumps(response.status_code)) + """) + assert result == 202 + + +@pytest.mark.parametrize("sample_path", _LOCAL_SAMPLES) +def test_local_sample_preserves_history_with_fresh_execution_clients(sample_path): + result = _run_sample(sample_path, """ + import asyncio, base64, json, uuid + from unittest.mock import Mock + import azure.functions as func + from azure.durable_functions import DurableOrchestrationContext + from durabletask.internal import orchestrator_service_pb2 as pb + from durabletask.task import CompletableTask + from google.protobuf.wrappers_pb2 import StringValue + import local_chat_client + + clients = [] + class TrackingClient(local_chat_client.LocalChatClient): + def __init__(self): + super().__init__() + self.entered = self.closed = False + clients.append(self) + async def __aenter__(self): + self.entered = True + return self + async def __aexit__(self, *args): + self.closed = True + local_chat_client.LocalChatClient = TrackingClient + import function_app + assert clients == [], 'client constructed during app import' + functions = function_app.app.get_functions() + assert clients == [], 'client constructed during indexing' + entity = next(fn.get_user_function() for fn in functions + if fn.get_function_name() == 'dafx-orders') + scheduled = [] + def call_entity(entity_id, operation, input_=None): + assert operation == 'run' + task = CompletableTask() + scheduled.append((entity_id, input_, task)) + return task + scheduler = Mock(instance_id='workflow-1') + scheduler.new_uuid.side_effect = [str(uuid.UUID(int=n)) for n in range(1, 5)] + scheduler.call_entity.side_effect = call_entity + context = DurableOrchestrationContext(scheduler) + + generator = None + if hasattr(function_app, 'orders'): + # Keep the actual binding injection; bypass only the SDK host transport. + handler = function_app.orders._function.get_user_function() + generator = handler.orchestrator_function(context) + task = next(generator) + else: + http = next(fn.get_user_function().__wrapped__ for fn in functions + if fn.get_function_name() == 'http-orders') + class Client: + async def signal_entity(self, entity_id, operation, input_): + call_entity(entity_id, operation, input_) + def submit(prompt): + request = func.HttpRequest( + method='POST', url='https://example.test/api/agents/orders/run', + headers={'Content-Type': 'application/json'}, + body=json.dumps({'message': prompt, 'session_id': 'orders-demo', + 'wait_for_response': False}).encode()) + response = asyncio.run(http(request, Client())) + assert response.status_code == 202, response.get_body() + return scheduled[-1][2] + task = submit('Assess the order.') + + state = None + replies = [] + for turn in (1, 2): + assert not task.is_complete + entity_id, request, pending = scheduled[-1] + batch = pb.EntityBatchRequest( + instanceId=str(entity_id), operations=[pb.OperationRequest( + operation='run', input=StringValue(value=json.dumps(request)))]) + if state is not None: + batch.entityState.CopyFrom(StringValue(value=state)) + transport = func.EntityContext(base64.b64encode(batch.SerializeToString())) + result = pb.EntityBatchResult.FromString( + base64.b64decode(entity(transport))) + assert not result.HasField('failureDetails'), result + assert len(result.results) == 1 + assert result.results[0].HasField('success'), result + payload = json.loads(result.results[0].success.result.value) + state = result.entityState.value + pending.complete(payload) + assert len(clients) == turn + assert all(client.entered and client.closed for client in clients) + if generator is not None: + response = task.get_result() + replies.append(response.text) + if turn == 1: + task = generator.send(response) + else: + try: + generator.send(response) + except StopIteration as done: + assert done.value == dict(zip(['assessment', 'plan'], replies)) + else: + raise AssertionError('orchestrator did not finish') + else: + from agent_framework import AgentResponse + replies.append(AgentResponse.from_dict(payload).text) + if turn == 1: + task = submit('Make a fulfillment plan.') + assert scheduled[0][0] == scheduled[1][0], 'turns used different entities' + assert state + print(json.dumps(replies)) + """) + assert result == [ + "User turn 1: Assess the order.", + "User turn 2: Make a fulfillment plan.", + ] + + +@pytest.mark.parametrize("sample_path", _LOCAL_ENDPOINT_SAMPLES) +@pytest.mark.parametrize("body", ["{not json", "{}", '{"message":""}']) +def test_local_agent_endpoint_rejects_invalid_input(sample_path, body): + result = _run_sample(sample_path, f""" + import asyncio, json + from unittest.mock import Mock + import azure.functions as func + import function_app + handler = next(fn.get_user_function().__wrapped__ + for fn in function_app.app.get_functions() + if fn.get_function_name() == 'http-orders') + client = Mock() + request = func.HttpRequest(method='POST', url='https://example.test', + headers={{'Content-Type': 'application/json'}}, + body={body.encode()!r}) + response = asyncio.run(handler(request, client)) + client.signal_entity.assert_not_called() + print(json.dumps(response.status_code)) + """) + assert result == 400 + + +def test_workflow_binding_sample_starts_parent(): + _require_workflows() + result = _run_sample("durable-workflow-binding", """ + import asyncio, json + import azure.functions as func + import function_app + class FakeClient: + async def start_new(self, name, *, client_input): + assert name == 'parent' and client_input == {} + return 'parent-42' + def create_check_status_response(self, request, instance_id): + assert request is not None and instance_id == 'parent-42' + return func.HttpResponse(status_code=202) + request = func.HttpRequest(method='POST', url='https://example.test', body=b'') + handler = function_app.start_parent._function.get_user_function().__wrapped__ + response = asyncio.run(handler(request, FakeClient())) + print(json.dumps(response.status_code)) + """) + assert result == 202 + + +def test_workflow_binding_sample_yields_child_and_returns_decoded_outputs(): + _require_workflows() + probe_path = str(_PACKAGE_ROOT / "tests" / "_yaml_workflow_probe.py") + result = _run_sample("durable-workflow-binding", f""" + import importlib, importlib.util, json, os, sys + from pathlib import Path + from unittest.mock import Mock, patch + from azure.durable_functions import DurableOrchestrationContext + from durabletask.task import CompletableTask + from azurefunctions.agents.extensions.agent_framework.provider import ( + AgentFrameworkBinding, + ) + import function_app + + spec = importlib.util.spec_from_file_location('sample_probe', {probe_path!r}) + probe = importlib.util.module_from_spec(spec) + spec.loader.exec_module(probe) + def index_sample(root): + module = importlib.reload(function_app) + assert module.app._durable_app is None + functions = {{f.get_function_name(): f for f in module.app.get_functions()}} + assert not any(name.startswith('http-') for name in functions) + assert not any(name.endswith(('-start', '-status', '-respond')) + for name in functions) + return module.app, functions + probe.index = index_sample + raw_outputs = [] + decode = probe.deserialize_workflow_output + def record_output(value): + raw_outputs.append(value) + return decode(value) + probe.deserialize_workflow_output = record_output + with patch.object(AgentFrameworkBinding, '_create_agent', + side_effect=AssertionError('unexpected agent')): + outputs, activities, answered = probe.run_workflow( + Path.cwd(), 'Child', {{'order': '42'}}) + assert outputs == ['Child workflow completed.'], outputs + assert activities == ['dafx-Child-_workflow_entry', 'dafx-Child-send_result'] + assert answered == [] + + pending = CompletableTask() + scheduler = Mock() + scheduler.call_sub_orchestrator.return_value = pending + context = DurableOrchestrationContext(scheduler, {{'order': '42'}}) + handler = function_app.parent._function.get_user_function() + generator = handler.orchestrator_function(context) + task = next(generator) + scheduler.call_sub_orchestrator.assert_called_once_with( + 'dafx-Child', input={{'order': '42'}}, instance_id=None) + assert not task.is_complete + assert len(raw_outputs) == 1 + pending.complete(raw_outputs[0]) + assert task.is_complete and task.get_result() == outputs + try: + generator.send(task.get_result()) + except StopIteration as done: + result = done.value + else: + raise AssertionError('parent did not finish') + print(json.dumps(result), flush=True) + sys.stdout.flush() + sys.stderr.flush() + os._exit(0) # Isolate embedded CLR shutdown after all assertions. + """) + assert result == {"child_outputs": ["Child workflow completed."]} + + +def test_agent_framework_durable_sample_uses_prepared_order_and_shared_session(): + result = _run_sample("agent_samples_agent-framework_durable", """ + import json + from types import SimpleNamespace + import function_app + calls = [] + session = object() + class Agent: + def create_session(self): + return session + def run(self, message, *, session): + calls.append((json.loads(message), session)) + return f'turn-{len(calls)}' + class Context: + def get_input(self): + return {'untrusted': 'order'} + def call_activity(self, name, payload): + assert name == 'prepare_order_activity' + assert payload == self.get_input() + return 'prepare' + context = Context() + def get_agent(received_context, name): + assert received_context is context and name == 'order-fulfillment' + return Agent() + function_app.app.get_agent = get_agent + handler = function_app.order_orchestrator._function.get_user_function() + generator = handler.orchestrator_function(context) + assert next(generator) == 'prepare' + assert not calls + prepared = {'order_id': 'D-2048', 'summary': {'subtotal': '49.90'}} + assert generator.send(prepared) == 'turn-1' + assert generator.send(SimpleNamespace(text='assessment')) == 'turn-2' + try: + generator.send(SimpleNamespace(text='plan')) + except StopIteration as done: + output = done.value + else: + raise AssertionError('orchestrator did not finish') + assert len(calls) == 2 + assert all(payload['order'] == prepared and used_session is session + for payload, used_session in calls) + assert calls[1][0]['risk_assessment'] == 'assessment' + print(json.dumps(output)) + """) + assert result == { + "order_id": "D-2048", + "risk_assessment": "assessment", + "fulfillment_plan": "plan", + } diff --git a/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py b/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py new file mode 100644 index 0000000..7d90d2c --- /dev/null +++ b/azurefunctions-agents-extensions-agent-framework/tests/test_yaml_workflows.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import builtins +from importlib.util import find_spec +import json +from pathlib import Path +import subprocess +import sys +from unittest.mock import Mock, call + +import pytest + +from azurefunctions.agents.extensions.agent_framework import AgentFunctionApp +from azurefunctions.agents.extensions.agent_framework import _workflows + + +@pytest.mark.parametrize("value", [None, 0, 1, "true", [], {}]) +def test_discover_workflows_flag_requires_bool(tmp_path, value): + with pytest.raises(TypeError, match="discover_workflows must be a bool"): + AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, discover_workflows=value, + ) + + +def test_workflow_discovery_does_not_register_standalone_agents(tmp_path, monkeypatch): + (tmp_path / "orders.agent.md").write_text("Handle orders.", encoding="utf-8") + load = Mock(return_value=[]) + monkeypatch.setattr(_workflows, "load_workflows", load) + app = AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, discover_workflows=True, + ) + assert set(app._markdown_agents) == {"orders"} + load.assert_called_once_with(tmp_path, app._markdown_agents, factory=None) + assert app._durable_agents == {} + assert app._hosted_workflows == {} + assert app.get_functions() == [] + assert app._durable_app is None + + +def test_workflow_files_are_ignored_without_workflow_opt_in(tmp_path): + (tmp_path / "bad.workflow.yaml").write_text("not valid: [", encoding="utf-8") + plain = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path) + assert plain.get_functions() == [] + durable = AgentFunctionApp( + client_factory=lambda: None, app_root=tmp_path, discover_agents=True, + ) + assert durable._hosted_workflows == {} + assert durable.get_functions() == [] + assert durable._durable_app is None + + +def test_factory_can_be_configured_without_workflow_discovery(tmp_path): + (tmp_path / "bad.workflow.yaml").write_text("not valid: [", encoding="utf-8") + factory = Mock() + app = AgentFunctionApp(client_factory=lambda: None, app_root=tmp_path, + workflow_factory=factory) + assert app._workflow_factory is factory + assert factory.mock_calls == [] + assert app._hosted_workflows == {} + assert app.get_functions() == [] + assert app._durable_app is None + + +@pytest.mark.parametrize("missing", ["agent_framework_declarative", "yaml", "clr"]) +def test_missing_workflow_dependencies(tmp_path, monkeypatch, missing): + (tmp_path / "orders.workflow.yaml").write_text("name: Orders", encoding="utf-8") + original = builtins.__import__ + + def blocked(name, *args, **kwargs): + if name == "agent_framework.declarative": + raise ModuleNotFoundError(name=missing) + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", blocked) + with pytest.raises(ImportError) as error: + _workflows.load_workflows(tmp_path, {}) + if missing == "agent_framework_declarative": + assert "[durable,workflows]" in str(error.value) + else: + assert error.value.name == missing + + +def test_custom_factory_is_used_unchanged_without_declarative_import( + tmp_path, monkeypatch, +): + from agent_framework import Workflow + original = builtins.__import__ + + def no_declarative(name, *args, **kwargs): + if "declarative" in name or name in {"yaml", "powerfx"}: + raise AssertionError("Custom factory must not import the default loader") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_declarative) + paths = [tmp_path / "one.workflow.yaml", tmp_path / "workflows/two.workflow.yml"] + for path in paths: + path.parent.mkdir(exist_ok=True) + path.write_text("caller-defined format", encoding="utf-8") + outputs = [Mock(spec=Workflow, name="One"), Mock(spec=Workflow, name="Two")] + for output, name in zip(outputs, ["One", "Two"]): + output.name = name + factory = Mock() + factory.create_workflow_from_yaml_path.side_effect = outputs + assert _workflows.load_workflows(tmp_path, {"ignored": object()}, factory) == ( + outputs + ) + assert factory.method_calls == [ + call.create_workflow_from_yaml_path(path) for path in paths + ] + + +def test_custom_factory_errors_propagate(tmp_path): + (tmp_path / "bad.workflow.yaml").touch() + error = RuntimeError("custom factory rejected definition") + factory = Mock() + factory.create_workflow_from_yaml_path.side_effect = error + with pytest.raises(RuntimeError) as caught: + _workflows.load_workflows(tmp_path, {}, factory) + assert caught.value is error + + +def test_factory_must_return_a_workflow(tmp_path): + (tmp_path / "bad.workflow.yaml").touch() + factory = Mock() + factory.create_workflow_from_yaml_path.return_value = object() + with pytest.raises(TypeError, match="MAF Workflow"): + _workflows.load_workflows(tmp_path, {}, factory) + + +def test_workflow_symlink_escape_is_rejected(tmp_path): + outside = tmp_path.parent / f"{tmp_path.name}-outside.yaml" + outside.write_text("name: Outside", encoding="utf-8") + try: + (tmp_path / "escape.workflow.yaml").symlink_to(outside) + except OSError as error: + pytest.skip(f"Symlinks unavailable: {error}") + with pytest.raises(ValueError, match="escapes app root"): + _workflows._definition_paths(tmp_path) + + +@pytest.mark.parametrize("mode", [ + "validation", "execution", "sample", "native", "configured-sample", +]) +def test_real_yaml_workflow_probes(mode): + if sys.version_info >= (3, 14) or find_spec("agent_framework_declarative") is None: + pytest.skip("Requires Python 3.13 and workflows extra") + filename = ( + "_native_workflow_probe.py" if mode == "native" else "_yaml_workflow_probe.py" + ) + result = subprocess.run( + [sys.executable, "-X", "utf8", str(Path(__file__).with_name( + filename)), mode], + capture_output=True, text=True, encoding="utf-8", timeout=180, + ) + assert result.returncode == 0, result.stdout + result.stderr + data = json.loads(result.stdout.strip().splitlines()[-1])["result"] + if mode == "execution": + assert set(data) == { + "simple", "state", "branch", "else", "loop", "agent", "human", + "if-agent", "if-else-agent", + } + elif mode == "sample": + assert data == { + "OrderReview": ["User turn 1: Review order 42."], + "Approval": ["approved"], + } + elif mode == "validation": + assert len(data) == 13 + elif mode == "configured-sample": + assert data == ["Local order 42."] + else: + assert len(data) == 13 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..0349211 --- /dev/null +++ b/azurefunctions-agents-extensions-base/README.md @@ -0,0 +1,90 @@ +# 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 exposes `open_agent()` to create a fresh Agent context for each +invocation. Provider-specific adapters can use the same lifecycle for durable +entity execution. + +Applications import the app class supplied by a provider package. Each Agent +Function App uses one provider, configured when the app is constructed. +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. + +`discover_agent_names()` enumerates `.agent.md` files directly in these two +directories, not nested directories. Provider apps can compile the discovered +names without constructing live clients. The Microsoft Agent Framework app uses +this discovery when `durable=True` is set. + +## Skills and MCP discovery + +The base package discovers immutable definitions from the shared app root: + +```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. The base package supplies discovery, +compilation, and lifecycle contracts, not an orchestration context wrapper or +hidden Agent activity. + +The Microsoft Agent Framework provider offers `durable=True` discovery and a +`durable_markdown_agent()` binding. It registers compiled markdown recipes with +DAFX and injects a durable proxy into generator orchestrators. Concrete clients +and tools are created and closed through `open_agent()` during entity execution, +not indexing or orchestration replay. See the +[provider documentation](../azurefunctions-agents-extensions-agent-framework/README.md#durable-agents). diff --git a/azurefunctions-agents-extensions-base/azurefunctions/__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..5ba3d62 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/__init__.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from .bindings import ( + compile_agent, configure_app, discover_agent_names, get_app_root, markdown_agent, +) +from .capabilities import ( + AgentCapabilities, + MCPAuthConfig, + MCPHTTPConfig, + MCPServerDefinition, + SkillDefinition, +) +from .providers import ( + AGENT_PROVIDER_ENTRY_POINT_GROUP, + AgentProvider, + CompiledAgent, + InvocationMetadata, + load_provider, +) + +__all__ = [ + "AGENT_PROVIDER_ENTRY_POINT_GROUP", + "AgentCapabilities", + "AgentProvider", + "CompiledAgent", + "InvocationMetadata", + "MCPAuthConfig", + "MCPHTTPConfig", + "MCPServerDefinition", + "SkillDefinition", + "compile_agent", + "configure_app", + "discover_agent_names", + "get_app_root", + "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..f03e866 --- /dev/null +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import functools +import inspect +import os +import threading +import weakref +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath, PureWindowsPath +from types import MappingProxyType +from typing import Any, TypeVar, cast, get_type_hints + +import azure.functions as func + +from .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('<>:"/\\|?*') + + +@dataclass +class _AppState: + app_root: Path + capabilities: AgentCapabilities + provider_id: str + provider: AgentProvider + provider_defaults: Mapping[str, object] + 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 get_app_root(app: object) -> Path: + """Return the configured definition root for provider-specific loaders.""" + return _configured_state(app).app_root + + +def compile_agent( + app: object, + agent_name: str, +) -> CompiledAgent: + """Compile a named markdown definition using the app's provider defaults. + + This performs no client creation or network I/O. The caller owns caching + and invokes the returned recipe's open_agent() at execution time. + """ + state = _configured_state(app) + with state.lock: + _validate_provider_capabilities(state.provider, state.capabilities) + return state.provider.compile_binding( + instructions=_resolve_instructions(state.app_root, agent_name), + agent_name=agent_name, + options=state.provider_defaults, + annotation=inspect.Signature.empty, + capabilities=state.capabilities, + ) + + +def discover_agent_names(app: object) -> list[str]: + """Find definitions directly under the app root and its agents directory. + + Validate all files before returning so ambiguous or escaping definitions + cannot partially publish a set of endpoints. + """ + root = _configured_state(app).app_root + names: dict[str, str] = {} + for directory in (root, root / "agents"): + if not directory.is_dir(): + continue + for source in sorted(directory.iterdir()): + if not source.name.endswith(".agent.md"): + continue + name = _validate_agent_name(source.name.removesuffix(".agent.md")) + if not source.is_file(): + raise ValueError(f"Agent definition {source.name!r} is not a file") + if name.casefold() in names: + raise ValueError(f"Ambiguous agent name {name!r}") + _resolve_instructions(root, name) + names[name.casefold()] = name + return sorted(names.values(), key=str.casefold) + + +def _validate_agent_name(agent_name: str) -> str: + if not isinstance(agent_name, str) or not agent_name.strip(): + 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) + 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/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..d584954 --- /dev/null +++ b/azurefunctions-agents-extensions-base/tests/test_bindings.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import asyncio +import gc +import inspect +import weakref +from contextlib import asynccontextmanager + +import azure.functions as func +import pytest + +from azurefunctions.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_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_imports.py b/azurefunctions-agents-extensions-base/tests/test_imports.py new file mode 100644 index 0000000..4b4f9fa --- /dev/null +++ b/azurefunctions-agents-extensions-base/tests/test_imports.py @@ -0,0 +1,29 @@ +import subprocess +import sys + + +def test_base_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 as base\n" + "assert not hasattr(base, 'configure_durable_app')\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..deb0aa0 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -17,6 +17,66 @@ 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,durable,mcp] + if [ "$(PYTHON_VERSION)" = "3.13" ]; then + python -m pip install -e .[workflows] + fi + displayName: 'Install Agents Framework Dependencies' + - bash: | + python -m pytest -q --instafail azurefunctions-agents-extensions-agent-framework/tests/ + displayName: "Run Agents Framework Tests for Python $(PYTHON_VERSION)" + - job: "BaseTests" displayName: "Base Extension Tests" dependsOn: []