Skip to content

feat: add agent binding - #159

Open
hallvictoria wants to merge 9 commits into
mainfrom
hallvictoria/agent-binding
Open

feat: add agent binding#159
hallvictoria wants to merge 9 commits into
mainfrom
hallvictoria/agent-binding

Conversation

@hallvictoria

@hallvictoria hallvictoria commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Purpose

Fixes https://github.com/Azure/azure-functions-bucees-planning/issues/1287

  • Add AiApp.agent_input() for injecting freshly hydrated MAF Agents into existing async Azure Functions
  • Add DurableAiApp support for activities and replay-safe orchestrator proxies
  • Cache immutable Agent blueprints while creating and closing a new Agent per invocation

Example customer experience:

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:
    response = await order_agent.run(...)

Does this introduce a breaking change?

[ ] Yes
[ ] No

Pull Request Type

What kind of change does this Pull Request introduce?

[ ] Bugfix
[ ] Feature
[ ] Code style update (formatting, local variables)
[ ] Refactoring (no functional changes, no api changes)
[ ] Documentation content changes
[ ] Other... Please describe:

How to Test

  • Get the code
git clone https://github.com/Azure/azure-functions-agents-runtime.git
cd azure-functions-agents-runtime
git checkout [branch-name]
pip install -e .
  • Test the code

What to Check

Verify that the following are valid

  • ...

Other Information

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Python “smart agent input binding” that lets existing Azure Functions (and Durable Functions) handlers inject a markdown-defined agent at runtime, while keeping triggers and deterministic business logic in customer code.

Changes:

  • Introduces agent_input(...) plus AiApp / DurableAiApp wrappers to inject a per-invocation agent_framework.Agent, and a DurableAiAgent proxy for replay-safe orchestrator usage.
  • Adds a binding-only composition + hydration pipeline (composition.py → cached AgentBlueprint → fresh agent per invocation), including MCP definition caching to rebuild fresh MCP tools per agent context.
  • Expands tests, docs, and adds hybrid samples demonstrating HTTP/Queue and Durable activity/orchestrator patterns.

Reviewed changes

Copilot reviewed 43 out of 43 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_registration_capabilities.py Verifies unattended/read-only skill tools are non-approval-gated while scripts remain gated.
tests/test_package_imports.py Updates public export surface assertions for new binding APIs.
tests/test_hydration.py Adds unit coverage for blueprint hydration, agent context lifetime, concurrency, and managed timeouts.
tests/test_hybrid_binding_sample.py Validates the new hybrid samples index correctly and behave as documented.
tests/test_discovery_mcp.py Ensures MCP tool instances are rebuilt fresh even when definitions are cached.
tests/test_composition.py Covers binding-only composition rules (minimal front matter, slug normalization, diagnostics).
tests/test_bindings.py Exercises agent_input behavior across Function, Durable activity, and orchestrator modes.
src/azure_functions_agents/runner.py Adjusts SkillsProvider wiring to allow unattended read-only operations.
src/azure_functions_agents/hydration.py Implements immutable AgentBlueprint + per-invocation agent lifecycle and managed execution for Durable activity.
src/azure_functions_agents/discovery/mcp.py Caches immutable MCP server definitions and rebuilds fresh MCP tools per owning agent context.
src/azure_functions_agents/composition.py Adds binding-only project snapshot + target resolution without full declarative validation.
src/azure_functions_agents/bindings.py Introduces the public binding API (agent_input, AiApp, DurableAiApp, DurableAiAgent) and generated internal Durable activity.
src/azure_functions_agents/app.py Switches create_function_app() to return enhanced AiApp / DurableAiApp.
src/azure_functions_agents/init.py Exports new binding APIs as part of the public package surface.
samples/README.md Lists new hybrid samples and describes what they demonstrate.
samples/hybrid-function-agent/src/tools/order_totals.py Sample tool used by the hybrid function agent.
samples/hybrid-function-agent/src/skills/order-review/SKILL.md Sample skill used by the hybrid function agent.
samples/hybrid-function-agent/src/requirements.txt Sample requirements entry for editable install.
samples/hybrid-function-agent/src/order-fulfillment.agent.md Minimal binding-projected agent definition for sample.
samples/hybrid-function-agent/src/mcp.json Sample MCP server config.
samples/hybrid-function-agent/src/local.settings.template.json Sample local settings template.
samples/hybrid-function-agent/src/host.json Sample host settings.
samples/hybrid-function-agent/src/function_app.py Hybrid HTTP + (intended) queue trigger sample using AiApp.agent_input.
samples/hybrid-function-agent/src/agents.config.yaml Sample global config for model/timeout.
samples/hybrid-function-agent/README.md Sample README describing hybrid function binding usage.
samples/hybrid-durable-agent/src/tools/order_totals.py Sample tool used by the hybrid durable agent.
samples/hybrid-durable-agent/src/skills/order-review/SKILL.md Sample skill used by the hybrid durable agent.
samples/hybrid-durable-agent/src/requirements.txt Sample requirements entry for editable install.
samples/hybrid-durable-agent/src/order-fulfillment.agent.md Minimal binding-projected agent definition for durable sample.
samples/hybrid-durable-agent/src/mcp.json Sample MCP server config.
samples/hybrid-durable-agent/src/local.settings.template.json Sample local settings template.
samples/hybrid-durable-agent/src/host.json Sample host settings.
samples/hybrid-durable-agent/src/function_app.py Durable activity + orchestrator sample using DurableAiApp and DurableAiAgent.
samples/hybrid-durable-agent/src/agents.config.yaml Sample global config for model/timeout.
samples/hybrid-durable-agent/README.md Sample README describing hybrid durable binding usage.
README.md Documents hybrid binding usage and constraints for Functions + Durable.
pyproject.toml Updates agent-framework package pins to newer versions supporting the lifecycle/session patterns used.
docs/workflows.md Documents how smart agent input relates to Durable workflows/orchestrators.
docs/observability.md Documents new binding spans and emitted attributes/outcomes.
docs/front-matter-spec.md Documents the reduced front-matter projection for agent_input bindings.
docs/frds/README.md Adds FRD entry for agent input binding.
docs/frds/0008-agent-input-binding.md Adds finalized FRD describing design, constraints, and test plan.
docs/architecture.md Updates module map and pipeline stages to include binding projection + hydration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/azure_functions_agents/hydration.py
Comment thread samples/hybrid-function-agent/src/function_app.py Outdated
Comment thread src/azure_functions_agents/hydration.py Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/azure_functions_agents/bindings.py:100

  • _BindingRuntime.resolve() caches blueprints under the raw agent_name string, but compose_binding_target() internally uses agent_name.strip() for lookup. Passing a value with leading/trailing whitespace (e.g. from env/config) will bypass the cache fast-path and create duplicate dict entries for the same agent, causing unnecessary snapshot/definition work and unbounded key growth over time.

Normalize the key once (strip + non-empty check) and use that consistently for cache lookup and insertion before composing the target.
src/azure_functions_agents/composition.py:123

  • load_binding_definition() currently uses the raw markdown body (post.content) as instructions without applying the repo’s standard env-var substitution. In the declarative loader (config/loader.py), instructions are passed through substitute_env_vars_in_text() by default, so binding-mode definitions that rely on $VARS in the markdown body will behave differently.

Consider applying the same substitution to post.content for binding projection so instruction handling is consistent across declarative and bound agents.

@hallvictoria
hallvictoria marked this pull request as ready for review August 14, 2026 17:05
@hallvictoria
hallvictoria requested a review from a team as a code owner August 14, 2026 17:05
Comment thread src/azure_functions_agents/bindings.py Outdated

class _BindingRuntime:
def __init__(self, app: func.FunctionApp, app_root: Path | None) -> None:
self.app = app

@TsuyoshiUshio Tsuyoshi Ushio (TsuyoshiUshio) Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although _RUNTIMES is a WeakKeyDictionary, each _BindingRuntime value holds a strong reference to the same app used as its key. This leaves a strong reference chain, _RUNTIMES -> runtime -> app, so dropping all external references to the app will not invalidate the weak key. The runtime—and the snapshots and blueprints it retains—therefore cannot be collected. Could _BindingRuntime store a weakref.ref(app) instead and dereference it only when registering the Durable activity?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated! _BindingRuntime now stores weakref.ref(app) and dereferences it only during Durable activity registration in bindings.py

return BindingAgentDefinition(
name=name,
description=description,
instructions=str(post.content),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This binding projection uses the raw markdown body, bypassing the environment-variable substitution performed by the regular agent loader. As a result, instructions such as Contact $TEAM_NAME are resolved when the file is consumed by create_function_app(), but remain literal when the same file is consumed through agent_input. This conflicts with the documented authoring contract that $VAR/%VAR% placeholders in every *.agent.md markdown body are substituted by default. Even if substitute_variables is intentionally ignored by the minimal binding projection, could the binding path apply substitute_env_vars_in_text() unconditionally (or explicitly document and test this binding-specific exception)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated - now it uses the canonical environment substitution behavior in composition.py, including honoring substitute_variables: false

@TsuyoshiUshio

Tsuyoshi Ushio (TsuyoshiUshio) commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

You are supporting Orchestrator as a convenient syntax sugar. Then how about support retry on DurableAiApp. It will be small change. :) Obviously it is not blocker. Just nice to have. The first one has been addressed, so that I can approve after seeing your response. Let me know if you need approval.

@hallvictoria

Copy link
Copy Markdown
Contributor Author

Thanks for the comment Tsuyoshi Ushio (@TsuyoshiUshio)! I'm not sure what you mean by supporting retry though - do you mean the retry policy decorator app.retry()? If so, that is already supported since DurableAiApp inherits from DFApp

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()`

@larohra Laveesh Rohra (larohra) Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DurableAiAgent proxy is a substantial second abstraction over the standard Durable activity pattern: it still generates an internal activity, but also owns the activity name, payload/result schema, retry/idempotency behavior, and what transcript data enters Durable history. That reduces the apparent call-site boilerplate without removing the underlying replay, serialization, and failure-semantics concepts. Could we keep agent_input focused on Functions and activities for v1, document that orchestrators must call an explicit activity, and defer this proxy until we have evidence that the standardized one-line form is needed? If we retain it, please add a clear rationale and define why the library—not the customer-owned activity—should own those Durable semantics.

f"{source}: {reason}"
for source, reason in self._snapshot.discovery.failed_loads
)
raise ValueError(f"Agent binding discovery failed: {failures}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

required: A malformed or unrelated tool, skill, or MCP asset causes _load_project_snapshot() to populate failed_loads, and this branch then rejects every agent binding. That means a valid bound agent cannot start merely because another discovered asset is broken, even when the binding does not use it. Could discovery failures be scoped to the selected agent or treated as non-fatal unless the referenced capability is required? At minimum, please document this app-wide failure coupling as an intentional contract.

response_data = response.to_dict() if hasattr(response, "to_dict") else {}
result = {
"text": str(getattr(response, "text", "") or ""),
"messages": response_data.get("messages", []),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Returning the full messages array (and usage details) from every agent run means the complete transcript is persisted in Durable orchestration history and replay payloads. For longer conversations this can grow history quickly and may retain sensitive content unnecessarily. Could the default result be reduced to the final text plus identifiers/usage, with full messages opt-in or summarized before crossing the Durable boundary? Please also make the history-size and transcript-retention tradeoff explicit in the public docs.

Comment thread tests/test_bindings.py
]
assert len(activity_bindings) == 1
assert activity_bindings[0]["activity"] == "_afa_agent_binding_run"
internal_activity = next(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

required: This test verifies that the generated activity is registered and has the expected type hint, but it never invokes _afa_agent_binding_run. Could we add a direct execution test that patches run_blueprint and asserts the payload is mapped into the invocation, the response is projected into the documented JSON shape, and non-serializable results fail explicitly? This is the runtime path that actually performs model work, so registration-only coverage could miss regressions in the generated activity.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The binding path substitutes variables in the markdown body, but it does not apply the existing substitution behavior to the required front-matter values before deriving the binding definition and slug. That can make the same agent file resolve differently between declarative loading and agent_input. Could we reuse the loader complete substitution path, or explicitly document that only instructions are substituted and front-matter values are intentionally excluded?

@TsuyoshiUshio

Copy link
Copy Markdown
Contributor

Thanks for the comment Tsuyoshi Ushio (Tsuyoshi Ushio (@TsuyoshiUshio))! I'm not sure what you mean by supporting retry though - do you mean the retry policy decorator app.retry()? If so, that is already supported since DurableAiApp inherits from DFApp

When we use orchestrator mode, DurableAiAgent instance (planner) use yield planner.run()
That internall calls DurableAiAgent.run and it eventuall calls self._context.call_activity(_DURABLE_ACTIVITY_NAME, payload) It is possible to support run() method support retry that means customer can call call_activity_with_retry instead.

image

sometthing like:

def run(
    self,
    messages=None,
    *,
    options=None,
    retry_options: df.RetryOptions | None = None,
    stream=False,
):
    payload = {
        "agent_slug": self._blueprint.slug,
        "messages": messages,
        "options": dict(options) if options is not None else None,
        "instance_id": self._context.instance_id,
    }

    if retry_options is not None:
        return self._context.call_activity_with_retry(
            _DURABLE_ACTIVITY_NAME,
            retry_options,
            payload,
        )

    return self._context.call_activity(
        _DURABLE_ACTIVITY_NAME,
        payload,
    )

again, it is not mandatory. Just an idea. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants