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