From e6b0443f0121b3f539af18c86fb0dafa4fd2e880 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 12 Aug 2026 15:58:00 -0500 Subject: [PATCH 1/6] initial prototype --- README.md | 46 ++ docs/architecture.md | 21 +- docs/frds/0008-agent-input-binding.md | 620 ++++++++++++++++++ docs/frds/README.md | 1 + docs/front-matter-spec.md | 4 +- docs/observability.md | 10 + docs/workflows.md | 9 + pyproject.toml | 6 +- samples/README.md | 4 + samples/hybrid-durable-agent/README.md | 35 + .../src/agents.config.yaml | 2 + .../hybrid-durable-agent/src/function_app.py | 62 ++ samples/hybrid-durable-agent/src/host.json | 17 + .../src/local.settings.template.json | 10 + samples/hybrid-durable-agent/src/mcp.json | 8 + .../src/order-fulfillment.agent.md | 8 + .../hybrid-durable-agent/src/requirements.txt | 1 + .../src/skills/order-review/SKILL.md | 12 + .../src/tools/order_totals.py | 12 + samples/hybrid-function-agent/README.md | 32 + .../src/agents.config.yaml | 2 + .../hybrid-function-agent/src/function_app.py | 52 ++ samples/hybrid-function-agent/src/host.json | 17 + .../src/local.settings.template.json | 10 + samples/hybrid-function-agent/src/mcp.json | 8 + .../src/order-fulfillment.agent.md | 8 + .../src/requirements.txt | 1 + .../src/skills/order-review/SKILL.md | 12 + .../src/tools/order_totals.py | 12 + src/azure_functions_agents/__init__.py | 10 + src/azure_functions_agents/app.py | 6 +- src/azure_functions_agents/bindings.py | 407 ++++++++++++ src/azure_functions_agents/composition.py | 211 ++++++ src/azure_functions_agents/discovery/mcp.py | 138 +++- src/azure_functions_agents/hydration.py | 211 ++++++ src/azure_functions_agents/runner.py | 6 +- tests/test_bindings.py | 372 +++++++++++ tests/test_composition.py | 142 ++++ tests/test_discovery_mcp.py | 1 + tests/test_hybrid_binding_sample.py | 175 +++++ tests/test_hydration.py | 255 +++++++ tests/test_package_imports.py | 8 + tests/test_registration_capabilities.py | 33 + 43 files changed, 2985 insertions(+), 32 deletions(-) create mode 100644 docs/frds/0008-agent-input-binding.md create mode 100644 samples/hybrid-durable-agent/README.md create mode 100644 samples/hybrid-durable-agent/src/agents.config.yaml create mode 100644 samples/hybrid-durable-agent/src/function_app.py create mode 100644 samples/hybrid-durable-agent/src/host.json create mode 100644 samples/hybrid-durable-agent/src/local.settings.template.json create mode 100644 samples/hybrid-durable-agent/src/mcp.json create mode 100644 samples/hybrid-durable-agent/src/order-fulfillment.agent.md create mode 100644 samples/hybrid-durable-agent/src/requirements.txt create mode 100644 samples/hybrid-durable-agent/src/skills/order-review/SKILL.md create mode 100644 samples/hybrid-durable-agent/src/tools/order_totals.py create mode 100644 samples/hybrid-function-agent/README.md create mode 100644 samples/hybrid-function-agent/src/agents.config.yaml create mode 100644 samples/hybrid-function-agent/src/function_app.py create mode 100644 samples/hybrid-function-agent/src/host.json create mode 100644 samples/hybrid-function-agent/src/local.settings.template.json create mode 100644 samples/hybrid-function-agent/src/mcp.json create mode 100644 samples/hybrid-function-agent/src/order-fulfillment.agent.md create mode 100644 samples/hybrid-function-agent/src/requirements.txt create mode 100644 samples/hybrid-function-agent/src/skills/order-review/SKILL.md create mode 100644 samples/hybrid-function-agent/src/tools/order_totals.py create mode 100644 src/azure_functions_agents/bindings.py create mode 100644 src/azure_functions_agents/composition.py create mode 100644 src/azure_functions_agents/hydration.py create mode 100644 tests/test_bindings.py create mode 100644 tests/test_composition.py create mode 100644 tests/test_hybrid_binding_sample.py create mode 100644 tests/test_hydration.py diff --git a/README.md b/README.md index 69ef8422..5dd5922b 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,52 @@ func start Your agent is now running at `http://localhost:7071/agents/main/` with a built-in chat UI, HTTP API (`/agents/main/chat`, `/agents/main/chatstream`), and MCP tool exposed through the Functions MCP endpoint (`/runtime/webhooks/mcp`). +## Hybrid Functions with agent input + +Use `agent_input` when an existing Function should keep its trigger and deterministic logic while invoking a markdown-defined agent in process. The smart decorator must be innermost, immediately above the handler: + +```python +import json + +from agent_framework import Agent +from azurefunctions.extensions.http.fastapi import Request, Response + +from azure_functions_agents import AiApp + +app = AiApp() + + +@app.route(route="orders/{orderId}", methods=["POST"]) +@app.agent_input(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order( + req: Request, + order_agent: Agent, +) -> Response: + response = await order_agent.run( + json.dumps( + { + "order_id": req.path_params["orderId"], + "order": await req.json(), + "task": "validate", + } + ) + ) + return Response(content=response.text) +``` + +Existing `func.FunctionApp()` instances can use `agent_input(app, ...)` instead. `create_function_app()` now returns an enhanced `AiApp` or `DurableAiApp`, preserving its existing declarative routes and triggers while allowing hybrid handlers to be added. + +`agent_name` is the source filename stem or normalized slug, not the front-matter display name. For bindings, the agent file requires only string `name` and `description` fields; its markdown body supplies instructions. Other per-agent front-matter fields are ignored. Model, timeout, system tools, discovered tools, skills, and MCP servers come from app-level configuration. + +Function and activity handlers using `agent_input` must be declared with `async def`. They receive a raw `agent_framework.Agent` that is built and entered for that Function invocation, then closed when the handler returns, raises, or is cancelled. The app caches only an immutable blueprint and reusable dependency descriptions, never the live Agent or its MCP tools. These handlers control sessions, run options, middleware, streaming, and model-call timeouts; the Azure Functions invocation timeout remains the outer bound. Do not retain the Agent after the handler returns. + +Durable apps use `DurableAiApp` or a caller-owned `df.DFApp` with an explicit mode: + +- `mode="activity"` injects a raw Agent into an `async def` activity handler. +- `mode="orchestrator"` injects `DurableAiAgent`; `run()` returns a replay-safe Durable task backed by the generated `_afa_agent_binding_run` activity. Streaming is unsupported. + +See [`samples/hybrid-function-agent/`](samples/hybrid-function-agent/) for an `AiApp` with HTTP and queue handlers, and [`samples/hybrid-durable-agent/`](samples/hybrid-durable-agent/) for a `DurableAiApp` with activity and orchestrator handlers. + ## Features **Architecture overview:** see [`docs/architecture.md`](docs/architecture.md) for the module map and data flow pipeline. diff --git a/docs/architecture.md b/docs/architecture.md index 13c31aea..ecd6bcdd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,10 @@ flowchart LR J["client_manager.py
ClientManager"] -.->|"chat client"| K["runner.py
run_agent
run_agent_stream
build_subagent_tools"] H -.->|"handler closures + AgentCatalog"| K K -.->|"prompt + tools + session"| L["Microsoft Agent Framework"] + A -->|"binding projection"| M["composition.py
ProjectSnapshot"] + M -->|"BindingAgentEntry"| N["bindings.py
agent_input / AiApp / DurableAiApp"] + N -.->|"cached AgentBlueprint"| O["hydration.py
fresh Agent hydration"] + O -.->|"entered Agent per invocation"| L ``` Read left to right: files on disk become typed config, typed config becomes a `ResolvedAgent`, and each resolved agent is registered as Azure Functions bindings plus optional built-in endpoints. The two extra nodes in the middle (`app.py`'s identity index and `registration/catalog.py`'s `build_catalog`) exist for multi-agent delegation (FRD 0007, Section 5 below): every agent's slug and capabilities are indexed and frozen *before* `H` mutates the `FunctionApp`, so a coordinator's `delegate_` tools can resolve any specialist regardless of file order. @@ -35,12 +39,16 @@ A few boundaries are worth calling out explicitly: - **Composition is two-pass and side-effect-free until pass 2.** `app.py`'s composition root builds the app-wide identity-slug index, fails fast on collisions, validates every `subagents:` reference, and freezes each agent's `ResolvedAgent` + `AgentCapabilities` into an immutable `AgentCatalog` — none of this touches the `FunctionApp` object. Only pass 2 (trigger/endpoint registration) mutates it (FRD 0007 §4.2). - **Registration is Azure-specific.** This is the first stage that knows about `azure.functions.FunctionApp`, decorators, routes, and trigger bindings. - **Execution is deferred.** The runner is not part of startup registration; it is called later by handler closures when an HTTP route or trigger actually fires. +- **Smart bindings are a parallel projection.** `composition.py` reads only binding-required front matter and reuses the root-keyed discovery caches. It does not feed `AgentSpec` or weaken declarative validation. ## 3. Module map | Package/module | Role | Key entry points | | --- | --- | --- | | `azure_functions_agents/app.py` | Top-level orchestrator that runs the startup pipeline and returns the configured app; owns the two-pass composition root (FRD 0007 §4.2) — builds the app-wide identity-slug index, fails fast on duplicate slugs, and freezes every agent's validated `ResolvedAgent` + `AgentCapabilities` into an immutable `AgentCatalog` before any `FunctionApp` mutation. | `create_function_app()`, `_fail_on_duplicate_slugs()` | +| `azure_functions_agents/composition.py` | Builds the immutable binding-only project snapshot and resolves a binding target by exact filename stem, then normalized slug. Requires only `name` and `description`; all other per-agent front matter is discarded. | `load_project_snapshot()`, `compose_binding_target()` | +| `azure_functions_agents/bindings.py` | Owns the smart callable wrapper, enhanced app classes, per-app blueprint registry, raw Agent injection into async Functions and activities, and the replay-safe orchestrator proxy and generated Durable activity. It uses public SDK decorators and signatures without mutating `FunctionBuilder` internals. | `agent_input()`, `AiApp`, `DurableAiApp`, `DurableAiAgent` | +| `azure_functions_agents/hydration.py` | Owns immutable binding blueprints, fresh per-invocation MAF Agent construction and context management, and runtime-managed calls for the generated Durable activity. | `AgentBlueprint`, `open_agent()`, `run_blueprint()` | | `azure_functions_agents/config/paths.py` | Resolves the app root and the optional config/history directory. | `set_app_root()`, `get_app_root()`, `resolve_config_dir()` | | `azure_functions_agents/config/env.py` | Performs env-var substitution and bool coercion across config string values in YAML, JSON, front matter, and markdown body content. | `substitute_env_vars_in_value()`, `resolve_env_vars_in_data()`, `substitute_env_vars_in_text()`, `_to_bool()` | | `azure_functions_agents/config/schema.py` | Defines the Pydantic models for raw, global, and merged config, including independent object-only chat and workflow Sub Agent grants. | `AgentSpec`, `GlobalConfig`, `ResolvedAgent`, `TriggerSpec`, `BuiltinEndpointsConfig`, `SubagentRef`, `WorkflowConfig`, `WorkflowSubagentRef` | @@ -50,7 +58,7 @@ A few boundaries are worth calling out explicitly: | `azure_functions_agents/config/validation.py` | Post-merge sanity checks for resolved agents, including rejecting unknown/duplicate/self references in both independent Sub Agent grants against the app-wide slug index. | `validate_resolved_agent()`, `validate_subagent_references()`, `validate_workflow_subagent_references()` | | `azure_functions_agents/discovery/skills.py` | Walks `skills//SKILL.md` files, validates frontmatter, and caches the name→directory map for MAF's `SkillsProvider`. | `discover_skills()`, `clear_skills_cache()` | | `azure_functions_agents/discovery/tools.py` | Imports `tools/*.py`, finds normal `FunctionTool`/plain-function tools, discovers `@workflow_tool` Activity targets, and caches both inventories. | `discover_project_tools()`, `discover_user_tools()` | -| `azure_functions_agents/discovery/mcp.py` | Loads `mcp.json`, applies `resolve_env_vars_in_data()`, and translates remote HTTP server definitions into MAF MCP tool wrappers. | `discover_mcp_servers()` | +| `azure_functions_agents/discovery/mcp.py` | Loads `mcp.json`, applies `resolve_env_vars_in_data()`, caches immutable resolved server definitions, and constructs fresh MAF MCP wrappers for each owning Agent context. | `discover_mcp_server_definitions()`, `discover_mcp_servers()` | | `azure_functions_agents/registration/capabilities.py` | Applies per-agent MCP/skills/tools filters and packages the final runtime inventory; also fails fast when an auto-derived `delegate_` tool name collides with another tool already on the same agent. | `AgentCapabilities`, `build_capabilities()`, `validate_subagent_tool_names()` | | `azure_functions_agents/registration/catalog.py` | Freezes every agent's `ResolvedAgent` + `AgentCapabilities` into one immutable, slug-keyed `AgentCatalog`, built once at startup and threaded read-only into request handlers (FRD 0007). | `AgentCatalog`, `CatalogEntry`, `build_catalog()` | | `azure_functions_agents/registration/_naming.py` | Fails fast via `allocate_unique_function_name()` / `allocate_unique_builtin_slug()` when two `.agent.md` files sanitize to the same identity slug — a **breaking change** (FRD 0007 §5 Decision #17) replacing the previous silent auto-suffix behavior; re-exports the `_slug.py` helpers for backward compatibility. | `allocate_unique_function_name()`, `allocate_unique_builtin_slug()` | @@ -74,6 +82,7 @@ A few boundaries are worth calling out explicitly: - `discovery/` answers **"what is available in this project folder?"** - `app.py`'s composition root answers **"is this configuration internally consistent app-wide?"** (unique slugs, valid `subagents:` references) — the one cross-agent question no single `ResolvedAgent` can answer by itself. - `registration/` answers **"which Azure Functions surfaces should exist for this agent?"** +- `composition.py` and `bindings.py` answer **"which markdown agent should this customer-owned handler receive?"** - `system_tools/` answers **"which runtime-provided tools can be attached on demand?"** - `runner.py` and `client_manager.py` answer **"once invoked, how does an agent call the model and its tools — including any specialist it delegates to?"** - `_observability.py` (cross-cutting) answers **"what did the run do, and is a failure the app's, runtime's, platform's, or a delegated specialist's fault?"** @@ -96,6 +105,14 @@ When the host imports your app module and calls `create_function_app()`, control That ordering matters because later modules assume earlier stages have already reduced free-form author input into typed, validated objects. For example, registration code does not re-parse YAML or front matter; it trusts `ResolvedAgent` and `AgentCapabilities`. Steps 6-10 are FRD 0007's "two-pass composition" (§4.2): everything through step 10 is pure/side-effect-free (no `FunctionApp` mutation), and only step 11 is pass 2. +### Smart binding startup and execution + +`AiApp.agent_input()` and the free `agent_input(app, ...)` decorator use a separate binding-only path. At import time, the innermost decorator loads a per-app `ProjectSnapshot`, checks binding identity, resolves the requested source stem or slug, removes the injected parameter from the worker-facing signature, and returns a normal callable for the outer Azure decorator. Existing declarative parsing remains unchanged. + +Function and activity handlers using `agent_input` must be coroutines. At invocation, each receives a raw `agent_framework.Agent` built from the app-owned immutable `AgentBlueprint`. The wrapper enters the Agent on the worker's current event loop and always closes it after the handler; the customer controls sessions, options, middleware, streaming, number of calls, and model-call timeout. Fresh chat clients, history providers, web/sandbox tools, MCP wrappers, mutable tool lists, and Agent contexts prevent state or lifecycle sharing across invocations of the same slug. + +For Durable orchestrators, `DurableAiAgent.run()` performs no model or tool I/O. It validates a JSON payload and calls the generated `_afa_agent_binding_run` activity. Replay schedules the same history action and receives its recorded JSON result. The generated activity hydrates and closes a fresh Agent around one runtime-managed call. Durable Entity injection is not supported. + ## 4. Pipeline stages The `create_function_app()` docstring in `src/azure_functions_agents/app.py:create_function_app()` is the source of truth. The steps below restate it in module terms. @@ -367,7 +384,7 @@ This design keeps global config declarative: shared config says what exists, whi ### Other notable boundaries -- **Skills:** discovered as `SKILL.md` directories and handed to MAF's `SkillsProvider`. The provider exposes `load_skill` / `read_skill_resource` tools to the agent and scopes file access to the skill directory by design — no runtime-wide file tools required. +- **Skills:** discovered as `SKILL.md` directories and handed to MAF's `SkillsProvider`. The provider exposes `load_skill` / `read_skill_resource` tools to the agent and scopes file access to the skill directory by design — no runtime-wide file tools required. Because Functions execute unattended and repository-local skills are trusted application inputs, those read-only operations do not require approval; `run_skill_script` remains approval-gated and has no runner by default. - **Connectors:** connector actions are exposed to agents through MCP servers in `mcp.json`; connector-triggered agents use `trigger.type: connector_trigger`. - **Built-in endpoints:** endpoint registration is a separate module so the trigger-registration path stays focused on Azure Function bindings rather than UI and chat surface concerns. - **Multi-agent delegation:** `subagents:` is itself an extension point of sorts — it lets an agent's own front matter opt other, already-registered agents into its tool set without any code changes. See Section 5. diff --git a/docs/frds/0008-agent-input-binding.md b/docs/frds/0008-agent-input-binding.md new file mode 100644 index 00000000..2d8974e9 --- /dev/null +++ b/docs/frds/0008-agent-input-binding.md @@ -0,0 +1,620 @@ +--- +frd: 0008 +title: Python agent input binding +status: Finalized +author: hallvictoria +created: 2026-08-11 +updated: 2026-08-12 +issues: [#1163, #1175, #1284] +pull_requests: [] +branch: hallvictoria/agent-binding +--- + +# FRD 0008 — Python agent input binding + +## 1. Summary + +Add a Python smart input decorator that lets an existing Azure Function resolve a +markdown agent definition and receive a fresh, hydrated Microsoft Agent Framework +`Agent`. The customer keeps ordinary Functions and Durable Functions triggers and +application logic; the runtime resolves and caches an immutable hydration blueprint at +indexing time, then constructs and closes a new MAF Agent for every invocation. + +This v1 is Python-side dependency injection integrated with the Azure Functions +decorator pipeline. It is not a host-recognized custom input binding and requires no +.NET host extension or extension-bundle change. + +## 2. Motivation / problem + +Today `create_function_app()` turns every discovered definition into standalone +agent triggers or endpoints. Existing Function App customers instead need to call an +agent from their own HTTP, queue, timer, or other handler without replacing the +trigger, orchestration, validation, or deterministic business logic they already own. + +Although `run_agent()` is public, using it directly requires customer code to parse +and merge the definition and reconstruct its filtered tools, skills, MCP servers, +model, system tools, history, identity, and telemetry. A first-class decorator should +perform that glue consistently with declarative Serverless Agent endpoints. + +The Python worker cannot receive a live Python MAF `Agent` object from Functions host +binding metadata. A true host binding could send only serialized metadata and would +require coordinated host-extension and worker-converter work. Python-side injection +therefore provides the requested in-process object without introducing a redundant +host round trip. + +## 3. Goals / Non-goals + +**Goals** + +- Let async Python v2 Functions and Durable activities declare a raw injected Agent by + logical agent name, while Durable orchestrators receive a replay-safe scheduling proxy. +- Support both a concise `AiApp.agent_input()` API and existing caller-owned + `FunctionApp` objects through the same free `agent_input(app, ...)` implementation. +- Resolve the supported `agent.md` / `.agent.md` convention. For smart bindings, + recognize only required `name` and `description` front matter plus the markdown body + as instructions; ignore every other per-agent front-matter property. +- Hydrate the app-level model, discovered user and system tools, skills, MCP, and + per-call history without customer glue. +- Validate binding definitions and discovered assets at indexing time with actionable + errors. +- Cache compiled definitions and reusable dependencies, never a live MAF `Agent`. +- Give async handlers the entered raw MAF `Agent` so customer code controls sessions, + middleware, options, tools, and the number and shape of `run()` calls. +- Preserve the active Functions trace context and current app managed-identity + configuration through model and tool calls. +- Preserve all existing `create_function_app()` behavior and ordinary Functions + binding metadata. +- Preserve Durable replay determinism by scheduling orchestrator agent calls through a + generated activity, while allowing direct activity and entity injection. +- Demonstrate HTTP, event-driven, and Durable hybrid handlers in samples and tests. + +**Non-goals** + +- A host-recognized `agentInput` binding, .NET host extension, extension-bundle entry, + or Python worker converter in v1. +- Synchronous Function or activity handlers and Durable Entity injection. MAF's Python + Agent execution, streaming, and owned-resource lifecycle are asynchronous; v1 does + not add a blocking compatibility facade or process executor. +- Executing model or tool I/O directly in orchestrator replay code; orchestrator + injection is a replay-safe scheduling facade. +- Per-agent binding overrides for model, timeout, tools, skills, MCP, system tools, + subagents, workflows, schemas, triggers, or built-in endpoints. These front-matter + properties remain available to the declarative `create_function_app()` path but are + ignored by `agent_input`. +- Bound-agent subagent delegation or Dynamic Workflow management tools in v1. +- End-user token exchange, on-behalf-of authentication, or caller-token forwarding. +- Multi-agent orchestration, A2A, new provider policy, or changes to MAF's public API. +- Changes to agent-folder layout, Agent Plugins packaging, or remote-build dependency + installation; this feature consumes the outputs coordinated by #1163, #1175, and + #1284. + +## 4. Proposed design + +| Pipeline stage | Module(s) | Change | +| --- | --- | --- | +| discover | `composition.py`, existing `config/loader.py`, `discovery/*` | Build one app-root snapshot of binding definitions and app-level tools, skills, and MCP servers. | +| translate | `composition.py` | Project each binding target to required name/description, markdown instructions, and app-level capabilities; ignore other per-agent front matter. | +| register | `bindings.py`, `app.py` | Add `AiApp`, the free decorator, and one internal Durable activity without copying Azure Functions binding classes. | +| execute | `hydration.py`, `runner.py`, `client_manager.py` | Build and enter a fresh MAF Agent from a cached blueprint per invocation; route orchestrator calls through the internal activity. | + +### 4.1 Authoring and public API + +New applications may use a thin `FunctionApp` subclass: + +```python +import json + +from agent_framework import Agent +from azurefunctions.extensions.http.fastapi import Request, Response + +from azure_functions_agents import AiApp + +app = AiApp() + + +@app.function_name(name="ProcessOrder") +@app.route(route="orders/{orderId}", methods=["POST"]) +@app.agent_input(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order( + req: Request, + order_agent: Agent, +) -> Response: + response = await order_agent.run( + json.dumps( + { + "order_id": req.path_params["orderId"], + "order": await req.json(), + } + ) + ) + return Response(content=response.text) +``` + + `agent_input` must be the innermost decorator, immediately above the handler. + Python applies it first, so the standard Azure decorators receive the runtime's + worker-facing wrapper rather than the source handler's injected parameter. + +Existing applications keep their app object and Azure SDK decorators: + +```python +import json + +import azure.functions as func +from agent_framework import Agent +from azurefunctions.extensions.http.fastapi import Request, Response + +from azure_functions_agents import agent_input + +app = func.FunctionApp() + + +@app.function_name(name="ProcessOrder") +@app.route(route="orders/{orderId}", methods=["POST"]) +@agent_input(app, arg_name="order_agent", agent_name="order-fulfillment") +async def process_order( + req: Request, + order_agent: Agent, +) -> Response: + response = await order_agent.run( + json.dumps( + { + "order_id": req.path_params["orderId"], + "order": await req.json(), + } + ) + ) + return Response(content=response.text) +``` + +`agent_name` accepts either the filename stem or normalized slug. Resolution first +compares exact filename stems, then applies the existing slug normalization and looks +up the catalog slug. For example, both `order-fulfillment` and `order_fulfillment` +resolve `order-fulfillment.agent.md`, whose catalog slug is `order_fulfillment`. It +does not resolve the mutable front-matter display `name`. Existing app-wide duplicate +slug validation makes this fallback unambiguous; diagnostics list filename stems and +slugs side by side. + +A definition referenced by at least one `agent_input` decorator is reachable and may +omit a standalone trigger and built-in endpoints. The binding projection requires only +string `name` and `description` values and treats the markdown body as instructions. +All other front-matter keys are ignored without validation or warnings, including +`trigger`, `builtin_endpoints`, model/tool/skill/MCP filters, schemas, workflows, and +subagents. The customer's Function owns triggering, request/response adaptation, and +Durable behavior. Invalid values in ignored keys are silently discarded and cannot +configure the bound Agent; customers validate binding behavior against app-level +configuration. The existing declarative loader continues to recognize and validate +the complete front-matter schema unchanged. Lookup errors explicitly state that +`agent_name` is a filename stem or normalized slug, not the front-matter display name. + +Durable handlers select an explicit mode because `agent_input` is applied before the +outer Azure decorator and cannot inspect binding metadata without private SDK state: + +```python +import json + +import azure.durable_functions as df + +from azure_functions_agents import DurableAiAgent, DurableAiApp + +app = DurableAiApp() + + +@app.orchestration_trigger(context_name="context") +@app.agent_input( + arg_name="planner", + agent_name="order-fulfillment", + mode="orchestrator", +) +def order_orchestrator( + context: df.DurableOrchestrationContext, + planner: DurableAiAgent, +): + plan = yield planner.run(json.dumps(context.get_input())) + return plan["text"] +``` + +`mode` is one of `"function"` (default), `"activity"`, or `"orchestrator"`. +Functions and activities must be coroutine functions and receive the fresh raw MAF +`Agent`. Synchronous Functions and activities fail during decorator application with +an actionable error directing the author to use `async def`. This is a MAF protocol +requirement, not only a wrapper implementation choice: MAF Python is async-first. +Non-streaming `Agent.run()` returns an awaitable, streaming returns an async response +stream, and Agent context entry and exit are asynchronous. MAF exposes no blocking +Agent execution or lifecycle protocol for a synchronous Function or activity handler. + +Orchestrators remain synchronous generators and receive `DurableAiAgent`; `run()` +returns a Durable `TaskBase`, which the handler yields exactly once. `DurableAiAgent` +is required because Durable orchestrator code must be deterministic and replay-safe: +it cannot directly perform model calls, network I/O, or tool execution. The proxy is +therefore deliberately not a MAF Agent. It only validates deterministic, +JSON-serializable input and schedules the runtime-owned activity where Agent hydration +and all external I/O occur. The yielded result is a JSON dictionary containing `text`, +`messages`, `response_id`, and `usage` fields rather than a live MAF response. Durable +Entity injection is outside v1 because entity handlers are synchronous and MAF exposes +no blocking Agent execution or lifecycle protocol. + +### 4.2 SDK integration + +`AiApp` subclasses `azure.functions.FunctionApp`; `DurableAiApp` subclasses +`azure.durable_functions.DFApp`. Each adds only the same smart decorator, while all +standard decorators and binding objects remain owned by the Azure SDKs. Keeping two +classes preserves the current guarantee that non-Durable apps are not `DFApp` +instances. Existing app instances use the free decorator because adding methods by +monkey-patch would weaken typing and global behavior. Durable modes require a +`DurableAiApp` or caller-owned `DFApp`; applying them to a plain `FunctionApp` fails at +import with an actionable error. + +`DurableAiApp` is an optional convenience type, not a runtime requirement. It preserves +the Durable SDK's activity and orchestration decorators while adding the bound +`@app.agent_input(...)` method and explicit `app_root` configuration. A caller-owned +`azure.durable_functions.DFApp` used with the free `agent_input(app, ...)` decorator +has equivalent binding behavior. Registration of the generated +`_afa_agent_binding_run` activity is triggered by the first orchestrator binding, not +by constructing or subclassing `DurableAiApp`. Non-Durable applications can continue +to use `AiApp` or a caller-owned `azure.functions.FunctionApp`. + +The smart decorator accepts a plain callable and returns a wrapped callable. It does +not create, copy, inspect, or mutate `FunctionBuilder` or binding objects. The wrapper +preserves handler metadata with `functools.wraps`, exposes an `inspect.Signature` with +`arg_name` removed to the standard decorators and Python worker, and injects the agent +when called. The source signature remains available through `__wrapped__` for tooling. + +Because this depends on Python's bottom-up decorator application, v1 supports one +order only: `agent_input` is innermost, then the standard trigger/binding decorator, +then optional `function_name`. Applying it to an Azure `FunctionBuilder` fails at +import with an error showing the supported order. The implementation accepts only a +plain function, coroutine function, or generator function, which rejects a +`FunctionBuilder` without importing or inspecting its private class. There is no SDK +compatibility adapter and no `_configure_function_builder` access. + +The wrapper form follows `mode`: + +- `function` and `activity`: async wrapper for required coroutine handlers; +- `orchestrator`: synchronous generator wrapper that delegates with `yield from`. + +Runtime probes against the supported `azure-functions>=2.1.0,<3` and +`azure-functions-durable>=1.2.10,<2` ranges verify callable recognition, public +`inspect.signature()` behavior, and generated binding metadata. + +### 4.3 Composition, validation, and caching + +`composition.py` extracts shared read-only project loading from `app.py`. Its narrow +interface is: + +```python +@dataclass(frozen=True) +class DiscoveryInventory: + user_tools: tuple[FunctionTool, ...] + workflow_tools: tuple[WorkflowTool, ...] + skills: tuple[tuple[str, Path], ...] + mcp_servers: tuple[tuple[str, MCPServerDefinition], ...] + failed_loads: tuple[tuple[str, str], ...] + +@dataclass(frozen=True) +class ProjectSnapshot: + app_root: Path + config: GlobalConfig + sources: tuple[BindingAgentSource, ...] + discovery: DiscoveryInventory + +def load_project_snapshot(app_root: Path | None) -> ProjectSnapshot: ... +def compose_binding_target( + snapshot: ProjectSnapshot, agent_name: str +) -> BindingAgentEntry: ... +``` + +Shared loading: + +1. resolves the app root and config; +2. indexes filename stems and normalized slugs without parsing front matter; +3. discovers tools, workflow tools, skills, and MCP servers; +4. parses only the selected target, requiring `name` and `description`, retaining + markdown instructions, and ignoring other metadata. + +`DiscoveryInventory` is an immutable projection of existing `ProjectTools`, +`SkillDiscoveryResult`, and `MCPDiscoveryResult` outputs. It introduces no second +discovery mechanism. Names are retained for skill/MCP diagnostics and deterministic +ordering; all discovery failures are normalized to `(source, reason)` pairs. + +Full declarative composition remains owned by `create_function_app()` and continues +to use `AgentSpec`, `compose()`, complete validation, and `CatalogEntry` exactly as +today. Binding composition is deliberately separate: it creates +`BindingAgentDefinition` and `BindingAgentEntry` values without merging per-agent +front matter. The two consumers share root resolution and discovery caches, not parsed +definition objects. `agents.config.yaml` remains authoritative for app-level model, +timeout, system-tool, and user-tool defaults; all discovered skills and MCP servers +are enabled because v1 binding definitions have no per-agent filters. + +The plain declarative path constructs `AiApp`; the workflow-enabled path constructs +`DurableAiApp`. Each gives its private binding runtime the resolved root and shared +discovery caches while retaining current public return compatibility. The binding +runtime creates and retains its own `ProjectSnapshot` on the first smart decorator, +including when the same app was returned by `create_function_app()`. Full declarative +composition never stores or reuses that binding snapshot. Different app objects own +separate binding snapshots even when their roots match; only existing process-level +discovery caches are shared by resolved root. + +Smart-binding composition runs synchronously when each innermost decorator is applied +at module import. It indexes definition paths to enforce global filename-derived slug +uniqueness, resolves and parses the requested target, and builds app-level capabilities +for that target. Invalid YAML or missing/invalid `name` or `description` in an unrelated +definition does not fail binding composition. Those errors in the selected target fail +with its source path and field. + +Each app owns a private binding registry keyed by resolved app root and normalized +slug. The registry uses a lock around first compilation so concurrent decorator +registration cannot compile the same target twice. Later decorators reuse the same +immutable `BindingAgentEntry`. There is no composition at handler invocation. Errors +include the requested name, normalized slug, app root, and available filename/slug +pairs. + +### 4.4 Hydration and invocation lifetime + +The MAF packages move to the latest resolver-compatible releases available on +2026-08-11: `agent-framework-core==1.13.0`, `agent-framework-openai==1.12.0`, and +`agent-framework-foundry==1.10.4`. A pip dry run confirmed this exact set resolves. +Core 1.13 exposes explicit `AgentSession`, unified `Agent.run(..., stream=...)`, and +the Agent async context-manager lifecycle used for client and MCP resources. + +Each app owns an immutable `AgentBlueprint` per resolved root and normalized slug. A +blueprint contains the parsed markdown instructions, logical identity, model and +default options, reusable discovered function-tool definitions, skill paths, resolved +MCP server definitions, system-tool configuration, and history-provider factory. It +contains no entered Agent, async exit stack, MCP connection, request-scoped sandbox +tool, or mutable MAF session. + +The immutable blueprint is the safe cache boundary. MAF does not explicitly guarantee +that one entered Agent and its owned dependencies are immutable or safe for concurrent +reuse. Multiple invocations of the same Function can overlap; separate `AgentSession` +values isolate conversation history, but do not isolate the Agent's tools, middleware, +clients, or lifecycle. Raw Agent injection also permits invocation code to customize +tools and middleware, so sharing that object could expose mutations to later or +concurrent invocations. + +Caching a live Agent would additionally require every invocation-specific dependency +to move outside it. That condition does not hold: Agent construction can include an +invocation-derived resolved ID, sandbox fallback session ID, history provider, mutable +tool lists, MCP wrappers, and HTTP clients. Entering and exiting one cached Agent per +invocation would allow one invocation to close resources still used by another; +entering it once for the process would instead require runtime-owned event-loop startup, +health recovery, synchronization, and reliable asynchronous shutdown. Serializing all +runs behind a per-Agent lock would avoid overlap but introduce head-of-line blocking +and remove same-Function concurrency. The design therefore caches the compiled recipe +and reusable immutable descriptions, then gives each invocation its own entered Agent +and owned resources. + +MCP discovery caches resolved immutable server definitions rather than entered tool +wrappers. Each hydration builds fresh MCP tools and HTTP clients because MAF's Agent +context manager enters and closes every MCP tool it owns. The process-wide +`ClientManager`, credential-provider caches, immutable function tools, and skill paths +remain reusable where their existing contracts permit it. A fresh provider chat client, +history provider, web-request tool set, sandbox tool set, and MAF Agent are constructed +for each invocation. + +For async Functions and activities, the wrapper constructs the Agent on the current +worker event loop, enters it before calling the customer handler, injects the entered +raw `agent_framework.Agent`, and exits it in `finally` semantics when the handler +returns, raises, or is cancelled. The customer may add or remove tools and middleware, +create `AgentSession` values, call `run()` zero or more times, stream responses, and +choose per-run options. Mutations are invocation-local because no live Agent is reused. +The Agent must not be retained after the handler returns. + +Because the runtime no longer intercepts raw `Agent.run()`, async customer code owns +session selection and model-call timeout policy. Calling `run()` without a session uses +MAF's stateless behavior; passing a new `AgentSession` creates an isolated conversation. +The Azure Functions invocation timeout remains the outer bound. MAF model/tool spans +remain nested under the binding invocation span and worker span. + +The async wrapper is conceptually: + +```python +async def wrapped_handler(*args, **kwargs): + agent = blueprint.build(invocation_context) + async with agent: + kwargs[arg_name] = agent + return await user_handler(*args, **kwargs) +``` + +For orchestrators, `DurableAiAgent.run()` performs no model, tool, environment, cache, +or network access. It synchronously validates JSON-serializable input and returns +`context.call_activity("_afa_agent_binding_run", payload)`. The payload contains the +normalized slug, messages/options, and orchestration instance ID. No generated UUID, +wall-clock value, process cache value, or mutable call counter enters the payload; the +Durable history's activity schedule order is the call sequence. A single internal +async activity is registered once per `DurableAiApp` when its first orchestrator +binding is decorated. It hydrates a fresh Agent from the blueprint, performs one +runtime-managed call, closes the Agent, and returns the documented JSON dictionary. On +replay, the generator schedules the same activity at the same history +position and `yield` receives the recorded result; the activity and model call are not +re-executed. Two source calls produce two ordered history actions without a custom +sequence field. Streaming is unsupported in orchestrators. + +Cancellation of an async handler naturally cancels its current-loop work and still +exits the Agent context. No binding-specific executor or shutdown API is required; +shared client-manager cleanup remains available through `shutdown_client_manager()`. +The obsolete preview names `AiAgent`, `SyncAiAgent`, `shutdown_agent_cache`, and +`shutdown_agent_runtime` are not exported; async handlers import `Agent` directly from +`agent_framework`. + +When a Functions `Context` parameter is available, its invocation ID seeds runtime +correlation and invocation-scoped resources; otherwise the runtime generates an ID. + +### 4.5 Identity and observability + +`AiApp` and the free decorator call the existing idempotent observability bootstrap. +Hydration and the user handler execute inside `agent.binding.invoke `, nested +under the worker's active Function span. Attributes include agent identity/model, +Function name, invocation ID when available, outcome, and fault domain. MAF model and +tool spans inherit this active context. Prompt and response content remain governed by +the existing sensitive-data setting. + +An orchestrator emits no model span during replay. Its generated activity creates the +`agent.binding.run ` span with Durable instance ID; the durable task/activity +trace context provides correlation with the orchestration. + +Model, storage, MCP, and system tools continue to use the Function App's configured +managed identity and existing client-ID precedence. Caller bearer tokens are neither +accepted nor forwarded, and v1 makes no end-user delegated-identity guarantee. + +### 4.6 V1 validation and limitations + +Binding parsing fails only for invalid YAML, missing/non-string `name` or +`description`, duplicate filename-derived slugs, failed discovery, unknown +`agent_name`, invalid decorator order, an incompatible app/mode pair, or a handler +shape incompatible with its declared mode. Ignored front-matter fields never make a +binding target invalid. + +`function` and `activity` modes require coroutine functions. `orchestrator` mode +requires a synchronous generator function and a runtime +`DurableOrchestrationContext`. Durable proxy messages and options must be +JSON-serializable. Orchestrator streaming and raw Agent access are unsupported. Async +`function` and `activity` handlers receive raw MAF Agents and support the complete MAF +API. `entity` is not a valid mode. + +Subagent, workflow, trigger, endpoint, schema, and per-agent capability declarations +are ignored by the binding projection rather than rejected. They continue to affect +the same file when it is also consumed by `create_function_app()`. + +### 4.7 Compatibility and migration + +`create_function_app()` remains the zero-code declarative path. It delegates to the +same composition pipeline and returns an enhanced plain or Durable app while retaining +its existing public return compatibility, routes, triggers, indexing logs, and +workflow behavior. + +Existing `func.FunctionApp()` customers add the package import and free decorator; +they do not replace their app object or standard bindings. Customers starting a new +hybrid app may use `AiApp`; Durable customers may use `DurableAiApp` or the free +decorator with an existing `DFApp`. A customer may also add hybrid handlers directly +to the enhanced plain or Durable app returned by `create_function_app()`. Referencing +a definition already exposed declaratively is allowed: the declarative path uses the +complete front matter, while the binding path uses its minimal projection and cached +blueprint. Direct `run_agent()` callers remain supported and retain their current behavior. + +A smart decorator validates its requested target, not every authoring file in the +root. Full reachability validation remains part of `create_function_app()`. Therefore, +a caller-owned app that never calls `create_function_app()` does not validate ignored +fields or reachability for unrelated definitions; those files have no binding effect +until referenced. This is intentional so independent hybrid modules can be imported in +any order without a process-global registration phase. + +The MAF dependency update is a package-level compatibility change. Existing +declarative and direct-runner tests must pass against the exact resolved set and its +unified `run(stream=...)` and Agent context-manager APIs before release; compatibility +shims for MAF 1.3 are not part of v1. + +## 5. Decisions log + +| # | Decision | Options considered | Choice | Decided by | Date | +| - | -------- | ------------------ | ------ | ---------- | ---- | +| 1 | Integration layer | Python smart injection / true host binding / phased metadata | Python smart injection in v1; no host extension | Human | 2026-08-11 | +| 2 | Existing app ergonomics | subclass only / monkey-patch / free decorator | `AiApp.agent_input` plus free `agent_input(app, ...)` sharing one implementation | Human | 2026-08-11 | +| 3 | Injected type | raw MAF `Agent` / managed facade / both | Fresh raw MAF `Agent` per invocation | Human | 2026-08-11 | +| 4 | Durable scope | activities / direct orchestrator injection / exclude v1 | Exclude all Durable injection scenarios in v1 | Human | 2026-08-11 | +| 5 | Identity | app identity / OBO delegation / claims only | Existing app managed identity and trace context only | Human | 2026-08-11 | +| 6 | Definition lookup | filename identity / display name / path | Existing filename-derived logical identity and slug normalization | Agent | 2026-08-11 | +| 7 | Definition lifetime | cache Agent / cache catalog only / rebuild everything | Cache static composition and capabilities; never cache live Agents | Agent | 2026-08-11 | +| 8 | Binding-only definitions | require endpoint / permit any endpoint-less definition / explicit reachability | Permit no-trigger definitions only when referenced by a smart binding | Agent | 2026-08-11 | +| 9 | Repository isolation | required worktree / current checkout branch | Use local `hallvictoria/agent-binding` branch without a worktree, by explicit request | Human | 2026-08-11 | +| 10 | Decorator integration | mutate FunctionBuilder / support one pure-wrapper order / subclass only | Pure callable wrapper; `agent_input` must be innermost; no SDK-private state | Agent | 2026-08-11 | +| 11 | Composition timing | full snapshot at app creation / lazy invocation / eager targeted composition | Compile each referenced target eagerly at decoration time and cache per app/root | Agent | 2026-08-11 | +| 12 | Durable app behavior | allow normal functions on DFApp / inspect target metadata / reject DFApp | Reject `DFApp` entirely for v1 smart bindings | Agent | 2026-08-11 | +| 13 | MAF cleanup | close Agent / enter Agent / own created resources | For supported MAF 1.3, use an invocation `AsyncExitStack` only for runtime-created scoped resources; Agent and shared clients have no per-call close | Agent | 2026-08-11 | +| 14 | Unreferenced hybrid definitions | global validation / target-only validation | Caller-owned hybrid apps validate targets only; `create_function_app()` retains full reachability validation | Agent | 2026-08-11 | +| 15 | Durable binding scope | activities only / activities + replay-safe orchestrators / activities + orchestrators + entities | Supersedes #4 and #12: support all three in v1; orchestrators schedule an internal activity, activities use async/sync facades, and entities use a synchronous facade with at-least-once caveats | Human | 2026-08-11 | +| 16 | Binding front matter | complete schema / selected capability fields / name and description only | Binding projection recognizes only required `name` and `description` plus markdown instructions; every other per-agent field is ignored | Human | 2026-08-11 | +| 17 | MAF version | retain 1.3 / adopt latest compatible release | Supersedes #13: use resolver-verified core 1.13.0, OpenAI 1.12.0, and Foundry 1.10.4; use explicit sessions and Agent async lifecycle | Human | 2026-08-11 | +| 18 | Agent lifetime | fresh per invocation / singleton / bounded pool | Supersedes #3 and #7: cache one runtime-owned MAF Agent per app/root/slug and isolate calls with fresh AgentSessions; inject controlled facades rather than mutable raw objects | Human | 2026-08-11 | +| 19 | Cross-mode async ownership | caller event loops / cache per loop / process executor loop | Own cached Agents and their lifecycle on one process async executor; bridge async and sync facades while propagating trace context | Agent | 2026-08-11 | +| 20 | Cached Agent concurrency | concurrent re-entry / per-session lock / per-Agent lease | Serialize complete runs through a per-cache-entry async lock; different Agent entries remain concurrent | Agent | 2026-08-11 | +| 21 | Durable replay identity | generated sequence / mutable counter / history order | Use only deterministic payload fields; Durable activity history position distinguishes repeated calls and supplies recorded results on replay | Agent | 2026-08-11 | +| 22 | Hybrid snapshot ownership | share parsed snapshot / separate snapshots with shared discovery | Full and binding composition own separate parsed snapshots; binding snapshots are per app, while root-keyed discovery caches are shared | Agent | 2026-08-11 | +| 23 | Binding validation scope | validate full project / target only | Binding composition validates its requested minimal projection only; `create_function_app()` independently retains full-project validation | Agent | 2026-08-11 | +| 24 | Agent cache boundary | live Agent / immutable blueprint / no cache | Supersedes #18-#20: cache compiled definitions and reusable dependency descriptions, never a live MAF Agent; hydrate and close a fresh Agent per Function invocation | Human | 2026-08-12 | +| 25 | Customer control | controlled async facade / raw Agent / both | Supersedes the async portion of #15 and #18: async Functions and activities receive the entered raw `agent_framework.Agent`; sync handlers/entities retain an invocation-owned blocking facade and orchestrators retain the replay-safe scheduling proxy | Human | 2026-08-12 | +| 26 | Raw-Agent execution policy | runtime-owned session/timeout / customer-owned / hidden middleware | Async raw-Agent callers own sessions, run options, middleware, streaming, and model-call timeout; generated Durable activity and sync facades retain runtime-managed sessions and timeout | Agent | 2026-08-12 | +| 27 | MCP lifetime | cache live MCP tools / cache resolved definitions / rediscover files | Cache immutable resolved MCP definitions and construct fresh MCP tools/HTTP clients for each Agent context, because MAF enters and closes owned MCP tools | Agent | 2026-08-12 | +| 28 | Revised public surface | retain cache-era names / aliases / clean replacement | Remove preview-only `AiAgent` and `shutdown_agent_cache`; customers annotate async injection with `agent_framework.Agent`, while `shutdown_agent_runtime()` names the remaining sync-executor/client-manager cleanup | Agent | 2026-08-12 | +| 29 | Synchronous handler scope | blocking facade / per-call `asyncio.run()` / async-only | Supersedes the sync and entity portions of #15, #19, #25, and #28: require coroutine Functions and activities, retain only the synchronous replay-safe orchestrator proxy, exclude entity injection, and remove `SyncAiAgent`, `AgentExecutor`, and `shutdown_agent_runtime` | Human | 2026-08-12 | + +## 6. Test plan + +- [ ] Unit: logical-name resolution, malformed YAML, required name/description, + duplicate slugs, and ignored trigger/endpoint/model/tool/skill/MCP/schema/workflow/ + subagent fields, including invalid values in ignored fields. +- [ ] Unit: binding hydration uses markdown instructions, app-level model and system + tools, all discovered user tools/skills/MCP, and session-aware history. +- [ ] Unit: repeated async invocations reuse the same immutable blueprint but receive + distinct raw Agents, clients, MCP tools, context stacks, and mutable tool lists. +- [ ] Unit: concurrent invocations of the same slug overlap without an Agent lease and + cannot leak customer mutations or conversation state across invocation boundaries. +- [ ] Unit: async handler success, failure, and cancellation always exit the Agent; + trace-context propagation and invocation-ID fallback remain covered. +- [ ] Unit: synchronous Function/activity handlers and `mode="entity"` fail during + decorator application with actionable async-only diagnostics. +- [ ] Indexing: real `FunctionApp.get_functions()` coverage for `AiApp`, caller-owned + `FunctionApp`, `DurableAiApp`, caller-owned `DFApp`, and `create_function_app()`. +- [ ] Indexing: the supported innermost decorator order, actionable rejection of the + reverse order, worker-facing signature, unchanged trigger/output binding JSON, + duplicate argument errors, mode/app mismatch, and handler-shape validation. +- [ ] Indexing: SDK probes run against `azure-functions` 2.1 and the resolved upper + supported release plus Durable 1.2.10 without importing or mutating + `FunctionBuilder` internals. +- [ ] Durable: async activities receive fresh raw Agents; orchestrator replay + schedules the same internal activity at the same history position and never repeats + model I/O; two calls preserve order; payload and result serialization are stable. +- [ ] Durable: `stream=True` on `DurableAiAgent.run()` fails before activity scheduling + with an actionable non-streaming error. +- [ ] Observability: active-parent correlation, nested MAF spans, outcomes, invocation + attributes, current-loop propagation, Durable correlation/replay, sensitive-data + gating, and managed-identity client selection. +- [ ] Samples: a standalone `AiApp` demonstrates async HTTP and event-driven handlers, + while a standalone `DurableAiApp` demonstrates an async activity and replay-safe + orchestrator; both use a minimal binding definition, tool, skill, and MCP server. +- [ ] E2E: Core Tools indexes and invokes hybrid HTTP and Durable apps; fake clients + cover normal CI and the official credentialed lane covers model calls where available. +- [ ] Dependency: runner, streaming, MCP lifecycle, and observability behavior pass on + core 1.13.0, OpenAI 1.12.0, and Foundry 1.10.4 with no remaining MAF 1.3 assumptions. +- [ ] Dependency: the first Phase 3 change updates all three `pyproject.toml` pins and + a clean resolver install selects the exact approved versions. +- [ ] Regression: an existing multi-agent fixture produces the expected ordered + Function names and serialized binding dictionaries after the `composition.py` + extraction; direct runner, trigger, endpoint, workflow, package-import, and sample + tests remain green. + +## 7. Docs impact + +- [ ] `docs/architecture.md` — `composition.py` module ownership, unchanged full + declarative pass, minimal binding projection, blueprint ownership, and + Durable smart-invocation pipeline. +- [ ] `docs/front-matter-spec.md` — binding projection recognizes only name, + description, and markdown instructions; all other fields are ignored. +- [ ] `docs/observability.md` — binding invocation/activity spans and Durable + correlation/replay. +- [ ] `docs/workflows.md` — async Durable activity, orchestrator proxy, and internal + activity behavior. +- [ ] `README.md` — normal and Durable hybrid APIs, Agent lifecycle, and migration from + `FunctionApp`, `DFApp`, and `create_function_app()`. +- [ ] `samples/README.md`, `samples/hybrid-function-agent/`, and + `samples/hybrid-durable-agent/` — runnable ordinary and Durable examples. + +No `schema.py` change is planned, so generated front-matter reference regeneration is +not expected. + +## 8. Status & sign-off + +- **Architecture review (phase 2):** Approved. The independent review found + the prior fresh-Agent/non-Durable design viable and approved it. Human amendments + then added Durable modes, minimal front matter, updated MAF packages, and cached Agents. + The amendment review requested cache-concurrency, executor-bridge, replay, return- + type, version, and entity-retry clarification; those contracts are now recorded. + A final review then requested concrete discovery inventory fields, snapshot/context + lifecycle, focused edge tests, and an explicit note that dependency edits occur only + after sign-off. Those contracts are now recorded. A human amendment on 2026-08-12 + replaced live-Agent caching with cached immutable blueprints and fresh raw Agents; + the amendment preserves Durable proxies and invocation-owned sync facades. +- **Async-only amendment:** Approved by hallvictoria on 2026-08-12. V1 now follows + MAF's native async protocol for Functions and activities, removes the blocking sync + facade/executor, and excludes Durable Entity injection while preserving the + replay-safe synchronous orchestrator proxy. +- **Human sign-off:** Approved by hallvictoria on 2026-08-12. Status set to `Finalized`. diff --git a/docs/frds/README.md b/docs/frds/README.md index 2843239f..85026f6f 100644 --- a/docs/frds/README.md +++ b/docs/frds/README.md @@ -36,6 +36,7 @@ The full lifecycle that produces an FRD lives in [`../../AGENTS.md`](../../AGENT | [0005](0005-web-request-system-tool.md) | `web_request` system tool | In review | | [0006](0006-endpoint-authentication.md) | Endpoint & HTTP trigger authentication (API key / Entra ID) | Finalized | | [0007](0007-multi-agent-delegation.md) | Multi-agent delegation (agent-as-tool) | In review | +| [0008](0008-agent-input-binding.md) | Python agent input binding | Finalized | > `_template.md` is the template, not an FRD — the leading underscore keeps it > sorted first and excludes it from numbering. diff --git a/docs/front-matter-spec.md b/docs/front-matter-spec.md index b81ee09c..93f7325d 100644 --- a/docs/front-matter-spec.md +++ b/docs/front-matter-spec.md @@ -9,6 +9,8 @@ Azure Functions agents use a **two-tier configuration system**: Each agent is defined in a `.agent.md` file with YAML front matter followed by markdown instructions. The front matter configures the agent-specific behavior, while the markdown body contains the agent's system prompt. +> **Smart agent input binding:** A definition referenced by `AiApp.agent_input()` or `agent_input(app, ...)` has a deliberately smaller projection. The binding requires only non-empty string `name` and `description` fields and uses the markdown body as instructions. It ignores every other per-agent field, even if that ignored value would be invalid for declarative `create_function_app()` usage. Model, timeout, system tools, user tools, skills, and MCP servers come from `agents.config.yaml` and app-level discovery. `agent_name` resolves the filename stem first and then its normalized slug; it never resolves the display `name`. + ### Configuration Model **Global configuration defines infrastructure and defaults:** @@ -147,7 +149,7 @@ Fields are organized into categories based on how they can be used: ### Required Fields (Agent Front Matter Only) -**Summary:** Every `.agent.md` file must have `name` and `description`. It must also have either a `trigger` or at least one enabled `builtin_endpoints` value. +**Summary:** Every `.agent.md` file must have `name` and `description`. Declarative definitions consumed by `create_function_app()` must also have a trigger, enabled built-in endpoint, or a valid internal subagent reference. Binding-only definitions referenced by `agent_input` need no trigger or endpoint. #### `name` - **Type:** `string` diff --git a/docs/observability.md b/docs/observability.md index 4729060e..8918c560 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -90,6 +90,16 @@ name — for example `server.address` for the session-pool host. MAF keeps emitt ## Spans and attributes we emit today +### Span `agent.binding.invoke ` + +One span surrounds each async smart-binding handler invocation, including fresh Agent hydration, all customer-controlled calls on the raw Agent, and Agent closure. MAF model and tool spans inherit the active Functions trace context. Attributes include `gen_ai.agent.name`, the configured `gen_ai.request.model`, `faas.name` and `faas.invocation_id` when a Functions `Context` is present, `af.lifecycle_stage=agent_run`, and `af.binding.outcome` (`success`, `cancelled`, or `error`). Async customer code owns model-call timeout policy, so the binding does not emit a timeout outcome for raw Agent calls. + +### Span `agent.binding.run ` + +One span is emitted when the generated Durable activity performs its runtime-managed Agent call. It includes the binding identity attributes above, `durable.instance_id`, and `af.binding.outcome` (`success`, `timeout`, `cancelled`, or `error`). + +Orchestrator replay emits no binding or model span because `DurableAiAgent.run()` only schedules an activity. The generated activity emits the span when it actually performs model/tool work. + ### Cross-cutting `af.*` (any runtime span) | Attribute | Meaning | diff --git a/docs/workflows.md b/docs/workflows.md index 7c4f239b..c6c7ca83 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -1,5 +1,14 @@ # Dynamic workflows (experimental v1) +## Durable smart agent input + +The `agent_input` feature is separate from markdown-authored Dynamic Workflows. It lets customer-owned Durable handlers invoke one markdown agent while retaining their own orchestration code: + +- Activities declare `mode="activity"`, must use `async def`, and receive a fresh raw `agent_framework.Agent`. The Agent is closed when the activity handler exits and must not be retained. +- Synchronous generator orchestrators declare `mode="orchestrator"` and receive `DurableAiAgent`. Its non-streaming `run()` returns a `TaskBase` for the handler to yield; model and tool I/O occurs only in the runtime-generated `_afa_agent_binding_run` activity, preserving replay determinism. + +The yielded orchestrator result is a JSON object with `text`, `messages`, `response_id`, and `usage`. Messages and options passed to the proxy must be JSON-serializable. + > [!NOTE] > **Status: public experimental v1.** The API is intentionally small and > may change based on early feedback, but the behavior described here is diff --git a/pyproject.toml b/pyproject.toml index 5e9023b3..8452fe14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,9 +12,9 @@ requires-python = ">=3.13" dependencies = [ "azure-functions>=2.1.0,<3", "azure-functions-durable>=1.2.10,<2", - "agent-framework-core==1.3.*", - "agent-framework-openai==1.3.*", - "agent-framework-foundry==1.3.*", + "agent-framework-core==1.13.0", + "agent-framework-openai==1.12.0", + "agent-framework-foundry==1.10.4", "pydantic>=2.13.4,<3", "python-frontmatter>=1.1.0,<2", "azurefunctions-extensions-http-fastapi>=1.0.1,<2", diff --git a/samples/README.md b/samples/README.md index a44a3af2..8c2346b7 100644 --- a/samples/README.md +++ b/samples/README.md @@ -13,6 +13,10 @@ app deployable with [`azd up`](https://learn.microsoft.com/azure/developer/azure | [workflow-incident-triage](workflow-incident-triage/) | HTTP | | | | | | ✅ | | [workflow-queue-p0-report](workflow-queue-p0-report/) | Queue | ✅ workflow-safe | | | | | | | [secured-endpoints](secured-endpoints/) | HTTP + MCP | | | | | | | +| [hybrid-function-agent](hybrid-function-agent/) | HTTP + Queue | ✅ order totals | | ✅ MS Learn | ✅ order-review | | | +| [hybrid-durable-agent](hybrid-durable-agent/) | Durable Activity + Orchestrator | ✅ order totals | | ✅ MS Learn | ✅ order-review | | | + +The [hybrid-function-agent](hybrid-function-agent/) sample demonstrates `AiApp` injecting one agent into async HTTP and queue handlers. The [hybrid-durable-agent](hybrid-durable-agent/) sample demonstrates `DurableAiApp` injection into an async activity and a replay-safe orchestrator. ## Design previews diff --git a/samples/hybrid-durable-agent/README.md b/samples/hybrid-durable-agent/README.md new file mode 100644 index 00000000..ce4f0ca8 --- /dev/null +++ b/samples/hybrid-durable-agent/README.md @@ -0,0 +1,35 @@ +# Hybrid Durable agent binding + +This sample uses `DurableAiApp` to keep deterministic Durable Functions orchestration code in Python while invoking a markdown-defined Serverless Agent through replay-safe bindings. + +It demonstrates: + +- an HTTP starter using the standard Durable client binding; +- an async Durable activity receiving a fresh raw `agent_framework.Agent`; +- a synchronous generator orchestrator receiving `DurableAiAgent`, which schedules the runtime-generated `_afa_agent_binding_run` activity. + +`DurableAiAgent` performs no model, network, or tool I/O in the orchestrator. The generated activity hydrates a fresh Agent, performs the runtime-managed call, closes the Agent, and records the JSON-safe result in Durable history. + +The binding projection reads only `name`, `description`, and the markdown body from `order-fulfillment.agent.md`. Model, timeout, tools, skills, MCP servers, and system tools come from app-level configuration and discovery. + +Activity handlers using `agent_input` must be declared with `async def`. Each activity invocation receives its own entered Agent; the runtime closes it when the handler exits, so do not retain it beyond that invocation. + +## Run locally + +From `src/`, copy `local.settings.template.json` to `local.settings.json`, fill in the Foundry settings, start Azurite, then run: + +```bash +func start +``` + +Start `order_orchestrator` with a JSON order object: + +```bash +curl -X POST http://localhost:7071/orders/orchestrations \ + -H "Content-Type: application/json" \ + -d '{"items":[{"sku":"A-100","quantity":2}]}' +``` + +The response contains the standard Durable status URLs for the new orchestration instance. + +For ordinary HTTP and queue bindings using `AiApp`, see the sibling [`hybrid-function-agent`](../hybrid-function-agent/) sample. diff --git a/samples/hybrid-durable-agent/src/agents.config.yaml b/samples/hybrid-durable-agent/src/agents.config.yaml new file mode 100644 index 00000000..1568be97 --- /dev/null +++ b/samples/hybrid-durable-agent/src/agents.config.yaml @@ -0,0 +1,2 @@ +model: $FOUNDRY_MODEL +timeout: 120 diff --git a/samples/hybrid-durable-agent/src/function_app.py b/samples/hybrid-durable-agent/src/function_app.py new file mode 100644 index 00000000..979f35b8 --- /dev/null +++ b/samples/hybrid-durable-agent/src/function_app.py @@ -0,0 +1,62 @@ +import json +from typing import cast + +import azure.durable_functions as df +from agent_framework import Agent +from azurefunctions.extensions.http.fastapi import Request, Response + +from azure_functions_agents import DurableAiAgent, DurableAiApp + +app = DurableAiApp() + + +@app.durable_client_input(client_name="client") +@app.route(route="orders/orchestrations", methods=["POST"]) +async def start_order_orchestration( + req: Request, + client: str, +) -> Response: + durable_client = cast(df.DurableOrchestrationClient, client) + instance_id = await durable_client.start_new( + "order_orchestrator", + client_input=await req.json(), + ) + management = durable_client.create_http_management_payload(instance_id) + return Response( + content=json.dumps(management), + status_code=202, + media_type="application/json", + headers={ + "Location": management["statusQueryGetUri"], + "Retry-After": "10", + }, + ) + + +@app.activity_trigger(input_name="order") +@app.agent_input( + arg_name="order_agent", + agent_name="order-fulfillment", + mode="activity", +) +async def assess_order_activity(order: dict, order_agent: Agent) -> str: + response = await order_agent.run( + json.dumps({"order": order, "task": "assess risk"}) + ) + return response.text + + +@app.orchestration_trigger(context_name="context") +@app.agent_input( + arg_name="planner", + agent_name="order-fulfillment", + mode="orchestrator", +) +def order_orchestrator( + context: df.DurableOrchestrationContext, + planner: DurableAiAgent, +): + plan = yield planner.run( + json.dumps({"order": context.get_input(), "task": "create a plan"}) + ) + return plan["text"] diff --git a/samples/hybrid-durable-agent/src/host.json b/samples/hybrid-durable-agent/src/host.json new file mode 100644 index 00000000..4a421d09 --- /dev/null +++ b/samples/hybrid-durable-agent/src/host.json @@ -0,0 +1,17 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + }, + "logging": { + "logLevel": { + "default": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/samples/hybrid-durable-agent/src/local.settings.template.json b/samples/hybrid-durable-agent/src/local.settings.template.json new file mode 100644 index 00000000..a983aedc --- /dev/null +++ b/samples/hybrid-durable-agent/src/local.settings.template.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "AZURE_FUNCTIONS_AGENTS_PROVIDER": "foundry", + "FOUNDRY_PROJECT_ENDPOINT": "https://..services.ai.azure.com/api/projects/", + "FOUNDRY_MODEL": "gpt-5.4" + } +} diff --git a/samples/hybrid-durable-agent/src/mcp.json b/samples/hybrid-durable-agent/src/mcp.json new file mode 100644 index 00000000..4873b588 --- /dev/null +++ b/samples/hybrid-durable-agent/src/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "microsoft-learn": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp" + } + } +} diff --git a/samples/hybrid-durable-agent/src/order-fulfillment.agent.md b/samples/hybrid-durable-agent/src/order-fulfillment.agent.md new file mode 100644 index 00000000..be6a3e01 --- /dev/null +++ b/samples/hybrid-durable-agent/src/order-fulfillment.agent.md @@ -0,0 +1,8 @@ +--- +name: Order Fulfillment +description: Validates, triages, and plans order fulfillment work +--- + +You are an order fulfillment specialist. +Assess the supplied order or event, identify risks and missing information, and return a concise actionable response. +Never claim that an external action completed unless a tool result confirms it. diff --git a/samples/hybrid-durable-agent/src/requirements.txt b/samples/hybrid-durable-agent/src/requirements.txt new file mode 100644 index 00000000..73d526eb --- /dev/null +++ b/samples/hybrid-durable-agent/src/requirements.txt @@ -0,0 +1 @@ +-e ../../..[monitor] diff --git a/samples/hybrid-durable-agent/src/skills/order-review/SKILL.md b/samples/hybrid-durable-agent/src/skills/order-review/SKILL.md new file mode 100644 index 00000000..ade47dd6 --- /dev/null +++ b/samples/hybrid-durable-agent/src/skills/order-review/SKILL.md @@ -0,0 +1,12 @@ +--- +name: order-review +description: Review order payloads for fulfillment readiness and operational risk. +--- + +# Order review + +Use `summarize_order_quantities` before assessing an order with line items. + +Flag missing SKUs, non-positive quantities, unusually large totals, and details that +prevent fulfillment. Keep recommendations concise and never claim an external action +completed without a confirming tool result. diff --git a/samples/hybrid-durable-agent/src/tools/order_totals.py b/samples/hybrid-durable-agent/src/tools/order_totals.py new file mode 100644 index 00000000..561709cf --- /dev/null +++ b/samples/hybrid-durable-agent/src/tools/order_totals.py @@ -0,0 +1,12 @@ +from __future__ import annotations + + +def summarize_order_quantities(items: list[dict[str, object]]) -> dict[str, int]: + """Count line items and total integer quantities in an order.""" + quantities = [item.get("quantity", 0) for item in items] + return { + "line_items": len(items), + "total_quantity": sum( + quantity for quantity in quantities if isinstance(quantity, int) + ), + } diff --git a/samples/hybrid-function-agent/README.md b/samples/hybrid-function-agent/README.md new file mode 100644 index 00000000..03309588 --- /dev/null +++ b/samples/hybrid-function-agent/README.md @@ -0,0 +1,32 @@ +# Hybrid Function agent binding + +This sample uses `AiApp` to keep ordinary Azure Functions triggers and deterministic application logic in Python while injecting a markdown-defined Serverless Agent in process. + +It demonstrates: + +- an HTTP-triggered function using a fresh raw `agent_framework.Agent`; +- a queue-triggered function using a fresh raw Agent. + +The binding projection reads only `name`, `description`, and the markdown body from `order-fulfillment.agent.md`. Model, timeout, tools, skills, MCP servers, and system tools come from app-level configuration and discovery. + +Functions using `agent_input` must be declared with `async def`. Each invocation receives its own entered Agent and may control sessions, options, middleware, streaming, and model-call timeout. The runtime closes the Agent when the handler exits; do not retain it beyond that invocation. + +## Run locally + +From `src/`, copy `local.settings.template.json` to `local.settings.json`, fill in the Foundry settings, start Azurite, then run: + +```bash +func start +``` + +Invoke the HTTP function: + +```bash +curl -X POST http://localhost:7071/orders/42 \ + -H "Content-Type: application/json" \ + -d '{"items":[{"sku":"A-100","quantity":2}]}' +``` + +Add JSON messages to the `orders` queue to invoke the event-driven handler. + +For Durable activity and orchestrator bindings, see the sibling [`hybrid-durable-agent`](../hybrid-durable-agent/) sample. diff --git a/samples/hybrid-function-agent/src/agents.config.yaml b/samples/hybrid-function-agent/src/agents.config.yaml new file mode 100644 index 00000000..1568be97 --- /dev/null +++ b/samples/hybrid-function-agent/src/agents.config.yaml @@ -0,0 +1,2 @@ +model: $FOUNDRY_MODEL +timeout: 120 diff --git a/samples/hybrid-function-agent/src/function_app.py b/samples/hybrid-function-agent/src/function_app.py new file mode 100644 index 00000000..77bbee2f --- /dev/null +++ b/samples/hybrid-function-agent/src/function_app.py @@ -0,0 +1,52 @@ +import json + +import azure.functions as func +from agent_framework import Agent +from azurefunctions.extensions.http.fastapi import Request, Response + +from azure_functions_agents import AiApp + +app = AiApp() + + +@app.route(route="orders/{orderId}", methods=["POST"]) +@app.agent_input(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order( + req: Request, + order_agent: Agent, +) -> Response: + order_id = req.path_params["orderId"] + order = await req.json() + if not isinstance(order, dict) or "items" not in order: + return Response( + content="Request body must contain an items array.", + status_code=400, + ) + + response = await order_agent.run( + json.dumps({"order_id": order_id, "items": order["items"], "task": "validate"}) + ) + return Response( + content=json.dumps({"order_id": order_id, "assessment": response.text}), + media_type="application/json", + ) + + +# @app.queue_trigger( +# arg_name="message", +# queue_name="orders", +# connection="AzureWebJobsStorage", +# ) +# @app.agent_input(arg_name="order_agent", agent_name="order-fulfillment") +# async def process_order_event( +# message: func.QueueMessage, +# order_agent: Agent, +# ) -> None: +# await order_agent.run( +# json.dumps( +# { +# "event": json.loads(message.get_body().decode("utf-8")), +# "task": "triage", +# } +# ) +# ) diff --git a/samples/hybrid-function-agent/src/host.json b/samples/hybrid-function-agent/src/host.json new file mode 100644 index 00000000..4a421d09 --- /dev/null +++ b/samples/hybrid-function-agent/src/host.json @@ -0,0 +1,17 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + }, + "logging": { + "logLevel": { + "default": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/samples/hybrid-function-agent/src/local.settings.template.json b/samples/hybrid-function-agent/src/local.settings.template.json new file mode 100644 index 00000000..a983aedc --- /dev/null +++ b/samples/hybrid-function-agent/src/local.settings.template.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "AZURE_FUNCTIONS_AGENTS_PROVIDER": "foundry", + "FOUNDRY_PROJECT_ENDPOINT": "https://..services.ai.azure.com/api/projects/", + "FOUNDRY_MODEL": "gpt-5.4" + } +} diff --git a/samples/hybrid-function-agent/src/mcp.json b/samples/hybrid-function-agent/src/mcp.json new file mode 100644 index 00000000..4873b588 --- /dev/null +++ b/samples/hybrid-function-agent/src/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "microsoft-learn": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp" + } + } +} diff --git a/samples/hybrid-function-agent/src/order-fulfillment.agent.md b/samples/hybrid-function-agent/src/order-fulfillment.agent.md new file mode 100644 index 00000000..be6a3e01 --- /dev/null +++ b/samples/hybrid-function-agent/src/order-fulfillment.agent.md @@ -0,0 +1,8 @@ +--- +name: Order Fulfillment +description: Validates, triages, and plans order fulfillment work +--- + +You are an order fulfillment specialist. +Assess the supplied order or event, identify risks and missing information, and return a concise actionable response. +Never claim that an external action completed unless a tool result confirms it. diff --git a/samples/hybrid-function-agent/src/requirements.txt b/samples/hybrid-function-agent/src/requirements.txt new file mode 100644 index 00000000..73d526eb --- /dev/null +++ b/samples/hybrid-function-agent/src/requirements.txt @@ -0,0 +1 @@ +-e ../../..[monitor] diff --git a/samples/hybrid-function-agent/src/skills/order-review/SKILL.md b/samples/hybrid-function-agent/src/skills/order-review/SKILL.md new file mode 100644 index 00000000..ade47dd6 --- /dev/null +++ b/samples/hybrid-function-agent/src/skills/order-review/SKILL.md @@ -0,0 +1,12 @@ +--- +name: order-review +description: Review order payloads for fulfillment readiness and operational risk. +--- + +# Order review + +Use `summarize_order_quantities` before assessing an order with line items. + +Flag missing SKUs, non-positive quantities, unusually large totals, and details that +prevent fulfillment. Keep recommendations concise and never claim an external action +completed without a confirming tool result. diff --git a/samples/hybrid-function-agent/src/tools/order_totals.py b/samples/hybrid-function-agent/src/tools/order_totals.py new file mode 100644 index 00000000..561709cf --- /dev/null +++ b/samples/hybrid-function-agent/src/tools/order_totals.py @@ -0,0 +1,12 @@ +from __future__ import annotations + + +def summarize_order_quantities(items: list[dict[str, object]]) -> dict[str, int]: + """Count line items and total integer quantities in an order.""" + quantities = [item.get("quantity", 0) for item in items] + return { + "line_items": len(items), + "total_quantity": sum( + quantity for quantity in quantities if isinstance(quantity, int) + ), + } diff --git a/src/azure_functions_agents/__init__.py b/src/azure_functions_agents/__init__.py index 6cc7e657..948119d4 100644 --- a/src/azure_functions_agents/__init__.py +++ b/src/azure_functions_agents/__init__.py @@ -110,6 +110,12 @@ def _patched_warn( from ._function_tool import tool, workflow_tool # noqa: E402 from .app import create_function_app # noqa: E402 +from .bindings import ( # noqa: E402 + AiApp, + DurableAiAgent, + DurableAiApp, + agent_input, +) from .client_manager import ( # noqa: E402 ClientManager, MAFClientManager, @@ -132,9 +138,13 @@ def _patched_warn( "DEFAULT_MODEL", "DEFAULT_TIMEOUT", "AgentResult", + "AiApp", "ClientManager", + "DurableAiAgent", + "DurableAiApp", "MAFClientManager", "__version__", + "agent_input", "create_function_app", "create_sandbox_tools", "create_web_request_tools", diff --git a/src/azure_functions_agents/app.py b/src/azure_functions_agents/app.py index 1c8b5386..01890572 100644 --- a/src/azure_functions_agents/app.py +++ b/src/azure_functions_agents/app.py @@ -6,12 +6,12 @@ from pathlib import Path from typing import Any -import azure.durable_functions as df import azure.functions as func from ._logger import logger from ._observability import configure_observability from ._source_marker import source_marker +from .bindings import AiApp, DurableAiApp from .config.loader import load_agent_specs, load_global_config from .config.merge import compose from .config.paths import get_app_root, set_app_root @@ -159,9 +159,9 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: for resolved in resolved_agents ) app: func.FunctionApp = ( - df.DFApp(http_auth_level=func.AuthLevel.FUNCTION) + DurableAiApp(http_auth_level=func.AuthLevel.FUNCTION, app_root=resolved_root) if workflows_requested - else func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) + else AiApp(http_auth_level=func.AuthLevel.FUNCTION, app_root=resolved_root) ) # Collect indexing summary for structured logging diff --git a/src/azure_functions_agents/bindings.py b/src/azure_functions_agents/bindings.py new file mode 100644 index 00000000..0748b7ce --- /dev/null +++ b/src/azure_functions_agents/bindings.py @@ -0,0 +1,407 @@ +"""Public Python smart input binding for hybrid Azure Functions apps.""" + +from __future__ import annotations + +import asyncio +import functools +import inspect +import json +import threading +import weakref +from collections.abc import Callable, Generator, Mapping +from pathlib import Path +from typing import Any, Literal, TypeVar, cast + +import azure.durable_functions as df +import azure.functions as func + +from ._observability import FaultDomain, LifecycleStage, configure_observability, start_span +from .composition import ProjectSnapshot, compose_binding_target +from .composition import load_project_snapshot as _load_project_snapshot +from .config.paths import get_app_root +from .hydration import ( + AgentBlueprint, + InvocationMetadata, + open_agent, + run_blueprint, +) + +type AgentInputMode = Literal["function", "activity", "orchestrator"] + +_F = TypeVar("_F", bound=Callable[..., Any]) +_DURABLE_ACTIVITY_NAME = "_afa_agent_binding_run" + + +class DurableAiAgent: + """Replay-safe orchestrator facade that schedules model work as an activity.""" + + def __init__( + self, + context: df.DurableOrchestrationContext, + blueprint: AgentBlueprint, + ) -> None: + self._context = context + self._blueprint = blueprint + + def run( + self, + messages: Any = None, + *, + options: Mapping[str, Any] | None = None, + stream: bool = False, + ) -> Any: + if stream: + raise ValueError( + "DurableAiAgent does not support streaming; yield one non-streaming run() task" + ) + payload = { + "agent_slug": self._blueprint.slug, + "messages": messages, + "options": dict(options) if options is not None else None, + "instance_id": self._context.instance_id, + } + try: + json.dumps(payload) + except (TypeError, ValueError) as exc: + raise ValueError( + "DurableAiAgent messages and options must be JSON-serializable" + ) from exc + return self._context.call_activity(_DURABLE_ACTIVITY_NAME, payload) + + +class _BindingRuntime: + def __init__(self, app: func.FunctionApp, app_root: Path | None) -> None: + self.app = app + self.app_root = Path(app_root).resolve() if app_root is not None else get_app_root() + self._snapshot: ProjectSnapshot | None = None + self._blueprints: dict[str, AgentBlueprint] = {} + self._lock = threading.RLock() + self._durable_activity_registered = False + + def resolve(self, agent_name: str) -> AgentBlueprint: + with self._lock: + blueprint = self._blueprints.get(agent_name) + if blueprint is not None: + return blueprint + if self._snapshot is None: + self._snapshot = _load_project_snapshot(self.app_root) + if self._snapshot.discovery.failed_loads: + failures = "; ".join( + f"{source}: {reason}" + for source, reason in self._snapshot.discovery.failed_loads + ) + raise ValueError(f"Agent binding discovery failed: {failures}") + entry = compose_binding_target(self._snapshot, agent_name) + existing = self._blueprints.get(entry.definition.slug) + blueprint = existing if existing is not None else AgentBlueprint(entry) + self._blueprints[agent_name] = blueprint + self._blueprints[entry.definition.slug] = blueprint + self._blueprints[entry.definition.filename_stem] = blueprint + return blueprint + + def blueprint_for_slug(self, slug: str) -> AgentBlueprint: + with self._lock: + blueprint = self._blueprints.get(slug) + if blueprint is None: + raise ValueError(f"Durable agent binding target {slug!r} is not registered") + return blueprint + + def register_durable_activity(self) -> None: + with self._lock: + if self._durable_activity_registered: + return + if not isinstance(self.app, df.DFApp): + raise TypeError( + "Durable agent_input modes require DurableAiApp or azure.durable_functions.DFApp" + ) + + async def _afa_agent_binding_run( + payload: dict, # type: ignore[type-arg] + ) -> dict[str, Any]: + slug = str(payload.get("agent_slug") or "") + blueprint = self.blueprint_for_slug(slug) + instance_id = str(payload.get("instance_id") or "") or None + response = await run_blueprint( + blueprint, + payload.get("messages"), + session_id=instance_id, + options=payload.get("options"), + invocation=InvocationMetadata(durable_instance_id=instance_id), + ) + response_data = response.to_dict() if hasattr(response, "to_dict") else {} + result = { + "text": str(getattr(response, "text", "") or ""), + "messages": response_data.get("messages", []), + "response_id": getattr(response, "response_id", None), + "usage": response_data.get("usage_details"), + } + try: + json.dumps(result) + except (TypeError, ValueError) as exc: + raise RuntimeError( + f"Agent binding result for {slug!r} is not JSON-serializable " + "for Durable history" + ) from exc + return result + + activity_decorator = cast(Any, self.app).activity_trigger( + input_name="payload", + activity=_DURABLE_ACTIVITY_NAME, + ) + activity_decorator(_afa_agent_binding_run) + self._durable_activity_registered = True + + +_RUNTIMES: weakref.WeakKeyDictionary[func.FunctionApp, _BindingRuntime] = ( + weakref.WeakKeyDictionary() +) +_RUNTIMES_LOCK = threading.Lock() + + +def _runtime_for(app: func.FunctionApp, app_root: Path | None = None) -> _BindingRuntime: + with _RUNTIMES_LOCK: + runtime = _RUNTIMES.get(app) + if runtime is None: + runtime = _BindingRuntime(app, app_root) + _RUNTIMES[app] = runtime + elif app_root is not None and runtime.app_root != Path(app_root).resolve(): + raise ValueError( + f"The app already owns an agent binding runtime for {runtime.app_root}; " + f"it cannot also use {Path(app_root).resolve()}" + ) + return runtime + + +def _worker_signature(handler: Callable[..., Any], arg_name: str) -> inspect.Signature: + signature = inspect.signature(handler) + parameter = signature.parameters.get(arg_name) + if parameter is None: + raise TypeError( + f"agent_input arg_name {arg_name!r} is not present in handler {handler.__name__!r}" + ) + if parameter.kind in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + }: + raise TypeError( + f"agent_input parameter {arg_name!r} must be positional-or-keyword or keyword-only" + ) + return signature.replace( + parameters=[ + candidate + for candidate in signature.parameters.values() + if candidate.name != arg_name + ] + ) + + +def _source_call( + handler: Callable[..., Any], + source_signature: inspect.Signature, + worker_signature: inspect.Signature, + args: tuple[Any, ...], + kwargs: dict[str, Any], + arg_name: str, + injected: Any, +) -> Any: + if arg_name in kwargs: + raise TypeError(f"agent_input parameter {arg_name!r} is runtime-managed") + bound = worker_signature.bind(*args, **kwargs) + bound.apply_defaults() + values = dict(bound.arguments) + values[arg_name] = injected + positional: list[Any] = [] + keywords: dict[str, Any] = {} + for parameter in source_signature.parameters.values(): + if parameter.kind is inspect.Parameter.POSITIONAL_ONLY: + positional.append(values[parameter.name]) + elif parameter.kind is inspect.Parameter.VAR_POSITIONAL: + positional.extend(values.get(parameter.name, ())) + elif parameter.kind is inspect.Parameter.VAR_KEYWORD: + keywords.update(values.get(parameter.name, {})) + elif parameter.name in values: + keywords[parameter.name] = values[parameter.name] + return handler(*positional, **keywords) + + +def _durable_context( + worker_signature: inspect.Signature, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> df.DurableOrchestrationContext: + bound = worker_signature.bind(*args, **kwargs) + for value in bound.arguments.values(): + if isinstance(value, df.DurableOrchestrationContext): + return value + raise TypeError( + "orchestrator mode requires a DurableOrchestrationContext handler parameter" + ) + + +def _invocation_metadata( + worker_signature: inspect.Signature, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> InvocationMetadata: + bound = worker_signature.bind(*args, **kwargs) + for value in bound.arguments.values(): + if isinstance(value, func.Context): + return InvocationMetadata( + function_name=str(value.function_name or "") or None, + invocation_id=str(value.invocation_id or "") or None, + ) + return InvocationMetadata() + + +def agent_input( + app: func.FunctionApp, + *, + arg_name: str, + agent_name: str, + mode: AgentInputMode = "function", +) -> Callable[[_F], _F]: + """Inject a hydrated raw Agent or a replay-safe Durable proxy.""" + if mode not in {"function", "activity", "orchestrator"}: + raise ValueError( + "agent_input mode must be 'function', 'activity', or 'orchestrator'" + ) + if mode != "function" and not isinstance(app, df.DFApp): + raise TypeError( + "Durable agent_input modes require DurableAiApp or azure.durable_functions.DFApp" + ) + runtime = _runtime_for(app) + + def decorate(handler: _F) -> _F: + if not inspect.isfunction(handler): + raise TypeError( + "agent_input must be the innermost decorator, immediately above the handler" + ) + source_signature = inspect.signature(handler) + visible_signature = _worker_signature(handler, arg_name) + if mode == "orchestrator" and not inspect.isgeneratorfunction(handler): + raise TypeError("orchestrator mode requires a synchronous generator handler") + if mode != "orchestrator" and not inspect.iscoroutinefunction(handler): + raise TypeError( + f"agent_input mode {mode!r} requires an async def handler because " + "MAF Agent execution and lifecycle are asynchronous" + ) + + configure_observability() + blueprint = runtime.resolve(agent_name) + if mode == "orchestrator": + runtime.register_durable_activity() + + @functools.wraps(handler) + def orchestrator_wrapper(*args: Any, **kwargs: Any) -> Generator[Any, Any, Any]: + context = _durable_context(visible_signature, args, kwargs) + result = _source_call( + handler, + source_signature, + visible_signature, + args, + kwargs, + arg_name, + DurableAiAgent(context, blueprint), + ) + return (yield from result) + + wrapped: Callable[..., Any] = orchestrator_wrapper + else: + + @functools.wraps(handler) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + invocation = _invocation_metadata(visible_signature, args, kwargs) + with start_span( + f"agent.binding.invoke {blueprint.slug}", + fault_domain=FaultDomain.RUNTIME, + lifecycle_stage=LifecycleStage.AGENT_RUN, + attributes={ + "gen_ai.agent.name": blueprint.slug, + "gen_ai.request.model": blueprint.entry.config.model, + "faas.name": invocation.function_name, + "faas.invocation_id": invocation.invocation_id, + }, + ) as span: + try: + async with open_agent(blueprint, invocation) as agent: + result = await _source_call( + handler, + source_signature, + visible_signature, + args, + kwargs, + arg_name, + agent, + ) + except asyncio.CancelledError: + span.set_attribute("af.binding.outcome", "cancelled") + raise + except BaseException: + span.set_attribute("af.binding.outcome", "error") + raise + span.set_attribute("af.binding.outcome", "success") + return result + + wrapped = async_wrapper + + wrapped.__signature__ = visible_signature # type: ignore[attr-defined] + return cast(_F, wrapped) + + return decorate + + +class AiApp(func.FunctionApp): + """FunctionApp with the smart ``agent_input`` decorator.""" + + def __init__( + self, + http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, + *, + app_root: Path | None = None, + ) -> None: + super().__init__(http_auth_level=http_auth_level) + self._agent_binding_root = app_root + + def agent_input( + self, + *, + arg_name: str, + agent_name: str, + mode: AgentInputMode = "function", + ) -> Callable[[_F], _F]: + _runtime_for(self, self._agent_binding_root) + return agent_input( + self, + arg_name=arg_name, + agent_name=agent_name, + mode=mode, + ) + + +class DurableAiApp(df.DFApp): # type: ignore[misc] + """DFApp with async Function/activity and orchestrator agent injection.""" + + def __init__( + self, + http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, + *, + app_root: Path | None = None, + ) -> None: + super().__init__(http_auth_level=http_auth_level) + self._agent_binding_root = app_root + + def agent_input( + self, + *, + arg_name: str, + agent_name: str, + mode: AgentInputMode = "function", + ) -> Callable[[_F], _F]: + _runtime_for(self, self._agent_binding_root) + return agent_input( + self, + arg_name=arg_name, + agent_name=agent_name, + mode=mode, + ) \ No newline at end of file diff --git a/src/azure_functions_agents/composition.py b/src/azure_functions_agents/composition.py new file mode 100644 index 00000000..74e32152 --- /dev/null +++ b/src/azure_functions_agents/composition.py @@ -0,0 +1,211 @@ +"""Binding-only project composition for smart agent injection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import frontmatter +import yaml # type: ignore[import-untyped] +from agent_framework import FunctionTool + +from ._function_tool import WorkflowTool +from ._slug import _function_name_from_source +from .config.loader import ( + _collect_agent_files, + _resolve_agents_dir, + load_global_config, +) +from .config.paths import get_app_root +from .config.schema import GlobalConfig +from .discovery.mcp import MCPServerDefinition, discover_mcp_server_definitions +from .discovery.skills import discover_skills +from .discovery.tools import discover_project_tools + + +@dataclass(frozen=True) +class BindingAgentDefinition: + """Minimal agent authoring surface recognized by ``agent_input``.""" + + name: str + description: str + instructions: str + source_file: Path + filename_stem: str + slug: str + + +@dataclass(frozen=True) +class BindingAgentSource: + """Definition identity available without parsing its front matter.""" + + source_file: Path + filename_stem: str + slug: str + + +@dataclass(frozen=True) +class DiscoveryInventory: + """Immutable projection of the existing root-keyed discovery results.""" + + user_tools: tuple[FunctionTool, ...] + workflow_tools: tuple[WorkflowTool, ...] + skills: tuple[tuple[str, Path], ...] + mcp_servers: tuple[tuple[str, MCPServerDefinition], ...] + failed_loads: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True) +class ProjectSnapshot: + """Binding composition state owned by one FunctionApp instance.""" + + app_root: Path + config: GlobalConfig + sources: tuple[BindingAgentSource, ...] + discovery: DiscoveryInventory + + +@dataclass(frozen=True) +class BindingAgentEntry: + """A resolved binding target and the app-level assets used to hydrate it.""" + + definition: BindingAgentDefinition + config: GlobalConfig + discovery: DiscoveryInventory + + +def _filename_stem(source_file: Path) -> str: + name = source_file.name + lower_name = name.lower() + for suffix in (".agent.md", ".claude.md"): + if lower_name.endswith(suffix): + return name[: -len(suffix)] + return source_file.stem + + +def _binding_agent_files(app_root: Path) -> list[Path]: + files = _collect_agent_files(app_root) + agents_dir = _resolve_agents_dir(app_root) + if agents_dir is not None: + files.extend(_collect_agent_files(agents_dir)) + return sorted(files) + + +def _required_string(metadata: dict[str, object], field: str, source_file: Path) -> str: + value = metadata.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{source_file}: field `{field}`: expected a non-empty string") + return value.strip() + + +def load_binding_definition(source_file: Path) -> BindingAgentDefinition: + """Load only name, description, and markdown instructions from an agent file.""" + resolved_source = source_file.resolve() + try: + post = frontmatter.load(str(resolved_source)) + except yaml.YAMLError as exc: + raise ValueError(f"{resolved_source}: invalid YAML frontmatter: {exc}") from exc + except Exception as exc: + raise ValueError(f"{resolved_source}: failed to parse frontmatter: {exc}") from exc + + metadata: dict[str, object] = dict(post.metadata or {}) + name = _required_string(metadata, "name", resolved_source) + description = _required_string(metadata, "description", resolved_source) + filename_stem = _filename_stem(resolved_source) + slug = _function_name_from_source(resolved_source, name, warn_on_missing=False) + return BindingAgentDefinition( + name=name, + description=description, + instructions=str(post.content), + source_file=resolved_source, + filename_stem=filename_stem, + slug=slug, + ) + + +def _build_discovery_inventory(app_root: Path) -> DiscoveryInventory: + tools = discover_project_tools(app_root) + skills = discover_skills(app_root) + mcp = discover_mcp_server_definitions(app_root) + failed_loads = sorted([*tools.failed_loads, *skills.failed_loads, *mcp.failed_loads]) + return DiscoveryInventory( + user_tools=tuple(tools.user_tools), + workflow_tools=tuple(tools.workflow_tools), + skills=tuple(sorted(skills.skills.items())), + mcp_servers=tuple(sorted(mcp.definitions.items())), + failed_loads=tuple(failed_loads), + ) + + +def _binding_source(source_file: Path) -> BindingAgentSource: + resolved_source = source_file.resolve() + filename_stem = _filename_stem(resolved_source) + return BindingAgentSource( + source_file=resolved_source, + filename_stem=filename_stem, + slug=_function_name_from_source( + resolved_source, + filename_stem, + warn_on_missing=False, + ), + ) + + +def _fail_on_duplicate_binding_slugs(sources: tuple[BindingAgentSource, ...]) -> None: + sources_by_slug: dict[str, list[Path]] = {} + for source in sources: + sources_by_slug.setdefault(source.slug, []).append(source.source_file) + for slug, colliding_paths in sorted(sources_by_slug.items()): + if len(colliding_paths) > 1: + listed = ", ".join(str(source) for source in sorted(colliding_paths)) + raise ValueError( + f"Duplicate agent slug {slug!r} is used by {len(colliding_paths)} source files: " + f"{listed}. Rename one of the colliding source files." + ) + + +def load_project_snapshot(app_root: Path | None = None) -> ProjectSnapshot: + """Build one binding-only snapshot without invoking declarative validation.""" + resolved_root = Path(app_root).resolve() if app_root is not None else get_app_root() + sources = tuple(_binding_source(source_file) for source_file in _binding_agent_files(resolved_root)) + _fail_on_duplicate_binding_slugs(sources) + return ProjectSnapshot( + app_root=resolved_root, + config=load_global_config(resolved_root), + sources=sources, + discovery=_build_discovery_inventory(resolved_root), + ) + + +def compose_binding_target( + snapshot: ProjectSnapshot, + agent_name: str, +) -> BindingAgentEntry: + """Resolve a filename stem first, then its normalized identity slug.""" + requested = agent_name.strip() + if not requested: + raise ValueError("agent_name must be a non-empty filename stem or normalized slug") + + exact = [source for source in snapshot.sources if source.filename_stem == requested] + if len(exact) == 1: + definition = load_binding_definition(exact[0].source_file) + return BindingAgentEntry(definition, snapshot.config, snapshot.discovery) + + normalized = _function_name_from_source( + f"{requested}.agent.md", + requested, + warn_on_missing=False, + ) + by_slug = [source for source in snapshot.sources if source.slug == normalized] + if len(by_slug) == 1: + definition = load_binding_definition(by_slug[0].source_file) + return BindingAgentEntry(definition, snapshot.config, snapshot.discovery) + + available = ", ".join( + f"{source.filename_stem} ({source.slug})" for source in snapshot.sources + ) + raise ValueError( + f"Agent definition {agent_name!r} was not found under {snapshot.app_root}. " + "agent_name must be a filename stem or normalized slug, not the front-matter " + f"display name. Available definitions: {available or ''}" + ) \ No newline at end of file diff --git a/src/azure_functions_agents/discovery/mcp.py b/src/azure_functions_agents/discovery/mcp.py index 1c40e60c..249d4974 100644 --- a/src/azure_functions_agents/discovery/mcp.py +++ b/src/azure_functions_agents/discovery/mcp.py @@ -17,10 +17,38 @@ type MCPTool = MCPStreamableHTTPTool -_DISCOVERED_MCP_SERVERS_CACHE: dict[Path, dict[str, MCPTool]] = {} +_DISCOVERED_MCP_DEFINITIONS_CACHE: dict[ + Path, + tuple[dict[str, MCPServerDefinition], list[tuple[str, str]]], +] = {} _DEFAULT_TOKEN_REFRESH_OFFSET_SECONDS = 300 +@dataclass(frozen=True) +class MCPServerDefinition: + """Immutable resolved MCP configuration that can build an owned tool.""" + + name: str + config_json: str + + def build_tool(self) -> MCPTool: + config = cast(dict[str, Any], json.loads(self.config_json)) + tool, error = _build_mcp_tool(self.name, config) + if tool is None: + raise RuntimeError( + f"Validated MCP server {self.name!r} could not be built: {error}" + ) + return tool + + +@dataclass +class MCPDefinitionDiscoveryResult: + """Resolved MCP definitions and discovery failures.""" + + definitions: dict[str, MCPServerDefinition] + failed_loads: list[tuple[str, str]] + + @dataclass class MCPDiscoveryResult: """Result of MCP server discovery including successes and failures.""" @@ -31,7 +59,7 @@ class MCPDiscoveryResult: def clear_mcp_cache() -> None: """Clear cached MCP server discovery results.""" - _DISCOVERED_MCP_SERVERS_CACHE.clear() + _DISCOVERED_MCP_DEFINITIONS_CACHE.clear() def _build_header_provider(server: dict[str, Any]) -> Any: @@ -164,23 +192,71 @@ def _build_mcp_tool(name: str, server: dict[str, Any]) -> tuple[MCPTool | None, return None, error -def discover_mcp_servers(app_root: Path) -> MCPDiscoveryResult: +def _definition_from_config( + name: str, + server: dict[str, Any], +) -> tuple[MCPServerDefinition | None, str | None]: + server_type = str(server.get("type", "")).lower() + if "command" in server or server_type in {"local", "stdio"}: + error = "MCP stdio transport is not supported" + logger.warning("%s; skipping server '%s'", error, name) + return None, error + + if "url" in server or server_type in {"http", "streamable-http"}: + if server_type and server_type not in {"http", "streamable-http"}: + error = ( + f"unknown server type '{server_type}'; supported types are " + "'http' and 'streamable-http'" + ) + logger.warning("MCP server '%s': %s", name, error) + return None, error + url = str(server.get("url", "")).strip() + if not url: + error = "missing 'url'" + logger.warning("MCP server '%s': %s, skipping", name, error) + return None, error + if has_unresolved_placeholders(url): + error = f"could not resolve url '{url}'" + logger.warning("MCP server '%s': %s, skipping", name, error) + return None, error + return MCPServerDefinition( + name=name, + config_json=json.dumps(server, sort_keys=True, separators=(",", ":")), + ), None + + if server_type: + error = ( + f"unknown server type '{server_type}'; supported types are " + "'http' and 'streamable-http'" + ) + logger.warning("MCP server '%s': %s", name, error) + else: + error = "unrecognized config (expected 'url' plus type 'http' or 'streamable-http')" + logger.warning("MCP server '%s': %s, skipping", name, error) + return None, error + + +def discover_mcp_server_definitions(app_root: Path) -> MCPDefinitionDiscoveryResult: + """Load and cache immutable resolved MCP server definitions.""" resolved_root = Path(app_root).resolve() - cached_servers = _DISCOVERED_MCP_SERVERS_CACHE.get(resolved_root) - if cached_servers is not None: - return MCPDiscoveryResult(servers=dict(cached_servers), failed_loads=[]) + cached = _DISCOVERED_MCP_DEFINITIONS_CACHE.get(resolved_root) + if cached is not None: + return MCPDefinitionDiscoveryResult( + definitions=dict(cached[0]), + failed_loads=list(cached[1]), + ) path = resolved_root / "mcp.json" if not path.exists(): - _DISCOVERED_MCP_SERVERS_CACHE[resolved_root] = {} - return MCPDiscoveryResult(servers={}, failed_loads=[]) + _DISCOVERED_MCP_DEFINITIONS_CACHE[resolved_root] = ({}, []) + return MCPDefinitionDiscoveryResult(definitions={}, failed_loads=[]) try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: logger.warning("Failed to read MCP config from %s: %s", path, exc) - _DISCOVERED_MCP_SERVERS_CACHE[resolved_root] = {} - return MCPDiscoveryResult(servers={}, failed_loads=[]) + _DISCOVERED_MCP_DEFINITIONS_CACHE[resolved_root] = ({}, []) + return MCPDefinitionDiscoveryResult(definitions={}, failed_loads=[]) if not isinstance(data, dict): logger.warning( @@ -188,33 +264,51 @@ def discover_mcp_servers(app_root: Path) -> MCPDiscoveryResult: path, type(data).__name__, ) - _DISCOVERED_MCP_SERVERS_CACHE[resolved_root] = {} - return MCPDiscoveryResult(servers={}, failed_loads=[]) + _DISCOVERED_MCP_DEFINITIONS_CACHE[resolved_root] = ({}, []) + return MCPDefinitionDiscoveryResult(definitions={}, failed_loads=[]) data = cast(dict[str, Any], resolve_env_vars_in_data(data)) servers = data.get("servers", {}) if not isinstance(servers, dict): logger.warning("Invalid MCP config in %s: 'servers' must be an object", path) - _DISCOVERED_MCP_SERVERS_CACHE[resolved_root] = {} - return MCPDiscoveryResult(servers={}, failed_loads=[]) + _DISCOVERED_MCP_DEFINITIONS_CACHE[resolved_root] = ({}, []) + return MCPDefinitionDiscoveryResult(definitions={}, failed_loads=[]) - tools: dict[str, MCPTool] = {} + definitions: dict[str, MCPServerDefinition] = {} failed_loads: list[tuple[str, str]] = [] for name in sorted(servers.keys()): config = servers[name] if not isinstance(name, str) or not isinstance(config, dict): continue - built, error = _build_mcp_tool(name, config) - if built is not None: - tools[name] = built + definition, error = _definition_from_config(name, config) + if definition is not None: + definitions[name] = definition elif error is not None: failed_loads.append((name, error)) - if tools: - logger.info("Loaded %d MCP server(s) from %s", len(tools), path) + if definitions: + logger.info("Loaded %d MCP server(s) from %s", len(definitions), path) else: logger.info("No valid MCP servers found in %s", path) if failed_loads: logger.warning("Failed to load %d MCP server(s)", len(failed_loads)) - _DISCOVERED_MCP_SERVERS_CACHE[resolved_root] = tools - return MCPDiscoveryResult(servers=dict(tools), failed_loads=failed_loads) + _DISCOVERED_MCP_DEFINITIONS_CACHE[resolved_root] = ( + dict(definitions), + list(failed_loads), + ) + return MCPDefinitionDiscoveryResult( + definitions=dict(definitions), + failed_loads=list(failed_loads), + ) + + +def discover_mcp_servers(app_root: Path) -> MCPDiscoveryResult: + """Build fresh MAF MCP tools from cached immutable definitions.""" + discovered = discover_mcp_server_definitions(app_root) + return MCPDiscoveryResult( + servers={ + name: definition.build_tool() + for name, definition in discovered.definitions.items() + }, + failed_loads=discovered.failed_loads, + ) diff --git a/src/azure_functions_agents/hydration.py b/src/azure_functions_agents/hydration.py new file mode 100644 index 00000000..27dc41da --- /dev/null +++ b/src/azure_functions_agents/hydration.py @@ -0,0 +1,211 @@ +"""Blueprint hydration for smart agent bindings.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Mapping +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass +from typing import Any + +from agent_framework import Agent, AgentSession + +from ._observability import FaultDomain, LifecycleStage, start_span +from .client_manager import get_client_manager +from .composition import BindingAgentEntry +from .config.env import runtime_env_value +from .config.merge import DEFAULT_TIMEOUT +from .config.schema import WebRequestConfig +from .runner import ( + _build_chat_options_from_environment, + _build_history_provider, + _build_role_agent, + _session_lock_bounded_by, + _validate_session_id, +) +from .system_tools.sandbox import create_sandbox_tools +from .system_tools.web_request import create_web_request_tools + + +@dataclass(frozen=True) +class InvocationMetadata: + function_name: str | None = None + invocation_id: str | None = None + durable_instance_id: str | None = None + + +@dataclass(frozen=True) +class AgentBlueprint: + """Immutable app-owned recipe for constructing invocation-scoped Agents.""" + + entry: BindingAgentEntry + + @property + def slug(self) -> str: + return self.entry.definition.slug + + @property + def timeout(self) -> float: + if self.entry.config.timeout is not None: + return self.entry.config.timeout + raw_timeout = runtime_env_value("AZURE_FUNCTIONS_AGENTS_TIMEOUT_SECONDS") + if raw_timeout: + try: + return float(raw_timeout) + except ValueError: + pass + return DEFAULT_TIMEOUT + + def build(self, invocation: InvocationMetadata | None = None) -> Agent[Any]: + config = self.entry.config + chat_client, _ = get_client_manager().build_chat_client_with_target(config.model) + excluded = set(config.tools.exclude) if config.tools is not None else set() + user_tools = [ + tool + for tool in self.entry.discovery.user_tools + if str(getattr(tool, "name", "") or "") not in excluded + ] + web_request_config = config.system_tools.web_request if config.system_tools else None + web_request_tools: list[Any] = [] + if web_request_config is not False: + web_request_tools = create_web_request_tools( + web_request_config + if isinstance(web_request_config, WebRequestConfig) + else WebRequestConfig() + ) + + sandbox_tools: list[Any] = [] + sandbox = ( + config.system_tools.dynamic_sessions_code_interpreter + if config.system_tools + else None + ) + if sandbox is not None: + sandbox_tools = create_sandbox_tools( + sandbox.model_dump(), + fallback_session_id=invocation.invocation_id if invocation else None, + ) + + return _build_role_agent( + chat_client, + instructions=self.entry.definition.instructions, + tools=user_tools, + mcp_tools=[ + definition.build_tool() + for _, definition in self.entry.discovery.mcp_servers + ], + skill_paths=[path for _, path in self.entry.discovery.skills], + sandbox_tools=sandbox_tools, + web_request_tools=web_request_tools, + system_addendum=None, + workflow_enabled=False, + workflow_durable_client=None, + agent_name=self.slug, + resolved_id=invocation.invocation_id if invocation else None, + history_provider=_build_history_provider(), + delegate_tools=None, + ) + + +async def _enter_agent(owner: Agent[Any]) -> Agent[Any]: + try: + return await owner.__aenter__() + except BaseException: + with suppress(Exception): + await owner.__aexit__(None, None, None) + raise + + +@asynccontextmanager +async def open_agent( + blueprint: AgentBlueprint, + invocation: InvocationMetadata | None = None, +) -> AsyncIterator[Agent[Any]]: + """Build, enter, yield, and always close one invocation-owned Agent.""" + owner = blueprint.build(invocation) + entered = await _enter_agent(owner) + try: + yield entered + except BaseException as exc: + await owner.__aexit__(type(exc), exc, exc.__traceback__) + raise + else: + await owner.__aexit__(None, None, None) + + +def _session(session_id: str | None) -> AgentSession: + validated = _validate_session_id(session_id) + return AgentSession(session_id=validated) if validated is not None else AgentSession() + + +async def _run_managed( + agent: Agent[Any], + blueprint: AgentBlueprint, + messages: Any = None, + *, + session_id: str | None = None, + options: Mapping[str, Any] | None = None, + invocation: InvocationMetadata | None = None, +) -> Any: + session = _session(session_id) + resolved_session_id = session.session_id + timeout = blueprint.timeout + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + attributes = { + "gen_ai.agent.name": blueprint.slug, + "gen_ai.request.model": blueprint.entry.config.model, + "faas.name": invocation.function_name if invocation else None, + "faas.invocation_id": invocation.invocation_id if invocation else None, + "durable.instance_id": invocation.durable_instance_id if invocation else None, + } + with start_span( + f"agent.binding.run {blueprint.slug}", + fault_domain=FaultDomain.RUNTIME, + lifecycle_stage=LifecycleStage.AGENT_RUN, + attributes=attributes, + ) as span: + try: + async with _session_lock_bounded_by(resolved_session_id, deadline): + remaining = max(0.0, deadline - loop.time()) + if remaining <= 0: + raise TimeoutError + response = await asyncio.wait_for( + agent.run( + messages, + session=session, + options=options or _build_chat_options_from_environment(), + ), + timeout=remaining, + ) + except asyncio.CancelledError: + span.set_attribute("af.binding.outcome", "cancelled") + raise + except TimeoutError: + span.set_attribute("af.binding.outcome", "timeout") + raise RuntimeError(f"Agent run timed out after {timeout}s") from None + except BaseException: + span.set_attribute("af.binding.outcome", "error") + raise + span.set_attribute("af.binding.outcome", "success") + return response + + +async def run_blueprint( + blueprint: AgentBlueprint, + messages: Any = None, + *, + session_id: str | None = None, + options: Mapping[str, Any] | None = None, + invocation: InvocationMetadata | None = None, +) -> Any: + """Hydrate one Agent, perform one runtime-managed call, and close it.""" + async with open_agent(blueprint, invocation) as agent: + return await _run_managed( + agent, + blueprint, + messages, + session_id=session_id, + options=options, + invocation=invocation, + ) diff --git a/src/azure_functions_agents/runner.py b/src/azure_functions_agents/runner.py index 28836810..2c3e7cac 100644 --- a/src/azure_functions_agents/runner.py +++ b/src/azure_functions_agents/runner.py @@ -356,7 +356,11 @@ def _build_skills_provider(skill_paths: list[Path] | None) -> Any: with warnings.catch_warnings(): warnings.simplefilter("ignore", category=ExperimentalWarning) - return SkillsProvider.from_paths(list(skill_paths)) + return SkillsProvider.from_paths( + list(skill_paths), + disable_load_skill_approval=True, + disable_read_skill_resource_approval=True, + ) # --------------------------------------------------------------------------- diff --git a/tests/test_bindings.py b/tests/test_bindings.py new file mode 100644 index 00000000..ab198c3f --- /dev/null +++ b/tests/test_bindings.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +import asyncio +import inspect +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, get_type_hints +from unittest.mock import AsyncMock, MagicMock, Mock + +import azure.durable_functions as df +import azure.functions as func +import pytest +from agent_framework import Agent + +from azure_functions_agents import AiApp as ExportedAiApp +from azure_functions_agents import DurableAiApp as ExportedDurableAiApp +from azure_functions_agents import bindings as bindings_module +from azure_functions_agents.bindings import ( + AiApp, + DurableAiAgent, + DurableAiApp, + agent_input, +) +from azure_functions_agents.hydration import AgentBlueprint + + +def _write_agent(root: Path) -> None: + (root / "order-fulfillment.agent.md").write_text( + "---\nname: Orders\ndescription: Processes orders\n---\nBe useful.\n", + encoding="utf-8", + ) + + +def _agent_double() -> Any: + agent = MagicMock(spec=Agent) + agent.__aenter__ = AsyncMock(return_value=agent) + agent.__aexit__ = AsyncMock(return_value=None) + return agent + + +@pytest.mark.asyncio +async def test_ai_app_hides_and_injects_async_parameter( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _write_agent(tmp_path) + app = AiApp(app_root=tmp_path) + seen: list[Any] = [] + agents: list[Any] = [] + + def build(_blueprint: AgentBlueprint, _invocation: Any = None) -> Any: + agent = _agent_double() + agents.append(agent) + return agent + + monkeypatch.setattr(AgentBlueprint, "build", build) + + @app.agent_input(arg_name="order_agent", agent_name="order-fulfillment") + async def process_order(req: func.HttpRequest, order_agent: Agent[Any]) -> str: + seen.append(order_agent) + return req.method + + app.route(route="orders", methods=["POST"])(process_order) + assert list(inspect.signature(process_order).parameters) == ["req"] + request = func.HttpRequest("POST", "https://example.test/orders", body=b"") + assert await process_order(request) == "POST" + assert await process_order(request) == "POST" + assert len(seen) == len(agents) == 2 + assert seen == agents + assert agents[0] is not agents[1] + assert all(isinstance(agent, Agent) for agent in agents) + assert all(agent.__aenter__.await_count == 1 for agent in agents) + assert all(agent.__aexit__.await_count == 1 for agent in agents) + + [registered] = app.get_functions() + assert registered.get_function_name() == "process_order" + bindings = [binding.get_dict_repr() for binding in registered.get_bindings()] + assert any(binding.get("type") == "httpTrigger" for binding in bindings) + assert all(binding.get("name") != "order_agent" for binding in bindings) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error", [RuntimeError("failed"), asyncio.CancelledError()]) +async def test_async_handler_failure_or_cancellation_closes_agent( + error: BaseException, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _write_agent(tmp_path) + app = AiApp(app_root=tmp_path) + agent = _agent_double() + monkeypatch.setattr(AgentBlueprint, "build", lambda *_args: agent) + + @app.agent_input(arg_name="agent", agent_name="order-fulfillment") + async def handler(agent: Agent[Any]) -> None: + raise error + + with pytest.raises(type(error)): + await handler() + + agent.__aexit__.assert_awaited_once() + assert agent.__aexit__.await_args.args[0] is type(error) + + +@pytest.mark.asyncio +async def test_free_decorator_supports_existing_app_and_async_handler( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _write_agent(tmp_path) + app = func.FunctionApp() + agent = _agent_double() + + from azure_functions_agents.config.paths import set_app_root + + set_app_root(tmp_path) + monkeypatch.setattr(AgentBlueprint, "build", lambda *_args: agent) + + @agent_input(app, arg_name="agent", agent_name="order_fulfillment") + async def handler(value: str, agent: Agent[Any]) -> tuple[str, Agent[Any]]: + return value, agent + + value, injected = await handler("ok") + assert value == "ok" + assert injected is agent + assert list(inspect.signature(handler).parameters) == ["value"] + agent.__aexit__.assert_awaited_once() + + +def test_free_decorator_rejects_sync_handler(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = func.FunctionApp() + + from azure_functions_agents.config.paths import set_app_root + + set_app_root(tmp_path) + + with pytest.raises(TypeError, match=r"requires an async def handler"): + + @agent_input(app, arg_name="agent", agent_name="order_fulfillment") + def handler(value: str, agent: Agent[Any]) -> str: + return value + + +def test_reverse_decorator_order_is_rejected(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = AiApp(app_root=tmp_path) + + with pytest.raises(TypeError, match="innermost decorator"): + + @app.agent_input(arg_name="agent", agent_name="order-fulfillment") + @app.route(route="wrong") + async def wrong_order(req: func.HttpRequest, agent: Agent[Any]) -> str: + return req.method + + +def test_orchestrator_facade_schedules_json_activity_once(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = DurableAiApp(app_root=tmp_path) + + @app.agent_input( + arg_name="planner", + agent_name="order-fulfillment", + mode="orchestrator", + ) + def orchestrator( + context: df.DurableOrchestrationContext, + planner: DurableAiAgent, + ): + result = yield planner.run({"order": 42}) + return result + + app.orchestration_trigger(context_name="context")(orchestrator) + context = Mock(spec=df.DurableOrchestrationContext) + context.instance_id = "instance-1" + context.call_activity.return_value = object() + generator = orchestrator(context) + task = next(generator) + assert task is context.call_activity.return_value + context.call_activity.assert_called_once_with( + "_afa_agent_binding_run", + { + "agent_slug": "order_fulfillment", + "messages": {"order": 42}, + "options": None, + "instance_id": "instance-1", + }, + ) + + functions = app.get_functions() + activity_bindings = [ + binding.get_dict_repr() + for function in functions + for binding in function.get_bindings() + if binding.get_dict_repr().get("type") == "activityTrigger" + ] + assert len(activity_bindings) == 1 + assert activity_bindings[0]["activity"] == "_afa_agent_binding_run" + internal_activity = next( + function + for function in functions + if function.get_function_name() == "_afa_agent_binding_run" + ).get_user_function() + assert get_type_hints(internal_activity)["payload"] is dict + + +def test_orchestrator_proxy_rejects_streaming_and_non_json_input(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = DurableAiApp(app_root=tmp_path) + captured: list[DurableAiAgent] = [] + + @app.agent_input( + arg_name="planner", + agent_name="order-fulfillment", + mode="orchestrator", + ) + def orchestrator( + context: df.DurableOrchestrationContext, + planner: DurableAiAgent, + ): + captured.append(planner) + yield None + + context = Mock(spec=df.DurableOrchestrationContext) + context.instance_id = "instance-1" + next(orchestrator(context)) + + with pytest.raises(ValueError, match="does not support streaming"): + captured[0].run("hello", stream=True) + with pytest.raises(ValueError, match="JSON-serializable"): + captured[0].run(object()) + context.call_activity.assert_not_called() + + +@pytest.mark.asyncio +async def test_durable_activity_injects_raw_agent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _write_agent(tmp_path) + app = DurableAiApp(app_root=tmp_path) + agent = _agent_double() + monkeypatch.setattr(AgentBlueprint, "build", lambda *_args: agent) + + @app.agent_input( + arg_name="agent", + agent_name="order-fulfillment", + mode="activity", + ) + async def activity(payload: str, agent: Agent[Any]) -> tuple[str, Agent[Any]]: + return payload, agent + + payload, injected = await activity("work") + assert payload == "work" + assert isinstance(injected, Agent) + agent.__aexit__.assert_awaited_once() + + +def test_sync_activity_and_entity_mode_are_rejected(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = DurableAiApp(app_root=tmp_path) + + with pytest.raises(TypeError, match=r"requires an async def handler"): + + @app.agent_input( + arg_name="activity_agent", + agent_name="order-fulfillment", + mode="activity", + ) + def activity(payload: str, activity_agent: Agent[Any]) -> str: + return payload + + with pytest.raises(ValueError, match=r"'function', 'activity', or 'orchestrator'"): + app.agent_input( + arg_name="entity_agent", + agent_name="order-fulfillment", + mode="entity", # type: ignore[arg-type] + ) + + +def test_multiple_orchestrators_register_one_internal_activity(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = DurableAiApp(app_root=tmp_path) + + def first(context: df.DurableOrchestrationContext, agent: DurableAiAgent): + yield agent.run("first") + + def second(context: df.DurableOrchestrationContext, agent: DurableAiAgent): + yield agent.run("second") + + app.agent_input( + arg_name="agent", + agent_name="order-fulfillment", + mode="orchestrator", + )(first) + app.agent_input( + arg_name="agent", + agent_name="order-fulfillment", + mode="orchestrator", + )(second) + + activity_bindings = [ + binding.get_dict_repr() + for function in app.get_functions() + for binding in function.get_bindings() + if binding.get_dict_repr().get("type") == "activityTrigger" + ] + assert len(activity_bindings) == 1 + + +def test_durable_modes_require_df_app(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = AiApp(app_root=tmp_path) + + with pytest.raises(TypeError, match="require DurableAiApp"): + app.agent_input( + arg_name="agent", + agent_name="order-fulfillment", + mode="activity", + ) + + +def test_binding_app_types_are_exported() -> None: + assert ExportedAiApp is AiApp + assert ExportedDurableAiApp is DurableAiApp + + +def test_app_instances_own_separate_agent_blueprints(tmp_path: Path) -> None: + _write_agent(tmp_path) + first_app = AiApp(app_root=tmp_path) + second_app = AiApp(app_root=tmp_path) + + @first_app.agent_input(arg_name="agent", agent_name="order-fulfillment") + async def first(agent: Agent[Any]) -> Agent[Any]: + return agent + + @second_app.agent_input(arg_name="agent", agent_name="order-fulfillment") + async def second(agent: Agent[Any]) -> Agent[Any]: + return agent + + first_blueprint = bindings_module._runtime_for(first_app)._blueprints["order_fulfillment"] + second_blueprint = bindings_module._runtime_for(second_app)._blueprints["order_fulfillment"] + assert first_blueprint is not second_blueprint + + +def test_concurrent_orchestrator_decorators_register_one_activity(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = DurableAiApp(app_root=tmp_path) + + def decorate(index: int) -> None: + def orchestrator( + context: df.DurableOrchestrationContext, + agent: DurableAiAgent, + ): + yield agent.run(str(index)) + + app.agent_input( + arg_name="agent", + agent_name="order-fulfillment", + mode="orchestrator", + )(orchestrator) + + with ThreadPoolExecutor(max_workers=4) as executor: + list(executor.map(decorate, range(8))) + + activity_bindings = [ + binding.get_dict_repr() + for function in app.get_functions() + for binding in function.get_bindings() + if binding.get_dict_repr().get("type") == "activityTrigger" + ] + assert len(activity_bindings) == 1 \ No newline at end of file diff --git a/tests/test_composition.py b/tests/test_composition.py new file mode 100644 index 00000000..ca866df6 --- /dev/null +++ b/tests/test_composition.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from azure_functions_agents.composition import ( + compose_binding_target, + load_project_snapshot, +) + + +def _write_agent(root: Path, filename: str, metadata: str, body: str = "Instructions") -> Path: + source = root / filename + source.write_text( + f"---\n{textwrap.dedent(metadata).strip()}\n---\n{textwrap.dedent(body).strip()}\n", + encoding="utf-8", + ) + return source + + +def test_binding_snapshot_ignores_non_binding_frontmatter(tmp_path: Path) -> None: + source = _write_agent( + tmp_path, + "order-fulfillment.agent.md", + """ + name: Order Processor + description: Processes orders + trigger: definitely-not-a-valid-trigger + builtin_endpoints: [invalid, shape] + model: 42 + tools: invalid + workflows: invalid + subagents: invalid + """, + ) + + snapshot = load_project_snapshot(tmp_path) + entry = compose_binding_target(snapshot, "order-fulfillment") + + assert entry.definition.source_file == source.resolve() + assert entry.definition.name == "Order Processor" + assert entry.definition.description == "Processes orders" + assert entry.definition.instructions.strip() == "Instructions" + assert entry.definition.slug == "order_fulfillment" + + +def test_binding_target_accepts_normalized_slug(tmp_path: Path) -> None: + _write_agent( + tmp_path, + "order-fulfillment.agent.md", + "name: Order Processor\ndescription: Processes orders", + ) + + snapshot = load_project_snapshot(tmp_path) + + assert compose_binding_target(snapshot, "order_fulfillment").definition.name == ( + "Order Processor" + ) + + +@pytest.mark.parametrize("field", ["name", "description"]) +def test_binding_definition_requires_minimal_string_fields( + field: str, + tmp_path: Path, +) -> None: + metadata = "description: Present" if field == "name" else "name: Present" + source = _write_agent(tmp_path, "missing.agent.md", metadata) + snapshot = load_project_snapshot(tmp_path) + + with pytest.raises(ValueError, match=rf"{field}.*non-empty string"): + compose_binding_target(snapshot, "missing") + + assert source.exists() + + +def test_binding_snapshot_rejects_duplicate_normalized_slugs(tmp_path: Path) -> None: + metadata = "name: Agent\ndescription: Agent description" + _write_agent(tmp_path, "order-fulfillment.agent.md", metadata) + _write_agent(tmp_path, "order_fulfillment.agent.md", metadata) + + with pytest.raises(ValueError, match=r"Duplicate agent slug 'order_fulfillment'"): + load_project_snapshot(tmp_path) + + +def test_binding_lookup_diagnostic_lists_available_identities(tmp_path: Path) -> None: + _write_agent( + tmp_path, + "order-fulfillment.agent.md", + "name: Display Name\ndescription: Processes orders", + ) + snapshot = load_project_snapshot(tmp_path) + + with pytest.raises(ValueError, match=r"order-fulfillment \(order_fulfillment\)"): + compose_binding_target(snapshot, "Display Name") + + +def test_binding_snapshot_rejects_invalid_yaml_with_source_path(tmp_path: Path) -> None: + source = tmp_path / "broken.agent.md" + source.write_text( + "---\nname: Broken\ndescription: [unterminated\n---\nInstructions\n", + encoding="utf-8", + ) + + snapshot = load_project_snapshot(tmp_path) + + with pytest.raises(ValueError, match=r"broken\.agent\.md.*invalid YAML"): + compose_binding_target(snapshot, "broken") + + +def test_binding_target_ignores_unrelated_invalid_definition(tmp_path: Path) -> None: + _write_agent( + tmp_path, + "selected.agent.md", + "name: Selected\ndescription: Selected agent", + ) + (tmp_path / "broken.agent.md").write_text( + "---\nname: Broken\ndescription: [unterminated\n---\nInstructions\n", + encoding="utf-8", + ) + + snapshot = load_project_snapshot(tmp_path) + + assert compose_binding_target(snapshot, "selected").definition.name == "Selected" + + +def test_binding_snapshot_retains_app_level_configuration(tmp_path: Path) -> None: + _write_agent( + tmp_path, + "main.agent.md", + "name: Main\ndescription: Main agent", + ) + (tmp_path / "agents.config.yaml").write_text( + "model: gpt-test\ntimeout: 42\n", + encoding="utf-8", + ) + + snapshot = load_project_snapshot(tmp_path) + + assert snapshot.config.model == "gpt-test" + assert snapshot.config.timeout == 42 \ No newline at end of file diff --git a/tests/test_discovery_mcp.py b/tests/test_discovery_mcp.py index fa04dfa3..edb1d011 100644 --- a/tests/test_discovery_mcp.py +++ b/tests/test_discovery_mcp.py @@ -87,6 +87,7 @@ def counting_read_text(self: Path, *args: object, **kwargs: object) -> str: assert list(first.servers) == ["demo"] assert list(second.servers) == ["demo"] + assert first.servers["demo"] is not second.servers["demo"] assert read_count == 1 diff --git a/tests/test_hybrid_binding_sample.py b/tests/test_hybrid_binding_sample.py new file mode 100644 index 00000000..caed8590 --- /dev/null +++ b/tests/test_hybrid_binding_sample.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import importlib.util +import inspect +import json +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock + +import azure.durable_functions as df +import azure.functions as func +import pytest +from azurefunctions.extensions.http.fastapi import Request, Response + +from azure_functions_agents.composition import compose_binding_target, load_project_snapshot +from azure_functions_agents.config import paths + +SAMPLES_ROOT = Path(__file__).resolve().parents[1] / "samples" +FUNCTION_SAMPLE_SRC = SAMPLES_ROOT / "hybrid-function-agent" / "src" +DURABLE_SAMPLE_SRC = SAMPLES_ROOT / "hybrid-durable-agent" / "src" + + +def _load_sample( + sample_src: Path, + module_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> ModuleType: + monkeypatch.setattr(paths, "_app_root", None) + monkeypatch.delenv("AZURE_FUNCTIONS_AGENTS_APP_ROOT", raising=False) + monkeypatch.delenv("AzureWebJobsScriptRoot", raising=False) + monkeypatch.chdir(sample_src) + spec = importlib.util.spec_from_file_location( + module_name, + sample_src / "function_app.py", + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("sample_src", [FUNCTION_SAMPLE_SRC, DURABLE_SAMPLE_SRC]) +def test_hybrid_binding_samples_use_minimal_definition(sample_src: Path) -> None: + snapshot = load_project_snapshot(sample_src) + + definition = compose_binding_target(snapshot, "order-fulfillment").definition + assert definition.name == "Order Fulfillment" + assert definition.slug == "order_fulfillment" + assert definition.instructions.startswith("You are an order fulfillment specialist.") + assert [tool.name for tool in snapshot.discovery.user_tools] == [ + "summarize_order_quantities" + ] + assert [name for name, _ in snapshot.discovery.skills] == ["order-review"] + assert [name for name, _ in snapshot.discovery.mcp_servers] == ["microsoft-learn"] + + +def test_ai_app_sample_indexes_standard_triggers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_sample( + FUNCTION_SAMPLE_SRC, + "hybrid_function_agent_sample", + monkeypatch, + ) + + assert isinstance(module.app, func.FunctionApp) + assert not isinstance(module.app, df.DFApp) + indexed_functions = module.app.get_functions() + functions = { + function.get_function_name(): [ + binding.get_dict_repr().get("type") for binding in function.get_bindings() + ] + for function in indexed_functions + } + assert functions == { + "process_order": ["httpTrigger", "http"], + "process_order_event": ["queueTrigger"], + } + process_order = next( + function + for function in indexed_functions + if function.get_function_name() == "process_order" + ).get_user_function() + assert process_order.__annotations__["req"] is Request + assert process_order.__annotations__["return"] is Response + + +@pytest.mark.asyncio +async def test_ai_app_sample_sends_complete_order_as_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_sample( + FUNCTION_SAMPLE_SRC, + "hybrid_function_agent_prompt_sample", + monkeypatch, + ) + process_order = next( + function + for function in module.app.get_functions() + if function.get_function_name() == "process_order" + ).get_user_function() + source_handler = inspect.unwrap(process_order) + agent = SimpleNamespace( + run=AsyncMock(return_value=SimpleNamespace(text="Order is ready.")) + ) + + async def receive() -> dict[str, object]: + return { + "type": "http.request", + "body": b'{"items":[{"sku":"A-100","quantity":2}]}', + "more_body": False, + } + + request = Request( + { + "type": "http", + "method": "POST", + "path": "/orders/2", + "path_params": {"orderId": "2"}, + "headers": [], + "query_string": b"", + }, + receive, + ) + + response = await source_handler(request, agent) + + [prompt] = agent.run.await_args.args + assert isinstance(prompt, str) + assert json.loads(prompt) == { + "order_id": "2", + "items": [{"sku": "A-100", "quantity": 2}], + "task": "validate", + } + assert response.status_code == 200 + + +def test_durable_ai_app_sample_indexes_durable_modes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_sample( + DURABLE_SAMPLE_SRC, + "hybrid_durable_agent_sample", + monkeypatch, + ) + + assert isinstance(module.app, df.DFApp) + indexed_functions = module.app.get_functions() + functions = { + function.get_function_name(): [ + binding.get_dict_repr().get("type") for binding in function.get_bindings() + ] + for function in indexed_functions + } + assert functions == { + "start_order_orchestration": ["httpTrigger", "http", "durableClient"], + "assess_order_activity": ["activityTrigger"], + "_afa_agent_binding_run": ["activityTrigger"], + "order_orchestrator": ["orchestrationTrigger"], + } + starter = next( + function + for function in indexed_functions + if function.get_function_name() == "start_order_orchestration" + ).get_user_function() + assert starter.__annotations__["req"] is Request + assert starter.__annotations__["client"] is str + assert starter.__annotations__["return"] is Response + activity = next( + function + for function in indexed_functions + if function.get_function_name() == "assess_order_activity" + ).get_user_function() + assert activity.__annotations__["order"] is dict diff --git a/tests/test_hydration.py b/tests/test_hydration.py new file mode 100644 index 00000000..b77b0b3c --- /dev/null +++ b/tests/test_hydration.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +import azure_functions_agents.hydration as hydration +from azure_functions_agents.composition import ( + BindingAgentDefinition, + BindingAgentEntry, + DiscoveryInventory, +) +from azure_functions_agents.config.schema import GlobalConfig +from azure_functions_agents.hydration import ( + AgentBlueprint, + open_agent, + run_blueprint, +) + + +def _entry( + tmp_path: Path, + slug: str = "main", + *, + timeout: float | None = None, +) -> BindingAgentEntry: + definition = BindingAgentDefinition( + name="Main", + description="Main agent", + instructions="Be useful.", + source_file=tmp_path / f"{slug}.agent.md", + filename_stem=slug, + slug=slug, + ) + discovery = DiscoveryInventory((), (), (), (), ()) + return BindingAgentEntry(definition, GlobalConfig(timeout=timeout), discovery) + + +class _FakeAgent: + active_total = 0 + max_active_total = 0 + + def __init__(self, *, delay: float = 0.01) -> None: + self.delay = delay + self.enter_count = 0 + self.exit_count = 0 + self.exit_args: tuple[Any, ...] | None = None + self.sessions: list[Any] = [] + + async def __aenter__(self) -> _FakeAgent: + self.enter_count += 1 + return self + + async def __aexit__(self, *args: Any) -> None: + self.exit_count += 1 + self.exit_args = args + + async def run(self, _messages: Any, **kwargs: Any) -> Any: + self.sessions.append(kwargs["session"]) + type(self).active_total += 1 + type(self).max_active_total = max( + type(self).max_active_total, + type(self).active_total, + ) + try: + await asyncio.sleep(self.delay) + finally: + type(self).active_total -= 1 + return SimpleNamespace(text="done") + + +class _EnteredAgent(_FakeAgent): + def __init__(self, entered: _FakeAgent) -> None: + super().__init__() + self.entered = entered + + async def __aenter__(self) -> _FakeAgent: + self.enter_count += 1 + return self.entered + + +class _FailingEnterAgent(_FakeAgent): + async def __aenter__(self) -> _FakeAgent: + self.enter_count += 1 + raise RuntimeError("enter failed") + + +@pytest.fixture(autouse=True) +def reset_fake_concurrency() -> None: + _FakeAgent.active_total = 0 + _FakeAgent.max_active_total = 0 + + +def test_blueprint_build_materializes_fresh_mutable_dependencies( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + clients: list[object] = [] + mcp_tools: list[object] = [] + histories: list[object] = [] + builds: list[dict[str, Any]] = [] + + class Manager: + def build_chat_client_with_target(self, _model: str | None) -> tuple[object, object]: + client = object() + clients.append(client) + return client, object() + + class MCPDefinition: + def build_tool(self) -> object: + tool = object() + mcp_tools.append(tool) + return tool + + def build_history() -> object: + history = object() + histories.append(history) + return history + + def build_role_agent(client: object, **kwargs: Any) -> _FakeAgent: + builds.append({"client": client, **kwargs}) + return _FakeAgent() + + definition = BindingAgentDefinition( + name="Main", + description="Main agent", + instructions="Be useful.", + source_file=tmp_path / "main.agent.md", + filename_stem="main", + slug="main", + ) + discovery = DiscoveryInventory( + (SimpleNamespace(name="project_tool"),), + (), + (("skill", tmp_path / "skills" / "skill"),), + (("server", MCPDefinition()),), # type: ignore[arg-type] + (), + ) + blueprint = AgentBlueprint(BindingAgentEntry(definition, GlobalConfig(), discovery)) + monkeypatch.setattr(hydration, "get_client_manager", lambda: Manager()) + monkeypatch.setattr(hydration, "_build_history_provider", build_history) + monkeypatch.setattr(hydration, "_build_role_agent", build_role_agent) + monkeypatch.setattr( + hydration, + "create_web_request_tools", + lambda _config: [object()], + ) + + first = blueprint.build() + second = blueprint.build() + + assert first is not second + assert len(clients) == len(mcp_tools) == len(histories) == len(builds) == 2 + assert builds[0]["client"] is not builds[1]["client"] + assert builds[0]["tools"] is not builds[1]["tools"] + assert builds[0]["mcp_tools"][0] is not builds[1]["mcp_tools"][0] + assert builds[0]["web_request_tools"][0] is not builds[1]["web_request_tools"][0] + assert builds[0]["history_provider"] is not builds[1]["history_provider"] + + +@pytest.mark.asyncio +async def test_run_blueprint_builds_fresh_agents_for_concurrent_invocations( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + agents: list[_FakeAgent] = [] + + def build( + _blueprint: AgentBlueprint, + _invocation: Any = None, + ) -> _FakeAgent: + agent = _FakeAgent() + agents.append(agent) + return agent + + monkeypatch.setattr(AgentBlueprint, "build", build) + blueprint = AgentBlueprint(_entry(tmp_path)) + + first, second = await asyncio.gather( + run_blueprint(blueprint, "one"), + run_blueprint(blueprint, "two"), + ) + + assert first.text == second.text == "done" + assert len(agents) == 2 + assert all(agent.enter_count == agent.exit_count == 1 for agent in agents) + assert agents[0].sessions[0] is not agents[1].sessions[0] + assert agents[0].sessions[0].session_id != agents[1].sessions[0].session_id + assert _FakeAgent.max_active_total == 2 + + +@pytest.mark.asyncio +async def test_open_agent_exits_context_owner_not_entered_value( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + entered = _FakeAgent() + owner = _EnteredAgent(entered) + monkeypatch.setattr(AgentBlueprint, "build", lambda *_args: owner) + + async with open_agent(AgentBlueprint(_entry(tmp_path))) as agent: + assert agent is entered + + assert owner.exit_count == 1 + assert entered.exit_count == 0 + + +@pytest.mark.asyncio +async def test_open_agent_closes_owner_with_handler_exception( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + owner = _FakeAgent() + monkeypatch.setattr(AgentBlueprint, "build", lambda *_args: owner) + + with pytest.raises(ValueError, match="handler failed"): + async with open_agent(AgentBlueprint(_entry(tmp_path))): + raise ValueError("handler failed") + + assert owner.exit_count == 1 + assert owner.exit_args is not None + assert owner.exit_args[0] is ValueError + + +@pytest.mark.asyncio +async def test_failed_enter_rolls_back_owner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + owner = _FailingEnterAgent() + monkeypatch.setattr(AgentBlueprint, "build", lambda *_args: owner) + + with pytest.raises(RuntimeError, match="enter failed"): + async with open_agent(AgentBlueprint(_entry(tmp_path))): + pass + + assert owner.exit_count == 1 + + +@pytest.mark.asyncio +async def test_run_blueprint_enforces_managed_timeout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + owner = _FakeAgent(delay=0.05) + monkeypatch.setattr(AgentBlueprint, "build", lambda *_args: owner) + + with pytest.raises(RuntimeError, match=r"timed out after 0\.01s"): + await run_blueprint(AgentBlueprint(_entry(tmp_path, timeout=0.01)), "hello") + + assert owner.exit_count == 1 + diff --git a/tests/test_package_imports.py b/tests/test_package_imports.py index 67d5cce1..5f4d1e7d 100644 --- a/tests/test_package_imports.py +++ b/tests/test_package_imports.py @@ -25,9 +25,13 @@ def test_public_exports_include_only_supported_preview_api() -> None: "DEFAULT_MODEL", "DEFAULT_TIMEOUT", "AgentResult", + "AiApp", "ClientManager", + "DurableAiAgent", + "DurableAiApp", "MAFClientManager", "__version__", + "agent_input", "create_function_app", "create_sandbox_tools", "create_web_request_tools", @@ -43,6 +47,10 @@ def test_public_exports_include_only_supported_preview_api() -> None: ] assert not hasattr(azure_functions_agents, "run_copilot_agent") assert not hasattr(azure_functions_agents, "run_copilot_agent_stream") + assert not hasattr(azure_functions_agents, "AiAgent") + assert not hasattr(azure_functions_agents, "SyncAiAgent") + assert not hasattr(azure_functions_agents, "shutdown_agent_cache") + assert not hasattr(azure_functions_agents, "shutdown_agent_runtime") def test_tool_shim_is_callable() -> None: diff --git a/tests/test_registration_capabilities.py b/tests/test_registration_capabilities.py index 446f4c69..738cd47c 100644 --- a/tests/test_registration_capabilities.py +++ b/tests/test_registration_capabilities.py @@ -154,6 +154,39 @@ def test_build_skills_provider_returns_provider_for_skill_paths(tmp_path: Path) assert isinstance(provider, ContextProvider) +@pytest.mark.asyncio +async def test_build_skills_provider_allows_unattended_read_only_tools( + tmp_path: Path, +) -> None: + from agent_framework import AgentSession, SessionContext + + skill_dir = tmp_path / "alpha" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: alpha\ndescription: A test skill.\n---\n\n# Alpha\n", + encoding="utf-8", + ) + + from azure_functions_agents.runner import _build_skills_provider + + provider = _build_skills_provider([skill_dir]) + assert provider is not None + context = SessionContext(input_messages=[]) + await provider.before_run( + agent=SimpleNamespace(), # type: ignore[arg-type] + session=AgentSession(), + context=context, + state={}, + ) + + approval_modes = {tool.name: tool.approval_mode for tool in context.tools} + assert approval_modes == { + "load_skill": "never_require", + "read_skill_resource": "never_require", + "run_skill_script": "always_require", + } + + # --------------------------------------------------------------------------- # web_request tool channel — build-once-at-registration, default-on wiring. # The factory import is lazy (``import_module`` inside ``capabilities.py``), From ccc117ca7c97471fd21e33c5e7cc12dab0bf5b72 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:51:50 -0500 Subject: [PATCH 2/6] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../hybrid-function-agent/src/function_app.py | 36 +++++++++---------- src/azure_functions_agents/hydration.py | 6 ++-- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/samples/hybrid-function-agent/src/function_app.py b/samples/hybrid-function-agent/src/function_app.py index 77bbee2f..f0d5f8c0 100644 --- a/samples/hybrid-function-agent/src/function_app.py +++ b/samples/hybrid-function-agent/src/function_app.py @@ -32,21 +32,21 @@ async def process_order( ) -# @app.queue_trigger( -# arg_name="message", -# queue_name="orders", -# connection="AzureWebJobsStorage", -# ) -# @app.agent_input(arg_name="order_agent", agent_name="order-fulfillment") -# async def process_order_event( -# message: func.QueueMessage, -# order_agent: Agent, -# ) -> None: -# await order_agent.run( -# json.dumps( -# { -# "event": json.loads(message.get_body().decode("utf-8")), -# "task": "triage", -# } -# ) -# ) +@app.queue_trigger( + arg_name="message", + queue_name="orders", + connection="AzureWebJobsStorage", +) +@app.agent_input(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order_event( + message: func.QueueMessage, + order_agent: Agent, +) -> None: + await order_agent.run( + json.dumps( + { + "event": json.loads(message.get_body().decode("utf-8")), + "task": "triage", + } + ) + ) diff --git a/src/azure_functions_agents/hydration.py b/src/azure_functions_agents/hydration.py index 27dc41da..6e8c830d 100644 --- a/src/azure_functions_agents/hydration.py +++ b/src/azure_functions_agents/hydration.py @@ -110,9 +110,9 @@ def build(self, invocation: InvocationMetadata | None = None) -> Agent[Any]: async def _enter_agent(owner: Agent[Any]) -> Agent[Any]: try: return await owner.__aenter__() - except BaseException: + except BaseException as exc: with suppress(Exception): - await owner.__aexit__(None, None, None) + await owner.__aexit__(type(exc), exc, exc.__traceback__) raise @@ -174,7 +174,7 @@ async def _run_managed( agent.run( messages, session=session, - options=options or _build_chat_options_from_environment(), + options=options if options is not None else _build_chat_options_from_environment(), ), timeout=remaining, ) From 18a47c5545def2c652cb8a0e0fd38d515dc75047 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 14 Aug 2026 11:53:13 -0500 Subject: [PATCH 3/6] Add more complex sample --- README.md | 3 + samples/README.md | 6 +- samples/hybrid-durable-agent/README.md | 17 ++- .../hybrid-durable-agent/src/function_app.py | 35 ++++- .../src/order-fulfillment.agent.md | 4 +- .../src/order_processing.py | 141 ++++++++++++++++++ .../src/skills/order-review/SKILL.md | 9 +- samples/hybrid-function-agent/README.md | 21 ++- .../hybrid-function-agent/src/function_app.py | 22 ++- .../src/order-fulfillment.agent.md | 4 +- .../src/order_processing.py | 141 ++++++++++++++++++ .../src/skills/order-review/SKILL.md | 9 +- 12 files changed, 386 insertions(+), 26 deletions(-) create mode 100644 samples/hybrid-durable-agent/src/order_processing.py create mode 100644 samples/hybrid-function-agent/src/order_processing.py diff --git a/README.md b/README.md index 0f042b79..8c2023ca 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,9 @@ Durable apps use `DurableAiApp` or a caller-owned `df.DFApp` with an explicit mo - `mode="orchestrator"` injects `DurableAiAgent`; `run()` returns a replay-safe Durable task backed by the generated `_afa_agent_binding_run` activity. Streaming is unsupported. See [`samples/hybrid-function-agent/`](samples/hybrid-function-agent/) for an `AiApp` with HTTP and queue handlers, and [`samples/hybrid-durable-agent/`](samples/hybrid-durable-agent/) for a `DurableAiApp` with activity and orchestrator handlers. +Both samples demonstrate a production-oriented handoff: deterministic Python validates +and normalizes orders, calculates trusted monetary fields, derives review signals, and +removes unnecessary PII before the agent performs contextual assessment or planning. ## Features diff --git a/samples/README.md b/samples/README.md index 8c2346b7..8f8353ac 100644 --- a/samples/README.md +++ b/samples/README.md @@ -16,7 +16,11 @@ app deployable with [`azd up`](https://learn.microsoft.com/azure/developer/azure | [hybrid-function-agent](hybrid-function-agent/) | HTTP + Queue | ✅ order totals | | ✅ MS Learn | ✅ order-review | | | | [hybrid-durable-agent](hybrid-durable-agent/) | Durable Activity + Orchestrator | ✅ order totals | | ✅ MS Learn | ✅ order-review | | | -The [hybrid-function-agent](hybrid-function-agent/) sample demonstrates `AiApp` injecting one agent into async HTTP and queue handlers. The [hybrid-durable-agent](hybrid-durable-agent/) sample demonstrates `DurableAiApp` injection into an async activity and a replay-safe orchestrator. +The [hybrid-function-agent](hybrid-function-agent/) sample demonstrates `AiApp` +injecting one agent into async HTTP and queue handlers after deterministic order +validation, enrichment, and PII minimization. The +[hybrid-durable-agent](hybrid-durable-agent/) sample applies the same preprocessing +in an activity, then chains agent-based risk assessment and replay-safe planning. ## Design previews diff --git a/samples/hybrid-durable-agent/README.md b/samples/hybrid-durable-agent/README.md index ce4f0ca8..754d56d9 100644 --- a/samples/hybrid-durable-agent/README.md +++ b/samples/hybrid-durable-agent/README.md @@ -5,8 +5,17 @@ This sample uses `DurableAiApp` to keep deterministic Durable Functions orchestr It demonstrates: - an HTTP starter using the standard Durable client binding; +- a deterministic activity that validates, normalizes, calculates totals, derives + review signals, and removes unnecessary customer PII; - an async Durable activity receiving a fresh raw `agent_framework.Agent`; -- a synchronous generator orchestrator receiving `DurableAiAgent`, which schedules the runtime-generated `_afa_agent_binding_run` activity. +- a synchronous generator orchestrator that chains preprocessing, risk assessment, + and planning with `DurableAiAgent`. + +The preprocessing activity turns the raw order into a compact decision packet. +Application code owns facts such as monetary calculations and threshold checks; the +agent first interprets fulfillment risk, then creates a plan from that assessment. +Keeping preprocessing in an activity preserves orchestrator replay determinism and +gives production applications a natural place for database or service enrichment. `DurableAiAgent` performs no model, network, or tool I/O in the orchestrator. The generated activity hydrates a fresh Agent, performs the runtime-managed call, closes the Agent, and records the JSON-safe result in Durable history. @@ -27,9 +36,11 @@ Start `order_orchestrator` with a JSON order object: ```bash curl -X POST http://localhost:7071/orders/orchestrations \ -H "Content-Type: application/json" \ - -d '{"items":[{"sku":"A-100","quantity":2}]}' + -d '{"order_id":"D-2048","customer":{"id":"C-1007","email":"buyer@example.com","loyalty_tier":"gold"},"currency":"usd","shipping":{"country":"ca","method":"overnight"},"items":[{"sku":"A-100","quantity":2,"unit_price":"24.95"},{"sku":"B-200","quantity":30,"unit_price":"40.00"}]}' ``` -The response contains the standard Durable status URLs for the new orchestration instance. +The response contains the standard Durable status URLs for the new orchestration +instance. The completed orchestration output contains the order ID, risk assessment, +and fulfillment plan. For ordinary HTTP and queue bindings using `AiApp`, see the sibling [`hybrid-function-agent`](../hybrid-function-agent/) sample. diff --git a/samples/hybrid-durable-agent/src/function_app.py b/samples/hybrid-durable-agent/src/function_app.py index 979f35b8..af76636f 100644 --- a/samples/hybrid-durable-agent/src/function_app.py +++ b/samples/hybrid-durable-agent/src/function_app.py @@ -4,6 +4,7 @@ import azure.durable_functions as df from agent_framework import Agent from azurefunctions.extensions.http.fastapi import Request, Response +from order_processing import prepare_order_for_agent from azure_functions_agents import DurableAiAgent, DurableAiApp @@ -33,6 +34,11 @@ async def start_order_orchestration( ) +@app.activity_trigger(input_name="order") +def prepare_order_activity(order: dict) -> dict[str, object]: + return prepare_order_for_agent(order) + + @app.activity_trigger(input_name="order") @app.agent_input( arg_name="order_agent", @@ -41,7 +47,12 @@ async def start_order_orchestration( ) async def assess_order_activity(order: dict, order_agent: Agent) -> str: response = await order_agent.run( - json.dumps({"order": order, "task": "assess risk"}) + json.dumps( + { + "order": order, + "task": "assess fulfillment risk using the trusted calculated fields", + } + ) ) return response.text @@ -56,7 +67,25 @@ def order_orchestrator( context: df.DurableOrchestrationContext, planner: DurableAiAgent, ): + prepared_order = yield context.call_activity( + "prepare_order_activity", + context.get_input(), + ) + assessment = yield context.call_activity( + "assess_order_activity", + prepared_order, + ) plan = yield planner.run( - json.dumps({"order": context.get_input(), "task": "create a plan"}) + json.dumps( + { + "order": prepared_order, + "risk_assessment": assessment, + "task": "create a fulfillment plan with prioritized human-review actions", + } + ) ) - return plan["text"] + return { + "order_id": prepared_order["order_id"], + "risk_assessment": assessment, + "fulfillment_plan": plan["text"], + } diff --git a/samples/hybrid-durable-agent/src/order-fulfillment.agent.md b/samples/hybrid-durable-agent/src/order-fulfillment.agent.md index be6a3e01..51abd6e4 100644 --- a/samples/hybrid-durable-agent/src/order-fulfillment.agent.md +++ b/samples/hybrid-durable-agent/src/order-fulfillment.agent.md @@ -4,5 +4,7 @@ description: Validates, triages, and plans order fulfillment work --- You are an order fulfillment specialist. -Assess the supplied order or event, identify risks and missing information, and return a concise actionable response. +The supplied order has already been validated and minimized by application code. +Treat its calculated summary and review signals as trusted facts. Explain operational +risk, identify missing fulfillment context, and return a concise actionable response. Never claim that an external action completed unless a tool result confirms it. diff --git a/samples/hybrid-durable-agent/src/order_processing.py b/samples/hybrid-durable-agent/src/order_processing.py new file mode 100644 index 00000000..b014d5f6 --- /dev/null +++ b/samples/hybrid-durable-agent/src/order_processing.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_CENT = Decimal("0.01") + + +class OrderItem(BaseModel): + model_config = ConfigDict(extra="ignore") + + sku: str + quantity: int = Field(gt=0, strict=True) + unit_price: Decimal = Field(ge=0, allow_inf_nan=False) + + @field_validator("sku") + @classmethod + def normalize_sku(cls, value: str) -> str: + normalized = value.strip().upper() + if not normalized: + raise ValueError("SKU cannot be empty") + return normalized + + +class Customer(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + loyalty_tier: Literal["standard", "silver", "gold", "platinum"] = "standard" + + @field_validator("id") + @classmethod + def normalize_id(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("Customer ID cannot be empty") + return normalized + + @field_validator("loyalty_tier", mode="before") + @classmethod + def normalize_loyalty_tier(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class Shipping(BaseModel): + model_config = ConfigDict(extra="ignore") + + country: str + method: Literal["standard", "two_day", "overnight", "same_day"] + + @field_validator("country") + @classmethod + def normalize_country(cls, value: str) -> str: + normalized = value.strip().upper() + if len(normalized) != 2 or not normalized.isalpha(): + raise ValueError("Shipping country must be a two-letter code") + return normalized + + @field_validator("method", mode="before") + @classmethod + def normalize_method(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class Order(BaseModel): + model_config = ConfigDict(extra="ignore") + + order_id: str | None = None + currency: str = "USD" + customer: Customer + shipping: Shipping + items: list[OrderItem] = Field(min_length=1) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + normalized = value.strip().upper() + if len(normalized) != 3 or not normalized.isalpha(): + raise ValueError("Currency must be a three-letter code") + return normalized + + +def _money(value: Decimal) -> str: + return f"{value.quantize(_CENT, rounding=ROUND_HALF_UP):.2f}" + + +def prepare_order_for_agent(payload: object, *, order_id: str | None = None) -> dict[str, object]: + """Validate an order and return the minimum trusted context needed by the agent.""" + order = Order.model_validate(payload) + resolved_order_id = order_id or order.order_id + if not resolved_order_id: + raise ValueError("Order ID is required") + + prepared_items: list[dict[str, object]] = [] + subtotal = Decimal("0") + total_quantity = 0 + for item in order.items: + unit_price = item.unit_price.quantize(_CENT, rounding=ROUND_HALF_UP) + line_total = unit_price * item.quantity + subtotal += line_total + total_quantity += item.quantity + prepared_items.append( + { + "sku": item.sku, + "quantity": item.quantity, + "unit_price": _money(unit_price), + "line_total": _money(line_total), + } + ) + + review_signals: list[str] = [] + if subtotal >= Decimal("1000"): + review_signals.append("high_value_order") + if total_quantity >= 25: + review_signals.append("bulk_quantity") + if order.shipping.method in {"overnight", "same_day"}: + review_signals.append("expedited_shipping") + if order.shipping.country != "US": + review_signals.append("international_shipping") + + return { + "order_id": resolved_order_id, + "currency": order.currency, + "customer": { + "id": order.customer.id, + "loyalty_tier": order.customer.loyalty_tier, + }, + "shipping": { + "country": order.shipping.country, + "method": order.shipping.method, + }, + "items": prepared_items, + "summary": { + "line_items": len(prepared_items), + "total_quantity": total_quantity, + "subtotal": _money(subtotal), + }, + "review_signals": review_signals, + } \ No newline at end of file diff --git a/samples/hybrid-durable-agent/src/skills/order-review/SKILL.md b/samples/hybrid-durable-agent/src/skills/order-review/SKILL.md index ade47dd6..b60a514e 100644 --- a/samples/hybrid-durable-agent/src/skills/order-review/SKILL.md +++ b/samples/hybrid-durable-agent/src/skills/order-review/SKILL.md @@ -5,8 +5,9 @@ description: Review order payloads for fulfillment readiness and operational ris # Order review -Use `summarize_order_quantities` before assessing an order with line items. +Treat `summary` and `review_signals` as trusted outputs from deterministic application +code. Use `summarize_order_quantities` only when those prepared fields are absent. -Flag missing SKUs, non-positive quantities, unusually large totals, and details that -prevent fulfillment. Keep recommendations concise and never claim an external action -completed without a confirming tool result. +Explain how the signals affect fulfillment, identify operational details still needed, +and prioritize human-review actions. Keep recommendations concise and never claim an +external action completed without a confirming tool result. diff --git a/samples/hybrid-function-agent/README.md b/samples/hybrid-function-agent/README.md index 03309588..229a6bd5 100644 --- a/samples/hybrid-function-agent/README.md +++ b/samples/hybrid-function-agent/README.md @@ -4,8 +4,20 @@ This sample uses `AiApp` to keep ordinary Azure Functions triggers and determini It demonstrates: -- an HTTP-triggered function using a fresh raw `agent_framework.Agent`; -- a queue-triggered function using a fresh raw Agent. +- an HTTP-triggered function that validates an order before using a fresh raw + `agent_framework.Agent`; +- a queue-triggered function that applies the same preprocessing before triage; +- a Pydantic validation boundary that normalizes identifiers, country, currency, + shipping method, and line items; +- deterministic `Decimal` calculations and rule-based review signals; +- data minimization that excludes customer name, email, and unknown fields from + the model prompt. + +`order_processing.py` turns an operational order into a compact decision packet. +Application code owns facts such as subtotals and threshold checks; the agent owns +the contextual fulfillment assessment. Invalid HTTP orders receive `400`, while an +invalid queue message fails the invocation so normal queue retry and poison-message +handling can take effect. The binding projection reads only `name`, `description`, and the markdown body from `order-fulfillment.agent.md`. Model, timeout, tools, skills, MCP servers, and system tools come from app-level configuration and discovery. @@ -24,9 +36,10 @@ Invoke the HTTP function: ```bash curl -X POST http://localhost:7071/orders/42 \ -H "Content-Type: application/json" \ - -d '{"items":[{"sku":"A-100","quantity":2}]}' + -d '{"customer":{"id":"C-1007","email":"buyer@example.com","loyalty_tier":"gold"},"currency":"usd","shipping":{"country":"ca","method":"overnight"},"items":[{"sku":"A-100","quantity":2,"unit_price":"24.95"},{"sku":"B-200","quantity":30,"unit_price":"40.00"}]}' ``` -Add JSON messages to the `orders` queue to invoke the event-driven handler. +Add the same JSON shape with an `order_id` field to the `orders` queue to invoke +the event-driven handler. For Durable activity and orchestrator bindings, see the sibling [`hybrid-durable-agent`](../hybrid-durable-agent/) sample. diff --git a/samples/hybrid-function-agent/src/function_app.py b/samples/hybrid-function-agent/src/function_app.py index f0d5f8c0..c9e772ec 100644 --- a/samples/hybrid-function-agent/src/function_app.py +++ b/samples/hybrid-function-agent/src/function_app.py @@ -3,6 +3,8 @@ import azure.functions as func from agent_framework import Agent from azurefunctions.extensions.http.fastapi import Request, Response +from order_processing import prepare_order_for_agent +from pydantic import ValidationError from azure_functions_agents import AiApp @@ -17,14 +19,22 @@ async def process_order( ) -> Response: order_id = req.path_params["orderId"] order = await req.json() - if not isinstance(order, dict) or "items" not in order: + try: + prepared_order = prepare_order_for_agent(order, order_id=order_id) + except (ValidationError, ValueError): return Response( - content="Request body must contain an items array.", + content=json.dumps({"error": "Order failed validation."}), status_code=400, + media_type="application/json", ) response = await order_agent.run( - json.dumps({"order_id": order_id, "items": order["items"], "task": "validate"}) + json.dumps( + { + "order": prepared_order, + "task": "assess fulfillment readiness using the trusted calculated fields", + } + ) ) return Response( content=json.dumps({"order_id": order_id, "assessment": response.text}), @@ -42,11 +52,13 @@ async def process_order_event( message: func.QueueMessage, order_agent: Agent, ) -> None: + event = json.loads(message.get_body().decode("utf-8")) + prepared_order = prepare_order_for_agent(event) await order_agent.run( json.dumps( { - "event": json.loads(message.get_body().decode("utf-8")), - "task": "triage", + "order": prepared_order, + "task": "triage fulfillment exceptions using the trusted calculated fields", } ) ) diff --git a/samples/hybrid-function-agent/src/order-fulfillment.agent.md b/samples/hybrid-function-agent/src/order-fulfillment.agent.md index be6a3e01..51abd6e4 100644 --- a/samples/hybrid-function-agent/src/order-fulfillment.agent.md +++ b/samples/hybrid-function-agent/src/order-fulfillment.agent.md @@ -4,5 +4,7 @@ description: Validates, triages, and plans order fulfillment work --- You are an order fulfillment specialist. -Assess the supplied order or event, identify risks and missing information, and return a concise actionable response. +The supplied order has already been validated and minimized by application code. +Treat its calculated summary and review signals as trusted facts. Explain operational +risk, identify missing fulfillment context, and return a concise actionable response. Never claim that an external action completed unless a tool result confirms it. diff --git a/samples/hybrid-function-agent/src/order_processing.py b/samples/hybrid-function-agent/src/order_processing.py new file mode 100644 index 00000000..b014d5f6 --- /dev/null +++ b/samples/hybrid-function-agent/src/order_processing.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_CENT = Decimal("0.01") + + +class OrderItem(BaseModel): + model_config = ConfigDict(extra="ignore") + + sku: str + quantity: int = Field(gt=0, strict=True) + unit_price: Decimal = Field(ge=0, allow_inf_nan=False) + + @field_validator("sku") + @classmethod + def normalize_sku(cls, value: str) -> str: + normalized = value.strip().upper() + if not normalized: + raise ValueError("SKU cannot be empty") + return normalized + + +class Customer(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + loyalty_tier: Literal["standard", "silver", "gold", "platinum"] = "standard" + + @field_validator("id") + @classmethod + def normalize_id(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("Customer ID cannot be empty") + return normalized + + @field_validator("loyalty_tier", mode="before") + @classmethod + def normalize_loyalty_tier(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class Shipping(BaseModel): + model_config = ConfigDict(extra="ignore") + + country: str + method: Literal["standard", "two_day", "overnight", "same_day"] + + @field_validator("country") + @classmethod + def normalize_country(cls, value: str) -> str: + normalized = value.strip().upper() + if len(normalized) != 2 or not normalized.isalpha(): + raise ValueError("Shipping country must be a two-letter code") + return normalized + + @field_validator("method", mode="before") + @classmethod + def normalize_method(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class Order(BaseModel): + model_config = ConfigDict(extra="ignore") + + order_id: str | None = None + currency: str = "USD" + customer: Customer + shipping: Shipping + items: list[OrderItem] = Field(min_length=1) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + normalized = value.strip().upper() + if len(normalized) != 3 or not normalized.isalpha(): + raise ValueError("Currency must be a three-letter code") + return normalized + + +def _money(value: Decimal) -> str: + return f"{value.quantize(_CENT, rounding=ROUND_HALF_UP):.2f}" + + +def prepare_order_for_agent(payload: object, *, order_id: str | None = None) -> dict[str, object]: + """Validate an order and return the minimum trusted context needed by the agent.""" + order = Order.model_validate(payload) + resolved_order_id = order_id or order.order_id + if not resolved_order_id: + raise ValueError("Order ID is required") + + prepared_items: list[dict[str, object]] = [] + subtotal = Decimal("0") + total_quantity = 0 + for item in order.items: + unit_price = item.unit_price.quantize(_CENT, rounding=ROUND_HALF_UP) + line_total = unit_price * item.quantity + subtotal += line_total + total_quantity += item.quantity + prepared_items.append( + { + "sku": item.sku, + "quantity": item.quantity, + "unit_price": _money(unit_price), + "line_total": _money(line_total), + } + ) + + review_signals: list[str] = [] + if subtotal >= Decimal("1000"): + review_signals.append("high_value_order") + if total_quantity >= 25: + review_signals.append("bulk_quantity") + if order.shipping.method in {"overnight", "same_day"}: + review_signals.append("expedited_shipping") + if order.shipping.country != "US": + review_signals.append("international_shipping") + + return { + "order_id": resolved_order_id, + "currency": order.currency, + "customer": { + "id": order.customer.id, + "loyalty_tier": order.customer.loyalty_tier, + }, + "shipping": { + "country": order.shipping.country, + "method": order.shipping.method, + }, + "items": prepared_items, + "summary": { + "line_items": len(prepared_items), + "total_quantity": total_quantity, + "subtotal": _money(subtotal), + }, + "review_signals": review_signals, + } \ No newline at end of file diff --git a/samples/hybrid-function-agent/src/skills/order-review/SKILL.md b/samples/hybrid-function-agent/src/skills/order-review/SKILL.md index ade47dd6..b60a514e 100644 --- a/samples/hybrid-function-agent/src/skills/order-review/SKILL.md +++ b/samples/hybrid-function-agent/src/skills/order-review/SKILL.md @@ -5,8 +5,9 @@ description: Review order payloads for fulfillment readiness and operational ris # Order review -Use `summarize_order_quantities` before assessing an order with line items. +Treat `summary` and `review_signals` as trusted outputs from deterministic application +code. Use `summarize_order_quantities` only when those prepared fields are absent. -Flag missing SKUs, non-positive quantities, unusually large totals, and details that -prevent fulfillment. Keep recommendations concise and never claim an external action -completed without a confirming tool result. +Explain how the signals affect fulfillment, identify operational details still needed, +and prioritize human-review actions. Keep recommendations concise and never claim an +external action completed without a confirming tool result. From 64ba4ae2492de6eadb041abd6dcd651672d47ad5 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 14 Aug 2026 12:03:01 -0500 Subject: [PATCH 4/6] fix uv lock --- uv.lock | 64 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/uv.lock b/uv.lock index 518ecbd8..265a5466 100644 --- a/uv.lock +++ b/uv.lock @@ -11,45 +11,47 @@ resolution-markers = [ [[package]] name = "agent-framework-core" -version = "1.3.0" +version = "1.13.0" source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } dependencies = [ + { name = "msgspec" }, { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/agent-framework-core/1.3/agent_framework_core-1.3.0.tar.gz", hash = "sha256:91c3659718b733f70dde6fb3626edb044733e0f7aa5f9726c9774e17fae328ef" } +sdist = { url = "https://ms-feed-17.pkgs.visualstudio.com/02a0e93b-9e7a-46f6-8851-5a56920f8f7e/_packaging/2aa351dc-4441-4d20-a430-25f505f2a55a/pypi/download/agent-framework-core/1.13/agent_framework_core-1.13.0.tar.gz", hash = "sha256:9f3003cab3cfadb28d50589b223ae022e5780966be73cafb4f1f357b5284811c" } wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/agent-framework-core/1.3/agent_framework_core-1.3.0-py3-none-any.whl", hash = "sha256:b7a5baf2beb383e9042af057df79dae4fda0b836cbc8530b3b2a57a3c12bb7ac" }, + { url = "https://ms-feed-17.pkgs.visualstudio.com/02a0e93b-9e7a-46f6-8851-5a56920f8f7e/_packaging/2aa351dc-4441-4d20-a430-25f505f2a55a/pypi/download/agent-framework-core/1.13/agent_framework_core-1.13.0-py3-none-any.whl", hash = "sha256:ba354e70a2749a0d5e9a5d4b7c40773011717b453c3a39bfa2dccd2bceb0f131" }, ] [[package]] name = "agent-framework-foundry" -version = "1.3.0" +version = "1.10.4" source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } dependencies = [ { name = "agent-framework-core" }, { name = "agent-framework-openai" }, + { name = "aiohttp" }, { name = "azure-ai-inference" }, { name = "azure-ai-projects" }, ] -sdist = { url = "https://ms-feed-2.pkgs.visualstudio.com/f5581750-f66a-4ee8-b9cc-8269930bd7c4/_packaging/637e612b-d5d4-45c1-8a31-e0deb2a5a828/pypi/download/agent-framework-foundry/1.3/agent_framework_foundry-1.3.0.tar.gz", hash = "sha256:8a4b137efa0a7000e60fb396ad90e01c271d14a52f1325f1f0a32177d944bcff" } +sdist = { url = "https://ms-feed-17.pkgs.visualstudio.com/02a0e93b-9e7a-46f6-8851-5a56920f8f7e/_packaging/2aa351dc-4441-4d20-a430-25f505f2a55a/pypi/download/agent-framework-foundry/1.10.4/agent_framework_foundry-1.10.4.tar.gz", hash = "sha256:85dcebeccfa0bc9f14dada9f3ac7ebebfc730329237421ab02566b08b77f5485" } wheels = [ - { url = "https://ms-feed-2.pkgs.visualstudio.com/f5581750-f66a-4ee8-b9cc-8269930bd7c4/_packaging/637e612b-d5d4-45c1-8a31-e0deb2a5a828/pypi/download/agent-framework-foundry/1.3/agent_framework_foundry-1.3.0-py3-none-any.whl", hash = "sha256:49987bc01b077f6c60af33c475f9770a02b4ff6d6822aede18fc5471b46ffd41" }, + { url = "https://ms-feed-17.pkgs.visualstudio.com/02a0e93b-9e7a-46f6-8851-5a56920f8f7e/_packaging/2aa351dc-4441-4d20-a430-25f505f2a55a/pypi/download/agent-framework-foundry/1.10.4/agent_framework_foundry-1.10.4-py3-none-any.whl", hash = "sha256:5661926ca0af8705949704691d6b3859c9e3fe2678a1391ba1450b5114eb0731" }, ] [[package]] name = "agent-framework-openai" -version = "1.3.0" +version = "1.12.0" source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } dependencies = [ { name = "agent-framework-core" }, { name = "openai" }, ] -sdist = { url = "https://ms-feed-12.pkgs.visualstudio.com/80ca31fc-64d9-45fa-acb1-85ac9c6202b2/_packaging/00cb4235-4c66-4418-be20-d052995979fb/pypi/download/agent-framework-openai/1.3/agent_framework_openai-1.3.0.tar.gz", hash = "sha256:770828447875ee169dde8cd2f2a0343f427d856af7c83895ca12d59f8c24a7f2" } +sdist = { url = "https://ms-feed-12.pkgs.visualstudio.com/80ca31fc-64d9-45fa-acb1-85ac9c6202b2/_packaging/00cb4235-4c66-4418-be20-d052995979fb/pypi/download/agent-framework-openai/1.12/agent_framework_openai-1.12.0.tar.gz", hash = "sha256:f0da9c0c2a3e68d94d583a97c416b89719b836ef7c265cab6139cdefb0fd8e26" } wheels = [ - { url = "https://ms-feed-12.pkgs.visualstudio.com/80ca31fc-64d9-45fa-acb1-85ac9c6202b2/_packaging/00cb4235-4c66-4418-be20-d052995979fb/pypi/download/agent-framework-openai/1.3/agent_framework_openai-1.3.0-py3-none-any.whl", hash = "sha256:1953dcb9f3e852362be84b4316ee69639313a7f119eab6ce8c88949e1f24aa4b" }, + { url = "https://ms-feed-12.pkgs.visualstudio.com/80ca31fc-64d9-45fa-acb1-85ac9c6202b2/_packaging/00cb4235-4c66-4418-be20-d052995979fb/pypi/download/agent-framework-openai/1.12/agent_framework_openai-1.12.0-py3-none-any.whl", hash = "sha256:1b6a622bb409976440970696f488e8a43efc75c6ba269feee20e621897939328" }, ] [[package]] @@ -256,7 +258,7 @@ wheels = [ [[package]] name = "azure-ai-projects" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } dependencies = [ { name = "azure-core" }, @@ -266,9 +268,9 @@ dependencies = [ { name = "openai" }, { name = "typing-extensions" }, ] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/azure-ai-projects/2.1/azure_ai_projects-2.1.0.tar.gz", hash = "sha256:f0749fa9a174255aa1a5550fb6078208521518472907a4c6dd552767d9b39caa" } +sdist = { url = "https://ms-feed-12.pkgs.visualstudio.com/80ca31fc-64d9-45fa-acb1-85ac9c6202b2/_packaging/00cb4235-4c66-4418-be20-d052995979fb/pypi/download/azure-ai-projects/2.3/azure_ai_projects-2.3.0.tar.gz", hash = "sha256:6e3006b7b8aa51c6ff9db61ef4aac3717f8a712cd1a183d5a1d34e2eb33450bd" } wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/azure-ai-projects/2.1/azure_ai_projects-2.1.0-py3-none-any.whl", hash = "sha256:6f259d8eb9167d2dfd372006d0221a8118faeaeb05829fa898b595bc6f19c699" }, + { url = "https://ms-feed-12.pkgs.visualstudio.com/80ca31fc-64d9-45fa-acb1-85ac9c6202b2/_packaging/00cb4235-4c66-4418-be20-d052995979fb/pypi/download/azure-ai-projects/2.3/azure_ai_projects-2.3.0-py3-none-any.whl", hash = "sha256:1da20aeac9663740a97644efedbb9f59eb2a332d3e01bfde7aa03027936edf44" }, ] [[package]] @@ -455,9 +457,9 @@ monitor = [ [package.metadata] requires-dist = [ - { name = "agent-framework-core", specifier = "==1.3.*" }, - { name = "agent-framework-foundry", specifier = "==1.3.*" }, - { name = "agent-framework-openai", specifier = "==1.3.*" }, + { name = "agent-framework-core", specifier = "==1.13.0" }, + { name = "agent-framework-foundry", specifier = "==1.10.4" }, + { name = "agent-framework-openai", specifier = "==1.12.0" }, { name = "aiohttp", specifier = ">=3.14.2,<4" }, { name = "azure-functions", specifier = ">=2.1.0,<3" }, { name = "azure-functions-durable", specifier = ">=1.2.10,<2" }, @@ -1330,6 +1332,38 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msal-extensions/1.3.1/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca" }, ] +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msgspec/0.21.1/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90" }, +] + [[package]] name = "msrest" version = "0.7.1" From 20f6f6ea1eb0221e9558c1fb18335f715a7bb674 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 14 Aug 2026 13:36:04 -0500 Subject: [PATCH 5/6] fix tests + feedback --- src/azure_functions_agents/bindings.py | 9 ++-- tests/test_bindings.py | 18 ++++++++ tests/test_hybrid_binding_sample.py | 60 +++++++++++++++++++++++--- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/azure_functions_agents/bindings.py b/src/azure_functions_agents/bindings.py index 0748b7ce..8f1eb4fa 100644 --- a/src/azure_functions_agents/bindings.py +++ b/src/azure_functions_agents/bindings.py @@ -71,7 +71,7 @@ def run( class _BindingRuntime: def __init__(self, app: func.FunctionApp, app_root: Path | None) -> None: - self.app = app + self._app_ref = weakref.ref(app) self.app_root = Path(app_root).resolve() if app_root is not None else get_app_root() self._snapshot: ProjectSnapshot | None = None self._blueprints: dict[str, AgentBlueprint] = {} @@ -110,7 +110,10 @@ def register_durable_activity(self) -> None: with self._lock: if self._durable_activity_registered: return - if not isinstance(self.app, df.DFApp): + app = self._app_ref() + if app is None: + raise RuntimeError("The FunctionApp owning this agent binding runtime was collected") + if not isinstance(app, df.DFApp): raise TypeError( "Durable agent_input modes require DurableAiApp or azure.durable_functions.DFApp" ) @@ -144,7 +147,7 @@ async def _afa_agent_binding_run( ) from exc return result - activity_decorator = cast(Any, self.app).activity_trigger( + activity_decorator = cast(Any, app).activity_trigger( input_name="payload", activity=_DURABLE_ACTIVITY_NAME, ) diff --git a/tests/test_bindings.py b/tests/test_bindings.py index ab198c3f..17c17cfb 100644 --- a/tests/test_bindings.py +++ b/tests/test_bindings.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +import gc import inspect +import weakref from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any, get_type_hints @@ -343,6 +345,22 @@ async def second(agent: Agent[Any]) -> Agent[Any]: assert first_blueprint is not second_blueprint +def test_runtime_registry_does_not_retain_app_or_cached_runtime(tmp_path: Path) -> None: + _write_agent(tmp_path) + app = AiApp(app_root=tmp_path) + runtime = bindings_module._runtime_for(app, tmp_path) + runtime.resolve("order-fulfillment") + app_ref = weakref.ref(app) + runtime_ref = weakref.ref(runtime) + + del app + del runtime + gc.collect() + + assert app_ref() is None + assert runtime_ref() is None + + def test_concurrent_orchestrator_decorators_register_one_activity(tmp_path: Path) -> None: _write_agent(tmp_path) app = DurableAiApp(app_root=tmp_path) diff --git a/tests/test_hybrid_binding_sample.py b/tests/test_hybrid_binding_sample.py index caed8590..88a59f58 100644 --- a/tests/test_hybrid_binding_sample.py +++ b/tests/test_hybrid_binding_sample.py @@ -3,6 +3,7 @@ import importlib.util import inspect import json +import sys from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import AsyncMock @@ -29,6 +30,8 @@ def _load_sample( monkeypatch.delenv("AZURE_FUNCTIONS_AGENTS_APP_ROOT", raising=False) monkeypatch.delenv("AzureWebJobsScriptRoot", raising=False) monkeypatch.chdir(sample_src) + monkeypatch.syspath_prepend(str(sample_src)) + sys.modules.pop("order_processing", None) spec = importlib.util.spec_from_file_location( module_name, sample_src / "function_app.py", @@ -87,7 +90,7 @@ def test_ai_app_sample_indexes_standard_triggers( @pytest.mark.asyncio -async def test_ai_app_sample_sends_complete_order_as_text( +async def test_ai_app_sample_preprocesses_order_before_agent_handoff( monkeypatch: pytest.MonkeyPatch, ) -> None: module = _load_sample( @@ -108,7 +111,22 @@ async def test_ai_app_sample_sends_complete_order_as_text( async def receive() -> dict[str, object]: return { "type": "http.request", - "body": b'{"items":[{"sku":"A-100","quantity":2}]}', + "body": json.dumps( + { + "customer": { + "id": "C-42", + "email": "buyer@example.com", + "name": "Example Buyer", + "loyalty_tier": "gold", + }, + "currency": "usd", + "shipping": {"country": "ca", "method": "overnight"}, + "items": [ + {"sku": " a-100 ", "quantity": 2, "unit_price": "24.95"}, + {"sku": "b-200", "quantity": 30, "unit_price": "40.00"}, + ], + } + ).encode(), "more_body": False, } @@ -129,10 +147,41 @@ async def receive() -> dict[str, object]: [prompt] = agent.run.await_args.args assert isinstance(prompt, str) assert json.loads(prompt) == { - "order_id": "2", - "items": [{"sku": "A-100", "quantity": 2}], - "task": "validate", + "order": { + "order_id": "2", + "currency": "USD", + "customer": {"id": "C-42", "loyalty_tier": "gold"}, + "shipping": {"country": "CA", "method": "overnight"}, + "items": [ + { + "sku": "A-100", + "quantity": 2, + "unit_price": "24.95", + "line_total": "49.90", + }, + { + "sku": "B-200", + "quantity": 30, + "unit_price": "40.00", + "line_total": "1200.00", + }, + ], + "summary": { + "line_items": 2, + "total_quantity": 32, + "subtotal": "1249.90", + }, + "review_signals": [ + "high_value_order", + "bulk_quantity", + "expedited_shipping", + "international_shipping", + ], + }, + "task": "assess fulfillment readiness using the trusted calculated fields", } + assert "buyer@example.com" not in prompt + assert "Example Buyer" not in prompt assert response.status_code == 200 @@ -155,6 +204,7 @@ def test_durable_ai_app_sample_indexes_durable_modes( } assert functions == { "start_order_orchestration": ["httpTrigger", "http", "durableClient"], + "prepare_order_activity": ["activityTrigger"], "assess_order_activity": ["activityTrigger"], "_afa_agent_binding_run": ["activityTrigger"], "order_orchestrator": ["orchestrationTrigger"], From 7b4f723ae3d144ffc4e0ad4b46fc10a83b40fd0a Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 14 Aug 2026 15:09:53 -0500 Subject: [PATCH 6/6] feedback --- README.md | 2 +- docs/architecture.md | 2 +- docs/frds/0008-agent-input-binding.md | 13 +++++--- docs/front-matter-spec.md | 2 +- samples/hybrid-durable-agent/README.md | 2 +- samples/hybrid-function-agent/README.md | 2 +- src/azure_functions_agents/composition.py | 7 +++- tests/test_composition.py | 40 +++++++++++++++++++++++ 8 files changed, 60 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f3903466..e1c55e10 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ async def process_order( Existing `func.FunctionApp()` instances can use `agent_input(app, ...)` instead. `create_function_app()` now returns an enhanced `AiApp` or `DurableAiApp`, preserving its existing declarative routes and triggers while allowing hybrid handlers to be added. -`agent_name` is the source filename stem or normalized slug, not the front-matter display name. For bindings, the agent file requires only string `name` and `description` fields; its markdown body supplies instructions. Other per-agent front-matter fields are ignored. Model, timeout, system tools, discovered tools, skills, and MCP servers come from app-level configuration. +`agent_name` is the source filename stem or normalized slug, not the front-matter display name. For bindings, the agent file requires only string `name` and `description` fields; its markdown body supplies instructions and follows the standard environment-substitution behavior, including `substitute_variables: false`. Other per-agent front-matter fields are ignored. Model, timeout, system tools, discovered tools, skills, and MCP servers come from app-level configuration. Function and activity handlers using `agent_input` must be declared with `async def`. They receive a raw `agent_framework.Agent` that is built and entered for that Function invocation, then closed when the handler returns, raises, or is cancelled. The app caches only an immutable blueprint and reusable dependency descriptions, never the live Agent or its MCP tools. These handlers control sessions, run options, middleware, streaming, and model-call timeouts; the Azure Functions invocation timeout remains the outer bound. Do not retain the Agent after the handler returns. diff --git a/docs/architecture.md b/docs/architecture.md index 5c5e686a..ab22c401 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ A few boundaries are worth calling out explicitly: | Package/module | Role | Key entry points | | --- | --- | --- | | `azure_functions_agents/app.py` | Top-level two-pass composition root. Before app mutation it builds the slug index, `AgentCatalog`, complete workflow-handler catalog, and immutable workflow-agent policy catalog. It chooses `DurableAiApp` when any agent enables workflows (otherwise `AiApp`), registers the workflow runtime once, then registers each agent. | `create_function_app()`, `_fail_on_duplicate_slugs()` | -| `azure_functions_agents/composition.py` | Builds the immutable binding-only project snapshot and resolves a binding target by exact filename stem, then normalized slug. Requires only `name` and `description`; all other per-agent front matter is discarded. | `load_project_snapshot()`, `compose_binding_target()` | +| `azure_functions_agents/composition.py` | Builds the immutable binding-only project snapshot and resolves a binding target by exact filename stem, then normalized slug. Requires only `name` and `description`, honors `substitute_variables` for markdown instructions, and discards all other per-agent front matter. | `load_project_snapshot()`, `compose_binding_target()` | | `azure_functions_agents/bindings.py` | Owns the smart callable wrapper, enhanced app classes, per-app blueprint registry, raw Agent injection into async Functions and activities, and the replay-safe orchestrator proxy and generated Durable activity. It uses public SDK decorators and signatures without mutating `FunctionBuilder` internals. | `agent_input()`, `AiApp`, `DurableAiApp`, `DurableAiAgent` | | `azure_functions_agents/hydration.py` | Owns immutable binding blueprints, fresh per-invocation MAF Agent construction and context management, and runtime-managed calls for the generated Durable activity. | `AgentBlueprint`, `open_agent()`, `run_blueprint()` | | `azure_functions_agents/config/paths.py` | Resolves the app root and the optional config/history directory. | `set_app_root()`, `get_app_root()`, `resolve_config_dir()` | diff --git a/docs/frds/0008-agent-input-binding.md b/docs/frds/0008-agent-input-binding.md index 2d8974e9..bbb50f07 100644 --- a/docs/frds/0008-agent-input-binding.md +++ b/docs/frds/0008-agent-input-binding.md @@ -4,7 +4,7 @@ title: Python agent input binding status: Finalized author: hallvictoria created: 2026-08-11 -updated: 2026-08-12 +updated: 2026-08-14 issues: [#1163, #1175, #1284] pull_requests: [] branch: hallvictoria/agent-binding @@ -51,8 +51,9 @@ host round trip. - Support both a concise `AiApp.agent_input()` API and existing caller-owned `FunctionApp` objects through the same free `agent_input(app, ...)` implementation. - Resolve the supported `agent.md` / `.agent.md` convention. For smart bindings, - recognize only required `name` and `description` front matter plus the markdown body - as instructions; ignore every other per-agent front-matter property. + recognize required `name` and `description` front matter, the markdown body as + instructions, and `substitute_variables` only as its parsing control; ignore every + other per-agent front-matter property. - Hydrate the app-level model, discovered user and system tools, skills, MCP, and per-call history without customer glue. - Validate binding definitions and discovered assets at indexing time with actionable @@ -177,7 +178,8 @@ slugs side by side. A definition referenced by at least one `agent_input` decorator is reachable and may omit a standalone trigger and built-in endpoints. The binding projection requires only string `name` and `description` values and treats the markdown body as instructions. -All other front-matter keys are ignored without validation or warnings, including +It honors `substitute_variables` only to control standard environment substitution in +that body. All other front-matter keys are ignored without validation or warnings, including `trigger`, `builtin_endpoints`, model/tool/skill/MCP filters, schemas, workflows, and subagents. The customer's Function owns triggering, request/response adaptation, and Durable behavior. Invalid values in ignored keys are silently discarded and cannot @@ -536,6 +538,7 @@ shims for MAF 1.3 are not part of v1. | 27 | MCP lifetime | cache live MCP tools / cache resolved definitions / rediscover files | Cache immutable resolved MCP definitions and construct fresh MCP tools/HTTP clients for each Agent context, because MAF enters and closes owned MCP tools | Agent | 2026-08-12 | | 28 | Revised public surface | retain cache-era names / aliases / clean replacement | Remove preview-only `AiAgent` and `shutdown_agent_cache`; customers annotate async injection with `agent_framework.Agent`, while `shutdown_agent_runtime()` names the remaining sync-executor/client-manager cleanup | Agent | 2026-08-12 | | 29 | Synchronous handler scope | blocking facade / per-call `asyncio.run()` / async-only | Supersedes the sync and entity portions of #15, #19, #25, and #28: require coroutine Functions and activities, retain only the synchronous replay-safe orchestrator proxy, exclude entity injection, and remove `SyncAiAgent`, `AgentExecutor`, and `shutdown_agent_runtime` | Human | 2026-08-12 | +| 30 | Binding instruction substitution | raw markdown / unconditional substitution / standard per-agent control | Narrowly supersedes #16: apply the standard markdown environment substitution behavior and honor `substitute_variables`; continue ignoring all capability and runtime fields | Agent | 2026-08-14 | ## 6. Test plan @@ -544,6 +547,8 @@ shims for MAF 1.3 are not part of v1. subagent fields, including invalid values in ignored fields. - [ ] Unit: binding hydration uses markdown instructions, app-level model and system tools, all discovered user tools/skills/MCP, and session-aware history. +- [ ] Unit: binding markdown instructions substitute `$VAR` and `%VAR%` placeholders + by default and preserve them when `substitute_variables: false`. - [ ] Unit: repeated async invocations reuse the same immutable blueprint but receive distinct raw Agents, clients, MCP tools, context stacks, and mutable tool lists. - [ ] Unit: concurrent invocations of the same slug overlap without an Agent lease and diff --git a/docs/front-matter-spec.md b/docs/front-matter-spec.md index d6fc667e..02bbabff 100644 --- a/docs/front-matter-spec.md +++ b/docs/front-matter-spec.md @@ -9,7 +9,7 @@ Azure Functions agents use a **two-tier configuration system**: Each agent is defined in a `.agent.md` file with YAML front matter followed by markdown instructions. The front matter configures the agent-specific behavior, while the markdown body contains the agent's system prompt. -> **Smart agent input binding:** A definition referenced by `AiApp.agent_input()` or `agent_input(app, ...)` has a deliberately smaller projection. The binding requires only non-empty string `name` and `description` fields and uses the markdown body as instructions. It ignores every other per-agent field, even if that ignored value would be invalid for declarative `create_function_app()` usage. Model, timeout, system tools, user tools, skills, and MCP servers come from `agents.config.yaml` and app-level discovery. `agent_name` resolves the filename stem first and then its normalized slug; it never resolves the display `name`. +> **Smart agent input binding:** A definition referenced by `AiApp.agent_input()` or `agent_input(app, ...)` has a deliberately smaller projection. The binding requires only non-empty string `name` and `description` fields and uses the markdown body as instructions. It recognizes `substitute_variables` only to control markdown-body environment substitution and ignores every other per-agent field, even if that ignored value would be invalid for declarative `create_function_app()` usage. Model, timeout, system tools, user tools, skills, and MCP servers come from `agents.config.yaml` and app-level discovery. `agent_name` resolves the filename stem first and then its normalized slug; it never resolves the display `name`. ### Configuration Model diff --git a/samples/hybrid-durable-agent/README.md b/samples/hybrid-durable-agent/README.md index 754d56d9..35126c1f 100644 --- a/samples/hybrid-durable-agent/README.md +++ b/samples/hybrid-durable-agent/README.md @@ -19,7 +19,7 @@ gives production applications a natural place for database or service enrichment `DurableAiAgent` performs no model, network, or tool I/O in the orchestrator. The generated activity hydrates a fresh Agent, performs the runtime-managed call, closes the Agent, and records the JSON-safe result in Durable history. -The binding projection reads only `name`, `description`, and the markdown body from `order-fulfillment.agent.md`. Model, timeout, tools, skills, MCP servers, and system tools come from app-level configuration and discovery. +The binding projection reads `name`, `description`, the markdown body, and its `substitute_variables` parsing control from `order-fulfillment.agent.md`. Model, timeout, tools, skills, MCP servers, and system tools come from app-level configuration and discovery. Activity handlers using `agent_input` must be declared with `async def`. Each activity invocation receives its own entered Agent; the runtime closes it when the handler exits, so do not retain it beyond that invocation. diff --git a/samples/hybrid-function-agent/README.md b/samples/hybrid-function-agent/README.md index 229a6bd5..61bedbea 100644 --- a/samples/hybrid-function-agent/README.md +++ b/samples/hybrid-function-agent/README.md @@ -19,7 +19,7 @@ the contextual fulfillment assessment. Invalid HTTP orders receive `400`, while invalid queue message fails the invocation so normal queue retry and poison-message handling can take effect. -The binding projection reads only `name`, `description`, and the markdown body from `order-fulfillment.agent.md`. Model, timeout, tools, skills, MCP servers, and system tools come from app-level configuration and discovery. +The binding projection reads `name`, `description`, the markdown body, and its `substitute_variables` parsing control from `order-fulfillment.agent.md`. Model, timeout, tools, skills, MCP servers, and system tools come from app-level configuration and discovery. Functions using `agent_input` must be declared with `async def`. Each invocation receives its own entered Agent and may control sessions, options, middleware, streaming, and model-call timeout. The runtime closes the Agent when the handler exits; do not retain it beyond that invocation. diff --git a/src/azure_functions_agents/composition.py b/src/azure_functions_agents/composition.py index 74e32152..d596bde9 100644 --- a/src/azure_functions_agents/composition.py +++ b/src/azure_functions_agents/composition.py @@ -11,6 +11,7 @@ from ._function_tool import WorkflowTool from ._slug import _function_name_from_source +from .config.env import _to_bool, substitute_env_vars_in_text from .config.loader import ( _collect_agent_files, _resolve_agents_dir, @@ -111,12 +112,16 @@ def load_binding_definition(source_file: Path) -> BindingAgentDefinition: metadata: dict[str, object] = dict(post.metadata or {}) name = _required_string(metadata, "name", resolved_source) description = _required_string(metadata, "description", resolved_source) + substitute_variables = _to_bool(metadata.get("substitute_variables", True), default=True) + instructions = str(post.content) + if substitute_variables: + instructions = substitute_env_vars_in_text(instructions) filename_stem = _filename_stem(resolved_source) slug = _function_name_from_source(resolved_source, name, warn_on_missing=False) return BindingAgentDefinition( name=name, description=description, - instructions=str(post.content), + instructions=instructions, source_file=resolved_source, filename_stem=filename_stem, slug=slug, diff --git a/tests/test_composition.py b/tests/test_composition.py index ca866df6..00db5f2d 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -60,6 +60,46 @@ def test_binding_target_accepts_normalized_slug(tmp_path: Path) -> None: ) +def test_binding_definition_substitutes_environment_in_instructions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("TEAM_NAME", "Fulfillment") + _write_agent( + tmp_path, + "order-fulfillment.agent.md", + "name: Order Processor\ndescription: Processes orders", + "Contact $TEAM_NAME or %TEAM_NAME%.", + ) + + snapshot = load_project_snapshot(tmp_path) + entry = compose_binding_target(snapshot, "order-fulfillment") + + assert entry.definition.instructions.strip() == "Contact Fulfillment or Fulfillment." + + +def test_binding_definition_honors_substitution_opt_out( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("TEAM_NAME", "Fulfillment") + _write_agent( + tmp_path, + "order-fulfillment.agent.md", + """ + name: Order Processor + description: Processes orders + substitute_variables: false + """, + "Contact $TEAM_NAME or %TEAM_NAME%.", + ) + + snapshot = load_project_snapshot(tmp_path) + entry = compose_binding_target(snapshot, "order-fulfillment") + + assert entry.definition.instructions.strip() == "Contact $TEAM_NAME or %TEAM_NAME%." + + @pytest.mark.parametrize("field", ["name", "description"]) def test_binding_definition_requires_minimal_string_fields( field: str,