diff --git a/.github/actions/github-app-token/action 2.yml b/.github/actions/github-app-token/action 2.yml new file mode 100644 index 00000000000..9021f9fdfe0 --- /dev/null +++ b/.github/actions/github-app-token/action 2.yml @@ -0,0 +1,112 @@ +name: Get GitHub automation token +description: Creates a GitHub App installation token with a temporary PAT fallback + +inputs: + mode: + description: Authentication mode (app, app-with-fallback, or pat) + required: false + default: app-with-fallback + azure-client-id: + description: Client ID of the Azure workload identity + required: false + azure-tenant-id: + description: Azure tenant ID + required: false + azure-subscription-id: + description: Azure subscription containing the Key Vault + required: false + key-vault-name: + description: Azure Key Vault name + required: false + key-name: + description: Key Vault key used to sign the GitHub App JWT + required: false + github-app-client-id: + description: GitHub App client ID + required: false + github-app-installation-id: + description: GitHub App installation ID + required: false + repository: + description: Repository to include in the installation token + required: false + fallback-token: + description: PAT used temporarily when app authentication is unavailable + required: false + +outputs: + token: + description: GitHub App installation token or fallback PAT + value: ${{ steps.select-token.outputs.token }} + source: + description: Selected authentication source + value: ${{ steps.select-token.outputs.source }} + +runs: + using: composite + steps: + - name: Validate authentication mode + shell: bash + env: + AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }} + run: | + if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then + echo "::error::Unsupported GitHub authentication mode." + exit 1 + fi + + - name: Sign in to Azure + id: azure-login + if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }} + continue-on-error: true + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + with: + client-id: ${{ inputs.azure-client-id }} + tenant-id: ${{ inputs.azure-tenant-id }} + subscription-id: ${{ inputs.azure-subscription-id }} + + - name: Create GitHub App installation token + id: app-token + if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }} + KEY_VAULT_NAME: ${{ inputs.key-vault-name }} + KEY_NAME: ${{ inputs.key-name }} + GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }} + GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }} + TARGET_REPOSITORY: ${{ inputs.repository }} + run: | + token="$(node "$GITHUB_ACTION_PATH/create-token.js")" + echo "::add-mask::$token" + echo "token=$token" >> "$GITHUB_OUTPUT" + + - name: Select authentication token + id: select-token + shell: bash + env: + AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + FALLBACK_TOKEN: ${{ inputs.fallback-token }} + run: | + if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then + token="$APP_TOKEN" + source="app" + echo "::notice::GitHub authentication source: app" + elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then + token="$FALLBACK_TOKEN" + source="pat-fallback" + echo "::warning::GitHub authentication source: PAT fallback" + elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then + token="$FALLBACK_TOKEN" + source="pat-forced" + echo "::warning::GitHub authentication source: PAT (forced rollout mode)" + else + echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided." + exit 1 + fi + + echo "::add-mask::$token" + echo "token=$token" >> "$GITHUB_OUTPUT" + echo "source=$source" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/github-app-token/create-token 2.js b/.github/actions/github-app-token/create-token 2.js new file mode 100644 index 00000000000..287f52b49ec --- /dev/null +++ b/.github/actions/github-app-token/create-token 2.js @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); + +function base64Url(value) { + return Buffer.from(value).toString('base64url'); +} + +function base64ToBase64Url(value) { + return Buffer.from(value, 'base64').toString('base64url'); +} + +function createJwtSigningInput(clientId, nowSeconds) { + const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const payload = base64Url(JSON.stringify({ + iat: nowSeconds - 60, + exp: nowSeconds + 540, + iss: clientId, + })); + return `${header}.${payload}`; +} + +function signJwt(signingInput, config, execute = execFileSync) { + const digest = crypto.createHash('sha256').update(signingInput).digest('base64'); + const signature = execute( + 'az', + [ + 'keyvault', 'key', 'sign', + '--subscription', config.azureSubscriptionId, + '--vault-name', config.keyVaultName, + '--name', config.keyName, + '--algorithm', 'RS256', + '--digest', digest, + '--query', 'signature', + '--output', 'tsv', + '--only-show-errors', + ], + { encoding: 'utf8' }, + ).trim(); + + if (!signature) { + throw new Error('Key Vault returned an empty signature.'); + } + + return `${signingInput}.${base64ToBase64Url(signature)}`; +} + +async function createInstallationToken(config, dependencies = {}) { + const execute = dependencies.execute ?? execFileSync; + const request = dependencies.fetch ?? fetch; + const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000); + const repositoryParts = config.targetRepository.split('/'); + + if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) { + throw new Error('TARGET_REPOSITORY must use the owner/repository format.'); + } + + const [, repository] = repositoryParts; + const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds); + const jwt = signJwt(signingInput, config, execute); + + const response = await request( + `https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`, + { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${jwt}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ + repositories: [repository], + permissions: { + contents: 'read', + issues: 'write', + members: 'read', + pull_requests: 'write', + }, + }), + }, + ); + + if (!response.ok) { + throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`); + } + + const result = await response.json(); + if (typeof result.token !== 'string' || result.token.length === 0) { + throw new Error('GitHub returned an empty installation token.'); + } + + return result.token; +} + +function readConfig(environment) { + const config = { + azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID, + keyVaultName: environment.KEY_VAULT_NAME, + keyName: environment.KEY_NAME, + githubAppClientId: environment.GITHUB_APP_CLIENT_ID, + githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID, + targetRepository: environment.TARGET_REPOSITORY, + }; + + if (Object.values(config).some((value) => !value)) { + throw new Error('Required GitHub App authentication configuration is missing.'); + } + + return config; +} + +async function main() { + try { + const token = await createInstallationToken(readConfig(process.env)); + process.stdout.write(token); + } catch { + console.error('GitHub App token generation failed.'); + process.exitCode = 1; + } +} + +if (require.main === module) { + void main(); +} + +module.exports = { + base64ToBase64Url, + createInstallationToken, + createJwtSigningInput, + readConfig, + signJwt, +}; diff --git a/dotnet/samples/04-hosting/af-hosting/README 2.md b/dotnet/samples/04-hosting/af-hosting/README 2.md new file mode 100644 index 00000000000..3e9256204cf --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/README 2.md @@ -0,0 +1,53 @@ +# Agent Framework hosting samples (bring your own route) + +These samples show how to expose an Agent Framework agent or workflow over the OpenAI Responses HTTP +protocol from an ASP.NET Core app that you write, where your app owns the HTTP route, authentication, and +where conversations are stored. + +## Two ways to expose an agent over the Responses protocol + +Agent Framework gives you two options: + +1. **`MapOpenAIResponses` (batteries included).** A single call maps a ready-made `/responses` endpoint that + handles the protocol, routing, and session storage for you. Pick this when you want a working endpoint + quickly and the built-in behavior fits. See [AgentWebChat](../../05-end-to-end/AgentWebChat) for a sample + that uses it. + +2. **Call the conversion helpers from your own route (these samples).** You write the ASP.NET Core route and + call the `OpenAIResponses` helper methods to translate between the Responses HTTP payloads and the agent. + The framework only does the protocol translation, so you keep full control of routing, authentication, + and where conversations are stored. Pick this when you need hosting behavior the built-in endpoint does + not provide. + +## Samples + +| Sample | What it shows | +|---|---| +| [`local_responses/`](./local_responses) | An agent behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods plus `AgentSessionStore` for conversation continuity. The simplest sample to start with. | +| [`local_responses_workflow/`](./local_responses_workflow) | A workflow behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods, `HostedWorkflowState`, an explicit `CheckpointManager`, and a checkpoint cursor your app keeps so a run resumes across turns. | + +Each sample is a **client/server pair** split into two projects: + +``` +local_responses/ +├── Server/ # exposes POST /responses using the OpenAIResponses helper methods +└── Client/ # consumes it two ways: a chat client and an agent +``` + +The `Client` shows the two ways to consume the endpoint from .NET, both against the same server: + +- A plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path). +- A Microsoft Agent Framework `AIAgent` (the higher-level agent path). + +## Relationship to `../FoundryHostedAgents/` + +The sibling [`../FoundryHostedAgents/`](../FoundryHostedAgents) directory contains samples for agents that +run inside the Foundry Hosted Agents platform, which hosts the agent and exposes the protocol for you. Use +those when you want the Foundry-managed hosting surface; use these when you want to host the agent in your +own ASP.NET Core app. + +| Aspect | `af-hosting/` (this directory) | `FoundryHostedAgents/` | +|---|---|---| +| Server stack | An ASP.NET Core app you write plus the hosting protocol helpers | Foundry Hosted Agents runtime | +| Who exposes the route | Your app | The platform | +| When to pick this | You need custom hosting code | You want the Foundry-managed hosting surface | \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests 2.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests 2.csproj new file mode 100644 index 00000000000..94fc6720017 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests 2.csproj @@ -0,0 +1,19 @@ + + + + $(TargetFrameworksCore) + True + $(NoWarn);OPENAI001; + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests 2.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests 2.cs new file mode 100644 index 00000000000..1aa58c177dc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests 2.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using OpenAI; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests; + +/// +/// Live integration tests for the app-owned routing helper surface ( plus +/// ) exercised against a real OpenAI model. These confirm the crucial +/// consumption paths — request conversion, an agent run, response rendering, and multi-turn session +/// continuity — behave correctly end to end with a real chat client. +/// +/// +/// Skipped unless the OpenAI configuration is present (OPENAI_API_KEY), so runs without secrets stay +/// green. The in-process, in-memory (no live model) coverage lives in the +/// Microsoft.Agents.AI.Hosting.OpenAI.UnitTests project (OpenAIResponsesHostingTests). +/// +public sealed class OpenAIResponsesHostingLiveTests +{ + private static string? ApiKey => Environment.GetEnvironmentVariable(TestSettings.OpenAIApiKey); + private static string ModelName => Environment.GetEnvironmentVariable(TestSettings.OpenAIChatModelName) ?? "gpt-4o-mini"; + + [Fact] + public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() + { + // Arrange + Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test."); + AIAgent agent = CreateAgent(); + AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); + JsonElement body = ParseBody("""{ "input": "Reply with exactly the word: apple" }"""); + + // Act + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); + AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + string responseId = OpenAIResponses.CreateResponseId(); + AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); + JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); + + // Assert + Assert.Equal(responseId, payload.GetProperty("id").GetString()); + Assert.Equal("response", payload.GetProperty("object").GetString()); + Assert.Contains("output", payload.EnumerateObject().Select(p => p.Name)); + } + + [Fact] + public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() + { + // Arrange + Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test."); + AIAgent agent = CreateAgent(); + AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); + + // Act: first turn establishes context, second turn continues from the first response id. + string firstResponseId = await RunTurnAsync(agent, sessionStore, """{ "input": "Remember the number 7." }"""); + JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); + OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); + string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; + AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId); + AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); + + // Assert: continuation succeeded and the model produced a textual answer. + Assert.Equal(secondSessionStoreId, firstResponseId); + Assert.False(string.IsNullOrWhiteSpace(secondResult.Text)); + } + + private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore sessionStore, string bodyJson) + { + JsonElement body = ParseBody(bodyJson); + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); + AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + string responseId = OpenAIResponses.CreateResponseId(); + _ = await agent.RunAsync(run.Messages, session, run.Options); + await sessionStore.SaveSessionAsync(agent, responseId, session); + return responseId; + } + + private static ChatClientAgent CreateAgent() => + new( + new OpenAIClient(ApiKey).GetChatClient(ModelName).AsIChatClient(), + instructions: "You are a concise assistant.", + name: "assistant"); + + private static JsonElement ParseBody(string json) + { + using JsonDocument doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } +} diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context 2.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context 2.py new file mode 100644 index 00000000000..d14a59e77ce --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context 2.py @@ -0,0 +1,223 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Human-in-the-loop (HITL) addressing helper for workflow executors. + +When a MAF :class:`~agent_framework.Workflow` runs on the Azure Functions durable +host, an executor can ask a human for input via ``ctx.request_info(...)``. To notify +that human out-of-band (for example by emailing them an approval link), the executor +needs the orchestration's ``instanceId`` and the request's ``requestId`` so it can +build the ``/respond`` URL the reviewer will POST back to. + +:class:`WorkflowHitlContext` packages that addressing. It reads the orchestration +metadata the durable host surfaces on the executor's runner context (see +``CapturingRunnerContext.host_metadata``) and builds the canonical respond/status +URLs that :class:`~agent_framework_azurefunctions.AgentFunctionApp` exposes -- so the +executor never has to thread the instance id or base URL by hand. + +Typical use, from inside a notify executor reached by an edge from the executor that +called ``request_info``:: + + hitl = WorkflowHitlContext.from_context(ctx) + if hitl is not None: # None when not on the Azure Functions durable host + url = hitl.build_respond_url(request_id) + send_email(to=reviewer, body=f"Approve or reject here: {url}") +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, cast + +from agent_framework_durabletask._workflows.runner_context import ( + HOST_METADATA_INSTANCE_ID, + HOST_METADATA_REQUEST_PATH_PREFIX, + HOST_METADATA_WORKFLOW_NAME, +) + +from ._routes import build_workflow_respond_url, build_workflow_status_url + +# App setting carrying the function app's host (e.g. ``myapp.azurewebsites.net``). +# Azure Functions sets this automatically in the cloud; for local ``func start`` runs +# add it to the ``Values`` map in ``local.settings.json`` (e.g. ``localhost:7071``). +WEBSITE_HOSTNAME_ENV = "WEBSITE_HOSTNAME" + +# Loopback hosts that resolve to ``http`` (not ``https``) when WEBSITE_HOSTNAME is +# host-only; covers the addresses ``func start`` can bind locally. +_LOOPBACK_HOSTS = frozenset({"localhost", "0.0.0.0", "::1"}) # noqa: S104 # nosec B104 + + +def _is_loopback(host: str) -> bool: + """Return whether ``host`` (optionally ``host:port``) is a local loopback address. + + Handles ``localhost``, IPv4 ``127.0.0.0/8`` and ``0.0.0.0``, and IPv6 ``::1`` + (including the bracketed ``[::1]:port`` form ``func start`` prints). + """ + normalized = host.strip().lower() + if normalized.startswith("["): # bracketed IPv6 like [::1]:7071 + normalized = normalized[1 : normalized.find("]")] if "]" in normalized else normalized[1:] + elif normalized.count(":") == 1: # host:port (bare IPv6 has multiple colons) + normalized = normalized.split(":", 1)[0] + return normalized in _LOOPBACK_HOSTS or normalized.startswith("127.") + + +@dataclass(frozen=True) +class WorkflowHitlContext: + """Builds Azure Functions HITL respond/status URLs from inside a workflow executor. + + Obtain one with :meth:`from_context`. It exposes the addressable *root* + orchestration's ``instance_id`` and ``workflow_name`` and builds the URLs an + external reviewer uses to resume the workflow. When the executor runs inside a + nested sub-workflow, ``request_path_prefix`` carries the ``{executor}~{ordinal}~`` + hops from the root down to this level, so :meth:`build_respond_url` qualifies a bare + request id back to the top-level instance automatically. The base URL is resolved + lazily (see :attr:`base_url`) from an explicit override or the ``WEBSITE_HOSTNAME`` + app setting. + """ + + instance_id: str + workflow_name: str + base_url_override: str | None = None + request_path_prefix: str = "" + + @classmethod + def from_context( + cls, + ctx: Any, + *, + base_url: str | None = None, + ) -> WorkflowHitlContext | None: + """Build a HITL context from a workflow executor's ``WorkflowContext``. + + Reads the orchestration metadata the durable host attached to the executor's + runner context. Returns ``None`` when that metadata is absent -- i.e. the same + executor is running in-process rather than on the Azure Functions durable host + -- so callers can skip notification and degrade gracefully. + + Args: + ctx: The ``WorkflowContext`` passed to the executor's handler. + base_url: Optional explicit base URL (scheme + host, e.g. + ``https://contoso.example.com``). Use this when the public URL differs + from ``WEBSITE_HOSTNAME`` -- for example behind a custom domain or API + Management gateway, where ``WEBSITE_HOSTNAME`` still reports the default + ``*.azurewebsites.net`` host. When omitted, the base URL is resolved + from ``WEBSITE_HOSTNAME`` on first use. + + Returns: + A :class:`WorkflowHitlContext`, or ``None`` if not running on a durable host. + """ + runner_context = getattr(ctx, "_runner_context", None) + raw_metadata = getattr(runner_context, "host_metadata", None) + if not isinstance(raw_metadata, dict): + return None + metadata = cast("dict[str, Any]", raw_metadata) + + instance_id = metadata.get(HOST_METADATA_INSTANCE_ID) + workflow_name = metadata.get(HOST_METADATA_WORKFLOW_NAME) + if not isinstance(instance_id, str) or not isinstance(workflow_name, str): + return None + + # Present when the executor runs inside a nested sub-workflow; absent/empty at + # the top level. Defaults to "" so the request id is used unqualified. + raw_prefix = metadata.get(HOST_METADATA_REQUEST_PATH_PREFIX) + request_path_prefix = raw_prefix if isinstance(raw_prefix, str) else "" + + return cls( + instance_id=instance_id, + workflow_name=workflow_name, + base_url_override=base_url, + request_path_prefix=request_path_prefix, + ) + + @staticmethod + async def pending_request_id(ctx: Any) -> str | None: + """Return the id of the most recently emitted ``request_info`` on ``ctx``. + + Call this **immediately after** ``await ctx.request_info(...)`` to recover the + request id the framework generated, so it can be forwarded (e.g. in a message + to a downstream notify executor that builds the respond URL) without the caller + generating an id by hand. + + Why "immediately after" is the rule, and why it is safe on the durable host: + the returned id is simply the newest entry in the executor's pending + request-info set, so reading right after a call always yields *that* call's id. + On the Azure Functions durable host every executor runs in its own activity with + its own runner context, so that set only ever holds this executor's own + requests (never another executor's), and the request you just emitted is always + the latest. If a single executor emits several ``request_info`` calls in one + turn, read this after **each** call (the only case where reading once at the end + would lose the earlier ids); or pass an explicit ``request_id`` to + ``request_info`` to address them directly. + + Returns ``None`` only when no request is pending (or the runner context does not + track request-info events, e.g. in process off the durable host). + """ + runner_context = getattr(ctx, "_runner_context", None) + getter = getattr(runner_context, "get_pending_request_info_events", None) + if getter is None: + return None + events = await getter() + if not events: + return None + # Dicts preserve insertion order, so the last key is the most recent request. + return next(reversed(events)) + + @property + def base_url(self) -> str: + """The scheme + host the respond/status URLs are built on (no trailing slash). + + Resolution order: the explicit ``base_url`` passed to :meth:`from_context`, then + the ``WEBSITE_HOSTNAME`` app setting (``http`` for localhost, otherwise + ``https``). + + Raises: + RuntimeError: If neither an override nor ``WEBSITE_HOSTNAME`` is available. + """ + if self.base_url_override: + return self.base_url_override.rstrip("/") + + hostname = os.environ.get(WEBSITE_HOSTNAME_ENV) + if not hostname: + raise RuntimeError( + "Cannot build a HITL URL: no base URL is available. Set the " + f"'{WEBSITE_HOSTNAME_ENV}' app setting (present automatically on Azure " + "Functions; add it to the 'Values' map in local.settings.json for local " + "`func start` runs, e.g. 'localhost:7071'), or pass base_url=... to " + "WorkflowHitlContext.from_context()." + ) + + # WEBSITE_HOSTNAME may include a scheme (unusual but possible); otherwise it is + # host-only, so infer one (http for local loopback, https otherwise). + if hostname.startswith(("http://", "https://")): + return hostname.rstrip("/") + scheme = "http" if _is_loopback(hostname) else "https" + return f"{scheme}://{hostname.rstrip('/')}" + + def build_respond_url(self, request_id: str) -> str: + """Build the URL a reviewer POSTs their response to, resuming the workflow. + + Mirrors the ``respondUrl`` AgentFunctionApp returns from its run/status + endpoints: ``{base}/{prefix}/workflow/{name}/respond/{instanceId}/{requestId}`` + (``prefix`` is the app's ``routePrefix``, ``api`` by default), always targeting + the addressable top-level instance. + + Args: + request_id: The pending request's id -- the id passed to (or generated by) + ``ctx.request_info``. Pass the **bare** id even from inside a nested + sub-workflow: any :attr:`request_path_prefix` is prepended for you to + qualify it (``{executor}~{ordinal}~{requestId}``) back to the root. + + Returns: + The fully-qualified respond URL. + """ + qualified_id = f"{self.request_path_prefix}{request_id}" + return build_workflow_respond_url(self.base_url, self.workflow_name, self.instance_id, qualified_id) + + def build_status_url(self) -> str: + """Build the workflow status URL for this orchestration instance. + + Returns ``{base}/{prefix}/workflow/{name}/status/{instanceId}`` (``prefix`` is the + app's ``routePrefix``, ``api`` by default), the same endpoint AgentFunctionApp + exposes for polling runtime status and pending HITL requests. + """ + return build_workflow_status_url(self.base_url, self.workflow_name, self.instance_id) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_routes 2.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_routes 2.py new file mode 100644 index 00000000000..7bdcbdec3fa --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_routes 2.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Single source of truth for the AgentFunctionApp HTTP route prefix and HITL URLs. + +The server endpoints (:mod:`._app`) and the in-workflow addressing helper +(:mod:`._hitl_context`) build the same ``{prefix}/workflow/{name}/...`` URLs. Keeping the +shape and the prefix logic here stops the two sides from drifting -- previously they were +only kept in sync by an integration test asserting the two strings match -- and lets a +customized ``routePrefix`` be honored instead of a hardcoded ``api`` that would 404 on +resume. The server derives the prefix from the incoming request URL (the value the host +actually routed); the helper, which runs inside an executor with no request context, +reads it from ``host.json``. +""" + +from __future__ import annotations + +import functools +import json +import logging +import os +from typing import Any, cast +from urllib.parse import urlsplit + +logger = logging.getLogger(__name__) + +# Azure Functions' default HTTP route prefix, applied when host.json does not override +# ``extensions.http.routePrefix``. +DEFAULT_ROUTE_PREFIX = "api" + + +@functools.lru_cache(maxsize=1) +def route_prefix() -> str: + """Return the app's HTTP route prefix, honoring ``host.json``. + + Azure Functions prepends ``extensions.http.routePrefix`` (default ``api``) to every + HTTP route, and it can be customized or set to an empty string. That value is not + exposed through an environment variable, so it is read from ``host.json`` under the + script root (``AzureWebJobsScriptRoot``, falling back to the current working + directory) and cached for the process. Any failure to locate or parse the file falls + back to the ``api`` default. Tests that vary ``host.json`` call ``route_prefix.cache_clear()``. + """ + return _read_route_prefix() + + +def _read_route_prefix() -> str: + # AzureWebJobsScriptRoot is the host-set path to the app root (mixed case is the real + # variable name and is case-sensitive on Linux, so it must not be upper-cased). + script_root = os.environ.get("AzureWebJobsScriptRoot") or os.getcwd() # noqa: SIM112 + host_json_path = os.path.join(script_root, "host.json") + try: + with open(host_json_path, encoding="utf-8") as f: + loaded = json.load(f) + except (OSError, ValueError): + logger.debug("Could not read '%s'; defaulting route prefix to '%s'.", host_json_path, DEFAULT_ROUTE_PREFIX) + return DEFAULT_ROUTE_PREFIX + if not isinstance(loaded, dict): + return DEFAULT_ROUTE_PREFIX + extensions = cast("dict[str, Any]", loaded).get("extensions") + if not isinstance(extensions, dict): + return DEFAULT_ROUTE_PREFIX + http = cast("dict[str, Any]", extensions).get("http") + if not isinstance(http, dict): + return DEFAULT_ROUTE_PREFIX + prefix = cast("dict[str, Any]", http).get("routePrefix") + return prefix.strip("/") if isinstance(prefix, str) else DEFAULT_ROUTE_PREFIX + + +def _prefix_segment(prefix: str | None) -> str: + """Return the route-prefix path segment with a trailing slash, or ``""`` when empty.""" + resolved = route_prefix() if prefix is None else prefix.strip("/") + return f"{resolved}/" if resolved else "" + + +def build_workflow_respond_url( + base_url: str, + workflow_name: str, + instance_id: str, + request_id: str, + *, + prefix: str | None = None, +) -> str: + """Build the canonical HITL respond URL a reviewer POSTs to. + + ``{base}/{prefix}/workflow/{name}/respond/{instanceId}/{requestId}``. When ``prefix`` + is omitted it is resolved from ``host.json``. ``request_id`` may be a literal + ``{requestId}`` placeholder to produce the templated form the run endpoint returns. + """ + return f"{base_url}/{_prefix_segment(prefix)}workflow/{workflow_name}/respond/{instance_id}/{request_id}" + + +def build_workflow_status_url( + base_url: str, + workflow_name: str, + instance_id: str, + *, + prefix: str | None = None, +) -> str: + """Build the workflow status URL: ``{base}/{prefix}/workflow/{name}/status/{instanceId}``.""" + return f"{base_url}/{_prefix_segment(prefix)}workflow/{workflow_name}/status/{instance_id}" + + +def split_request_url(request_url: str) -> tuple[str, str]: + """Return ``(base_url, route_prefix)`` derived from an incoming request URL. + + On the server the request URL is the authoritative source for the prefix, since the + host served it through the configured ``routePrefix``. The scheme and host form the + base URL, and the path before the first ``/workflow/`` segment is the prefix (empty + when the routes sit directly under the host). Falls back to ``(request_url, "")`` when + the value is not an absolute URL. + """ + parts = urlsplit(request_url) + if not (parts.scheme and parts.netloc): + return request_url.rstrip("/"), "" + base_url = f"{parts.scheme}://{parts.netloc}" + index = parts.path.find("/workflow/") + prefix = parts.path[:index].strip("/") if index != -1 else "" + return base_url, prefix diff --git a/python/packages/azurefunctions/tests/test_hitl_context 2.py b/python/packages/azurefunctions/tests/test_hitl_context 2.py new file mode 100644 index 00000000000..0ca4466e5e2 --- /dev/null +++ b/python/packages/azurefunctions/tests/test_hitl_context 2.py @@ -0,0 +1,231 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for WorkflowHitlContext (HITL respond-URL helper).""" + +# pyright: reportPrivateUsage=false + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_framework_azurefunctions import WorkflowHitlContext +from agent_framework_azurefunctions._hitl_context import WEBSITE_HOSTNAME_ENV, _is_loopback + + +def _ctx(metadata: Any) -> SimpleNamespace: + """Build a stand-in WorkflowContext exposing ``_runner_context.host_metadata``.""" + return SimpleNamespace(_runner_context=SimpleNamespace(host_metadata=metadata)) + + +class TestFromContext: + """Construction from a workflow executor's context.""" + + def test_returns_context_when_metadata_present(self) -> None: + hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "inst-1", "workflow_name": "content_moderation"})) + assert hitl is not None + assert hitl.instance_id == "inst-1" + assert hitl.workflow_name == "content_moderation" + + def test_returns_none_when_no_runner_context(self) -> None: + # A bare object without _runner_context (e.g. an unexpected ctx) yields None. + assert WorkflowHitlContext.from_context(SimpleNamespace()) is None + + def test_returns_none_when_metadata_absent(self) -> None: + # In-process RunnerContext has no host_metadata -> getattr default None. + assert WorkflowHitlContext.from_context(_ctx(None)) is None + + def test_returns_none_when_metadata_not_a_dict(self) -> None: + assert WorkflowHitlContext.from_context(_ctx("not-a-dict")) is None + + def test_returns_none_when_instance_id_missing(self) -> None: + assert WorkflowHitlContext.from_context(_ctx({"workflow_name": "wf"})) is None + + def test_returns_none_when_workflow_name_missing(self) -> None: + assert WorkflowHitlContext.from_context(_ctx({"instance_id": "inst-1"})) is None + + +class TestBaseUrl: + """base_url resolution from override and WEBSITE_HOSTNAME.""" + + def test_explicit_override_wins(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "ignored.azurewebsites.net") + hitl = WorkflowHitlContext.from_context( + _ctx({"instance_id": "i", "workflow_name": "wf"}), + base_url="https://contoso.example.com/", + ) + assert hitl is not None + # Trailing slash trimmed; override used verbatim over the env host. + assert hitl.base_url == "https://contoso.example.com" + + def test_website_hostname_gets_https(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "myapp.azurewebsites.net") + hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"})) + assert hitl is not None + assert hitl.base_url == "https://myapp.azurewebsites.net" + + def test_localhost_gets_http(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "localhost:7071") + hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"})) + assert hitl is not None + assert hitl.base_url == "http://localhost:7071" + + def test_override_with_scheme_preserved(self) -> None: + hitl = WorkflowHitlContext.from_context( + _ctx({"instance_id": "i", "workflow_name": "wf"}), + base_url="http://127.0.0.1:7071", + ) + assert hitl is not None + assert hitl.base_url == "http://127.0.0.1:7071" + + def test_raises_when_no_base_url_available(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(WEBSITE_HOSTNAME_ENV, raising=False) + hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"})) + assert hitl is not None + with pytest.raises(RuntimeError, match=WEBSITE_HOSTNAME_ENV): + _ = hitl.base_url + + +class TestUrlBuilders: + """respond/status URL shapes match the AgentFunctionApp routes.""" + + def test_build_respond_url(self) -> None: + hitl = WorkflowHitlContext.from_context( + _ctx({"instance_id": "inst-1", "workflow_name": "content_moderation"}), + base_url="https://app.example.com", + ) + assert hitl is not None + assert hitl.build_respond_url("req-9") == ( + "https://app.example.com/api/workflow/content_moderation/respond/inst-1/req-9" + ) + + def test_build_respond_url_accepts_qualified_id(self) -> None: + # A nested sub-workflow request id (executor~ordinal~rid) flows through unchanged. + hitl = WorkflowHitlContext.from_context( + _ctx({"instance_id": "inst-1", "workflow_name": "wf"}), + base_url="https://app.example.com", + ) + assert hitl is not None + assert hitl.build_respond_url("reviewer~0~req-9") == ( + "https://app.example.com/api/workflow/wf/respond/inst-1/reviewer~0~req-9" + ) + + def test_build_status_url(self) -> None: + hitl = WorkflowHitlContext.from_context( + _ctx({"instance_id": "inst-1", "workflow_name": "wf"}), + base_url="https://app.example.com", + ) + assert hitl is not None + assert hitl.build_status_url() == "https://app.example.com/api/workflow/wf/status/inst-1" + + +class TestNestedPrefix: + """request_path_prefix qualifies a bare request id back to the root instance.""" + + def test_prefix_read_from_metadata(self) -> None: + # host_metadata for a nested executor carries the root instance/workflow and the + # accumulated path prefix; instance_id/workflow_name are the *root* values. + hitl = WorkflowHitlContext.from_context( + _ctx({ + "instance_id": "root-inst", + "workflow_name": "moderation_pipeline", + "request_path_prefix": "review_sub~0~", + }), + base_url="https://app.example.com", + ) + assert hitl is not None + assert hitl.request_path_prefix == "review_sub~0~" + # A bare request id is qualified back to the top-level instance automatically. + assert hitl.build_respond_url("req-9") == ( + "https://app.example.com/api/workflow/moderation_pipeline/respond/root-inst/review_sub~0~req-9" + ) + + def test_deep_prefix(self) -> None: + hitl = WorkflowHitlContext.from_context( + _ctx({ + "instance_id": "root-inst", + "workflow_name": "wf", + "request_path_prefix": "outer~2~inner~1~", + }), + base_url="https://app.example.com", + ) + assert hitl is not None + assert hitl.build_respond_url("rid") == ( + "https://app.example.com/api/workflow/wf/respond/root-inst/outer~2~inner~1~rid" + ) + + def test_absent_prefix_defaults_empty(self) -> None: + # Top-level metadata may omit the key; the bare id is used unqualified. + hitl = WorkflowHitlContext.from_context( + _ctx({"instance_id": "inst-1", "workflow_name": "wf"}), + base_url="https://app.example.com", + ) + assert hitl is not None + assert hitl.request_path_prefix == "" + assert hitl.build_respond_url("rid") == ("https://app.example.com/api/workflow/wf/respond/inst-1/rid") + + +def _ctx_with_pending(pending: dict[str, Any] | None, *, has_getter: bool = True) -> SimpleNamespace: + """Build a ctx whose runner context returns the given pending request-info events.""" + if not has_getter: + return SimpleNamespace(_runner_context=SimpleNamespace()) + + async def _get() -> dict[str, Any]: + return pending or {} + + return SimpleNamespace(_runner_context=SimpleNamespace(get_pending_request_info_events=_get)) + + +class TestPendingRequestId: + """Reading back the framework-generated request id after request_info.""" + + async def test_returns_latest_request_id(self) -> None: + # Dicts preserve insertion order; the most recently emitted request wins. + ctx = _ctx_with_pending({"r1": object(), "r2": object()}) + assert await WorkflowHitlContext.pending_request_id(ctx) == "r2" + + async def test_returns_single_request_id(self) -> None: + ctx = _ctx_with_pending({"only-one": object()}) + assert await WorkflowHitlContext.pending_request_id(ctx) == "only-one" + + async def test_returns_none_when_no_pending(self) -> None: + ctx = _ctx_with_pending({}) + assert await WorkflowHitlContext.pending_request_id(ctx) is None + + async def test_returns_none_when_no_runner_context(self) -> None: + assert await WorkflowHitlContext.pending_request_id(SimpleNamespace()) is None + + async def test_returns_none_when_getter_absent(self) -> None: + # A runner context that doesn't track request-info events degrades to None. + ctx = _ctx_with_pending(None, has_getter=False) + assert await WorkflowHitlContext.pending_request_id(ctx) is None + + +class TestLoopback: + """Loopback detection covers the addresses ``func start`` can bind, not just localhost.""" + + @pytest.mark.parametrize( + ("host", "expected"), + [ + ("localhost", True), + ("localhost:7071", True), + ("127.0.0.1", True), + ("127.0.0.1:7071", True), + ("127.5.9.9", True), + ("0.0.0.0", True), + ("0.0.0.0:7071", True), + ("::1", True), + ("[::1]:7071", True), + ("myapp.azurewebsites.net", False), + ("contoso.example.com:443", False), + ], + ) + def test_is_loopback(self, host: str, expected: bool) -> None: + assert _is_loopback(host) is expected + + def test_ipv6_loopback_base_url_gets_http(self, monkeypatch: pytest.MonkeyPatch) -> None: + # WEBSITE_HOSTNAME may report a bracketed IPv6 loopback locally; it must resolve to http. + monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "[::1]:7071") + hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"})) + assert hitl is not None + assert hitl.base_url == "http://[::1]:7071" diff --git a/python/packages/azurefunctions/tests/test_routes 2.py b/python/packages/azurefunctions/tests/test_routes 2.py new file mode 100644 index 00000000000..f00e9893266 --- /dev/null +++ b/python/packages/azurefunctions/tests/test_routes 2.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for the shared route-prefix resolution and HITL URL builders.""" + +# pyright: reportPrivateUsage=false + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from agent_framework_azurefunctions import _routes + +SCRIPT_ROOT_ENV = "AzureWebJobsScriptRoot" + + +@pytest.fixture(autouse=True) +def _reset_prefix_cache() -> Iterator[None]: + """Keep the route-prefix cache from leaking across tests.""" + _routes.route_prefix.cache_clear() + yield + _routes.route_prefix.cache_clear() + + +def _write_host_json(directory: Path, config: dict[str, Any]) -> None: + (directory / "host.json").write_text(json.dumps(config), encoding="utf-8") + + +class TestRoutePrefix: + """Resolving ``extensions.http.routePrefix`` from host.json, with an ``api`` default.""" + + def test_defaults_to_api_without_host_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) + assert _routes.route_prefix() == "api" + + def test_defaults_to_api_when_prefix_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_host_json(tmp_path, {"version": "2.0", "extensions": {"http": {}}}) + monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) + assert _routes.route_prefix() == "api" + + def test_reads_custom_prefix(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "gateway"}}}) + monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) + assert _routes.route_prefix() == "gateway" + + def test_reads_empty_prefix(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": ""}}}) + monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) + assert _routes.route_prefix() == "" + + def test_strips_surrounding_slashes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "/custom/"}}}) + monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) + assert _routes.route_prefix() == "custom" + + def test_defaults_to_api_on_malformed_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / "host.json").write_text("{ not json", encoding="utf-8") + monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) + assert _routes.route_prefix() == "api" + + def test_caches_first_read(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "one"}}}) + monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) + assert _routes.route_prefix() == "one" + # A later host.json change is not observed within a running host. + _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "two"}}}) + assert _routes.route_prefix() == "one" + + +class TestUrlBuilders: + """Respond/status URL shapes, parameterized by an explicit prefix.""" + + def test_respond_url_default_prefix(self) -> None: + assert ( + _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="api") + == "https://h/api/workflow/wf/respond/i/r" + ) + + def test_respond_url_custom_prefix(self) -> None: + assert ( + _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="gw") + == "https://h/gw/workflow/wf/respond/i/r" + ) + + def test_respond_url_empty_prefix(self) -> None: + assert ( + _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="") + == "https://h/workflow/wf/respond/i/r" + ) + + def test_respond_url_template_placeholder(self) -> None: + assert ( + _routes.build_workflow_respond_url("https://h", "wf", "i", "{requestId}", prefix="api") + == "https://h/api/workflow/wf/respond/i/{requestId}" + ) + + def test_status_url_custom_prefix(self) -> None: + assert ( + _routes.build_workflow_status_url("https://h", "wf", "i", prefix="gw") + == "https://h/gw/workflow/wf/status/i" + ) + + def test_status_url_empty_prefix(self) -> None: + assert _routes.build_workflow_status_url("https://h", "wf", "i", prefix="") == "https://h/workflow/wf/status/i" + + +class TestSplitRequestUrl: + """Deriving base URL and route prefix from an incoming request URL.""" + + def test_default_prefix(self) -> None: + assert _routes.split_request_url("https://h:7071/api/workflow/wf/run") == ("https://h:7071", "api") + + def test_custom_prefix(self) -> None: + assert _routes.split_request_url("https://h/gw/workflow/wf/status/i") == ("https://h", "gw") + + def test_empty_prefix(self) -> None: + assert _routes.split_request_url("https://h/workflow/wf/run") == ("https://h", "") + + def test_multi_segment_prefix(self) -> None: + assert _routes.split_request_url("https://h/a/b/workflow/wf/run") == ("https://h", "a/b") + + def test_no_workflow_segment(self) -> None: + assert _routes.split_request_url("https://h/api/health") == ("https://h", "") + + def test_non_absolute_falls_back(self) -> None: + assert _routes.split_request_url("/api/workflow/wf/run") == ("/api/workflow/wf/run", "") diff --git a/python/packages/core/agent_framework/gemini/__init__ 2.py b/python/packages/core/agent_framework/gemini/__init__ 2.py new file mode 100644 index 00000000000..43673773599 --- /dev/null +++ b/python/packages/core/agent_framework/gemini/__init__ 2.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Google Gemini namespace for optional Agent Framework connectors. + +This module lazily re-exports objects from ``agent-framework-gemini``. +""" + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "GeminiChatClient": ("agent_framework_gemini", "agent-framework-gemini"), + "GeminiChatOptions": ("agent_framework_gemini", "agent-framework-gemini"), + "GeminiSettings": ("agent_framework_gemini", "agent-framework-gemini"), + "GoogleGeminiSettings": ("agent_framework_gemini", "agent-framework-gemini"), + "RawGeminiChatClient": ("agent_framework_gemini", "agent-framework-gemini"), + "ThinkingConfig": ("agent_framework_gemini", "agent-framework-gemini"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." + ) from exc + raise AttributeError(f"Module `gemini` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/gemini/__init__ 2.pyi b/python/packages/core/agent_framework/gemini/__init__ 2.pyi new file mode 100644 index 00000000000..c9082c9191a --- /dev/null +++ b/python/packages/core/agent_framework/gemini/__init__ 2.pyi @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_gemini import ( + GeminiChatClient, + GeminiChatOptions, + GeminiSettings, + GoogleGeminiSettings, + RawGeminiChatClient, + ThinkingConfig, +) + +__all__ = [ + "GeminiChatClient", + "GeminiChatOptions", + "GeminiSettings", + "GoogleGeminiSettings", + "RawGeminiChatClient", + "ThinkingConfig", +] diff --git a/python/packages/core/agent_framework/mistral/__init__ 2.py b/python/packages/core/agent_framework/mistral/__init__ 2.py new file mode 100644 index 00000000000..c4d742a75f1 --- /dev/null +++ b/python/packages/core/agent_framework/mistral/__init__ 2.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Mistral AI namespace for optional Agent Framework connectors. + +This module lazily re-exports objects from ``agent-framework-mistral``. +""" + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "MistralEmbeddingClient": ("agent_framework_mistral", "agent-framework-mistral"), + "MistralEmbeddingOptions": ("agent_framework_mistral", "agent-framework-mistral"), + "MistralEmbeddingSettings": ("agent_framework_mistral", "agent-framework-mistral"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." + ) from exc + raise AttributeError(f"Module `mistral` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/mistral/__init__ 2.pyi b/python/packages/core/agent_framework/mistral/__init__ 2.pyi new file mode 100644 index 00000000000..0868d21e02d --- /dev/null +++ b/python/packages/core/agent_framework/mistral/__init__ 2.pyi @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_mistral import MistralEmbeddingClient, MistralEmbeddingOptions, MistralEmbeddingSettings + +__all__ = [ + "MistralEmbeddingClient", + "MistralEmbeddingOptions", + "MistralEmbeddingSettings", +] diff --git a/python/packages/core/agent_framework/monty/__init__ 2.py b/python/packages/core/agent_framework/monty/__init__ 2.py new file mode 100644 index 00000000000..53640f45c98 --- /dev/null +++ b/python/packages/core/agent_framework/monty/__init__ 2.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Monty CodeAct namespace for optional Agent Framework connectors. + +This module lazily re-exports objects from ``agent-framework-monty``. +""" + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "FileMount": ("agent_framework_monty", "agent-framework-monty"), + "FileMountInput": ("agent_framework_monty", "agent-framework-monty"), + "MontyCodeActProvider": ("agent_framework_monty", "agent-framework-monty"), + "MontyExecuteCodeTool": ("agent_framework_monty", "agent-framework-monty"), + "MountMode": ("agent_framework_monty", "agent-framework-monty"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." + ) from exc + raise AttributeError(f"Module `monty` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/monty/__init__ 2.pyi b/python/packages/core/agent_framework/monty/__init__ 2.pyi new file mode 100644 index 00000000000..ac31cae8560 --- /dev/null +++ b/python/packages/core/agent_framework/monty/__init__ 2.pyi @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_monty import ( + FileMount, + FileMountInput, + MontyCodeActProvider, + MontyExecuteCodeTool, + MountMode, +) + +__all__ = [ + "FileMount", + "FileMountInput", + "MontyCodeActProvider", + "MontyExecuteCodeTool", + "MountMode", +] diff --git a/python/packages/core/agent_framework/tools/__init__ 2.py b/python/packages/core/agent_framework/tools/__init__ 2.py new file mode 100644 index 00000000000..b92a0865ef9 --- /dev/null +++ b/python/packages/core/agent_framework/tools/__init__ 2.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Built-in tools namespace for optional Agent Framework connectors. + +This module lazily re-exports objects from ``agent-framework-tools``. +""" + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "DOCKER_DEFAULT_IMAGE": ("agent_framework_tools.shell", "agent-framework-tools"), + "DockerNotAvailableError": ("agent_framework_tools.shell", "agent-framework-tools"), + "DockerShellTool": ("agent_framework_tools.shell", "agent-framework-tools"), + "LocalShellTool": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellCommandError": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellDecision": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellEnvironmentProvider": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellEnvironmentProviderOptions": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellEnvironmentSnapshot": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellExecutionError": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellExecutor": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellFamily": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellMode": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellPolicy": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellRequest": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellResult": ("agent_framework_tools.shell", "agent-framework-tools"), + "ShellTimeoutError": ("agent_framework_tools.shell", "agent-framework-tools"), + "default_instructions_formatter": ("agent_framework_tools.shell", "agent-framework-tools"), + "is_docker_available": ("agent_framework_tools.shell", "agent-framework-tools"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." + ) from exc + raise AttributeError(f"Module `tools` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/tools/__init__ 2.pyi b/python/packages/core/agent_framework/tools/__init__ 2.pyi new file mode 100644 index 00000000000..a1768b3b60d --- /dev/null +++ b/python/packages/core/agent_framework/tools/__init__ 2.pyi @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_tools.shell import ( + DOCKER_DEFAULT_IMAGE, + DockerNotAvailableError, + DockerShellTool, + LocalShellTool, + ShellCommandError, + ShellDecision, + ShellEnvironmentProvider, + ShellEnvironmentProviderOptions, + ShellEnvironmentSnapshot, + ShellExecutionError, + ShellExecutor, + ShellFamily, + ShellMode, + ShellPolicy, + ShellRequest, + ShellResult, + ShellTimeoutError, + default_instructions_formatter, + is_docker_available, +) + +__all__ = [ + "DOCKER_DEFAULT_IMAGE", + "DockerNotAvailableError", + "DockerShellTool", + "LocalShellTool", + "ShellCommandError", + "ShellDecision", + "ShellEnvironmentProvider", + "ShellEnvironmentProviderOptions", + "ShellEnvironmentSnapshot", + "ShellExecutionError", + "ShellExecutor", + "ShellFamily", + "ShellMode", + "ShellPolicy", + "ShellRequest", + "ShellResult", + "ShellTimeoutError", + "default_instructions_formatter", + "is_docker_available", +] diff --git a/python/packages/core/tests/core/test_gemini_namespace 2.py b/python/packages/core/tests/core/test_gemini_namespace 2.py new file mode 100644 index 00000000000..a59c7bd0e3c --- /dev/null +++ b/python/packages/core/tests/core/test_gemini_namespace 2.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from types import ModuleType + +import pytest + +import agent_framework.gemini as gemini + + +def test_gemini_namespace_dir_lists_lazy_exports() -> None: + names = dir(gemini) + for expected in ( + "GeminiChatClient", + "GeminiChatOptions", + "GeminiSettings", + "GoogleGeminiSettings", + "RawGeminiChatClient", + "ThinkingConfig", + ): + assert expected in names + + +def test_gemini_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None: + sentinel = object() + fake_module = ModuleType("agent_framework_gemini") + fake_module.GeminiChatClient = sentinel # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + monkeypatch.setitem(sys.modules, "agent_framework_gemini", fake_module) + + assert gemini.GeminiChatClient is sentinel + + +def test_gemini_namespace_unknown_attribute_raises_attribute_error() -> None: + with pytest.raises(AttributeError, match="Module `gemini` has no attribute DoesNotExist."): + _ = gemini.DoesNotExist # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +def test_gemini_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "agent_framework_gemini", None) + + with pytest.raises(ModuleNotFoundError, match="agent-framework-gemini"): + _ = gemini.GeminiChatClient diff --git a/python/packages/core/tests/core/test_mistral_namespace 2.py b/python/packages/core/tests/core/test_mistral_namespace 2.py new file mode 100644 index 00000000000..1f6cbb5a6cb --- /dev/null +++ b/python/packages/core/tests/core/test_mistral_namespace 2.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from types import ModuleType + +import pytest + +import agent_framework.mistral as mistral + + +def test_mistral_namespace_dir_lists_lazy_exports() -> None: + names = dir(mistral) + for expected in ( + "MistralEmbeddingClient", + "MistralEmbeddingOptions", + "MistralEmbeddingSettings", + ): + assert expected in names + + +def test_mistral_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None: + sentinel = object() + fake_module = ModuleType("agent_framework_mistral") + fake_module.MistralEmbeddingClient = sentinel # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + monkeypatch.setitem(sys.modules, "agent_framework_mistral", fake_module) + + assert mistral.MistralEmbeddingClient is sentinel + + +def test_mistral_namespace_unknown_attribute_raises_attribute_error() -> None: + with pytest.raises(AttributeError, match="Module `mistral` has no attribute DoesNotExist."): + _ = mistral.DoesNotExist # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +def test_mistral_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "agent_framework_mistral", None) + + with pytest.raises(ModuleNotFoundError, match="agent-framework-mistral"): + _ = mistral.MistralEmbeddingClient diff --git a/python/packages/core/tests/core/test_monty_namespace 2.py b/python/packages/core/tests/core/test_monty_namespace 2.py new file mode 100644 index 00000000000..364b59fd7bc --- /dev/null +++ b/python/packages/core/tests/core/test_monty_namespace 2.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from types import ModuleType + +import pytest + +import agent_framework.monty as monty + + +def test_monty_namespace_dir_lists_lazy_exports() -> None: + names = dir(monty) + for expected in ( + "FileMount", + "FileMountInput", + "MontyCodeActProvider", + "MontyExecuteCodeTool", + "MountMode", + ): + assert expected in names + + +def test_monty_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None: + sentinel = object() + fake_module = ModuleType("agent_framework_monty") + fake_module.MontyCodeActProvider = sentinel # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + monkeypatch.setitem(sys.modules, "agent_framework_monty", fake_module) + + assert monty.MontyCodeActProvider is sentinel + + +def test_monty_namespace_unknown_attribute_raises_attribute_error() -> None: + with pytest.raises(AttributeError, match="Module `monty` has no attribute DoesNotExist."): + _ = monty.DoesNotExist # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +def test_monty_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "agent_framework_monty", None) + + with pytest.raises(ModuleNotFoundError, match="agent-framework-monty"): + _ = monty.MontyCodeActProvider diff --git a/python/packages/core/tests/core/test_tools_namespace 2.py b/python/packages/core/tests/core/test_tools_namespace 2.py new file mode 100644 index 00000000000..1a2a5801c50 --- /dev/null +++ b/python/packages/core/tests/core/test_tools_namespace 2.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from types import ModuleType + +import pytest + +import agent_framework.tools as tools + + +def test_tools_namespace_dir_lists_lazy_exports() -> None: + names = dir(tools) + for expected in ( + "DockerShellTool", + "LocalShellTool", + "ShellEnvironmentProvider", + "ShellEnvironmentProviderOptions", + "ShellExecutor", + "ShellPolicy", + ): + assert expected in names + + +def test_tools_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None: + sentinel = object() + fake_module = ModuleType("agent_framework_tools.shell") + fake_module.LocalShellTool = sentinel # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + monkeypatch.setitem(sys.modules, "agent_framework_tools.shell", fake_module) + + assert tools.LocalShellTool is sentinel + + +def test_tools_namespace_unknown_attribute_raises_attribute_error() -> None: + with pytest.raises(AttributeError, match="Module `tools` has no attribute DoesNotExist."): + _ = tools.DoesNotExist # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +def test_tools_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "agent_framework_tools.shell", None) + + with pytest.raises(ModuleNotFoundError, match="agent-framework-tools"): + _ = tools.LocalShellTool diff --git a/python/packages/hosting-mcp/AGENTS 2.md b/python/packages/hosting-mcp/AGENTS 2.md new file mode 100644 index 00000000000..b4b403280e0 --- /dev/null +++ b/python/packages/hosting-mcp/AGENTS 2.md @@ -0,0 +1,53 @@ +# MCP Hosting Helpers (`agent-framework-hosting-mcp`) + +Side-effect-free adapters and conversion helpers for hosting Agent Framework +agents and workflows through the native MCP SDK. + +## Public API + +- `AgentMCPTool(target, ...)` generates one native MCP `Tool` from an agent, + converts and executes calls, and optionally persists sessions through an + existing `AgentState`. +- `WorkflowMCPTool(target, ...)` derives one native MCP `Tool` from a workflow's + single start-executor input type and converts completed workflow outputs. +- `mcp_to_run(arguments, *, argument_name="task", + chat_option_arguments=())` converts MCP tool arguments to `AgentRunArgs` and + copies only explicitly selected arguments into chat options. +- `mcp_from_run(result)` converts an Agent Framework response or message to MCP + `ContentBlock` values. + +## Boundary + +This package does not provide a server, routes, transport lifecycle, +authentication, authorization, session-key policy, concurrency policy, or +outbound delivery. Applications compose the adapter and conversion helpers +with native MCP SDK constructs. + +`AgentMCPTool` owns only the schema for its single generated agent tool. It +does not register that schema with a server. Applications call +`await adapter.list_tools()` and `await adapter.call_tool(...)` from native MCP +handlers. + +`WorkflowMCPTool` owns only the schema derived from the start executor. It +requires exactly one input type. Workflow factories and continuation policy +remain application-owned. Pending external-input requests raise because the +adapter does not own a human-in-the-loop continuation contract. + +When configured with `AgentState`, the adapter performs session get/run/set. +Applications still derive and authorize the session identifier and serialize +concurrent calls for the same session. + +MCP tool arguments are JSON-only and have no native multimodal content-block +union. Do not add a package-owned JSON convention for image or audio input. + +`mcp_from_run(...)` intentionally returns a flat content block list. It +preserves content-level metadata, while applications own MCP result-level +metadata and structured content. + +The helper targets `CallToolResult.content`. Do not add sampling-only +`ToolUseContent` to its output; MCP sampling has a separate response content +union. + +`CallToolResult` is a single final result. Streamable HTTP transports MCP +messages rather than partial result content; progress notifications and +experimental tasks remain application-owned protocol concerns. diff --git a/python/packages/hosting-mcp/LICENSE 2 b/python/packages/hosting-mcp/LICENSE 2 new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting-mcp/LICENSE 2 @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting-mcp/README 2.md b/python/packages/hosting-mcp/README 2.md new file mode 100644 index 00000000000..8b077efb99e --- /dev/null +++ b/python/packages/hosting-mcp/README 2.md @@ -0,0 +1,174 @@ +# agent-framework-hosting-mcp + +MCP conversion helpers for app-owned Agent Framework hosting. + +The package deliberately does not choose a web framework or wrap the MCP SDK +server lifecycle. It provides two conversion functions and small adapters: + +- `mcp_to_run(...)` converts native MCP tool arguments into Agent Framework run + arguments. +- `mcp_from_run(...)` converts an `AgentResponse` or `Message` into native MCP + `ContentBlock` values. +- `AgentMCPTool(...)` generates the native `Tool` definition from an agent and + keeps listing, parsing, execution, result conversion, and optional + `AgentState` session persistence aligned. +- `WorkflowMCPTool(...)` generates the native `Tool` definition from a + workflow's start-executor input type and converts completed workflow outputs. + +Application code keeps ownership of the MCP SDK's `Server`, handler +registration, request context, transport, session-key policy, authentication, +authorization, and deployment. + +For direct conversion, the argument name is part of the app-owned MCP tool +contract. Define it once and use the same value in both the native tool schema +and `mcp_to_run(...)`: + +```python +agent_input_argument = "task" +chat_option_arguments = { + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + }, +} + +tool = Tool( + name="run_agent", + inputSchema={ + "type": "object", + "properties": { + agent_input_argument: {"type": "string"}, + **chat_option_arguments, + }, + "required": [agent_input_argument], + }, +) +run = mcp_to_run( + arguments, + argument_name=agent_input_argument, + chat_option_arguments=chat_option_arguments, +) +``` + +Only names listed in `chat_option_arguments` are copied to `run["options"]`; +other MCP arguments remain available in the message's raw representation but +are not forwarded to the model client. The native MCP schema remains +responsible for validating exposed option types and ranges. + +For an agent exposed as one MCP tool, use the adapter so the schema and +conversion cannot drift: + +```python +agent_tool = AgentMCPTool( + agent, + name="run_agent", + argument_description="The request for the hosted agent.", + parameters={"audience": {"type": "string"}}, + chat_option_parameters={ + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + } + }, +) + +@server.list_tools() +async def list_tools(): + return await agent_tool.list_tools() + +@server.call_tool() +async def call_tool(name, arguments): + return await agent_tool.call_tool(name, arguments) +``` + +`AgentMCPTool` uses the agent's name and description unless overridden. +`parameters` adds app-owned JSON Schema properties that remain available in the +raw MCP arguments. `chat_option_parameters` adds properties and explicitly +copies their values into Agent Framework chat options. + +For a workflow exposed as one MCP tool, use `WorkflowMCPTool`: + +```python +workflow_tool = WorkflowMCPTool( + WorkflowState(create_workflow, cache_target=False), + name="run_workflow", +) +``` + +The start executor must declare exactly one input type. Dataclass, Pydantic, and +other object-shaped inputs become the MCP tool's top-level arguments. Primitive +inputs are wrapped in the configurable `argument_name` property. The adapter +validates MCP arguments against that derived type before calling +`workflow.run(...)`. + +Workflow instances preserve execution state, so applications that need +independent calls should supply a `WorkflowState` factory with +`cache_target=False`, as above. Checkpoint restoration, human-in-the-loop +responses, and continuation identifiers remain application-owned contracts. +If a workflow requests external input, the adapter raises instead of returning +an empty successful tool result. + +Pass an existing `AgentState` plus `session_id_parameter` to persist an +`AgentSession`: + +```python +state = AgentState(agent) +agent_tool = AgentMCPTool( + state, + parameters={"session_id": {"type": "string", "minLength": 1}}, + required_parameters={"session_id"}, + session_id_parameter="session_id", +) +``` + +The application must authenticate or authorize that session identifier and +serialize concurrent calls for the same session. The adapter only performs the +`AgentState` session-store get/run/set sequence. A configured session parameter +is always marked required in the generated MCP schema. + +The session identifier is an opaque, application-defined key. Neither MCP nor +Agent Framework prescribes its format. `AgentMCPTool` treats it as the key for +one mutable conversation: each call loads that session and stores the updated +session under the same key. It does not implement +`previous_response_id`-style branching. Branching requires an app-owned +contract with separate source and destination identifiers so the application +can authorize both, copy the source session, and store the result under the +destination key. + +MCP `tools/call` inputs are JSON objects defined by the app's `inputSchema`; +the protocol does not define image, audio, or resource content blocks for tool +arguments. This helper therefore converts one selected string argument and +does not impose a non-standard multimodal JSON convention. + +For non-image/audio binary output, `mcp_from_run(...)` uses an app-provided +`content.additional_properties["uri"]` when present and otherwise uses the +short fallback `af://binary`; the payload itself is stored only in the MCP +resource's `blob` field. + +`mcp_from_run(...)` targets `CallToolResult.content`, whose MCP content union +does not include sampling-only `ToolUseContent`. Agent Framework +`function_call` content is therefore omitted from tool results. MCP sampling +callbacks use a separate response contract and may convert function calls to +`ToolUseContent`. + +MCP tool calls return one final `CallToolResult`; they do not stream partial +content blocks. Streamable HTTP may carry multiple MCP messages, and apps may +send progress notifications while work runs, but neither mechanism turns +Agent Framework response updates into incremental tool results. Experimental +MCP tasks defer retrieval of the same final result. + +```python +run = mcp_to_run(arguments) +result = await agent.run( + run["messages"], + options=run["options"], +) +content = mcp_from_run(result) + +# Native MCP SDK application code returns `content` from its call_tool handler. +``` + +The surrounding MCP application still owns the low-level `Server`, handler +registration, Starlette/FastAPI composition, stdio or streamable HTTP +transport, request authentication, session-key trust, concurrency, and +deployment. diff --git a/python/packages/hosting-mcp/pyproject 2.toml b/python/packages/hosting-mcp/pyproject 2.toml new file mode 100644 index 00000000000..1374951e983 --- /dev/null +++ b/python/packages/hosting-mcp/pyproject 2.toml @@ -0,0 +1,81 @@ +[project] +name = "agent-framework-hosting-mcp" +description = "Agent and workflow MCP tool adapters for app-owned hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260721" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.11.0,<2", + "agent-framework-hosting==1.0.0a260721", + "mcp>=1.11.0,<2", + "pydantic>=2,<3", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_mcp"] +exclude = ['tests'] + +[tool.bandit] +targets = ["agent_framework_hosting_mcp"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_mcp --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 00032966193..950ecd92152 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1695,7 +1695,10 @@ def _prepare_message_for_openai( ) if function_call: all_messages.append(function_call) - case "function_approval_response" | "function_approval_request": + case "function_approval_request": + # The model's approval request is a server-issued item; under a + # continuation the server already has it, so replaying it inline + # duplicates it (#3295). Same treatment as function_call above. if request_uses_service_side_storage: continue prepared = self._prepare_content_for_openai( @@ -1705,6 +1708,19 @@ def _prepare_message_for_openai( ) if prepared: all_messages.append(prepared) + case "function_approval_response": + # The approval response is the user's decision that resumes an + # approval-paused run. It references the prior request by id and is + # not itself a server-stored item, so it must reach the model even + # under service-side storage; skipping it leaves the run paused + # forever (#7125). Same treatment as function_result above. + prepared = self._prepare_content_for_openai( + message.role, + content, + replays_local_storage=replays_local_storage, + ) + if prepared: + all_messages.append(prepared) case "mcp_server_tool_call" | "mcp_server_tool_result": # Hosted MCP call/result contents serialize as a single # top-level mcp_call input item; the result side emits an diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 80ea1f626a0..70932e725e5 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -3287,6 +3287,34 @@ def test_prepare_message_for_openai_with_function_approval_response() -> None: assert prepared_message["approve"] is True +def test_prepare_message_for_openai_keeps_approval_response_under_service_side_storage() -> None: + """The approval response resumes an approval-paused run and must survive service-side storage. + + It references the prior approval request by id and is not itself a server-stored item, so + stripping it (as the #3295 duplicate-item fix used to) leaves the run paused forever (#7125). + """ + client = OpenAIChatClient(model="test-model", api_key="test-key") + + function_call = Content.from_function_call( + call_id="call_789", + name="execute_command", + arguments='{"command": "ls"}', + ) + approval_response = Content.from_function_approval_response( + approved=True, + id="approval_003", + function_call=function_call, + ) + message = Message(role="user", contents=[approval_response]) + + result = client._prepare_message_for_openai(message, request_uses_service_side_storage=True) + + assert len(result) == 1 + assert result[0]["type"] == "mcp_approval_response" + assert result[0]["approval_request_id"] == "approval_003" + assert result[0]["approve"] is True + + def test_prepare_messages_for_openai_keeps_active_function_call_for_tool_loop() -> None: """An active tool loop retains its current function call until the model produces a follow-up.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -7799,8 +7827,12 @@ def test_prepare_messages_keeps_function_call_without_storage() -> None: def test_prepare_messages_strips_approval_items_under_storage() -> None: - """Approval request/response items also carry server-issued IDs and must be stripped under - storage. Without storage they are kept (#3295).""" + """Approval request items carry server-issued IDs and must be stripped under storage (#3295). + + The approval response is different: it is the user's new decision that resumes an + approval-paused run, not a server-stored item, so it stays even under storage (#7125). + Without storage both are kept. + """ client = OpenAIChatClient(model="test-model", api_key="test-key") function_call = Content.from_function_call( @@ -7825,7 +7857,7 @@ def test_prepare_messages_strips_approval_items_under_storage() -> None: storage_on = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=True) storage_on_types = [item.get("type") for item in storage_on] assert "mcp_approval_request" not in storage_on_types - assert "mcp_approval_response" not in storage_on_types + assert "mcp_approval_response" in storage_on_types storage_off = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False) storage_off_types = [item.get("type") for item in storage_off] diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/01_document_qa 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/01_document_qa 2.py new file mode 100644 index 00000000000..a8f039e89d5 --- /dev/null +++ b/python/samples/02-agents/context_providers/azure_content_understanding/01_document_qa 2.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# ] +# /// +# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/01_document_qa.py + + +import asyncio +import os +from pathlib import Path + +from agent_framework import Agent, Content, Message +from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +""" +Document Q&A — PDF upload with CU-powered extraction + +This sample demonstrates the simplest CU integration: upload a PDF and +ask questions about it. Azure Content Understanding extracts structured +markdown with table preservation — superior to LLM-only vision for +scanned PDFs, handwritten content, and complex layouts. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +# Path to a sample PDF — uses the shared sample asset if available, +# otherwise falls back to a public URL +SAMPLE_PDF_PATH = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf" + + +async def main() -> None: + credential = AzureCliCredential() + + # Set up Azure Content Understanding context provider + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + analyzer_id="prebuilt-documentSearch", # RAG-optimized document analyzer + max_wait=None, # wait until CU analysis finishes (no background deferral) + ) + + # Set up the LLM client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # Create agent with CU context provider. + # The provider extracts document content via CU and injects it into the + # LLM context so the agent can answer questions about the document. + async with credential, cu: + agent = Agent( + client=client, + name="DocumentQA", + instructions=( + "You are a helpful document analyst. Use the analyzed document " + "content and extracted fields to answer questions precisely." + ), + context_providers=[cu], + ) + + # --- Turn 1: Upload PDF and ask a question --- + # 4. Upload PDF and ask questions + # The CU provider extracts markdown + fields from the PDF and injects + # the full content into context so the agent can answer precisely. + print("--- Upload PDF and ask questions ---") + + pdf_bytes = SAMPLE_PDF_PATH.read_bytes() + + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text( + "What is this document about? Who is the vendor, and what is the total amount due?" + ), + Content.from_data( + pdf_bytes, + "application/pdf", + # Always provide filename — used as the document key + additional_properties={"filename": SAMPLE_PDF_PATH.name}, + ), + ], + ) + ) + usage = response.usage_details or {} + print(f"Agent: {response}") + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Upload PDF and ask questions --- +Agent: This document is an **invoice** for services and fees billed to + **MICROSOFT CORPORATION** (Invoice **INV-100**), including line items + (e.g., Consulting Services, Document Fee, Printing Fee) and a billing summary. + - **Vendor:** **CONTOSO LTD.** + - **Total amount due:** **$610.00** + [Input tokens: 988] +""" diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session 2.py new file mode 100644 index 00000000000..87a705dcd98 --- /dev/null +++ b/python/samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session 2.py @@ -0,0 +1,142 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# ] +# /// +# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session.py + + +import asyncio +import os +from pathlib import Path + +from agent_framework import Agent, AgentSession, Content, Message +from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +""" +Multi-Turn Session — Cached results across turns + +This sample demonstrates multi-turn document Q&A using an AgentSession. +The session persists CU analysis results and conversation history across +turns so the agent can answer follow-up questions about previously +uploaded documents without re-analyzing them. + +Key concepts: + - AgentSession keeps CU state and conversation history across agent.run() calls + - Turn 1: CU analyzes the PDF and injects full content into context + - Turn 2: Unrelated question — agent answers from general knowledge + - Turn 3: Detailed question — agent uses document content from conversation + history (injected in Turn 1) to answer precisely + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +SAMPLE_PDF_PATH = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf" + + +async def main() -> None: + # 1. Set up credentials and CU context provider + credential = AzureCliCredential() + + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + analyzer_id="prebuilt-documentSearch", + max_wait=None, # wait until CU analysis finishes (no background deferral) + ) + + # 2. Set up the LLM client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # 3. Create agent and persistent session + async with credential, cu: + agent = Agent( + client=client, + name="DocumentQA", + instructions=( + "You are a helpful document analyst. Use the analyzed document " + "content and extracted fields to answer questions precisely." + ), + context_providers=[cu], + ) + + # Create a persistent session — this keeps CU state across turns + session = AgentSession() + + # 4. Turn 1: Upload PDF + # CU analyzes the PDF and injects full content into context. + print("--- Turn 1: Upload PDF ---") + pdf_bytes = SAMPLE_PDF_PATH.read_bytes() + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text("What is this document about?"), + Content.from_data( + pdf_bytes, + "application/pdf", + additional_properties={"filename": SAMPLE_PDF_PATH.name}, + ), + ], + ), + session=session, # <-- persist state across turns + ) + usage = response.usage_details or {} + print(f"Agent: {response}") + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n") + + # 5. Turn 2: Unrelated question + # No document needed — agent answers from general knowledge. + print("--- Turn 2: Unrelated question ---") + response = await agent.run("What is the capital of France?", session=session) + usage = response.usage_details or {} + print(f"Agent: {response}") + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n") + + # 6. Turn 3: Detailed follow-up + # The agent answers from the full document content that was injected + # into conversation history in Turn 1. No re-analysis or tool call needed. + print("--- Turn 3: Detailed follow-up ---") + response = await agent.run( + "What is the shipping address on the invoice?", + session=session, + ) + usage = response.usage_details or {} + print(f"Agent: {response}") + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Turn 1: Upload PDF --- +Agent: This document is an **invoice** from **CONTOSO LTD.** to **MICROSOFT + CORPORATION**. Amount Due: $610.00. Invoice INV-100, dated 11/15/2019. + [Input tokens: 975] + +--- Turn 2: Unrelated question --- +Agent: Paris. + [Input tokens: 1134] + +--- Turn 3: Detailed follow-up --- +Agent: Shipping address (SHIP TO): Microsoft Delivery, 123 Ship St, + Redmond WA, 98052. + [Input tokens: 1155] +""" diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat 2.py new file mode 100644 index 00000000000..757bae1dc4b --- /dev/null +++ b/python/samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat 2.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# ] +# /// +# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat.py + + +import asyncio +import os +import time +from pathlib import Path + +from agent_framework import Agent, AgentSession, Content, Message +from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +""" +Multi-Modal Chat — PDF, audio, and video in a single turn + +This sample demonstrates CU's multi-modal capability: upload a PDF invoice, +an audio call recording, and a video file all at once. The provider analyzes +all three in parallel using the right CU analyzer for each media type. + +The provider auto-detects the media type and selects the right CU analyzer: + - PDF/images → prebuilt-documentSearch + - Audio → prebuilt-audioSearch + - Video → prebuilt-videoSearch + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +# Local PDF from package assets +SAMPLE_PDF = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf" + +# Public audio/video from Azure CU samples repo (raw GitHub URLs) +_CU_ASSETS = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main" +AUDIO_URL = f"{_CU_ASSETS}/audio/callCenterRecording.mp3" +VIDEO_URL = f"{_CU_ASSETS}/videos/sdk_samples/FlightSimulator.mp4" + + +async def main() -> None: + # 1. Set up credentials and CU context provider + credential = AzureCliCredential() + + # No analyzer_id specified — the provider auto-detects from media type: + # PDF/images → prebuilt-documentSearch + # Audio → prebuilt-audioSearch + # Video → prebuilt-videoSearch + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + max_wait=None, # wait until each analysis finishes + ) + + # 2. Set up the LLM client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # 3. Create agent and session + async with credential, cu: + agent = Agent( + client=client, + name="MultiModalAgent", + instructions=( + "You are a helpful assistant that can analyze documents, audio, " + "and video files. Answer questions using the extracted content." + ), + context_providers=[cu], + ) + + session = AgentSession() + + # --- Turn 1: Upload all 3 modalities at once --- + # The provider analyzes all files in parallel using the appropriate + # CU analyzer for each media type. All results are injected into + # the same context so the agent can answer about all of them. + turn1_prompt = ( + "I'm uploading three files: an invoice PDF, a call center " + "audio recording, and a flight simulator video. " + "Give a brief summary of each file." + ) + print("--- Turn 1: Upload PDF + audio + video (parallel analysis) ---") + print(" (CU analysis may take a few minutes for these audio/video files...)") + print(f"User: {turn1_prompt}") + t0 = time.perf_counter() + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text(turn1_prompt), + Content.from_data( + SAMPLE_PDF.read_bytes(), + "application/pdf", + additional_properties={"filename": "invoice.pdf"}, + ), + Content.from_uri( + AUDIO_URL, + media_type="audio/mp3", + additional_properties={"filename": "callCenterRecording.mp3"}, + ), + Content.from_uri( + VIDEO_URL, + media_type="video/mp4", + additional_properties={"filename": "FlightSimulator.mp4"}, + ), + ], + ), + session=session, + ) + elapsed = time.perf_counter() - t0 + usage = response.usage_details or {} + print(f" [Analyzed in {elapsed:.1f}s | Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + # --- Turn 2: Detail question about the PDF --- + turn2_prompt = "What are the line items and their amounts on the invoice?" + print("--- Turn 2: PDF detail ---") + print(f"User: {turn2_prompt}") + response = await agent.run(turn2_prompt, session=session) + usage = response.usage_details or {} + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + # --- Turn 3: Detail question about the audio --- + turn3_prompt = "What was the customer's issue in the call recording?" + print("--- Turn 3: Audio detail ---") + print(f"User: {turn3_prompt}") + response = await agent.run(turn3_prompt, session=session) + usage = response.usage_details or {} + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + # --- Turn 4: Detail question about the video --- + turn4_prompt = "What key scenes or actions are shown in the flight simulator video?" + print("--- Turn 4: Video detail ---") + print(f"User: {turn4_prompt}") + response = await agent.run(turn4_prompt, session=session) + usage = response.usage_details or {} + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + # --- Turn 5: Cross-document question --- + turn5_prompt = ( + "Across all three files, which one contains financial data, " + "which one involves a customer interaction, and which one is " + "a visual demonstration?" + ) + print("--- Turn 5: Cross-document question ---") + print(f"User: {turn5_prompt}") + response = await agent.run(turn5_prompt, session=session) + usage = response.usage_details or {} + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Turn 1: Upload PDF + audio + video (parallel analysis) --- +User: I'm uploading three files... + (CU analysis may take 1-2 minutes for audio/video files...) + [Analyzed in ~94s | Input tokens: ~2939] +Agent: ### invoice.pdf: An invoice from CONTOSO LTD. to MICROSOFT CORPORATION... + ### callCenterRecording.mp3: A customer service call about point balance... + ### FlightSimulator.mp4: A clip discussing neural text-to-speech... + +--- Turn 2-5: Detail and cross-document questions --- +(Agent answers from conversation history without re-analysis) +""" diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing 2.py new file mode 100644 index 00000000000..502cd328ca9 --- /dev/null +++ b/python/samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing 2.py @@ -0,0 +1,192 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# "pydantic", +# ] +# /// +# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing.py + + +import asyncio +import os +from pathlib import Path + +from agent_framework import Agent, AgentSession, Content, Message +from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +load_dotenv() + +""" +Invoice Processing — Structured output with prebuilt-invoice analyzer + +This sample demonstrates CU's structured field extraction combined with +LLM structured output (Pydantic model). The prebuilt-invoice analyzer extracts +typed fields (VendorName, InvoiceTotal, DueDate, LineItems, etc.) with +confidence scores. We use output_sections=["fields"] only (no markdown needed) +since we want the LLM to produce a structured JSON response from the extracted +fields, not summarize document text. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +SAMPLE_PDF_PATH = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf" + + +# Structured output model — the LLM will return JSON matching this schema +# Structured output models — the LLM returns JSON matching this schema. +# +# Note: the prebuilt-invoice analyzer extracts an extensive set of fields +# (VendorName, BillingAddress, ShippingAddress, TaxDetails, PONumber, etc.). +# This sample defines a simplified schema to extract only the fields of +# interest to the caller. The LLM maps the full CU field output to this +# subset automatically. +# Learn more about prebuilt analyzers: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers + + +class LineItem(BaseModel): + description: str + quantity: float | None = None + unit_price: float | None = None + amount: float | None = None + + +class LowConfidenceField(BaseModel): + field_name: str + confidence: float + + +class InvoiceResult(BaseModel): + vendor_name: str + total_amount: float | None = None + currency: str = "USD" + due_date: str | None = None + line_items: list[LineItem] = Field(default_factory=list) + low_confidence_fields: list[LowConfidenceField] = Field( + default_factory=list, + description="Fields with confidence < 0.8, including their confidence score", + ) + + +async def main() -> None: + # 1. Set up credentials and CU context provider + credential = AzureCliCredential() + + # Default analyzer is prebuilt-documentSearch (RAG-optimized). + # Per-file override via additional_properties["analyzer_id"] lets us + # use prebuilt-invoice for structured field extraction on specific files. + # + # Only request "fields" (not "markdown") — we want the extracted typed + # fields for structured output, not the raw document text. + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + analyzer_id="prebuilt-documentSearch", # default for all files + max_wait=None, # wait until CU analysis finishes + output_sections=["fields"], # fields only — structured output doesn't need markdown + ) + + # 2. Set up the LLM client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # 3. Create agent and session + async with credential, cu: + agent = Agent( + client=client, + name="InvoiceProcessor", + instructions=( + "You are an invoice processing assistant. Extract invoice data from " + "the provided CU fields (JSON with confidence scores). Return structured " + "output matching the requested schema. Flag fields with confidence < 0.8 " + "in the low_confidence_fields list." + ), + context_providers=[cu], + ) + + session = AgentSession() + + # 4. Upload an invoice PDF — uses structured output (Pydantic model) + print("--- Upload Invoice (Structured Output) ---") + + pdf_bytes = SAMPLE_PDF_PATH.read_bytes() + + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text( + "Process this invoice. Extract the vendor name, total amount, due date, and all line items." + ), + Content.from_data( + pdf_bytes, + "application/pdf", + # Per-file analyzer override: use prebuilt-invoice for + # structured field extraction (VendorName, InvoiceTotal, etc.) + # instead of the provider default (prebuilt-documentSearch). + additional_properties={ + "filename": SAMPLE_PDF_PATH.name, + "analyzer_id": "prebuilt-invoice", + }, + ), + ], + ), + session=session, + options={"response_format": InvoiceResult}, + ) + + # Parse the structured output from JSON text + try: + invoice = InvoiceResult.model_validate_json(response.text) + print(f"Vendor: {invoice.vendor_name}") + print(f"Total: {invoice.currency} {invoice.total_amount}") + print(f"Due date: {invoice.due_date}") + print(f"Line items ({len(invoice.line_items)}):") + for item in invoice.line_items: + print(f" - {item.description}: {item.amount}") + if invoice.low_confidence_fields: + print("⚠ Low confidence fields:") + for f in invoice.low_confidence_fields: + print(f" - {f.field_name}: {f.confidence:.3f}") + except Exception: + print(f"Agent (raw): {response.text}\n") + + # 5. Follow-up: free-text question about the invoice + print("\n--- Follow-up (Free Text) ---") + response = await agent.run( + "What is the payment term? Are there any fields with low confidence?", + session=session, + ) + print(f"Agent: {response}\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Upload Invoice (Structured Output) --- +Vendor: CONTOSO LTD. +Total: USD 110.0 +Due date: 2019-12-15 +Line items (3): + - Consulting Services: 60.0 + - Document Fee: 30.0 + - Printing Fee: 10.0 +⚠ Low confidence: VendorName, CustomerName + +--- Follow-up (Free Text) --- +Agent: The payment terms are not explicitly stated on the invoice... +""" diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search 2.py new file mode 100644 index 00000000000..39e4352eea5 --- /dev/null +++ b/python/samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search 2.py @@ -0,0 +1,165 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# ] +# /// +# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search.py + + +import asyncio +import os +from pathlib import Path + +from agent_framework import Agent, AgentSession, Content, Message +from agent_framework.foundry import ( + ContentUnderstandingContextProvider, + FileSearchConfig, + FoundryChatClient, +) +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +""" +Large Document + file_search RAG — CU extraction + OpenAI vector store + +For large documents (100+ pages) or long audio/video, injecting the full +CU-extracted content into the LLM context is impractical. This sample shows +how to use the built-in file_search integration: CU extracts markdown and +automatically uploads it to an OpenAI vector store for token-efficient RAG. + +When ``FileSearchConfig`` is provided, the provider: + 1. Extracts markdown via CU (handles scanned PDFs, audio, video) + 2. Uploads the extracted markdown to a vector store + 3. Registers a ``file_search`` tool on the agent context + 4. Cleans up the vector store on close + +Architecture: + Large PDF -> CU extracts markdown -> auto-upload to vector store -> file_search + Follow-up -> file_search retrieves top-k chunks -> LLM answers + +NOTE: Requires an async OpenAI client for vector store operations. + +This sample uses a single small invoice PDF for simplicity. In practice, +you can upload multiple files in the same session (each is indexed +separately in the vector store), and this pattern is most valuable for +large documents (up to 300 pages), long audio recordings, or video files +where full-context injection would exceed the LLM's context window. +CU supports PDFs up to 300 pages / 200 MB, and audio files up to 300 MB +— see the full service limits: +https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +SAMPLE_PDF_PATH = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf" + + +async def main() -> None: + # 1. Set up credentials and LLM client + credential = AzureCliCredential() + + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # 2. Get the async OpenAI client from FoundryChatClient for vector store operations + openai_client = client.client + + # 3. Create vector store and file_search tool + vector_store = await openai_client.vector_stores.create( + name="cu_large_doc_demo", + expires_after={"anchor": "last_active_at", "days": 1}, + ) + file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store.id]) + + # 4. Configure CU provider with file_search integration + # When file_search is set, CU-extracted markdown is automatically uploaded + # to the vector store and the file_search tool is registered on the context. + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + analyzer_id="prebuilt-documentSearch", + max_wait=None, # wait until CU analysis + vector store upload finishes + file_search=FileSearchConfig.from_foundry( + openai_client, + vector_store_id=vector_store.id, + file_search_tool=file_search_tool, + ), + ) + + pdf_bytes = SAMPLE_PDF_PATH.read_bytes() + + # The provider handles everything: CU extraction + vector store upload + file_search tool + async with credential, cu: + agent = Agent( + client=client, + name="LargeDocAgent", + instructions=( + "You are a document analyst. Use the file_search tool to find " + "relevant sections from the document and answer precisely. " + "Cite specific sections when answering." + ), + context_providers=[cu], + ) + + session = AgentSession() + + # Turn 1: Upload — CU extracts and uploads to vector store automatically + print("--- Turn 1: Upload document ---") + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text("What are the key points in this document?"), + Content.from_data( + pdf_bytes, + "application/pdf", + additional_properties={"filename": SAMPLE_PDF_PATH.name}, + ), + ], + ), + session=session, + ) + print(f"Agent: {response}\n") + + # Turn 2: Follow-up — file_search retrieves relevant chunks (token efficient) + print("--- Turn 2: Follow-up (RAG) ---") + response = await agent.run( + "What numbers or financial metrics are mentioned?", + session=session, + ) + print(f"Agent: {response}\n") + + # Explicitly delete the vector store created for this sample + await openai_client.vector_stores.delete(vector_store.id) + print("Done. Vector store deleted.") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Turn 1: Upload document --- +Agent: An invoice from Contoso Ltd. to Microsoft Corporation (INV-100). + Line items: Consulting Services $60, Document Fee $30, Printing Fee $10. + Subtotal $100, Sales tax $10, Total $110, Previous balance $500, Amount due $610. + +--- Turn 2: Follow-up (RAG) --- +Agent: Subtotal $100.00, Sales tax $10.00, Total $110.00, + Previous unpaid balance $500.00, Amount due $610.00. + Line items: 2 hours @ $30 = $60, 3 @ $10 = $30, 10 pages @ $1 = $10. + +Done. Vector store cleaned up automatically. +""" diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/README 2.md b/python/samples/02-agents/context_providers/azure_content_understanding/README 2.md new file mode 100644 index 00000000000..ffefee9d9e2 --- /dev/null +++ b/python/samples/02-agents/context_providers/azure_content_understanding/README 2.md @@ -0,0 +1,35 @@ +# Azure Content Understanding Samples + +These samples demonstrate how to use the `agent-framework-azure-contentunderstanding` package to add document, image, audio, and video understanding to your agents. + +## Prerequisites + +1. Azure CLI logged in: `az login` +2. Environment variables set (or `.env` file in the `python/` directory): + ``` + FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com + FOUNDRY_MODEL=gpt-4.1 + AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/ + ``` + +## Samples + +### Script samples (easy → advanced) + +| # | Sample | Description | Run | +|---|--------|-------------|-----| +| 01 | [Document Q&A](01_document_qa.py) | Upload a PDF, ask questions with CU-powered extraction | `uv run samples/02-agents/context_providers/azure_content_understanding/01_document_qa.py` | +| 02 | [Multi-Turn Session](02_multi_turn_session.py) | AgentSession persistence across turns | `uv run samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session.py` | +| 03 | [Multi-Modal Chat](03_multimodal_chat.py) | PDF + audio + video parallel analysis | `uv run samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat.py` | +| 04 | [Invoice Processing](04_invoice_processing.py) | Structured field extraction with prebuilt-invoice | `uv run samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing.py` | +| 05 | [Large Doc + file_search](05_large_doc_file_search.py) | CU extraction + OpenAI vector store RAG | `uv run samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search.py` | + +### Interactive web UI samples + +See the [DevUI sample index](../../devui/README.md) for the Azure Content Understanding agents. + +## Install (preview) + +```bash +pip install --pre agent-framework-azure-contentunderstanding +``` diff --git a/python/samples/02-agents/devui/agent_content_understanding/README 2.md b/python/samples/02-agents/devui/agent_content_understanding/README 2.md new file mode 100644 index 00000000000..a1baf0a5ce8 --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding/README 2.md @@ -0,0 +1,33 @@ +# DevUI Multi-Modal Agent + +Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding. + +## Setup + +1. Set environment variables (or create a `.env` file in `python/`): + ```bash + FOUNDRY_PROJECT_ENDPOINT=https://your-project.api.azureml.ms + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1 + AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/ + ``` + +2. Log in with Azure CLI: + ```bash + az login + ``` + +3. Run with DevUI: + ```bash + devui samples/02-agents/devui/agent_content_understanding + ``` + +4. Open the DevUI URL in your browser and start uploading files. + +## What You Can Do + +- **Upload PDFs** — including scanned/image-based PDFs that LLM vision struggles with +- **Upload images** — handwritten notes, infographics, charts +- **Upload audio** — meeting recordings, call center calls (transcription with speaker ID) +- **Upload video** — product demos, training videos (frame extraction + transcription) +- **Ask questions** across all uploaded documents +- **Check status** — "which documents are ready?" uses the auto-registered `list_documents()` tool diff --git a/python/samples/02-agents/devui/agent_content_understanding/__init__ 2.py b/python/samples/02-agents/devui/agent_content_understanding/__init__ 2.py new file mode 100644 index 00000000000..4a62c13ce44 --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding/__init__ 2.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent with Azure Content Understanding.""" + +from .agent import agent # ty: ignore[unresolved-import] # pyrefly: ignore + +__all__ = ["agent"] diff --git a/python/samples/02-agents/devui/agent_content_understanding/agent 2.py b/python/samples/02-agents/devui/agent_content_understanding/agent 2.py new file mode 100644 index 00000000000..d8f46155cec --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding/agent 2.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent — file upload + CU-powered analysis. + +This agent uses Azure Content Understanding to analyze uploaded files +(PDFs, scanned documents, handwritten images, audio recordings, video) +and answer questions about them through the DevUI web interface. + +Unlike the standard azure_responses_agent which sends files directly to the LLM, +this agent uses CU for structured extraction — superior for scanned PDFs, +handwritten content, audio transcription, and video analysis. + +Required environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL + +Run with DevUI: + devui samples/02-agents/devui/agent_content_understanding +""" + +import os + +from agent_framework import Agent +from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient +from azure.core.credentials import AzureKeyCredential +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +# --- Auth --- +_credential = AzureCliCredential() +_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY") +_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else _credential + +cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=_cu_credential, + # max_wait controls how long before_run() waits for CU analysis before + # deferring to background. For interactive DevUI use, a short timeout + # (e.g. 5s) keeps the chat responsive — the agent tells the user the + # file is still being analyzed and resolves it on the next turn. + # Use max_wait=None to always wait for analysis to complete. + max_wait=5.0, +) + +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=_credential, +) + +agent = Agent( + client=client, + name="MultiModalDocAgent", + instructions=( + "You are a helpful document analysis assistant. " + "When a user uploads files, they are automatically analyzed using Azure Content Understanding. " + "Use list_documents() to check which documents are ready, pending, or failed " + "and to see which files are available for answering questions. " + "Tell the user if any documents are still being analyzed. " + "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. " + "When answering, cite specific content from the documents." + ), + context_providers=[cu], +) diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/README 2.md b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/README 2.md new file mode 100644 index 00000000000..7a72416b118 --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/README 2.md @@ -0,0 +1,51 @@ +# DevUI File Search Agent + +Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + OpenAI file_search RAG. + +## How It Works + +1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat +2. **CU analyzes** the file — auto-selects the right analyzer per media type +3. **Markdown extracted** by CU is uploaded to an OpenAI vector store +4. **file_search** tool is registered — LLM retrieves top-k relevant chunks +5. **Ask questions** across all uploaded documents with token-efficient RAG + +## Setup + +1. Set environment variables (or create a `.env` file in `python/`): + ```bash + FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/ + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1 + AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/ + ``` + +2. Log in with Azure CLI: + ```bash + az login + ``` + +3. Run with DevUI: + ```bash + devui samples/02-agents/devui/agent_content_understanding_file_search_azure_openai + ``` + +4. Open the DevUI URL in your browser and start uploading files. + +## Supported File Types + +| Type | Formats | CU Analyzer (auto-detected) | +|------|---------|----------------------------| +| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown | `prebuilt-documentSearch` | +| Images | JPEG, PNG, TIFF, BMP | `prebuilt-documentSearch` | +| Audio | WAV, MP3, FLAC, OGG, M4A | `prebuilt-audioSearch` | +| Video | MP4, MOV, AVI, WebM | `prebuilt-videoSearch` | + +## Comparison with the multimodal agent + +| Feature | multimodal_agent | file_search_agent | +|---------|-----------------|-------------------| +| CU extraction | ✅ Full content injected | ✅ Content indexed in vector store | +| RAG | ❌ | ✅ file_search retrieves top-k chunks | +| Large docs (100+ pages) | ⚠️ May exceed context window | ✅ Token-efficient | +| Multiple large files | ⚠️ Context overflow risk | ✅ All indexed, searchable | +| Best for | Small docs, quick inspection | Large docs, multi-file Q&A | diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/__init__ 2.py b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/__init__ 2.py new file mode 100644 index 00000000000..60720f84899 --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/__init__ 2.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent with CU + file_search RAG.""" + +from .agent import agent # ty: ignore[unresolved-import] # pyrefly: ignore + +__all__ = ["agent"] diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/agent 2.py b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/agent 2.py new file mode 100644 index 00000000000..c5aa9fa1ade --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/agent 2.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent — CU extraction + file_search RAG. + +This agent combines Azure Content Understanding with OpenAI file_search +for token-efficient RAG over large or multi-modal documents. + +Upload flow: + 1. CU extracts high-quality markdown (handles scanned PDFs, audio, video) + 2. Extracted markdown is auto-uploaded to an OpenAI vector store + 3. file_search tool is registered so the LLM retrieves top-k chunks + 4. Vector store is configured to auto-expire after inactivity + +This is ideal for large documents (100+ pages), long audio recordings, +or multiple files in the same conversation where full-context injection +would exceed the LLM's context window. + +Analyzer auto-detection: + When no analyzer_id is specified, the provider auto-selects the + appropriate CU analyzer based on media type: + - Documents/images → prebuilt-documentSearch + - Audio → prebuilt-audioSearch + - Video → prebuilt-videoSearch + +Required environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL + +Run with DevUI: + devui samples/02-agents/devui/agent_content_understanding_file_search_azure_openai +""" + +import os + +from agent_framework import Agent +from agent_framework.foundry import ( + ContentUnderstandingContextProvider, + FileSearchConfig, + FoundryChatClient, +) +from azure.ai.projects import AIProjectClient +from azure.core.credentials import AzureKeyCredential +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +# --- Auth --- +_credential = AzureCliCredential() +_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY") +_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else AsyncAzureCliCredential() + +_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + +# --- LLM client + sync vector store setup --- +# DevUI loads agent modules synchronously at startup while an event loop is already +# running, so we cannot use async APIs here. A sync AIProjectClient is used for +# one-time vector store creation; runtime file uploads use client.client (async). +client = FoundryChatClient( + project_endpoint=_endpoint, + model=os.environ["FOUNDRY_MODEL"], + credential=_credential, +) + +_sync_project = AIProjectClient(endpoint=_endpoint, credential=_credential) # type: ignore[arg-type] +_sync_openai = _sync_project.get_openai_client() # ty: ignore[unresolved-attribute] # pyrefly: ignore +_vector_store = _sync_openai.vector_stores.create( + name="devui_cu_file_search", + expires_after={"anchor": "last_active_at", "days": 1}, +) +_sync_openai.close() + +_file_search_tool = client.get_file_search_tool( + vector_store_ids=[_vector_store.id], + max_num_results=3, # limit chunks to reduce input token usage +) + +# --- CU context provider with file_search --- +# client.client is the async OpenAI client used for runtime file uploads. +# No analyzer_id → auto-selects per media type (documents, audio, video) +cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=_cu_credential, + file_search=FileSearchConfig.from_foundry( + client.client, # reuse the LLM client's internal AsyncAzureOpenAI for file uploads + vector_store_id=_vector_store.id, + file_search_tool=_file_search_tool, + ), +) + +agent = Agent( + client=client, + name="FileSearchDocAgent", + instructions=( + "You are a helpful document analysis assistant with RAG capabilities. " + "When a user uploads files, they are automatically analyzed using Azure Content Understanding " + "and indexed in a vector store for efficient retrieval. " + "Analysis takes time (seconds for documents, longer for audio/video) — if a document " + "is still pending, let the user know and suggest they ask again shortly. " + "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. " + "Multiple files can be uploaded and queried in the same conversation. " + "When answering, cite specific content from the documents." + ), + context_providers=[cu], +) diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/README 2.md b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/README 2.md new file mode 100644 index 00000000000..6f07c701b07 --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/README 2.md @@ -0,0 +1,35 @@ +# DevUI Foundry File Search Agent + +Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + Foundry file_search RAG. + +This is the **Foundry** variant. For the Azure OpenAI Responses API variant, see +`agent_content_understanding_file_search_azure_openai`. + +## How It Works + +1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat +2. **CU analyzes** the file — auto-selects the right analyzer per media type +3. **Markdown extracted** by CU is uploaded to a Foundry vector store +4. **file_search** tool is registered — LLM retrieves top-k relevant chunks +5. **Ask questions** across all uploaded documents with token-efficient RAG + +## Setup + +1. Set environment variables (or create a `.env` file in `python/`): + ```bash + FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/ + FOUNDRY_MODEL=gpt-4.1 + AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/ + ``` + +2. Log in with Azure CLI: + ```bash + az login + ``` + +3. Run with DevUI: + ```bash + devui samples/02-agents/devui/agent_content_understanding_file_search_foundry + ``` + +4. Open the DevUI URL in your browser and start uploading files. diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/__init__ 2.py b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/__init__ 2.py new file mode 100644 index 00000000000..2a50eae8941 --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/__init__ 2.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/agent 2.py b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/agent 2.py new file mode 100644 index 00000000000..3af2ebfe223 --- /dev/null +++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/agent 2.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent — CU extraction + file_search RAG via Microsoft Foundry. + +This agent combines Azure Content Understanding with Foundry's file_search +for token-efficient RAG over large or multi-modal documents. + +Upload flow: + 1. CU extracts high-quality markdown (handles scanned PDFs, audio, video) + 2. Extracted markdown is uploaded to a Foundry vector store + 3. file_search tool is registered so the LLM retrieves top-k chunks + 4. Uploaded files are cleaned up on server shutdown + +This sample uses ``FoundryChatClient`` and ``FoundryFileSearchBackend``. +For the OpenAI Responses API variant, see +``agent_content_understanding_file_search_azure_openai``. + +Analyzer auto-detection: + When no analyzer_id is specified, the provider auto-selects the + appropriate CU analyzer based on media type: + - Documents/images → prebuilt-documentSearch + - Audio → prebuilt-audioSearch + - Video → prebuilt-videoSearch + +Required environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL + +Run with DevUI: + devui samples/02-agents/devui/agent_content_understanding_file_search_foundry +""" + +import os + +from agent_framework import Agent +from agent_framework.foundry import ( + ContentUnderstandingContextProvider, + FileSearchConfig, + FoundryChatClient, +) +from azure.core.credentials import AzureKeyCredential +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential +from dotenv import load_dotenv +from openai import AzureOpenAI + +load_dotenv() + +# --- Auth --- +# AzureCliCredential for Foundry. CU API key optional if on a different resource. +_credential = AzureCliCredential() +_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY") +_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else AsyncAzureCliCredential() + +# --- Foundry LLM client --- +client = FoundryChatClient( + project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT", ""), + model=os.environ.get("FOUNDRY_MODEL", ""), + credential=_credential, +) + +# --- Create vector store (sync client to avoid event loop conflicts in DevUI) --- +_token = _credential.get_token("https://ai.azure.com/.default").token +_sync_openai = AzureOpenAI( + azure_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT", ""), + azure_ad_token=_token, + api_version="2025-04-01-preview", +) +_vector_store = _sync_openai.vector_stores.create( + name="devui_cu_foundry_file_search", + expires_after={"anchor": "last_active_at", "days": 1}, +) +_sync_openai.close() + +_file_search_tool = client.get_file_search_tool( + vector_store_ids=[_vector_store.id], + max_num_results=3, # limit chunks to reduce input token usage +) + +# --- CU context provider with file_search --- +# No analyzer_id → auto-selects per media type (documents, audio, video) +cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=_cu_credential, + # max_wait is the combined budget for CU analysis + vector store upload. + # For file_search mode, 10s gives enough time for small documents to be + # analyzed and indexed in one turn. Larger files (audio, video) will + # be deferred to background and resolved on the next turn. + max_wait=10.0, + file_search=FileSearchConfig.from_foundry( + client.client, + vector_store_id=_vector_store.id, + file_search_tool=_file_search_tool, + ), +) + +agent = Agent( + client=client, + name="FoundryFileSearchDocAgent", + instructions=( + "You are a helpful document analysis assistant with RAG capabilities. " + "When a user uploads files, they are automatically analyzed using Azure Content Understanding " + "and indexed in a vector store for efficient retrieval. " + "Analysis takes time (seconds for documents, longer for audio/video) — if a document " + "is still pending, let the user know and suggest they ask again shortly. " + "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. " + "Multiple files can be uploaded and queried in the same conversation. " + "When answering, cite specific content from the documents." + ), + context_providers=[cu], +) diff --git a/python/samples/02-agents/providers/gemini/README 2.md b/python/samples/02-agents/providers/gemini/README 2.md new file mode 100644 index 00000000000..c1687368b8f --- /dev/null +++ b/python/samples/02-agents/providers/gemini/README 2.md @@ -0,0 +1,20 @@ +# Google Gemini Examples + +This folder contains examples demonstrating how to use Google Gemini models with the Agent Framework. + +## Examples + +| File | Description | +|------|-------------| +| [`gemini_basic.py`](gemini_basic.py) | Basic agent with a weather tool, demonstrating both streaming and non-streaming responses. | +| [`gemini_advanced.py`](gemini_advanced.py) | Extended thinking via `ThinkingConfig` for reasoning-heavy questions (Gemini 2.5+). | +| [`gemini_with_google_search.py`](gemini_with_google_search.py) | Google Search grounding for up-to-date answers. | +| [`gemini_with_google_maps.py`](gemini_with_google_maps.py) | Google Maps grounding for location and mapping information. | +| [`gemini_with_code_execution.py`](gemini_with_code_execution.py) | Built-in code execution tool for computing precise answers in a sandboxed environment. | + +## Environment Variables + +- `GOOGLE_MODEL` or `GEMINI_MODEL`: The Gemini model to use (for example, + `gemini-2.5-flash-lite` or `gemini-2.5-pro`) +- For Gemini Developer API: `GEMINI_API_KEY` or `GOOGLE_API_KEY` +- For Vertex AI: `GOOGLE_GENAI_USE_VERTEXAI=true`, `GOOGLE_CLOUD_PROJECT`, and `GOOGLE_CLOUD_LOCATION` diff --git a/python/samples/02-agents/providers/gemini/gemini_advanced 2.py b/python/samples/02-agents/providers/gemini/gemini_advanced 2.py new file mode 100644 index 00000000000..1d4e3467c23 --- /dev/null +++ b/python/samples/02-agents/providers/gemini/gemini_advanced 2.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = ["agent-framework-gemini"] +# /// + +"""Shows how to enable extended thinking with ThinkingConfig. + +Allows the model to reason through complex problems before responding. + +Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials +(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings +(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``). +""" + +import asyncio + +from agent_framework import Agent +from agent_framework.gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + """Example of extended thinking with a Python version comparison question.""" + print("=== Extended thinking ===") + + # 1. Configure Gemini extended thinking for a reasoning-heavy request. + options: GeminiChatOptions = { + "thinking_config": ThinkingConfig(thinking_budget=2048), + } + + # 2. Create the agent with the Gemini chat client and default thinking options. + agent = Agent( + client=GeminiChatClient(), + name="PythonAgent", + instructions="You are a helpful Python expert.", + default_options=options, + ) + + # 3. Stream the answer so you can see the final response as it arrives. + query = "What new language features were introduced in Python between 3.10 and 3.14?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Extended thinking === +User: What new language features were introduced in Python between 3.10 and 3.14? +Agent: Python 3.11 introduced exception groups and TaskGroup. +Python 3.12 added PEP 695 type parameter syntax. +Python 3.13-3.14 continued improving typing, performance, and developer ergonomics. +""" diff --git a/python/samples/02-agents/providers/gemini/gemini_basic 2.py b/python/samples/02-agents/providers/gemini/gemini_basic 2.py new file mode 100644 index 00000000000..15e4cf32908 --- /dev/null +++ b/python/samples/02-agents/providers/gemini/gemini_basic 2.py @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = ["agent-framework-gemini"] +# /// + +"""Shows how to use GeminiChatClient with an agent and a custom tool. + +Covers both non-streaming and streaming responses. + +Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials +(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings +(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``). +""" + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.gemini import GeminiChatClient +from dotenv import load_dotenv + +load_dotenv() + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, "The location to get the weather for."], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Runs the agent and waits for the complete response before printing it.""" + print("=== Non-streaming ===") + + # 1. Create the agent with the Gemini chat client and local weather tool. + agent = Agent( + client=GeminiChatClient(), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=[get_weather], + ) + + # 2. Ask the agent for a single weather lookup and print the final response. + query = "What's the weather like in Karlsruhe, Germany?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Runs the agent and prints each chunk as it is received.""" + print("=== Streaming ===") + + # 1. Create the same agent configuration for a streaming tool-call example. + agent = Agent( + client=GeminiChatClient(), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=[get_weather], + ) + + # 2. Ask a multi-location question and stream the model output as it arrives. + query = "What's the weather like in Portland and in Paris?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + """Run non-streaming and streaming examples.""" + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Non-streaming === +User: What's the weather like in Karlsruhe, Germany? +Result: The weather in Karlsruhe, Germany is currently sunny with a high of 16°C. + +=== Streaming === +User: What's the weather like in Portland and in Paris? +Agent: In Portland, it is currently rainy with a high of 11°C. In Paris, it is cloudy with a high of 27°C. +""" diff --git a/python/samples/02-agents/providers/gemini/gemini_with_code_execution 2.py b/python/samples/02-agents/providers/gemini/gemini_with_code_execution 2.py new file mode 100644 index 00000000000..adf7e0bcb25 --- /dev/null +++ b/python/samples/02-agents/providers/gemini/gemini_with_code_execution 2.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = ["agent-framework-gemini"] +# /// + +"""Shows how to enable Gemini's built-in code execution tool. + +Allows the model to write and run code in a sandboxed environment to answer questions. + +Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials +(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings +(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``). +""" + +import asyncio + +from agent_framework import Agent +from agent_framework.gemini import GeminiChatClient +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + """Run the code execution example.""" + print("=== Code execution ===") + + # 1. Create the agent with Gemini and the built-in code execution tool. + agent = Agent( + client=GeminiChatClient(), + name="CodeAgent", + instructions="You are a helpful assistant. Use code execution to compute precise answers.", + tools=[GeminiChatClient.get_code_interpreter_tool()], + ) + + # 2. Ask for a computed answer and stream the generated code and final result. + query = "What are the first 20 prime numbers? Compute them in code." + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Code execution === +User: What are the first 20 prime numbers? Compute them in code. +Agent: The first 20 prime numbers are 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, and 71. +""" diff --git a/python/samples/02-agents/providers/gemini/gemini_with_google_maps 2.py b/python/samples/02-agents/providers/gemini/gemini_with_google_maps 2.py new file mode 100644 index 00000000000..ebabf12fba3 --- /dev/null +++ b/python/samples/02-agents/providers/gemini/gemini_with_google_maps 2.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = ["agent-framework-gemini"] +# /// + +"""Shows how to enable Google Maps grounding. + +Allows Gemini to retrieve location and mapping information before responding. + +Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials +(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings +(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``). +""" + +import asyncio + +from agent_framework import Agent +from agent_framework.gemini import GeminiChatClient +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + """Run the Google Maps grounding example.""" + print("=== Google Maps grounding ===") + + # 1. Create the agent with Gemini and the built-in Google Maps grounding tool. + agent = Agent( + client=GeminiChatClient(), + name="MapsAgent", + instructions="You are a helpful travel assistant. Use Google Maps to provide accurate location information.", + tools=[GeminiChatClient.get_maps_grounding_tool()], + ) + + # 2. Ask a location-aware question and stream the grounded answer. + query = "What are some highly rated restaurants in the city center of Karlsruhe, Germany?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Google Maps grounding === +User: What are some highly rated restaurants in the city center of Karlsruhe, Germany? +Agent: Here are several highly rated restaurants near Karlsruhe city center, +along with their cuisine styles and approximate walking distance. +""" diff --git a/python/samples/02-agents/providers/gemini/gemini_with_google_search 2.py b/python/samples/02-agents/providers/gemini/gemini_with_google_search 2.py new file mode 100644 index 00000000000..160a74ece21 --- /dev/null +++ b/python/samples/02-agents/providers/gemini/gemini_with_google_search 2.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = ["agent-framework-gemini"] +# /// + +"""Shows how to enable Google Search grounding. + +Allows Gemini to retrieve up-to-date information from the web before responding. + +Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials +(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings +(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``). +""" + +import asyncio + +from agent_framework import Agent +from agent_framework.gemini import GeminiChatClient +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + """Run the Google Search grounding example.""" + print("=== Google Search grounding ===") + + # 1. Create the agent with Gemini and the built-in Google Search grounding tool. + agent = Agent( + client=GeminiChatClient(), + name="SearchAgent", + instructions="You are a helpful assistant. Use Google Search to provide accurate, up-to-date answers.", + tools=[GeminiChatClient.get_web_search_tool()], + ) + + # 2. Ask a current-events style question and stream the grounded answer. + query = "What is the latest stable release of the .NET SDK?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Google Search grounding === +User: What is the latest stable release of the .NET SDK? +Agent: As of April 14, 2026, the latest stable release of the .NET SDK is .NET 10.0 (SDK 10.0.201). +""" diff --git a/python/samples/02-agents/providers/mistral/README 2.md b/python/samples/02-agents/providers/mistral/README 2.md new file mode 100644 index 00000000000..b58ecf6ef26 --- /dev/null +++ b/python/samples/02-agents/providers/mistral/README 2.md @@ -0,0 +1,15 @@ +# Mistral AI Embedding Examples + +This folder contains examples demonstrating how to use Mistral AI embedding models with the Agent Framework. + +## Examples + +| File | Description | +|------|-------------| +| [`mistral_embeddings.py`](mistral_embeddings.py) | Basic embedding generation with the Mistral AI embedding client. | + +## Environment Variables + +- `MISTRAL_API_KEY`: Your Mistral AI API key +- `MISTRAL_EMBEDDING_MODEL`: Embedding model name (e.g., `mistral-embed`) +- `MISTRAL_SERVER_URL` (optional): Server URL override for custom deployments diff --git a/python/samples/02-agents/providers/mistral/mistral_embeddings 2.py b/python/samples/02-agents/providers/mistral/mistral_embeddings 2.py new file mode 100644 index 00000000000..20419ded62d --- /dev/null +++ b/python/samples/02-agents/providers/mistral/mistral_embeddings 2.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = ["agent-framework-mistral"] +# /// + +"""Shows how to generate embeddings using the Mistral AI embedding client. + +Requires ``MISTRAL_API_KEY`` and ``MISTRAL_EMBEDDING_MODEL`` environment variables. +""" + +import asyncio + +from agent_framework.mistral import MistralEmbeddingClient +from dotenv import load_dotenv + +load_dotenv() + + +async def basic_embedding_example() -> None: + """Generate embeddings for a list of texts.""" + print("=== Basic Embedding Generation ===") + + # 1. Create the embedding client (uses MISTRAL_API_KEY and MISTRAL_EMBEDDING_MODEL env vars). + client = MistralEmbeddingClient() + + # 2. Generate embeddings for multiple texts. + texts = ["Hello, world!", "How are you?", "Agent Framework with Mistral AI"] + result = await client.get_embeddings(texts) + + # 3. Print results. + print(f"Generated {len(result)} embeddings") + for i, embedding in enumerate(result): + print(f" Text {i + 1}: dimensions={embedding.dimensions}, vector={embedding.vector[:5]}...") + + if result.usage: + print( + f" Usage: {result.usage['input_token_count']} input tokens, " + f"{result.usage['total_token_count']} total tokens" + ) + + +async def embedding_with_options_example() -> None: + """Generate embeddings with custom dimensions.""" + print("\n=== Embedding with Custom Dimensions ===") + + from agent_framework.mistral import MistralEmbeddingOptions + + client = MistralEmbeddingClient() + + # Request a specific output dimension (model must support it). + options: MistralEmbeddingOptions = {"dimensions": 256} + result = await client.get_embeddings(["Dimensionality reduction example"], options=options) + + print(f" Dimensions: {result[0].dimensions}") + print(f" Vector (first 5): {result[0].vector[:5]}...") + + +async def main() -> None: + """Run embedding examples.""" + await basic_embedding_example() + await embedding_with_options_example() + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Basic Embedding Generation === +Generated 3 embeddings + Text 1: dimensions=1024, vector=[0.0123, -0.0456, 0.0789, -0.0012, 0.0345]... + Text 2: dimensions=1024, vector=[0.0234, -0.0567, 0.0891, -0.0023, 0.0456]... + Text 3: dimensions=1024, vector=[0.0345, -0.0678, 0.0912, -0.0034, 0.0567]... + Usage: 15 input tokens, 15 total tokens + +=== Embedding with Custom Dimensions === + Dimensions: 256 + Vector (first 5): [0.0456, -0.0789, 0.0123, -0.0456, 0.0789]... +""" diff --git a/python/samples/04-hosting/mcp/README 2.md b/python/samples/04-hosting/mcp/README 2.md new file mode 100644 index 00000000000..ee9bbad97df --- /dev/null +++ b/python/samples/04-hosting/mcp/README 2.md @@ -0,0 +1,148 @@ +# MCP hosting with native SDK constructs + +An MCP server that exposes tools must handle `tools/list` and `tools/call`. +There are two common ways to build that: + +1. Implement the list and call handlers directly. The application defines each + native tool schema, validates or parses its arguments, routes the selected + tool name, and returns its result. +2. Declare callable tools and let a higher-level server generate the list and + call handlers from those declarations. FastMCP follows this model by deriving + tool schemas and argument parsing from Python function signatures. + +Choose between them based on the other MCP features you want to expose and how +much control you need over the server, schema, validation, lifecycle, and +transport. Hosting one Agent Framework agent or workflow usually exposes only +one MCP tool, so either model remains small. A directly implemented +`call_tool` handler needs little routing logic when it has only one supported +tool name. + +Both approaches use the same two protocol-boundary functions from +`agent-framework-hosting-mcp`: + +- `mcp_to_run(...)` converts validated MCP tool arguments into Agent Framework + messages and selected chat options. +- `mcp_from_run(...)` converts a completed Agent Framework response into native + MCP result content blocks. + +These functions are the smallest Agent Framework integration boundary. The +later samples add optional helpers that derive tool schemas, execute agents or +workflows, and manage Agent Framework conversation state. + +## Samples + +### 1. Manual low-level server + +[`manual_app.py`](manual_app.py) shows the complete boundary directly. It +defines the native MCP `Tool`, registers low-level `list_tools` and `call_tool` +handlers, calls `mcp_to_run(...)`, runs the agent, and calls +`mcp_from_run(...)`. + +Use this when the application needs full control over a custom MCP contract. + +```bash +uv run manual_app.py +``` + +### 2. FastMCP server + +[`fastmcp_app.py`](fastmcp_app.py) keeps the same two conversion functions but +replaces the low-level server setup with FastMCP. FastMCP derives and validates +the tool schema from the decorated `run_agent(...)` function and owns the +streamable HTTP server. + +Use this when a normal Python function signature fully describes the MCP tool. +FastMCP keeps its generated schema and argument parsing aligned. + +```bash +uv run fastmcp_app.py +``` + +### 3. Agent-derived tool + +[`agent_app.py`](agent_app.py) adds `AgentMCPTool` to the low-level server. The adapter +derives the native tool name and description from the agent, owns the configured +argument schema, runs the agent, and applies the same conversion boundary. + +Use this when one Agent Framework agent should be represented as one generated +MCP tool. Unlike the FastMCP sample, this adapter derives the contract from the +agent and its adapter configuration rather than a decorated function signature. + +```bash +uv run agent_app.py +``` + +### 4. Session-aware agent + +[`session_app.py`](session_app.py) builds on `AgentMCPTool` with `AgentState`. +It loads and stores an `AgentSession` using an opaque, application-defined +`session_id` and serializes calls per ID in-process. + +Reusing the same ID continues and updates one conversation. This is not +`previous_response_id`-style branching. An application that needs forks should +define separate source and destination IDs, copy the source session, and store +the completed turn under the destination ID. + +```bash +uv run session_app.py +``` + +### 5. Workflow-derived tool + +[`workflow_app.py`](workflow_app.py) uses `WorkflowMCPTool` to derive the tool +schema from the workflow start executor's single input type. Dataclass or +Pydantic fields become top-level MCP arguments; primitive inputs are wrapped in +one configurable argument. + +The sample uses a `WorkflowState` factory with `cache_target=False` so each MCP +call receives a fresh workflow instance. Checkpoint and human-in-the-loop +continuation remain application-owned contracts. + +```bash +uv run workflow_app.py +``` + +## Concept summary + +| Concept | Responsibility | Used by | +|---|---|---| +| `mcp_to_run(...)` | Converts validated MCP arguments into Agent Framework messages and selected chat options. | `manual_app.py`, `fastmcp_app.py` | +| `mcp_from_run(...)` | Converts a completed agent response into MCP result content blocks. | `manual_app.py`, `fastmcp_app.py` | +| `AgentMCPTool` | Derives one native MCP tool from an agent and keeps schema, execution, and conversion aligned. | `agent_app.py`, `session_app.py` | +| `WorkflowMCPTool` | Derives one native MCP tool from a workflow start executor and converts workflow outputs. | `workflow_app.py` | + +## Run + +The agent samples require Microsoft Foundry configuration: + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5-nano +az login +``` + +Run one sample, connect an MCP client to `http://127.0.0.1:8000/mcp`, and invoke +its tool. The agent tools accept a `task` string and optionally +`reasoning_effort` with `low`, `medium`, or `high`. + +Each entry point declares its complete Agent Framework and third-party +dependency set using PEP 723 inline script metadata. + +## Common behavior + +- **No framework choice:** the package does not select FastMCP, Starlette, + Uvicorn, stdio, or streamable HTTP. +- **Chat options:** only explicitly selected MCP arguments are passed to the + model client. The samples expose `reasoning_effort` as an example, but any option + valid for the agent can be exposed. +- **Input shape:** MCP tool arguments are JSON objects and do not define native + multimodal input content blocks. The samples do not present an + application-specific image schema as protocol behavior. +- **Streaming:** streamable HTTP can carry multiple MCP messages, but a tool + call still produces one final `CallToolResult`. Progress notifications and + experimental deferred tasks remain application-owned protocol features. +- **Authentication:** the local endpoints are intentionally unauthenticated. + MCP transport session identifiers are not user authorization. Production + servers must authenticate and authorize before loading tenant or user state. +- **Errors:** conversion, model, workflow, and protocol errors propagate to the + MCP SDK rather than becoming success-shaped tool output. diff --git a/python/samples/04-hosting/mcp/agent_app 2.py b/python/samples/04-hosting/mcp/agent_app 2.py new file mode 100644 index 00000000000..c53bf9ec44c --- /dev/null +++ b/python/samples/04-hosting/mcp/agent_app 2.py @@ -0,0 +1,107 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-hosting-mcp", +# "azure-identity", +# "mcp>=1.27.0,<2", +# "starlette>=0.40", +# "uvicorn>=0.30", +# ] +# /// +# Run with: uv run agent_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host an Agent Framework agent with the native MCP streamable HTTP server. + +The hosting helper package only converts values at the protocol boundary. This +application owns the MCP tool schema, server, transport, authentication policy, +and Agent Framework session policy. + +This compact local sample is intentionally unauthenticated and stateless: +every tool call starts a fresh Agent Framework conversation. Add authentication +in the outer ASGI server before deriving a trusted user or tenant session key. + +Required environment variables: ``FOUNDRY_PROJECT_ENDPOINT`` and +``FOUNDRY_MODEL``. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_hosting_mcp import AgentMCPTool +from azure.identity.aio import DefaultAzureCredential +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +server = Server("agent-framework-hosting-mcp-sample") +credential = DefaultAzureCredential() +agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + name="MCPHostedAgent", + description="Answer a request with the hosted Agent Framework agent.", + instructions="Answer the user's request clearly and concisely.", +) +agent_tool = AgentMCPTool( + agent, + name="run_agent", + argument_description="The request for the hosted agent.", + chat_option_parameters={ + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Optional reasoning effort for models that support it.", + } + }, +) + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """Describe the app-owned MCP tool schema.""" + return await agent_tool.list_tools() + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]: + """Run the app-owned tool with native MCP and Agent Framework values.""" + return await agent_tool.call_tool(name, arguments) + + +session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=True, + stateless=True, +) + + +@asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Start and stop native MCP and model-client resources.""" + async with session_manager.run(), credential: + yield + + +app = Starlette( + routes=[Mount("/", app=session_manager.handle_request)], + lifespan=lifespan, +) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/python/samples/04-hosting/mcp/fastmcp_app 2.py b/python/samples/04-hosting/mcp/fastmcp_app 2.py new file mode 100644 index 00000000000..fb0901804e3 --- /dev/null +++ b/python/samples/04-hosting/mcp/fastmcp_app 2.py @@ -0,0 +1,97 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-hosting-mcp", +# "azure-identity", +# "mcp>=1.27.0,<2", +# ] +# /// +# Run with: uv run fastmcp_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host an Agent Framework agent with FastMCP and the conversion helpers. + +FastMCP derives the native MCP tool schema from the decorated function +signature. The Agent Framework hosting package only converts the validated +arguments and completed agent response at the protocol boundary. + +This compact local sample is intentionally unauthenticated and stateless: +every tool call starts a fresh Agent Framework conversation. + +Required environment variables: ``FOUNDRY_PROJECT_ENDPOINT`` and +``FOUNDRY_MODEL``. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Literal + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_hosting_mcp import mcp_from_run, mcp_to_run +from azure.identity.aio import DefaultAzureCredential +from mcp import types +from mcp.server.fastmcp import FastMCP + +credential = DefaultAzureCredential() +agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + name="MCPHostedAgent", + description="Answer a request with the hosted Agent Framework agent.", + instructions="Answer the user's request clearly and concisely.", +) + + +@asynccontextmanager +async def lifespan(_server: FastMCP[None]) -> AsyncIterator[None]: + """Close the model credential when the FastMCP server stops.""" + async with credential: + yield + + +server = FastMCP( + name="agent-framework-hosting-fastmcp-sample", + instructions="Expose an Agent Framework agent as an MCP tool.", + host="127.0.0.1", + port=8000, + streamable_http_path="/mcp", + json_response=True, + stateless_http=True, + lifespan=lifespan, +) + + +@server.tool( + name="run_agent", + description="Run the hosted Agent Framework agent.", + structured_output=False, +) +async def run_agent( + task: str, + reasoning_effort: Literal["low", "medium", "high"] | None = None, +) -> list[types.ContentBlock]: + """Run the agent with FastMCP-validated arguments.""" + arguments: dict[str, object] = {"task": task} + if reasoning_effort is not None: + arguments["reasoning_effort"] = reasoning_effort + + run = mcp_to_run(arguments, chat_option_arguments={"reasoning_effort"}) + result = await agent.run( + run["messages"], + options=run["options"], + stream=False, + ) + return mcp_from_run(result) + + +if __name__ == "__main__": + server.run(transport="streamable-http") diff --git a/python/samples/04-hosting/mcp/manual_app 2.py b/python/samples/04-hosting/mcp/manual_app 2.py new file mode 100644 index 00000000000..1faf24bc5a6 --- /dev/null +++ b/python/samples/04-hosting/mcp/manual_app 2.py @@ -0,0 +1,122 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-hosting-mcp", +# "azure-identity", +# "mcp>=1.27.0,<2", +# "starlette>=0.40", +# "uvicorn>=0.30", +# ] +# /// +# Run with: uv run manual_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host an Agent Framework agent using the conversion functions directly. + +This version is useful when an application's MCP tool contract does not fit the +single-agent ``AgentMCPTool`` adapter. The native tool schema and handler stay +fully visible while ``mcp_to_run`` and ``mcp_from_run`` bridge AF values. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_hosting_mcp import mcp_from_run, mcp_to_run +from azure.identity.aio import DefaultAzureCredential +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +TASK_ARGUMENT = "task" +CHAT_OPTION_ARGUMENTS = { + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Optional reasoning effort for models that support it.", + } +} + +server = Server("agent-framework-hosting-mcp-manual-sample") +credential = DefaultAzureCredential() +agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + name="ManualMCPAgent", + description="Answer requests through a manually defined MCP tool.", + instructions="Answer the user's request clearly and concisely.", +) + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """Return the app-owned native MCP tool definition.""" + return [ + types.Tool( + name="run_agent_manually", + description=agent.description or "", + inputSchema={ + "type": "object", + "properties": { + TASK_ARGUMENT: { + "type": "string", + "description": "The request for the hosted agent.", + }, + **CHAT_OPTION_ARGUMENTS, + }, + "required": [TASK_ARGUMENT], + "additionalProperties": False, + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]: + """Convert, run, and render without the agent-backed adapter.""" + if name != "run_agent_manually": + raise ValueError(f"Unknown MCP tool: {name}") + run = mcp_to_run( + arguments, + argument_name=TASK_ARGUMENT, + chat_option_arguments=CHAT_OPTION_ARGUMENTS, + ) + result = await agent.run(run["messages"], options=run["options"]) + return mcp_from_run(result) + + +session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=True, + stateless=True, +) + + +@asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Start and stop native MCP and model-client resources.""" + async with session_manager.run(), credential: + yield + + +app = Starlette( + routes=[Mount("/", app=session_manager.handle_request)], + lifespan=lifespan, +) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/python/samples/04-hosting/mcp/pyproject 2.toml b/python/samples/04-hosting/mcp/pyproject 2.toml new file mode 100644 index 00000000000..2d1bb331dcb --- /dev/null +++ b/python/samples/04-hosting/mcp/pyproject 2.toml @@ -0,0 +1,22 @@ +[project] +name = "agent-framework-hosting-sample-mcp" +version = "0.0.1" +description = "Streamable HTTP MCP server using Agent Framework hosting helpers." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-core", + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-mcp", + "azure-identity", + "mcp>=1.27.0,<2", + "starlette>=0.40", + "uvicorn>=0.30", +] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" } +agent-framework-hosting-mcp = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-mcp" } diff --git a/python/samples/04-hosting/mcp/session_app 2.py b/python/samples/04-hosting/mcp/session_app 2.py new file mode 100644 index 00000000000..c185c0ef130 --- /dev/null +++ b/python/samples/04-hosting/mcp/session_app 2.py @@ -0,0 +1,132 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-hosting-mcp", +# "azure-identity", +# "mcp>=1.27.0,<2", +# "starlette>=0.40", +# "uvicorn>=0.30", +# ] +# /// +# Run with: uv run session_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host a session-aware Agent Framework agent through native MCP constructs. + +The MCP tool accepts an opaque, app-defined ``session_id`` string. Its format is +not prescribed by MCP or Agent Framework; an application might use a UUID, a +database key, or a key derived from authenticated tenant and conversation IDs. +Reusing an ID continues and updates that one conversation. + +This is deliberately different from ``previous_response_id``-style branching, +where an earlier point can be used to create a new conversation branch. An app +that needs branching should accept separate source and destination IDs, load a +copy from the source, and store the updated session under the destination. + +This local sample uses the caller-provided ID directly for clarity. Production +servers must derive or authorize the session key from authenticated outer-server +context rather than trusting an arbitrary caller-provided identifier. +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from agent_framework import Agent, InMemoryHistoryProvider +from agent_framework.foundry import FoundryChatClient +from agent_framework_hosting import AgentState +from agent_framework_hosting_mcp import AgentMCPTool +from azure.identity.aio import DefaultAzureCredential +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +server = Server("agent-framework-hosting-mcp-session-sample") +credential = DefaultAzureCredential() +agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + name="SessionAwareAgent", + description="Answer requests while preserving app-owned conversation state.", + instructions="Answer clearly and use prior conversation context when relevant.", + context_providers=[InMemoryHistoryProvider()], + default_options={"store": False}, +) +state = AgentState(agent) +agent_tool = AgentMCPTool( + state, + name="run_agent", + argument_description="The request for the hosted agent.", + parameters={ + "session_id": { + "type": "string", + "minLength": 1, + "description": ( + "Opaque, app-defined conversation key. Reuse the same value to continue and update one conversation." + ), + } + }, + required_parameters={"session_id"}, + chat_option_parameters={ + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Optional reasoning effort for models that support it.", + } + }, + session_id_parameter="session_id", +) +session_locks: dict[str, asyncio.Lock] = {} + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """Return the agent-derived MCP tool definition.""" + return await agent_tool.list_tools() + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]: + """Serialize calls per app-owned session before using ``AgentState``.""" + session_id = arguments.get("session_id") if arguments else None + if not isinstance(session_id, str) or not session_id: + raise ValueError("MCP tool argument 'session_id' must be a non-empty string.") + lock = session_locks.setdefault(session_id, asyncio.Lock()) + async with lock: + return await agent_tool.call_tool(name, arguments) + + +session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=True, + stateless=True, +) + + +@asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Start and stop native MCP and model-client resources.""" + async with session_manager.run(), credential: + yield + + +app = Starlette( + routes=[Mount("/", app=session_manager.handle_request)], + lifespan=lifespan, +) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/python/samples/04-hosting/mcp/workflow_app 2.py b/python/samples/04-hosting/mcp/workflow_app 2.py new file mode 100644 index 00000000000..8bc715a53ed --- /dev/null +++ b/python/samples/04-hosting/mcp/workflow_app 2.py @@ -0,0 +1,107 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-hosting-mcp", +# "mcp>=1.27.0,<2", +# "starlette>=0.40", +# "uvicorn>=0.30", +# ] +# /// +# Run with: uv run workflow_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host a typed Agent Framework workflow through native MCP constructs. + +``WorkflowMCPTool`` derives the MCP arguments from the start executor's input +type. This sample uses a dataclass, so its fields become the tool's top-level +arguments. The application still owns the MCP server and transport. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +import uvicorn +from agent_framework import WorkflowBuilder, WorkflowContext, executor +from agent_framework_hosting import WorkflowState +from agent_framework_hosting_mcp import WorkflowMCPTool +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + + +@dataclass +class DraftRequest: + """Input contract exposed by the MCP tool.""" + + topic: str + audience: str + paragraph_count: int = 2 + + +def create_workflow(): + """Create an independent workflow instance for one MCP call.""" + + @executor(id="draft") + async def draft(request: DraftRequest, ctx: WorkflowContext[object, str]) -> None: + paragraphs = "\n\n".join( + f"Paragraph {index + 1}: {request.topic} for {request.audience}." + for index in range(request.paragraph_count) + ) + await ctx.yield_output(paragraphs) + + return WorkflowBuilder( + start_executor=draft, + name="Draft Workflow", + description="Draft short content for a specified topic and audience.", + output_from=[draft], + ).build() + + +server = Server("agent-framework-hosting-mcp-workflow-sample") +workflow_tool = WorkflowMCPTool( + WorkflowState(create_workflow, cache_target=False), + name="draft_content", +) + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """Return the workflow-derived MCP tool definition.""" + return await workflow_tool.list_tools() + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]: + """Run a fresh workflow instance with validated MCP arguments.""" + return await workflow_tool.call_tool(name, arguments) + + +session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=True, + stateless=True, +) + + +@asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Start and stop the native MCP transport.""" + async with session_manager.run(): + yield + + +app = Starlette( + routes=[Mount("/", app=session_manager.handle_request)], + lifespan=lifespan, +) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000)