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