From 55f8a85291f342edbc4572e9fe06992ef890f69f Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 30 Sep 2026 16:24:14 -0500 Subject: [PATCH 01/11] feat: add GitHub Copilot SDK agent binding --- README.md | 1 + .../LICENSE | 21 + .../MANIFEST.in | 3 + .../README.md | 208 ++++++++++ .../azurefunctions/__init__.py | 1 + .../azurefunctions/agents/__init__.py | 1 + .../agents/extensions/__init__.py | 1 + .../agents/extensions/copilot_sdk/__init__.py | 13 + .../agents/extensions/copilot_sdk/apps.py | 114 ++++++ .../agents/extensions/copilot_sdk/provider.py | 319 +++++++++++++++ .../agents/extensions/copilot_sdk/py.typed | 0 .../pyproject.toml | 71 ++++ .../samples/README.md | 6 + .../agent_samples_copilot-sdk/README.md | 15 + .../agent_samples_copilot-sdk/function_app.py | 76 ++++ .../agent_samples_copilot-sdk/host.json | 12 + .../local.settings.template.json | 10 + .../agent_samples_copilot-sdk/mcp.json | 9 + .../order-fulfillment.agent.md | 4 + .../requirements.txt | 1 + .../skills/order-policy/SKILL.md | 7 + .../README.md | 9 + .../function_app.py | 68 ++++ .../host.json | 7 + .../local.settings.template.json | 9 + .../order-fulfillment.agent.md | 2 + .../requirements.txt | 1 + .../tests/test_apps.py | 80 ++++ .../tests/test_imports.py | 38 ++ .../tests/test_provider.py | 371 ++++++++++++++++++ .../tests/test_samples.py | 69 ++++ eng/templates/jobs/build.yml | 3 + .../official/jobs/build-artifacts.yml | 3 + eng/templates/official/jobs/unit-tests.yml | 29 ++ 34 files changed, 1582 insertions(+) create mode 100644 azurefunctions-agents-extensions-copilot-sdk/LICENSE create mode 100644 azurefunctions-agents-extensions-copilot-sdk/MANIFEST.in create mode 100644 azurefunctions-agents-extensions-copilot-sdk/README.md create mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/__init__.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/__init__.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/__init__.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/py.typed create mode 100644 azurefunctions-agents-extensions-copilot-sdk/pyproject.toml create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/README.md create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/host.json create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/mcp.json create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/order-fulfillment.agent.md create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/requirements.txt create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/skills/order-policy/SKILL.md create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/host.json create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/order-fulfillment.agent.md create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/requirements.txt create mode 100644 azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py diff --git a/README.md b/README.md index 09d7070..ea668f3 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A supported Python version is required - see * [Base extension](azurefunctions-extensions-base/README.md) * [Agent provider base](azurefunctions-agents-extensions-base/README.md) * [Microsoft Agent Framework](azurefunctions-agents-extensions-agent-framework/README.md) +* [GitHub Copilot SDK](azurefunctions-agents-extensions-copilot-sdk/README.md) * [Azure Blob Storage bindings](azurefunctions-extensions-bindings-blob/README.md) * [Azure Cosmos DB bindings](azurefunctions-extensions-bindings-cosmosdb/README.md) * [Azure Event Hubs bindings](azurefunctions-extensions-bindings-eventhub/README.md) diff --git a/azurefunctions-agents-extensions-copilot-sdk/LICENSE b/azurefunctions-agents-extensions-copilot-sdk/LICENSE new file mode 100644 index 0000000..ce29e72 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/LICENSE @@ -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. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/MANIFEST.in b/azurefunctions-agents-extensions-copilot-sdk/MANIFEST.in new file mode 100644 index 0000000..2235005 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include azurefunctions *.py *.pyi +recursive-include tests *.py +include LICENSE README.md \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/README.md new file mode 100644 index 0000000..5dc98c5 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/README.md @@ -0,0 +1,208 @@ +# Azure Functions GitHub Copilot SDK Extension + +Inject GitHub Copilot SDK sessions built from raw `.agent.md` instructions into +Python Azure Functions. + +## Install + +```text +pip install azurefunctions-agents-extensions-copilot-sdk +``` + +Install Entra authentication for remote MCP servers with the MCP extra: + +```text +pip install "azurefunctions-agents-extensions-copilot-sdk[mcp]" +``` + +Install Durable Functions support with the durable extra: + +```text +pip install "azurefunctions-agents-extensions-copilot-sdk[durable]" +``` + +The GitHub Copilot SDK uses a native runtime. Pre-provision it during image or +deployment construction so a Function invocation never downloads executable +content: + +```text +python -m copilot download-runtime +``` + +## Use a Copilot SDK Agent app + +Create a zero-argument synchronous factory that returns a fresh +`CopilotClient`. Server environments should use explicit authentication rather +than an interactive logged-in user: + +```python +import os + +import azure.functions as func +from copilot import CopilotClient +from copilot.session import CopilotSession, PermissionHandler +from copilot.session_events import AssistantMessageData +from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp + + +def create_copilot_client() -> CopilotClient: + return CopilotClient( + mode="empty", + github_token=os.environ["COPILOT_GITHUB_TOKEN"], + use_logged_in_user=False, + telemetry=None, + ) + + +app = AgentFunctionApp( + client_factory=create_copilot_client, + model=os.environ["COPILOT_MODEL"], + on_permission_request=PermissionHandler.approve_all, +) + + +@app.route(route="orders", methods=["POST"]) +@app.markdown_agent(arg_name="agent", agent_name="orders") +async def process_order( + req: func.HttpRequest, + agent: CopilotSession, +) -> str: + response = await agent.send_and_wait(req.get_body().decode()) + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") + return response.data.content +``` + +`AgentFunctionApp` subclasses `azure.functions.FunctionApp` and pins every +Agent binding in the app to this provider. Place the complete instructions at +`orders.agent.md` or `agents/orders.agent.md`. The file is raw UTF-8 text; no +front matter or runtime configuration is interpreted. + +The app-level options are defaults. A `markdown_agent()` decorator can override +`client_factory`, `model`, `on_permission_request`, `provider`, and `tools` for +one binding. `app_root` can be set only on `AgentFunctionApp`. + +`PermissionHandler.approve_all` should be used only when every exposed tool is +trusted. Supply a restrictive SDK permission handler when tools can access +privileged data or perform external actions. + +## Authentication and BYOK + +Keep GitHub tokens and model-provider credentials in Function App settings or a +secret store. Do not put them in source, `.agent.md`, or `mcp.json` files. A +factory is called once per invocation, so it can read current settings without +sharing a client across requests. + +The optional `provider` value is passed to the Copilot SDK session for bring +your own key (BYOK) scenarios. For example: + +```python +import os + +from copilot.session import ProviderConfig + +provider: ProviderConfig = { + "type": "openai", + "wire_api": "responses", + "base_url": os.environ["MODEL_BASE_URL"], + "api_key": os.environ["MODEL_API_KEY"], +} + +app = AgentFunctionApp( + client_factory=create_copilot_client, + model=os.environ["COPILOT_MODEL"], + provider=provider, +) +``` + +The Copilot SDK also supports Azure and Anthropic provider configurations. Use +the provider fields supported by the installed SDK version, and keep all +credentials outside checked-in configuration. + +## Skills and MCP servers + +Skills and remote MCP servers are discovered automatically from the app root: + +```text +skills/inventory/SKILL.md +mcp.json +``` + +The base extension discovers Skill directory paths without reading their +contents. The Copilot SDK loads and validates the Skill files for each session. + +V1 supports remote HTTP MCP transports only: + +```json +{ + "servers": { + "inventory": { + "type": "streamable-http", + "url": "$INVENTORY_MCP_URL", + "tools": ["lookup_stock"], + "headers": {"X-Tenant": "%TENANT_ID%"}, + "auth": { + "scope": "$INVENTORY_MCP_SCOPE", + "client_id": "%AZURE_CLIENT_ID%" + } + } + } +} +``` + +`$VAR` and `%VAR%` references are resolved for each invocation. Missing values +fail before session creation. Servers with headers or Entra authentication must +use HTTPS, except for explicit loopback development endpoints. Entra tokens are +acquired through `DefaultAzureCredential` and held only for the invocation. + +Every Agent in the Function App receives all valid Skills and MCP servers under +the app root. V1 has no app-level or per-binding capability selectors. Skills, +MCP servers, and Python `tools=` can perform privileged operations; use separate +Function Apps when capability sets require isolation. + +## Invocation lifecycle + +A new client and session are created for every Function invocation. The +extension replaces the session system message with the raw Agent instructions +and disables ambient Copilot configuration discovery, session persistence, +memory, telemetry, built-in Skills, and tool search. Explicit Python tools, +discovered Skills, and discovered MCP servers remain available. + +Clients, sessions, and Entra credentials are closed on success, error, and +cancellation. The factory must return `CopilotClient` synchronously; shared or +awaitable clients are rejected. + +## Durable Agents + +Use `context.call_agent(agent_name, input_)` inside a synchronous generator +orchestrator. Agent execution runs in the extension's hidden activity, keeping +orchestrator replay deterministic: + +```python +from azurefunctions.agents.extensions.copilot_sdk import DurableAgentContext + + +@app.orchestration_trigger(context_name="context") +def order_orchestrator(context: DurableAgentContext): + assessment = yield context.call_agent( + "orders", + {"order": context.get_input()}, + ) + return {"assessment": assessment} +``` + +Importing the package remains safe without Durable installed. Using Durable +decorators requires the `[durable]` extra. All Durable Agent calls use the +provider and capabilities configured by `AgentFunctionApp`; an orchestrator +cannot select a different provider or capability set in V1. + +## Current limitations + +- Python 3.13 or later is required. +- Only remote HTTP MCP servers are supported; local process transports are not. +- Agent files contain raw instructions only; front matter is not interpreted. +- Capability filtering within one Function App is not supported in V1. +- Native runtime provisioning is an application deployment responsibility. + +See `samples/agent_samples_copilot-sdk` for direct HTTP and queue triggers and +`samples/agent_samples_copilot-sdk_durable` for Durable orchestration. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py new file mode 100644 index 0000000..78e5970 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py @@ -0,0 +1,13 @@ +from azurefunctions.agents.extensions.base.durable import DurableAgentContext + +from .apps import AgentFunctionApp +from .provider import COPILOT_SDK_PROVIDER_ID, ClientFactory + +__all__ = [ + "COPILOT_SDK_PROVIDER_ID", + "AgentFunctionApp", + "ClientFactory", + "DurableAgentContext", +] + +__version__ = "1.0.0b1" diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py new file mode 100644 index 0000000..e99f706 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import os +from collections.abc import Callable, Sequence +from typing import Any, TypeVar + +import azure.functions as func +from copilot.session import ProviderConfig +from copilot.tools import Tool + +from azurefunctions.agents.extensions.base import ( + configure_app, + durable_orchestration_trigger, +) +from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent + +from .provider import COPILOT_SDK_PROVIDER_ID, ClientFactory, PermissionHandler + +_F = TypeVar("_F", bound=Callable[..., Any]) + + +def _provider_options( + *, + client_factory: ClientFactory | None = None, + model: str | None = None, + on_permission_request: PermissionHandler | None = None, + provider: ProviderConfig | None = None, + tools: Tool | Sequence[Tool] | None = None, +) -> dict[str, object]: + options: dict[str, object] = {} + if client_factory is not None: + options["client_factory"] = client_factory + if model is not None: + options["model"] = model + if on_permission_request is not None: + options["on_permission_request"] = on_permission_request + if provider is not None: + options["provider"] = provider + if tools is not None: + options["tools"] = tools + return options + + +class _CopilotSdkAppMixin: + def markdown_agent( + self, + *, + arg_name: str, + agent_name: str, + client_factory: ClientFactory | None = None, + model: str | None = None, + on_permission_request: PermissionHandler | None = None, + provider: ProviderConfig | None = None, + tools: Tool | Sequence[Tool] | None = None, + ) -> Callable[[_F], _F]: + return base_markdown_agent( + self, + provider=COPILOT_SDK_PROVIDER_ID, + arg_name=arg_name, + agent_name=agent_name, + **_provider_options( + client_factory=client_factory, + model=model, + on_permission_request=on_permission_request, + provider=provider, + tools=tools, + ), + ) + + +class AgentFunctionApp( + _CopilotSdkAppMixin, + func.FunctionApp, +): + """Azure Functions app configured for GitHub Copilot SDK sessions.""" + + def __init__( + self, + *, + client_factory: ClientFactory, + model: str, + app_root: str | os.PathLike[str] | None = None, + on_permission_request: PermissionHandler | None = None, + provider: ProviderConfig | None = None, + tools: Tool | Sequence[Tool] | None = None, + http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, + ) -> None: + super().__init__(http_auth_level=http_auth_level) + configure_app( + self, + provider=COPILOT_SDK_PROVIDER_ID, + app_root=app_root, + provider_options=_provider_options( + client_factory=client_factory, + model=model, + on_permission_request=on_permission_request, + provider=provider, + tools=tools, + ), + ) + + def orchestration_trigger( + self, + context_name: str, + orchestration: str | None = None, + input_type: type | None = None, + ) -> Callable[..., Any]: + return durable_orchestration_trigger( + self, + sdk_decorator=super().orchestration_trigger, + context_name=context_name, + orchestration=orchestration, + input_type=input_type, + ) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py new file mode 100644 index 0000000..d45a8c7 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import asyncio +import inspect +import os +import re +from collections.abc import Callable, Mapping, Sequence +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +from ipaddress import ip_address +from typing import Any, AsyncIterator, cast, get_origin +from urllib.parse import urlsplit + +from copilot import CopilotClient +from copilot.session import ( + CopilotSession, + MCPHTTPServerConfig, + MCPStdioServerConfig, + ProviderConfig, + SystemMessageReplaceConfig, +) +from copilot.session_events import AssistantMessageData +from copilot.tools import Tool + +from azurefunctions.agents.extensions.base import ( + AgentCapabilities, + AgentProvider, + CompiledAgent, + InvocationMetadata, + MCPServerDefinition, + SkillDefinition, +) + +COPILOT_SDK_PROVIDER_ID = "copilot_sdk" +ClientFactory = Callable[[], CopilotClient] +PermissionHandler = Callable[..., Any] +_ENV_REFERENCE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%") + +_SUPPORTED_OPTIONS = frozenset( + { + "client_factory", + "model", + "on_permission_request", + "provider", + "tools", + } +) + + +@dataclass(frozen=True) +class _CopilotSdkOptions: + client_factory: ClientFactory + model: str + on_permission_request: PermissionHandler | None + provider: ProviderConfig | None + tools: tuple[Tool, ...] + + +@dataclass(frozen=True) +class CopilotSdkBinding(CompiledAgent): + instructions: str + agent_name: str + options: _CopilotSdkOptions + capabilities: AgentCapabilities + + @asynccontextmanager + async def open_agent( + self, + invocation: InvocationMetadata, + ) -> AsyncIterator[CopilotSession]: + client = self.options.client_factory() + if inspect.isawaitable(client): + if inspect.iscoroutine(client): + client.close() + raise TypeError( + "client_factory must return a CopilotClient synchronously, " + "not an awaitable" + ) + if not isinstance(client, CopilotClient): + raise TypeError("client_factory must return a CopilotClient") + + async with AsyncExitStack() as stack: + entered_client = await stack.enter_async_context(client) + mcp_servers = await _build_mcp_servers( + self.capabilities.mcp_servers, + stack, + ) + skill_directories = _skill_directories(self.capabilities.skills) + session = await entered_client.create_session( + model=self.options.model, + tools=list(self.options.tools), + system_message=SystemMessageReplaceConfig( + mode="replace", + content=self.instructions, + ), + provider=self.options.provider, + on_permission_request=self.options.on_permission_request, + streaming=False, + enable_config_discovery=False, + enable_session_telemetry=False, + request_extensions=False, + enable_session_store=False, + included_builtin_skills=[], + enable_skills=bool(skill_directories), + skill_directories=skill_directories, + mcp_servers=mcp_servers, + mcp_oauth_token_storage="in-memory", + skip_custom_instructions=True, + tool_search={"enabled": False}, + infinite_sessions={"enabled": False}, + memory={"enabled": False}, + ) + entered_session = await stack.enter_async_context(session) + yield entered_session + + async def run_agent( + self, + prompt: str, + invocation: InvocationMetadata, + ) -> str: + async with self.open_agent(invocation) as session: + response = await session.send_and_wait(prompt) + if response is None or not isinstance(response.data, AssistantMessageData): + raise TypeError( + "GitHub Copilot SDK response must contain a final assistant message" + ) + if not isinstance(response.data.content, str): + raise TypeError("GitHub Copilot SDK response content must be a string") + return response.data.content + + +class CopilotSdkProvider(AgentProvider): + provider_id = COPILOT_SDK_PROVIDER_ID + distribution_name = "azurefunctions-agents-extensions-copilot-sdk" + supported_capabilities = frozenset({"skills", "mcp"}) + + def compile_binding( + self, + *, + instructions: str, + agent_name: str, + options: Mapping[str, object], + annotation: object, + capabilities: AgentCapabilities, + ) -> CopilotSdkBinding: + unknown = sorted(set(options) - _SUPPORTED_OPTIONS) + if unknown: + raise TypeError( + "Unsupported GitHub Copilot SDK option(s): " + ", ".join(unknown) + ) + client_factory = options.get("client_factory") + if client_factory is None: + raise TypeError("client_factory option is required") + if not callable(client_factory): + raise TypeError("client_factory must be callable") + if inspect.iscoroutinefunction(client_factory): + raise TypeError("client_factory must be a synchronous function") + + model = options.get("model") + if not isinstance(model, str) or not model.strip(): + raise TypeError("model option must be a non-empty string") + if annotation is not inspect.Signature.empty: + annotation_origin = get_origin(annotation) + if ( + annotation is not CopilotSession + and annotation_origin is not CopilotSession + ): + raise TypeError( + "GitHub Copilot SDK binding parameter must be annotated " + "as copilot.session.CopilotSession" + ) + + permission_handler = options.get("on_permission_request") + if permission_handler is not None and not callable(permission_handler): + raise TypeError("on_permission_request must be callable") + provider = options.get("provider") + if provider is not None and not isinstance(provider, Mapping): + raise TypeError("provider must be a Copilot SDK ProviderConfig mapping") + + return CopilotSdkBinding( + instructions=instructions, + agent_name=agent_name, + options=_CopilotSdkOptions( + client_factory=cast(ClientFactory, client_factory), + model=model.strip(), + on_permission_request=cast( + PermissionHandler | None, + permission_handler, + ), + provider=cast(ProviderConfig | None, provider), + tools=_normalize_tools(options.get("tools")), + ), + capabilities=capabilities, + ) + + +def _normalize_tools(value: object) -> tuple[Tool, ...]: + if value is None: + return () + tools = ( + tuple(cast(Sequence[object], value)) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)) + else (value,) + ) + if any(not isinstance(tool, Tool) for tool in tools): + raise TypeError("tools must contain only copilot.tools.Tool values") + return cast(tuple[Tool, ...], tools) + + +def _skill_directories(skills: Sequence[SkillDefinition]) -> list[str]: + return sorted({str(skill.path.parent) for skill in skills}) + + +def _resolve_environment(value: str, *, field: str) -> str: + missing: set[str] = set() + + def replace(match: re.Match[str]) -> str: + name = match.group(1) or match.group(2) + resolved = os.environ.get(name) + if resolved is None: + missing.add(name) + return match.group(0) + return resolved + + result = _ENV_REFERENCE.sub(replace, value) + if missing: + raise ValueError( + f"MCP {field} references missing environment variable(s): " + f"{', '.join(sorted(missing))}" + ) + return result + + +def _is_loopback_host(hostname: str | None) -> bool: + if hostname is None: + return False + normalized = hostname.rstrip(".").casefold() + if normalized == "localhost": + return True + try: + return ip_address(normalized).is_loopback + except ValueError: + return False + + +async def _build_mcp_servers( + definitions: Sequence[MCPServerDefinition], + stack: AsyncExitStack, +) -> dict[str, MCPStdioServerConfig | MCPHTTPServerConfig]: + servers: dict[str, MCPStdioServerConfig | MCPHTTPServerConfig] = {} + for definition in definitions: + config = definition.config + url = _resolve_environment( + config.url, + field=f"server {definition.name!r} URL", + ) + parsed_url = urlsplit(url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise ValueError( + f"MCP server {definition.name!r} URL must use HTTP or HTTPS" + ) + headers = { + name: _resolve_environment( + value, + field=f"server {definition.name!r} header {name!r}", + ) + for name, value in config.headers + } + if ( + parsed_url.scheme == "http" + and (headers or config.auth is not None) + and not _is_loopback_host(parsed_url.hostname) + ): + raise ValueError( + f"MCP server {definition.name!r} must use HTTPS when headers or auth " + "are configured; HTTP is allowed only for loopback hosts" + ) + if config.auth is not None: + try: + from azure.identity import DefaultAzureCredential + except ImportError as error: + raise ImportError( + "MCP Entra authentication is not installed. Install " + "'azurefunctions-agents-extensions-copilot-sdk[mcp]'." + ) from error + scope = _resolve_environment( + config.auth.scope, + field=f"server {definition.name!r} auth scope", + ) + client_id = ( + _resolve_environment( + config.auth.client_id, + field=f"server {definition.name!r} auth client_id", + ) + if config.auth.client_id is not None + else None + ) + credential = DefaultAzureCredential( + managed_identity_client_id=client_id, + ) + stack.callback(credential.close) + token = await asyncio.to_thread(credential.get_token, scope) + headers["Authorization"] = f"Bearer {token.token}" + + servers[definition.name] = MCPHTTPServerConfig( + type="http", + url=url, + headers=headers, + tools=( + list(config.allowed_tools) + if config.allowed_tools is not None + else ["*"] + ), + ) + return servers + + +def create_provider() -> CopilotSdkProvider: + return CopilotSdkProvider() diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/py.typed b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml new file mode 100644 index 0000000..8bd8896 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml @@ -0,0 +1,71 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-agents-extensions-copilot-sdk" +dynamic = ["version"] +requires-python = ">=3.10" +authors = [ + { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, +] +description = "GitHub Copilot SDK integration for Azure Functions." +readme = "README.md" +license = { text = "MIT License" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "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", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Environment :: Web Environment", + "Development Status :: 3 - Alpha", +] +dependencies = [ + "azurefunctions-agents-extensions-base>=1.0.0b1", + "github-copilot-sdk>=1.0.14,<2", +] + +[project.optional-dependencies] +mcp = [ + "azure-identity>=1.25.3,<2", +] +durable = [ + "azurefunctions-agents-extensions-base[durable]>=1.0.0b1", +] +dev = [ + "azure-functions-durable>=2.0.0b2", + "azure-identity>=1.25.3,<2", + "coverage", + "flake8", + "mypy", + "pre-commit", + "pytest", + "pytest-cov", + "pytest-instafail", +] + +[project.entry-points."azurefunctions.agents.extensions.providers"] +copilot_sdk = "azurefunctions.agents.extensions.copilot_sdk.provider:create_provider" + +[tool.setuptools.dynamic] +version = { attr = "azurefunctions.agents.extensions.copilot_sdk.__version__" } + +[tool.setuptools.packages.find] +include = ["azurefunctions.agents.extensions.copilot_sdk*"] + +[tool.setuptools.package-data] +"azurefunctions.agents.extensions.copilot_sdk" = ["py.typed"] + +[tool.mypy] +strict = true + +[[tool.mypy.overrides]] +module = ["azure", "azure.*"] +ignore_missing_imports = true \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/README.md new file mode 100644 index 0000000..1aaa33b --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/README.md @@ -0,0 +1,6 @@ +# GitHub Copilot SDK Agent samples + +- `agent_samples_copilot-sdk` demonstrates direct HTTP and queue-triggered + session injection. +- `agent_samples_copilot-sdk_durable` demonstrates replay-safe Agent calls from + a Durable orchestration. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md new file mode 100644 index 0000000..4cc30f6 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md @@ -0,0 +1,15 @@ +# GitHub Copilot SDK Agent binding sample + +This sample injects a fresh `CopilotSession` into HTTP and queue-triggered +Functions. Copy `local.settings.template.json` to `local.settings.json`, replace +the placeholder settings, install `requirements.txt`, and pre-provision the +native runtime with: + +```text +python -m copilot download-runtime +``` + +Run the app with `func start`. The sample uses `mode="empty"`, disables SDK +telemetry, and exposes only the discovered Skill and remote inventory MCP tool. +`PermissionHandler.approve_all` is appropriate only when every configured tool +is trusted; use a restrictive handler for applications with broader tools. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py new file mode 100644 index 0000000..9288515 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py @@ -0,0 +1,76 @@ +import json +import os + +import azure.functions as func +from copilot import CopilotClient +from copilot.session import CopilotSession, PermissionHandler +from copilot.session_events import AssistantMessageData +from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp + + +def create_copilot_client() -> CopilotClient: + return CopilotClient( + mode="empty", + github_token=os.environ["COPILOT_GITHUB_TOKEN"], + use_logged_in_user=False, + log_level="none", + telemetry=None, + ) + + +app = AgentFunctionApp( + client_factory=create_copilot_client, + model=os.environ["COPILOT_MODEL"], + on_permission_request=PermissionHandler.approve_all, +) + + +async def _run(session: CopilotSession, prompt: str) -> str: + response = await session.send_and_wait(prompt) + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") + return response.data.content + + +@app.route(route="orders/{orderId}", methods=["POST"]) +@app.markdown_agent(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order( + req: func.HttpRequest, + order_agent: CopilotSession, +) -> func.HttpResponse: + try: + order = req.get_json() + except ValueError: + return func.HttpResponse( + body=json.dumps({"error": "Order failed validation."}), + status_code=400, + mimetype="application/json", + ) + + assessment = await _run( + order_agent, + json.dumps( + { + "order_id": req.route_params["orderId"], + "order": order, + "task": "assess fulfillment readiness", + } + ), + ) + return func.HttpResponse( + body=json.dumps({"assessment": assessment}), + mimetype="application/json", + ) + + +@app.queue_trigger( + arg_name="message", + queue_name="orders", + connection="AzureWebJobsStorage", +) +@app.markdown_agent(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order_event( + message: func.QueueMessage, + order_agent: CopilotSession, +) -> None: + await _run(order_agent, message.get_body().decode("utf-8")) \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/host.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/host.json new file mode 100644 index 0000000..bab9278 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json new file mode 100644 index 0000000..a43ea0d --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "COPILOT_GITHUB_TOKEN": "", + "COPILOT_MODEL": "gpt-5", + "INVENTORY_MCP_URL": "https://inventory.example.com/mcp" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/mcp.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/mcp.json new file mode 100644 index 0000000..9318245 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "inventory": { + "type": "streamable-http", + "url": "$INVENTORY_MCP_URL", + "tools": ["lookup_stock"] + } + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/order-fulfillment.agent.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/order-fulfillment.agent.md new file mode 100644 index 0000000..cacbb0f --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/order-fulfillment.agent.md @@ -0,0 +1,4 @@ +You assess whether an order is ready for fulfillment. + +Use available policy Skills and inventory tools when relevant. Return a concise +assessment with any required human-review actions. Never invent order details. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/requirements.txt b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/requirements.txt new file mode 100644 index 0000000..983df1d --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/requirements.txt @@ -0,0 +1 @@ +azurefunctions-agents-extensions-copilot-sdk==1.0.0b1 \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/skills/order-policy/SKILL.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/skills/order-policy/SKILL.md new file mode 100644 index 0000000..20624bc --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/skills/order-policy/SKILL.md @@ -0,0 +1,7 @@ +--- +name: order-policy +description: Rules for reviewing orders before fulfillment. +--- + +Require human review when an order contains incomplete shipping information or +requests an unsupported destination. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md new file mode 100644 index 0000000..f7da8b2 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md @@ -0,0 +1,9 @@ +# Durable GitHub Copilot SDK Agent binding sample + +This sample schedules Copilot SDK Agent calls as replay-safe Durable activities. +Install `requirements.txt`, configure the settings from +`local.settings.template.json`, pre-provision the native runtime with +`python -m copilot download-runtime`, and run `func start`. + +The orchestrator only schedules `context.call_agent(...)`; model and tool work +runs in the extension's hidden activity. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py new file mode 100644 index 0000000..83d0cb8 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py @@ -0,0 +1,68 @@ +import json +import os + +import azure.durable_functions as df +import azure.functions as func +from copilot import CopilotClient +from copilot.session import PermissionHandler +from azurefunctions.agents.extensions.copilot_sdk import ( + AgentFunctionApp, + DurableAgentContext, +) + + +def create_copilot_client() -> CopilotClient: + return CopilotClient( + mode="empty", + github_token=os.environ["COPILOT_GITHUB_TOKEN"], + use_logged_in_user=False, + log_level="none", + telemetry=None, + ) + + +app = AgentFunctionApp( + client_factory=create_copilot_client, + model=os.environ["COPILOT_MODEL"], + on_permission_request=PermissionHandler.approve_all, +) + + +@app.route(route="orders/orchestrations", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_order_orchestration( + req: func.HttpRequest, + client: df.DurableFunctionsClient, +) -> func.HttpResponse: + try: + order = req.get_json() + except ValueError: + return func.HttpResponse( + body=json.dumps({"error": "Order failed validation."}), + status_code=400, + mimetype="application/json", + ) + + instance_id = await client.start_new( + "order_orchestrator", + client_input=order, + ) + management = client.create_http_management_payload(req, instance_id) + return func.HttpResponse( + body=json.dumps(management), + status_code=202, + mimetype="application/json", + headers={"Location": management["statusQueryGetUri"]}, + ) + + +@app.orchestration_trigger(context_name="context") +def order_orchestrator(context: DurableAgentContext): + assessment = yield context.call_agent( + "order-fulfillment", + { + "order": context.get_input(), + "task": "assess fulfillment readiness", + }, + ) + return {"assessment": assessment} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/host.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/host.json new file mode 100644 index 0000000..55d1642 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json new file mode 100644 index 0000000..742b470 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json @@ -0,0 +1,9 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "COPILOT_GITHUB_TOKEN": "", + "COPILOT_MODEL": "gpt-5" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/order-fulfillment.agent.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/order-fulfillment.agent.md new file mode 100644 index 0000000..db51b5e --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/order-fulfillment.agent.md @@ -0,0 +1,2 @@ +You assess whether an order is ready for fulfillment. Return a concise risk +assessment and any required human-review actions. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/requirements.txt b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/requirements.txt new file mode 100644 index 0000000..5d34100 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/requirements.txt @@ -0,0 +1 @@ +azurefunctions-agents-extensions-copilot-sdk[durable]==1.0.0b1 \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py new file mode 100644 index 0000000..590d27c --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import inspect +from unittest.mock import Mock + +import azure.functions as func +from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp, apps + + +def test_typed_api_exposes_only_v1_options(): + assert list(inspect.signature(AgentFunctionApp.__init__).parameters) == [ + "self", + "client_factory", + "model", + "app_root", + "on_permission_request", + "provider", + "tools", + "http_auth_level", + ] + assert list(inspect.signature(AgentFunctionApp.markdown_agent).parameters) == [ + "self", + "arg_name", + "agent_name", + "client_factory", + "model", + "on_permission_request", + "provider", + "tools", + ] + + +def test_typed_agent_function_app_pins_copilot_provider(monkeypatch): + parent_init = Mock() + configure_app = Mock() + monkeypatch.setattr(func.FunctionApp, "__init__", parent_init) + monkeypatch.setattr(apps, "configure_app", configure_app) + factory = lambda: object() + + app = AgentFunctionApp( + client_factory=factory, + model="gpt-5", + app_root="app", + ) + + parent_init.assert_called_once_with(http_auth_level=func.AuthLevel.FUNCTION) + configure_app.assert_called_once_with( + app, + provider="copilot_sdk", + app_root="app", + provider_options={"client_factory": factory, "model": "gpt-5"}, + ) + + +def test_agent_function_app_uses_function_app_directly(): + assert func.FunctionApp in AgentFunctionApp.__bases__ + + +def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", parent_decorator) + app = object.__new__(AgentFunctionApp) + factory = lambda: object() + + result = app.markdown_agent( + arg_name="agent", + agent_name="orders", + client_factory=factory, + model="gpt-5", + ) + + assert result is parent_decorator.return_value + parent_decorator.assert_called_once_with( + app, + provider="copilot_sdk", + arg_name="agent", + agent_name="orders", + client_factory=factory, + model="gpt-5", + ) diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py new file mode 100644 index 0000000..d9a9581 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py @@ -0,0 +1,38 @@ +import subprocess +import sys + + +def test_copilot_sdk_exports_supported_api(): + import azurefunctions.agents.extensions.copilot_sdk as copilot_sdk + from azurefunctions.agents.extensions.base.durable import DurableAgentContext + + assert copilot_sdk.AgentFunctionApp is not None + assert copilot_sdk.DurableAgentContext is DurableAgentContext + assert copilot_sdk.COPILOT_SDK_PROVIDER_ID == "copilot_sdk" + assert not hasattr(copilot_sdk, "markdown_agent") + + +def test_copilot_sdk_import_does_not_import_durable(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import importlib.abc\n" + "import sys\n" + "class BlockDurable(importlib.abc.MetaPathFinder):\n" + " def find_spec(self, fullname, path, target=None):\n" + " if fullname == 'azure.durable_functions' or " + "fullname.startswith('azure.durable_functions.'):\n" + " raise ModuleNotFoundError(name=fullname)\n" + "sys.meta_path.insert(0, BlockDurable())\n" + "import azurefunctions.agents.extensions.copilot_sdk\n" + "assert 'azure.durable_functions' not in sys.modules" + ), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py new file mode 100644 index 0000000..ae84aa4 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import asyncio +import inspect +from pathlib import Path +from types import SimpleNamespace + +import pytest +from azurefunctions.agents.extensions.base import ( + AgentCapabilities, + InvocationMetadata, + MCPAuthConfig, + MCPHTTPConfig, + MCPServerDefinition, + SkillDefinition, +) +from azurefunctions.agents.extensions.copilot_sdk import provider +from copilot.session import CopilotSession +from copilot.session_events import AssistantMessageData + + +class _Session: + def __init__(self) -> None: + self.entered = False + self.closed = False + self.prompts: list[str] = [] + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.closed = True + + async def send_and_wait(self, prompt: str): + self.prompts.append(prompt) + return SimpleNamespace( + data=AssistantMessageData( + content=f"response:{prompt}", + message_id="message-1", + ) + ) + + +class _Client: + created: list[_Client] = [] + + def __init__(self) -> None: + self.entered = False + self.closed = False + self.session = _Session() + self.session_options = None + self.created.append(self) + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.closed = True + + async def create_session(self, **options): + self.session_options = options + return self.session + + +@pytest.fixture(autouse=True) +def fake_client(monkeypatch): + _Client.created.clear() + monkeypatch.setattr(provider, "CopilotClient", _Client) + + +def _compile(*, capabilities=AgentCapabilities(), **overrides): + options = {"client_factory": _Client, "model": "gpt-5"} + options.update(overrides) + return provider.CopilotSdkProvider().compile_binding( + instructions="raw instructions", + agent_name="orders", + options=options, + annotation=CopilotSession, + capabilities=capabilities, + ) + + +def test_binding_creates_and_closes_fresh_clients_and_sessions(): + binding = _compile() + + async def invoke_twice(): + async with binding.open_agent(InvocationMetadata()) as first: + assert first.entered + async with binding.open_agent(InvocationMetadata()) as second: + assert second.entered + + asyncio.run(invoke_twice()) + + assert len(_Client.created) == 2 + assert all(client.closed for client in _Client.created) + assert all(client.session.closed for client in _Client.created) + options = _Client.created[0].session_options + assert options["model"] == "gpt-5" + assert options["system_message"] == { + "mode": "replace", + "content": "raw instructions", + } + assert options["enable_config_discovery"] is False + assert options["enable_session_store"] is False + assert options["included_builtin_skills"] == [] + + +def test_binding_run_agent_returns_assistant_content(): + assert ( + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + == "response:hello" + ) + assert _Client.created[0].session.prompts == ["hello"] + assert _Client.created[0].closed + + +def test_provider_validates_required_options_and_annotation(): + with pytest.raises(TypeError, match="client_factory"): + provider.CopilotSdkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"model": "gpt-5"}, + annotation=CopilotSession, + capabilities=AgentCapabilities(), + ) + with pytest.raises(TypeError, match="model option"): + _compile(model="") + with pytest.raises(TypeError, match="CopilotSession"): + provider.CopilotSdkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"client_factory": _Client, "model": "gpt-5"}, + annotation=str, + capabilities=AgentCapabilities(), + ) + + +def test_provider_accepts_missing_annotation_for_durable_activity(): + binding = provider.CopilotSdkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"client_factory": _Client, "model": "gpt-5"}, + annotation=inspect.Signature.empty, + capabilities=AgentCapabilities(), + ) + + assert binding.agent_name == "orders" + + +def test_factory_and_invocation_errors_still_close_resources(monkeypatch): + binding = _compile() + + async def fail_create(self, **options): + raise RuntimeError("session failed") + + monkeypatch.setattr(_Client, "create_session", fail_create) + + with pytest.raises(RuntimeError, match="session failed"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].closed + + +def test_binding_maps_discovered_skills_and_mcp(monkeypatch): + monkeypatch.setenv("MCP_URL", "https://mcp.example.test") + binding = _compile( + capabilities=AgentCapabilities( + skills=( + SkillDefinition(Path("skills/inventory")), + SkillDefinition(Path("skills/orders")), + ), + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig( + "$MCP_URL", + allowed_tools=("lookup",), + headers=(("X-Tenant", "%TENANT_ID%"),), + ), + ), + ), + ) + ) + monkeypatch.setenv("TENANT_ID", "tenant-1") + + async def invoke(): + async with binding.open_agent(InvocationMetadata()): + pass + + asyncio.run(invoke()) + + options = _Client.created[0].session_options + assert options["enable_skills"] is True + assert options["skill_directories"] == ["skills"] + assert options["mcp_servers"] == { + "orders": { + "type": "http", + "url": "https://mcp.example.test", + "headers": {"X-Tenant": "tenant-1"}, + "tools": ["lookup"], + } + } + assert options["mcp_oauth_token_storage"] == "in-memory" + + +def test_mcp_credentials_require_https(monkeypatch): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig( + "http://mcp.example.test", + headers=(("X-Key", "secret"),), + ), + ), + ), + ) + ) + + with pytest.raises(ValueError, match="must use HTTPS"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].closed + + +def test_cancellation_closes_client_and_session(monkeypatch): + async def cancel(self, prompt): + raise asyncio.CancelledError + + monkeypatch.setattr(_Session, "send_and_wait", cancel) + + with pytest.raises(asyncio.CancelledError): + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].closed + assert _Client.created[0].session.closed + + +@pytest.mark.parametrize( + "response", + [None, SimpleNamespace(data=object())], +) +def test_binding_rejects_missing_final_assistant_message(monkeypatch, response): + async def send(self, prompt): + return response + + monkeypatch.setattr(_Session, "send_and_wait", send) + + with pytest.raises(TypeError, match="final assistant message"): + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].closed + assert _Client.created[0].session.closed + + +def test_binding_rejects_non_string_assistant_content(monkeypatch): + class FakeAssistantMessageData: + content = None + + async def send(self, prompt): + return SimpleNamespace(data=FakeAssistantMessageData()) + + monkeypatch.setattr(provider, "AssistantMessageData", FakeAssistantMessageData) + monkeypatch.setattr(_Session, "send_and_wait", send) + + with pytest.raises(TypeError, match="content must be a string"): + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].closed + assert _Client.created[0].session.closed + + +@pytest.mark.parametrize("factory_kind", ["async_callable", "returns_awaitable"]) +def test_binding_rejects_awaitable_factory_results(factory_kind): + async def create_client(): + return _Client() + + if factory_kind == "async_callable": + + class AsyncFactory: + async def __call__(self): + return _Client() + + client_factory = AsyncFactory() + else: + client_factory = lambda: create_client() + + binding = _compile(client_factory=client_factory) + + with pytest.raises(TypeError, match="must return.*not an awaitable"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + +def test_provider_rejects_async_factory_function(): + async def create_client(): + return _Client() + + with pytest.raises(TypeError, match="must be a synchronous function"): + _compile(client_factory=create_client) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"unknown": True}, "Unsupported"), + ({"tools": ["lookup"]}, "copilot.tools.Tool"), + ({"provider": "custom"}, "ProviderConfig"), + ], +) +def test_provider_rejects_unsupported_options(overrides, message): + with pytest.raises(TypeError, match=message): + _compile(**overrides) + + +def test_mcp_entra_token_is_injected_and_credential_is_closed(monkeypatch): + import azure.identity + + credentials = [] + + class FakeCredential: + def __init__(self, *, managed_identity_client_id): + self.managed_identity_client_id = managed_identity_client_id + self.scopes = [] + self.closed = False + credentials.append(self) + + def get_token(self, scope): + self.scopes.append(scope) + return SimpleNamespace(token="entra-token") + + def close(self): + self.closed = True + + monkeypatch.setattr(azure.identity, "DefaultAzureCredential", FakeCredential) + monkeypatch.setenv("MCP_SCOPE", "api://inventory/.default") + monkeypatch.setenv("MCP_CLIENT_ID", "client-id") + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "inventory", + MCPHTTPConfig( + "https://mcp.example.test", + auth=MCPAuthConfig( + scope="$MCP_SCOPE", + client_id="%MCP_CLIENT_ID%", + ), + ), + ), + ), + ) + ) + + async def invoke(): + async with binding.open_agent(InvocationMetadata()): + assert not credentials[0].closed + + asyncio.run(invoke()) + + assert credentials[0].managed_identity_client_id == "client-id" + assert credentials[0].scopes == ["api://inventory/.default"] + assert credentials[0].closed + assert _Client.created[0].session_options["mcp_servers"]["inventory"] == { + "type": "http", + "url": "https://mcp.example.test", + "headers": {"Authorization": "Bearer entra-token"}, + "tools": ["*"], + } diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py new file mode 100644 index 0000000..5622fa4 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_PACKAGE_ROOT = Path(__file__).parents[1] +_SAMPLES_ROOT = _PACKAGE_ROOT / "samples" + + +@pytest.mark.parametrize( + ("sample_name", "expected_names"), + [ + ( + "agent_samples_copilot-sdk", + {"process_order", "process_order_event"}, + ), + ( + "agent_samples_copilot-sdk_durable", + { + "azurefunctions_agents_run_markdown_agent", + "order_orchestrator", + "start_order_orchestration", + }, + ), + ], +) +def test_sample_indexes_all_functions(sample_name, expected_names): + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) + ) + environment.update( + { + "COPILOT_GITHUB_TOKEN": "index-only-placeholder", + "COPILOT_MODEL": "gpt-5", + "INVENTORY_MCP_URL": "https://inventory.example.test/mcp", + } + ) + completed = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json; import function_app; " + "print(json.dumps([function.get_function_name() " + "for function in function_app.app.get_functions()]))" + ), + ], + cwd=_SAMPLES_ROOT / sample_name, + env=environment, + check=True, + capture_output=True, + text=True, + ) + + assert set(json.loads(completed.stdout)) == expected_names + + +def test_direct_sample_assets_follow_discovery_conventions(): + sample_root = _SAMPLES_ROOT / "agent_samples_copilot-sdk" + + assert (sample_root / "order-fulfillment.agent.md").is_file() + assert (sample_root / "skills" / "order-policy" / "SKILL.md").is_file() + assert (sample_root / "mcp.json").is_file() diff --git a/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml index c78ebf9..0f919cb 100644 --- a/eng/templates/jobs/build.yml +++ b/eng/templates/jobs/build.yml @@ -13,6 +13,9 @@ jobs: agents_framework_extension: EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-agent-framework' EXTENSION_NAME: 'Agents Framework' + agents_copilot_sdk_extension: + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-copilot-sdk' + EXTENSION_NAME: 'Agents Copilot SDK' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' EXTENSION_NAME: 'Blob' diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml index 0053ace..e763a3a 100644 --- a/eng/templates/official/jobs/build-artifacts.yml +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -13,6 +13,9 @@ jobs: agents_framework_extension: EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-agent-framework' EXTENSION_NAME: 'Agents Framework' + agents_copilot_sdk_extension: + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-copilot-sdk' + EXTENSION_NAME: 'Agents Copilot SDK' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' EXTENSION_NAME: 'Blob' diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index 11d8741..1345099 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -74,6 +74,35 @@ jobs: python -m pytest -q --instafail azurefunctions-agents-extensions-agent-framework/tests/ displayName: "Run Agents Framework Tests for Python $(PYTHON_VERSION)" + - job: "AgentsCopilotSdkTests" + displayName: "Agents Copilot SDK Extension Tests" + dependsOn: [] + strategy: + matrix: + python313: + PYTHON_VERSION: '3.13' + python314: + PYTHON_VERSION: '3.14' + condition: always() + steps: + - task: PipAuthenticate@1 + displayName: 'Pip Authenticate' + inputs: + artifactFeeds: public/upstream-public + onlyAddExtraIndex: false + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + - bash: | + python -m pip install --upgrade pip + python -m pip install -e ./azurefunctions-agents-extensions-base + cd azurefunctions-agents-extensions-copilot-sdk + python -m pip install -U -e .[dev,mcp] + displayName: 'Install Agents Copilot SDK Dependencies' + - bash: | + python -m pytest -q --instafail azurefunctions-agents-extensions-copilot-sdk/tests/ + displayName: "Run Agents Copilot SDK Tests for Python $(PYTHON_VERSION)" + - job: "BaseTests" displayName: "Base Extension Tests" dependsOn: [] From a12cfed6129ba27255a61de7252e7d921c0c2dfd Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 30 Sep 2026 16:57:58 -0500 Subject: [PATCH 02/11] Minor fixes --- .../agents/extensions/copilot_sdk/provider.py | 10 +++++- .../agent_samples_copilot-sdk/README.md | 2 ++ .../agent_samples_copilot-sdk/function_app.py | 1 + .../local.settings.template.json | 1 + .../README.md | 2 ++ .../function_app.py | 1 + .../local.settings.template.json | 1 + .../tests/test_provider.py | 21 ++++++++++++ .../tests/test_samples.py | 32 +++++++++++++++++++ 9 files changed, 70 insertions(+), 1 deletion(-) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py index d45a8c7..086f647 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -11,7 +11,7 @@ from typing import Any, AsyncIterator, cast, get_origin from urllib.parse import urlsplit -from copilot import CopilotClient +from copilot import CopilotClient, ToolSet from copilot.session import ( CopilotSession, MCPHTTPServerConfig, @@ -86,9 +86,17 @@ async def open_agent( stack, ) skill_directories = _skill_directories(self.capabilities.skills) + available_tools = ToolSet() + if skill_directories: + available_tools.add_builtin("skill") + if mcp_servers: + available_tools.add_mcp("*") + for tool in self.options.tools: + available_tools.add_custom(tool.name) session = await entered_client.create_session( model=self.options.model, tools=list(self.options.tools), + available_tools=available_tools, system_message=SystemMessageReplaceConfig( mode="replace", content=self.instructions, diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md index 4cc30f6..7db1c21 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md @@ -11,5 +11,7 @@ python -m copilot download-runtime Run the app with `func start`. The sample uses `mode="empty"`, disables SDK telemetry, and exposes only the discovered Skill and remote inventory MCP tool. +Set `COPILOT_BASE_DIRECTORY` to an isolated writable directory for the app's +runtime state; use separate directories when hosting multiple tenants. `PermissionHandler.approve_all` is appropriate only when every configured tool is trusted; use a restrictive handler for applications with broader tools. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py index 9288515..d9c3e82 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py @@ -12,6 +12,7 @@ def create_copilot_client() -> CopilotClient: return CopilotClient( mode="empty", github_token=os.environ["COPILOT_GITHUB_TOKEN"], + base_directory=os.environ["COPILOT_BASE_DIRECTORY"], use_logged_in_user=False, log_level="none", telemetry=None, diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json index a43ea0d..bc1e589 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "python", "COPILOT_GITHUB_TOKEN": "", + "COPILOT_BASE_DIRECTORY": "", "COPILOT_MODEL": "gpt-5", "INVENTORY_MCP_URL": "https://inventory.example.com/mcp" } diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md index f7da8b2..5158157 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md @@ -4,6 +4,8 @@ This sample schedules Copilot SDK Agent calls as replay-safe Durable activities. Install `requirements.txt`, configure the settings from `local.settings.template.json`, pre-provision the native runtime with `python -m copilot download-runtime`, and run `func start`. +Set `COPILOT_BASE_DIRECTORY` to an isolated writable directory for the app's +runtime state; use separate directories when hosting multiple tenants. The orchestrator only schedules `context.call_agent(...)`; model and tool work runs in the extension's hidden activity. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py index 83d0cb8..0f6e08f 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py @@ -15,6 +15,7 @@ def create_copilot_client() -> CopilotClient: return CopilotClient( mode="empty", github_token=os.environ["COPILOT_GITHUB_TOKEN"], + base_directory=os.environ["COPILOT_BASE_DIRECTORY"], use_logged_in_user=False, log_level="none", telemetry=None, diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json index 742b470..ccb4fba 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "python", "COPILOT_GITHUB_TOKEN": "", + "COPILOT_BASE_DIRECTORY": "", "COPILOT_MODEL": "gpt-5" } } \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py index ae84aa4..c5c7ae5 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -17,6 +17,7 @@ from azurefunctions.agents.extensions.copilot_sdk import provider from copilot.session import CopilotSession from copilot.session_events import AssistantMessageData +from copilot.tools import Tool class _Session: @@ -105,6 +106,7 @@ async def invoke_twice(): assert options["enable_config_discovery"] is False assert options["enable_session_store"] is False assert options["included_builtin_skills"] == [] + assert list(options["available_tools"]) == [] def test_binding_run_agent_returns_assistant_content(): @@ -203,6 +205,25 @@ async def invoke(): } } assert options["mcp_oauth_token_storage"] == "in-memory" + assert list(options["available_tools"]) == ["builtin:skill", "mcp:*"] + + +def test_binding_allowlists_explicit_python_tools(): + binding = _compile( + tools=Tool( + name="lookup_order", + description="Look up an order", + ) + ) + + async def invoke(): + async with binding.open_agent(InvocationMetadata()): + pass + + asyncio.run(invoke()) + + options = _Client.created[0].session_options + assert list(options["available_tools"]) == ["custom:lookup_order"] def test_mcp_credentials_require_https(monkeypatch): diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py index 5622fa4..45e5c1c 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py @@ -67,3 +67,35 @@ def test_direct_sample_assets_follow_discovery_conventions(): assert (sample_root / "order-fulfillment.agent.md").is_file() assert (sample_root / "skills" / "order-policy" / "SKILL.md").is_file() assert (sample_root / "mcp.json").is_file() + + +@pytest.mark.parametrize( + "sample_name", + ["agent_samples_copilot-sdk", "agent_samples_copilot-sdk_durable"], +) +def test_sample_client_factories_use_explicit_storage(sample_name, tmp_path): + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) + ) + environment.update( + { + "COPILOT_BASE_DIRECTORY": str(tmp_path), + "COPILOT_GITHUB_TOKEN": "factory-only-placeholder", + "COPILOT_MODEL": "gpt-5", + "INVENTORY_MCP_URL": "https://inventory.example.test/mcp", + } + ) + + subprocess.run( + [ + sys.executable, + "-c", + "import function_app; function_app.create_copilot_client()", + ], + cwd=_SAMPLES_ROOT / sample_name, + env=environment, + check=True, + capture_output=True, + text=True, + ) From 3edd8c0954d5e2b6acc8ee53173b59b5d2445106 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 1 Oct 2026 11:03:15 -0500 Subject: [PATCH 03/11] Minor improvements --- .../README.md | 140 +++++++++++++----- .../agents/extensions/copilot_sdk/__init__.py | 2 + .../agents/extensions/copilot_sdk/apps.py | 36 ++++- .../agents/extensions/copilot_sdk/options.py | 132 +++++++++++++++++ .../agents/extensions/copilot_sdk/provider.py | 137 ++++++++++++----- .../pyproject.toml | 1 + .../agent_samples_copilot-sdk/README.md | 32 +++- .../agent_samples_copilot-sdk/direct_sdk.py | 106 +++++++++++++ .../agent_samples_copilot-sdk/function_app.py | 42 ++---- .../README.md | 10 +- .../function_app.py | 21 +-- .../tests/test_apps.py | 83 ++++++++--- .../tests/test_imports.py | 1 + .../tests/test_provider.py | 131 ++++++++++++---- .../tests/test_samples.py | 44 +++--- 15 files changed, 712 insertions(+), 206 deletions(-) create mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py create mode 100644 azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/direct_sdk.py diff --git a/azurefunctions-agents-extensions-copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/README.md index 5dc98c5..a223f42 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/README.md @@ -31,34 +31,25 @@ python -m copilot download-runtime ## Use a Copilot SDK Agent app -Create a zero-argument synchronous factory that returns a fresh -`CopilotClient`. Server environments should use explicit authentication rather -than an interactive logged-in user: +The extension uses these standard Function App settings: -```python -import os +| Setting | Purpose | +| --- | --- | +| `COPILOT_GITHUB_TOKEN` | GitHub token used by the default client. | +| `COPILOT_BASE_DIRECTORY` | Writable directory for isolated Copilot runtime state. | +| `COPILOT_MODEL` | Model used by Agent sessions. | +Use these exact names in `local.settings.json` and Azure Function App settings. +The extension creates the default `CopilotClient` in `mode="empty"`, disables +SDK telemetry, and does not use interactive logged-in-user authentication: + +```python import azure.functions as func -from copilot import CopilotClient -from copilot.session import CopilotSession, PermissionHandler +from copilot.session import CopilotSession from copilot.session_events import AssistantMessageData from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp - -def create_copilot_client() -> CopilotClient: - return CopilotClient( - mode="empty", - github_token=os.environ["COPILOT_GITHUB_TOKEN"], - use_logged_in_user=False, - telemetry=None, - ) - - -app = AgentFunctionApp( - client_factory=create_copilot_client, - model=os.environ["COPILOT_MODEL"], - on_permission_request=PermissionHandler.approve_all, -) +app = AgentFunctionApp() @app.route(route="orders", methods=["POST"]) @@ -78,20 +69,70 @@ Agent binding in the app to this provider. Place the complete instructions at `orders.agent.md` or `agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime configuration is interpreted. -The app-level options are defaults. A `markdown_agent()` decorator can override -`client_factory`, `model`, `on_permission_request`, `provider`, and `tools` for -one binding. `app_root` can be set only on `AgentFunctionApp`. +`AgentFunctionApp()` reads its model from `COPILOT_MODEL` and defaults +`on_permission_request` to `PermissionHandler.approve_all`. Pass `model=` or +`on_permission_request=` only to override those defaults. A `markdown_agent()` +decorator can override `client_factory`, `model`, `on_permission_request`, +`provider`, `tools`, and any typed Copilot session option for one binding. +`app_root` can be set only on `AgentFunctionApp`. `PermissionHandler.approve_all` should be used only when every exposed tool is trusted. Supply a restrictive SDK permission handler when tools can access privileged data or perform external actions. +### Customize Copilot sessions + +Pass Copilot session options directly to `markdown_agent()`. The typed keyword +arguments provide editor completion and static validation without string keys: + +```python +@app.markdown_agent( + arg_name="agent", + agent_name="orders", + reasoning_effort="high", + streaming=True, + enable_session_store=True, +) +async def process_order( + req: func.HttpRequest, + agent: CopilotSession, +) -> str: + ... +``` + +Unknown option names fail static validation and are also rejected when the +binding is compiled. Session options are applied after the extension defaults, +so they take precedence. This includes +extension-generated values such as `system_message`, `tools`, +`available_tools`, `mcp_servers`, `skill_directories`, and their enablement +flags. Override those values only when intentionally replacing the Agent's +instructions or discovered capabilities. + +For app-wide defaults, construct the exported typed options object: + +```python +from azurefunctions.agents.extensions.copilot_sdk import ( + AgentFunctionApp, + CopilotSessionOptions, +) + +app = AgentFunctionApp( + session_options=CopilotSessionOptions( + reasoning_effort="high", + streaming=True, + ), +) +``` + +Supplying any session option to `markdown_agent()` replaces the app-wide +`session_options` object for that binding. + ## Authentication and BYOK Keep GitHub tokens and model-provider credentials in Function App settings or a -secret store. Do not put them in source, `.agent.md`, or `mcp.json` files. A -factory is called once per invocation, so it can read current settings without -sharing a client across requests. +secret store. Do not put them in source, `.agent.md`, or `mcp.json` files. The +default client reads `COPILOT_GITHUB_TOKEN` and `COPILOT_BASE_DIRECTORY` when it +is first needed. The optional `provider` value is passed to the Copilot SDK session for bring your own key (BYOK) scenarios. For example: @@ -109,8 +150,6 @@ provider: ProviderConfig = { } app = AgentFunctionApp( - client_factory=create_copilot_client, - model=os.environ["COPILOT_MODEL"], provider=provider, ) ``` @@ -119,6 +158,27 @@ The Copilot SDK also supports Azure and Anthropic provider configurations. Use the provider fields supported by the installed SDK version, and keep all credentials outside checked-in configuration. +For advanced client authentication or runtime transports, pass a synchronous +zero-argument `client_factory`. The extension calls it once, validates the +result, and caches that `CopilotClient`: + +```python +from copilot import CopilotClient + + +def create_copilot_client() -> CopilotClient: + return CopilotClient(...) + + +app = AgentFunctionApp( + client_factory=create_copilot_client, +) +``` + +App-level bindings share the app's cached client. A decorator-level +`client_factory` override has a separate cache for that binding. Factories must +return `CopilotClient` synchronously; awaitable results are rejected. + ## Skills and MCP servers Skills and remote MCP servers are discovered automatically from the app root: @@ -162,15 +222,17 @@ Function Apps when capability sets require isolation. ## Invocation lifecycle -A new client and session are created for every Function invocation. The -extension replaces the session system message with the raw Agent instructions -and disables ambient Copilot configuration discovery, session persistence, -memory, telemetry, built-in Skills, and tool search. Explicit Python tools, -discovered Skills, and discovered MCP servers remain available. - -Clients, sessions, and Entra credentials are closed on success, error, and -cancellation. The factory must return `CopilotClient` synchronously; shared or -awaitable clients are rejected. +The extension creates one client lazily and reuses it across invocations for the +lifetime of the Python worker process. Each invocation creates and closes a +fresh session. The extension replaces the session system message with the raw +Agent instructions and disables ambient Copilot configuration discovery, +session persistence, memory, telemetry, built-in Skills, and tool search. +Explicit Python tools, discovered Skills, and discovered MCP servers remain +available. + +Sessions and Entra credentials are closed on success, error, and cancellation. +The cached client remains open so later invocations can reuse its runtime +connection. ## Durable Agents diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py index 78e5970..3226f59 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py @@ -1,12 +1,14 @@ from azurefunctions.agents.extensions.base.durable import DurableAgentContext from .apps import AgentFunctionApp +from .options import CopilotSessionOptions from .provider import COPILOT_SDK_PROVIDER_ID, ClientFactory __all__ = [ "COPILOT_SDK_PROVIDER_ID", "AgentFunctionApp", "ClientFactory", + "CopilotSessionOptions", "DurableAgentContext", ] diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py index e99f706..53fc64c 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py @@ -5,8 +5,10 @@ from typing import Any, TypeVar import azure.functions as func +from copilot.session import PermissionHandler as CopilotPermissionHandler from copilot.session import ProviderConfig from copilot.tools import Tool +from typing_extensions import Unpack from azurefunctions.agents.extensions.base import ( configure_app, @@ -14,7 +16,14 @@ ) from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent -from .provider import COPILOT_SDK_PROVIDER_ID, ClientFactory, PermissionHandler +from .options import CopilotSessionOptions +from .provider import ( + COPILOT_SDK_PROVIDER_ID, + ClientFactory, + PermissionHandler, + _cache_client_factory, + _default_client_factory, +) _F = TypeVar("_F", bound=Callable[..., Any]) @@ -26,10 +35,11 @@ def _provider_options( on_permission_request: PermissionHandler | None = None, provider: ProviderConfig | None = None, tools: Tool | Sequence[Tool] | None = None, + session_options: CopilotSessionOptions | None = None, ) -> dict[str, object]: options: dict[str, object] = {} if client_factory is not None: - options["client_factory"] = client_factory + options["client_factory"] = _cache_client_factory(client_factory) if model is not None: options["model"] = model if on_permission_request is not None: @@ -38,6 +48,8 @@ def _provider_options( options["provider"] = provider if tools is not None: options["tools"] = tools + if session_options is not None: + options["session_options"] = session_options return options @@ -52,6 +64,7 @@ def markdown_agent( on_permission_request: PermissionHandler | None = None, provider: ProviderConfig | None = None, tools: Tool | Sequence[Tool] | None = None, + **session_options: Unpack[CopilotSessionOptions], ) -> Callable[[_F], _F]: return base_markdown_agent( self, @@ -64,6 +77,7 @@ def markdown_agent( on_permission_request=on_permission_request, provider=provider, tools=tools, + session_options=session_options, ), ) @@ -77,12 +91,15 @@ class AgentFunctionApp( def __init__( self, *, - client_factory: ClientFactory, - model: str, + model: str | None = None, + client_factory: ClientFactory | None = None, app_root: str | os.PathLike[str] | None = None, - on_permission_request: PermissionHandler | None = None, + on_permission_request: PermissionHandler | None = ( + CopilotPermissionHandler.approve_all + ), provider: ProviderConfig | None = None, tools: Tool | Sequence[Tool] | None = None, + session_options: CopilotSessionOptions | None = None, http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, ) -> None: super().__init__(http_auth_level=http_auth_level) @@ -91,11 +108,16 @@ def __init__( provider=COPILOT_SDK_PROVIDER_ID, app_root=app_root, provider_options=_provider_options( - client_factory=client_factory, - model=model, + client_factory=( + client_factory + if client_factory is not None + else _default_client_factory + ), + model=(model if model is not None else os.environ["COPILOT_MODEL"]), on_permission_request=on_permission_request, provider=provider, tools=tools, + session_options=session_options, ), ) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py new file mode 100644 index 0000000..3f99a1c --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal, TypedDict + +from copilot import ToolSet +from copilot.canvas import ( + CanvasDeclaration, + CanvasHandler, + CanvasProviderIdentity, + ExtensionInfo, +) +from copilot.client import ( + AskUserVariant, + CapiSessionOptions, + CloudSessionOptions, + CopilotExpAssignmentResponse, + GitHubTokenProvider, + ManagedSettings, +) +from copilot.generated.rpc import RemoteSessionMode +from copilot.generated.session_events import SessionEvent +from copilot.session import ( + AutoModeSwitchHandler, + CommandDefinition, + ContextTier, + CreateSessionFsHandler, + CustomAgentConfig, + DefaultAgentConfig, + ElicitationHandler, + ExitPlanModeHandler, + GitHubMcpToolConfig, + InfiniteSessionConfig, + LargeToolOutputConfig, + McpAuthHandler, + MCPServerConfig, + MemoryConfiguration, + ModelCapabilitiesOverride, + NamedProviderConfig, + ProviderModelConfig, + ReasoningEffort, + ReasoningSummary, + SessionHooks, + SessionLimitsConfig, + SystemMessageConfig, + ToolSearchConfig, + UserInputHandler, +) + + +class CopilotSessionOptions(TypedDict, total=False): + """Optional arguments forwarded to ``CopilotClient.create_session``.""" + + session_id: str | None + client_name: str | None + reasoning_effort: ReasoningEffort | None + reasoning_summary: ReasoningSummary | None + enable_experimental_mode: bool | None + context_tier: ContextTier | None + system_message: SystemMessageConfig | None + tool_search: ToolSearchConfig | None + available_tools: list[str] | ToolSet | None + excluded_tools: list[str] | ToolSet | None + on_user_input_request: UserInputHandler | None + ask_user_variant: AskUserVariant | None + hooks: SessionHooks | None + working_directory: str | None + additional_directories: list[str] | None + capi: CapiSessionOptions | None + providers: list[NamedProviderConfig] | None + models: list[ProviderModelConfig] | None + enable_session_telemetry: bool | None + enable_citations: bool | None + enable_file_change_tracking: bool | None + excluded_builtin_agents: list[str] | None + session_limits: SessionLimitsConfig | None + skip_custom_instructions: bool | None + custom_agents_local_only: bool | None + coauthor_enabled: bool | None + manage_schedule_enabled: bool | None + model_capabilities: ModelCapabilitiesOverride | None + streaming: bool | None + include_sub_agent_streaming_events: bool | None + mcp_servers: dict[str, MCPServerConfig] | None + mcp_oauth_token_storage: Literal["persistent", "in-memory"] | None + auth_client_id_metadata_url: str | None + embedding_cache_storage: Literal["persistent", "in-memory"] | None + custom_agents: list[CustomAgentConfig] | None + default_agent: DefaultAgentConfig | dict[str, Any] | None + agent: str | None + config_directory: str | None + enable_config_discovery: bool | None + skip_embedding_retrieval: bool | None + organization_custom_instructions: str | None + enable_on_demand_instruction_discovery: bool | None + enable_file_hooks: bool | None + enable_host_git_operations: bool | None + enable_session_store: bool | None + enable_skills: bool | None + included_builtin_skills: list[str] | None + skill_directories: list[str] | None + plugin_directories: list[str] | None + instruction_directories: list[str] | None + disabled_skills: list[str] | None + disabled_mcp_servers: list[str] | None + infinite_sessions: InfiniteSessionConfig | None + large_output: LargeToolOutputConfig | None + memory: MemoryConfiguration | None + on_event: Callable[[SessionEvent], None] | None + commands: list[CommandDefinition] | None + on_elicitation_request: ElicitationHandler | None + on_mcp_auth_request: McpAuthHandler | None + enable_mcp_apps: bool + on_exit_plan_mode_request: ExitPlanModeHandler | None + on_auto_mode_switch_request: AutoModeSwitchHandler | None + create_session_fs_handler: CreateSessionFsHandler | None + github_token: str | None + github_token_provider: GitHubTokenProvider | None + remote_session: RemoteSessionMode | None + cloud: CloudSessionOptions | None + canvases: list[CanvasDeclaration] | None + request_canvas_renderer: bool | None + request_extensions: bool | None + extension_sdk_path: str | None + extension_info: ExtensionInfo | None + canvas_provider: CanvasProviderIdentity | None + canvas_handler: CanvasHandler | None + feature_flags: dict[str, bool] | None + exp_assignments: CopilotExpAssignmentResponse | None + enable_managed_settings: bool | None + github_mcp_tool_config: GitHubMcpToolConfig | None + managed_settings: ManagedSettings | None diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py index 086f647..cd8f4f6 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -4,7 +4,8 @@ import inspect import os import re -from collections.abc import Callable, Mapping, Sequence +import threading +from collections.abc import Awaitable, Callable, Mapping, Sequence from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from ipaddress import ip_address @@ -42,9 +43,13 @@ "model", "on_permission_request", "provider", + "session_options", "tools", } ) +_CREATE_SESSION_OPTIONS = frozenset( + inspect.signature(CopilotClient.create_session).parameters +) - {"self"} @dataclass(frozen=True) @@ -54,6 +59,53 @@ class _CopilotSdkOptions: on_permission_request: PermissionHandler | None provider: ProviderConfig | None tools: tuple[Tool, ...] + session_options: Mapping[str, object] + + +class _CachedClientFactory: + def __init__(self, factory: ClientFactory) -> None: + self._factory = factory + self._client: CopilotClient | None = None + self._lock = threading.Lock() + + def __call__(self) -> CopilotClient: + with self._lock: + if self._client is None: + client = self._factory() + if inspect.isawaitable(client): + if inspect.iscoroutine(client): + client.close() + raise TypeError( + "client_factory must return a CopilotClient synchronously, " + "not an awaitable" + ) + if not isinstance(client, CopilotClient): + raise TypeError("client_factory must return a CopilotClient") + self._client = client + return self._client + + +def _cache_client_factory(factory: ClientFactory) -> ClientFactory: + if isinstance(factory, _CachedClientFactory): + return factory + return _CachedClientFactory(factory) + + +def _default_client_factory() -> CopilotClient: + github_token = os.environ.get("COPILOT_GITHUB_TOKEN") + if not github_token: + raise ValueError("COPILOT_GITHUB_TOKEN must be set") + base_directory = os.environ.get("COPILOT_BASE_DIRECTORY") + if not base_directory: + raise ValueError("COPILOT_BASE_DIRECTORY must be set") + return CopilotClient( + mode="empty", + github_token=github_token, + base_directory=base_directory, + use_logged_in_user=False, + log_level="none", + telemetry=None, + ) @dataclass(frozen=True) @@ -69,18 +121,8 @@ async def open_agent( invocation: InvocationMetadata, ) -> AsyncIterator[CopilotSession]: client = self.options.client_factory() - if inspect.isawaitable(client): - if inspect.iscoroutine(client): - client.close() - raise TypeError( - "client_factory must return a CopilotClient synchronously, " - "not an awaitable" - ) - if not isinstance(client, CopilotClient): - raise TypeError("client_factory must return a CopilotClient") async with AsyncExitStack() as stack: - entered_client = await stack.enter_async_context(client) mcp_servers = await _build_mcp_servers( self.capabilities.mcp_servers, stack, @@ -93,31 +135,37 @@ async def open_agent( available_tools.add_mcp("*") for tool in self.options.tools: available_tools.add_custom(tool.name) - session = await entered_client.create_session( - model=self.options.model, - tools=list(self.options.tools), - available_tools=available_tools, - system_message=SystemMessageReplaceConfig( + create_session_options: dict[str, object] = { + "model": self.options.model, + "tools": list(self.options.tools), + "available_tools": available_tools, + "system_message": SystemMessageReplaceConfig( mode="replace", content=self.instructions, ), - provider=self.options.provider, - on_permission_request=self.options.on_permission_request, - streaming=False, - enable_config_discovery=False, - enable_session_telemetry=False, - request_extensions=False, - enable_session_store=False, - included_builtin_skills=[], - enable_skills=bool(skill_directories), - skill_directories=skill_directories, - mcp_servers=mcp_servers, - mcp_oauth_token_storage="in-memory", - skip_custom_instructions=True, - tool_search={"enabled": False}, - infinite_sessions={"enabled": False}, - memory={"enabled": False}, + "provider": self.options.provider, + "on_permission_request": self.options.on_permission_request, + "streaming": False, + "enable_config_discovery": False, + "enable_session_telemetry": False, + "request_extensions": False, + "enable_session_store": False, + "included_builtin_skills": [], + "enable_skills": bool(skill_directories), + "skill_directories": skill_directories, + "mcp_servers": mcp_servers, + "mcp_oauth_token_storage": "in-memory", + "skip_custom_instructions": True, + "tool_search": {"enabled": False}, + "infinite_sessions": {"enabled": False}, + "memory": {"enabled": False}, + } + create_session_options.update(self.options.session_options) + create_session = cast( + Callable[..., Awaitable[CopilotSession]], + client.create_session, ) + session = await create_session(**create_session_options) entered_session = await stack.enter_async_context(session) yield entered_session @@ -158,7 +206,7 @@ def compile_binding( ) client_factory = options.get("client_factory") if client_factory is None: - raise TypeError("client_factory option is required") + client_factory = _default_client_factory if not callable(client_factory): raise TypeError("client_factory must be callable") if inspect.iscoroutinefunction(client_factory): @@ -189,7 +237,9 @@ def compile_binding( instructions=instructions, agent_name=agent_name, options=_CopilotSdkOptions( - client_factory=cast(ClientFactory, client_factory), + client_factory=_cache_client_factory( + cast(ClientFactory, client_factory), + ), model=model.strip(), on_permission_request=cast( PermissionHandler | None, @@ -197,6 +247,9 @@ def compile_binding( ), provider=cast(ProviderConfig | None, provider), tools=_normalize_tools(options.get("tools")), + session_options=_normalize_session_options( + options.get("session_options") + ), ), capabilities=capabilities, ) @@ -215,6 +268,22 @@ def _normalize_tools(value: object) -> tuple[Tool, ...]: return cast(tuple[Tool, ...], tools) +def _normalize_session_options(value: object) -> Mapping[str, object]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise TypeError("session_options must be a mapping") + if any(not isinstance(name, str) for name in value): + raise TypeError("session_options keys must be strings") + options = cast(Mapping[str, object], value) + unknown = sorted(set(options) - _CREATE_SESSION_OPTIONS) + if unknown: + raise TypeError( + "Unsupported Copilot create_session option(s): " + ", ".join(unknown) + ) + return dict(options) + + def _skill_directories(skills: Sequence[SkillDefinition]) -> list[str]: return sorted({str(skill.path.parent) for skill in skills}) diff --git a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml index 8bd8896..fd529a6 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml +++ b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ dependencies = [ "azurefunctions-agents-extensions-base>=1.0.0b1", "github-copilot-sdk>=1.0.14,<2", + "typing-extensions>=4.12", ] [project.optional-dependencies] diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md index 7db1c21..87f7944 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md @@ -11,7 +11,31 @@ python -m copilot download-runtime Run the app with `func start`. The sample uses `mode="empty"`, disables SDK telemetry, and exposes only the discovered Skill and remote inventory MCP tool. -Set `COPILOT_BASE_DIRECTORY` to an isolated writable directory for the app's -runtime state; use separate directories when hosting multiple tenants. -`PermissionHandler.approve_all` is appropriate only when every configured tool -is trusted; use a restrictive handler for applications with broader tools. \ No newline at end of file +Configure `COPILOT_GITHUB_TOKEN`, `COPILOT_BASE_DIRECTORY`, and `COPILOT_MODEL` +using the exact names shown in `local.settings.template.json`. Set +`COPILOT_BASE_DIRECTORY` to an isolated writable directory for the app's runtime +state; use separate directories when hosting multiple tenants. +The extension creates one client lazily and reuses it for the lifetime of the +Python worker while creating a fresh session for each invocation. +`AgentFunctionApp()` defaults to `PermissionHandler.approve_all`, which is +appropriate only when every configured tool is trusted. Pass a restrictive +handler for applications with broader tools. + +## Compare with the direct Copilot SDK + +[`function_app.py`](function_app.py) uses `AgentFunctionApp` and the +`@app.markdown_agent` decorator to discover the Agent instructions, Skill, and +MCP server and inject a configured `CopilotSession` into each invocation. + +[`direct_sdk.py`](direct_sdk.py) performs the same order-readiness assessment +without the binding. It shows the client authentication, instruction loading, +Skill and MCP wiring, tool allowlist, session security options, lifecycle +management, and response validation that application code must otherwise own. + +The standalone script reads settings from its process environment; unlike Core +Tools, it does not load `local.settings.json`. Export the same `COPILOT_*` and +`INVENTORY_MCP_URL` values described above, then run: + +```text +python direct_sdk.py +``` diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/direct_sdk.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/direct_sdk.py new file mode 100644 index 0000000..9d25b67 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/direct_sdk.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +from copilot import CopilotClient, ToolSet +from copilot.session import ( + MCPHTTPServerConfig, + MCPStdioServerConfig, + PermissionHandler, + SystemMessageReplaceConfig, +) +from copilot.session_events import AssistantMessageData + +SAMPLE_ROOT = Path(__file__).resolve().parent + + +def required_setting(name: str) -> str: + value = os.environ.get(name) + if not value: + raise ValueError(f"{name} must be set") + return value + + +async def assess_order(order_id: str, order: dict[str, object]) -> str: + instructions = (SAMPLE_ROOT / "order-fulfillment.agent.md").read_text( + encoding="utf-8" + ) + skill_directories = [str(SAMPLE_ROOT / "skills")] + mcp_servers: dict[str, MCPStdioServerConfig | MCPHTTPServerConfig] = { + "inventory": { + "type": "http", + "url": required_setting("INVENTORY_MCP_URL"), + "tools": ["lookup_stock"], + } + } + available_tools = ToolSet() + available_tools.add_builtin("skill") + available_tools.add_mcp("*") + + async with CopilotClient( + mode="empty", + github_token=required_setting("COPILOT_GITHUB_TOKEN"), + base_directory=required_setting("COPILOT_BASE_DIRECTORY"), + use_logged_in_user=False, + log_level="none", + telemetry=None, + ) as client: + session = await client.create_session( + model=required_setting("COPILOT_MODEL"), + available_tools=available_tools, + system_message=SystemMessageReplaceConfig( + mode="replace", + content=instructions, + ), + on_permission_request=PermissionHandler.approve_all, + streaming=False, + enable_config_discovery=False, + enable_session_telemetry=False, + request_extensions=False, + enable_session_store=False, + included_builtin_skills=[], + enable_skills=True, + skill_directories=skill_directories, + mcp_servers=mcp_servers, + mcp_oauth_token_storage="in-memory", + skip_custom_instructions=True, + tool_search={"enabled": False}, + infinite_sessions={"enabled": False}, + memory={"enabled": False}, + ) + async with session: + response = await session.send_and_wait( + json.dumps( + { + "order_id": order_id, + "order": order, + "task": "assess fulfillment readiness", + } + ) + ) + + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") + if not isinstance(response.data.content, str): + raise RuntimeError("Copilot returned non-text assistant content") + return response.data.content + + +async def main() -> None: + assessment = await assess_order( + "sample-order-001", + { + "sku": "SKU-LOCAL-001", + "quantity": 2, + "destination": "Seattle", + "priority": "standard", + }, + ) + print(json.dumps({"assessment": assessment}, indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py index d9c3e82..9657585 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py @@ -1,36 +1,11 @@ import json -import os import azure.functions as func -from copilot import CopilotClient -from copilot.session import CopilotSession, PermissionHandler +from copilot.session import CopilotSession from copilot.session_events import AssistantMessageData from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp - -def create_copilot_client() -> CopilotClient: - return CopilotClient( - mode="empty", - github_token=os.environ["COPILOT_GITHUB_TOKEN"], - base_directory=os.environ["COPILOT_BASE_DIRECTORY"], - use_logged_in_user=False, - log_level="none", - telemetry=None, - ) - - -app = AgentFunctionApp( - client_factory=create_copilot_client, - model=os.environ["COPILOT_MODEL"], - on_permission_request=PermissionHandler.approve_all, -) - - -async def _run(session: CopilotSession, prompt: str) -> str: - response = await session.send_and_wait(prompt) - if response is None or not isinstance(response.data, AssistantMessageData): - raise RuntimeError("Copilot did not return a final assistant message") - return response.data.content +app = AgentFunctionApp() @app.route(route="orders/{orderId}", methods=["POST"]) @@ -48,8 +23,7 @@ async def process_order( mimetype="application/json", ) - assessment = await _run( - order_agent, + response = await order_agent.send_and_wait( json.dumps( { "order_id": req.route_params["orderId"], @@ -58,8 +32,10 @@ async def process_order( } ), ) + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") return func.HttpResponse( - body=json.dumps({"assessment": assessment}), + body=json.dumps({"assessment": response.data.content}), mimetype="application/json", ) @@ -74,4 +50,8 @@ async def process_order_event( message: func.QueueMessage, order_agent: CopilotSession, ) -> None: - await _run(order_agent, message.get_body().decode("utf-8")) \ No newline at end of file + response = await order_agent.send_and_wait( + message.get_body().decode("utf-8") + ) + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md index 5158157..2400c04 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md @@ -4,8 +4,14 @@ This sample schedules Copilot SDK Agent calls as replay-safe Durable activities. Install `requirements.txt`, configure the settings from `local.settings.template.json`, pre-provision the native runtime with `python -m copilot download-runtime`, and run `func start`. -Set `COPILOT_BASE_DIRECTORY` to an isolated writable directory for the app's -runtime state; use separate directories when hosting multiple tenants. +Configure `COPILOT_GITHUB_TOKEN`, `COPILOT_BASE_DIRECTORY`, and `COPILOT_MODEL` +using the exact names shown in `local.settings.template.json`. Set +`COPILOT_BASE_DIRECTORY` to an isolated writable directory for the app's runtime +state; use separate directories when hosting multiple tenants. +The extension creates one client lazily and reuses it for the lifetime of the +Python worker while creating a fresh session for each activity invocation. +`AgentFunctionApp()` defaults to `PermissionHandler.approve_all`; pass a +restrictive handler unless every configured tool is trusted. The orchestrator only schedules `context.call_agent(...)`; model and tool work runs in the extension's hidden activity. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py index 0f6e08f..e513e1f 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py @@ -1,32 +1,13 @@ import json -import os import azure.durable_functions as df import azure.functions as func -from copilot import CopilotClient -from copilot.session import PermissionHandler from azurefunctions.agents.extensions.copilot_sdk import ( AgentFunctionApp, DurableAgentContext, ) - -def create_copilot_client() -> CopilotClient: - return CopilotClient( - mode="empty", - github_token=os.environ["COPILOT_GITHUB_TOKEN"], - base_directory=os.environ["COPILOT_BASE_DIRECTORY"], - use_logged_in_user=False, - log_level="none", - telemetry=None, - ) - - -app = AgentFunctionApp( - client_factory=create_copilot_client, - model=os.environ["COPILOT_MODEL"], - on_permission_request=PermissionHandler.approve_all, -) +app = AgentFunctionApp() @app.route(route="orders/orchestrations", methods=["POST"]) diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py index 590d27c..6a77cd3 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py @@ -4,18 +4,25 @@ from unittest.mock import Mock import azure.functions as func -from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp, apps +from copilot import CopilotClient +from copilot.session import PermissionHandler +from azurefunctions.agents.extensions.copilot_sdk import ( + AgentFunctionApp, + CopilotSessionOptions, + apps, +) def test_typed_api_exposes_only_v1_options(): assert list(inspect.signature(AgentFunctionApp.__init__).parameters) == [ "self", - "client_factory", "model", + "client_factory", "app_root", "on_permission_request", "provider", "tools", + "session_options", "http_auth_level", ] assert list(inspect.signature(AgentFunctionApp.markdown_agent).parameters) == [ @@ -27,7 +34,25 @@ def test_typed_api_exposes_only_v1_options(): "on_permission_request", "provider", "tools", + "session_options", ] + assert ( + inspect.signature(AgentFunctionApp.markdown_agent) + .parameters["session_options"] + .kind + is inspect.Parameter.VAR_KEYWORD + ) + assert "streaming" in CopilotSessionOptions.__optional_keys__ + + +def test_typed_session_options_match_create_session(): + top_level_options = {"self", "model", "on_permission_request", "provider", "tools"} + create_session_options = ( + set(inspect.signature(CopilotClient.create_session).parameters) + - top_level_options + ) + + assert CopilotSessionOptions.__optional_keys__ == create_session_options def test_typed_agent_function_app_pins_copilot_provider(monkeypatch): @@ -36,20 +61,38 @@ def test_typed_agent_function_app_pins_copilot_provider(monkeypatch): monkeypatch.setattr(func.FunctionApp, "__init__", parent_init) monkeypatch.setattr(apps, "configure_app", configure_app) factory = lambda: object() + permission_handler = lambda *args: None - app = AgentFunctionApp( + AgentFunctionApp( client_factory=factory, model="gpt-5", app_root="app", + on_permission_request=permission_handler, ) parent_init.assert_called_once_with(http_auth_level=func.AuthLevel.FUNCTION) - configure_app.assert_called_once_with( - app, - provider="copilot_sdk", - app_root="app", - provider_options={"client_factory": factory, "model": "gpt-5"}, - ) + options = configure_app.call_args.kwargs["provider_options"] + assert callable(options["client_factory"]) + assert options["client_factory"] is not factory + assert options["model"] == "gpt-5" + assert options["on_permission_request"] is permission_handler + assert configure_app.call_args.kwargs["provider"] == "copilot_sdk" + assert configure_app.call_args.kwargs["app_root"] == "app" + + +def test_typed_agent_function_app_configures_default_client(monkeypatch): + parent_init = Mock() + configure_app = Mock() + monkeypatch.setattr(func.FunctionApp, "__init__", parent_init) + monkeypatch.setattr(apps, "configure_app", configure_app) + monkeypatch.setenv("COPILOT_MODEL", "gpt-5") + + AgentFunctionApp() + + options = configure_app.call_args.kwargs["provider_options"] + assert callable(options["client_factory"]) + assert options["model"] == "gpt-5" + assert options["on_permission_request"] is PermissionHandler.approve_all def test_agent_function_app_uses_function_app_directly(): @@ -67,14 +110,20 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): agent_name="orders", client_factory=factory, model="gpt-5", + streaming=True, + reasoning_effort="high", ) assert result is parent_decorator.return_value - parent_decorator.assert_called_once_with( - app, - provider="copilot_sdk", - arg_name="agent", - agent_name="orders", - client_factory=factory, - model="gpt-5", - ) + call = parent_decorator.call_args + assert call.args == (app,) + assert call.kwargs["provider"] == "copilot_sdk" + assert call.kwargs["arg_name"] == "agent" + assert call.kwargs["agent_name"] == "orders" + assert callable(call.kwargs["client_factory"]) + assert call.kwargs["client_factory"] is not factory + assert call.kwargs["model"] == "gpt-5" + assert call.kwargs["session_options"] == { + "streaming": True, + "reasoning_effort": "high", + } diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py index d9a9581..70554f7 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py @@ -7,6 +7,7 @@ def test_copilot_sdk_exports_supported_api(): from azurefunctions.agents.extensions.base.durable import DurableAgentContext assert copilot_sdk.AgentFunctionApp is not None + assert copilot_sdk.CopilotSessionOptions is not None assert copilot_sdk.DurableAgentContext is DurableAgentContext assert copilot_sdk.COPILOT_SDK_PROVIDER_ID == "copilot_sdk" assert not hasattr(copilot_sdk, "markdown_agent") diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py index c5c7ae5..2b853ba 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -2,6 +2,8 @@ import asyncio import inspect +import time +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from types import SimpleNamespace @@ -46,10 +48,11 @@ async def send_and_wait(self, prompt: str): class _Client: created: list[_Client] = [] - def __init__(self) -> None: + def __init__(self, **options) -> None: self.entered = False self.closed = False - self.session = _Session() + self.client_options = options + self.sessions: list[_Session] = [] self.session_options = None self.created.append(self) @@ -61,8 +64,10 @@ async def __aexit__(self, exc_type, exc_value, traceback): self.closed = True async def create_session(self, **options): + session = _Session() + self.sessions.append(session) self.session_options = options - return self.session + return session @pytest.fixture(autouse=True) @@ -83,7 +88,7 @@ def _compile(*, capabilities=AgentCapabilities(), **overrides): ) -def test_binding_creates_and_closes_fresh_clients_and_sessions(): +def test_binding_reuses_client_and_closes_fresh_sessions(): binding = _compile() async def invoke_twice(): @@ -94,9 +99,10 @@ async def invoke_twice(): asyncio.run(invoke_twice()) - assert len(_Client.created) == 2 - assert all(client.closed for client in _Client.created) - assert all(client.session.closed for client in _Client.created) + assert len(_Client.created) == 1 + assert not _Client.created[0].closed + assert len(_Client.created[0].sessions) == 2 + assert all(session.closed for session in _Client.created[0].sessions) options = _Client.created[0].session_options assert options["model"] == "gpt-5" assert options["system_message"] == { @@ -109,24 +115,93 @@ async def invoke_twice(): assert list(options["available_tools"]) == [] +def test_binding_applies_create_session_overrides(): + binding = _compile( + session_options={ + "streaming": True, + "enable_session_store": True, + "reasoning_effort": "high", + "system_message": {"mode": "append", "content": "extra"}, + } + ) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + options = _Client.created[0].session_options + assert options["streaming"] is True + assert options["enable_session_store"] is True + assert options["reasoning_effort"] == "high" + assert options["system_message"] == {"mode": "append", "content": "extra"} + + +def test_provider_rejects_unknown_create_session_override(): + with pytest.raises(TypeError, match="Unsupported Copilot create_session option"): + _compile(session_options={"not_a_session_option": True}) + + +def test_client_cache_is_safe_during_concurrent_first_use(): + factory_calls = 0 + + def create_client(): + nonlocal factory_calls + factory_calls += 1 + time.sleep(0.01) + return _Client() + + binding = _compile(client_factory=create_client) + + with ThreadPoolExecutor(max_workers=8) as executor: + clients = list( + executor.map( + lambda _: binding.options.client_factory(), + range(8), + ) + ) + + assert factory_calls == 1 + assert all(client is clients[0] for client in clients) + + +def test_app_level_client_cache_is_shared_across_bindings(): + client_factory = provider._cache_client_factory(_Client) + + first = _compile(client_factory=client_factory) + second = _compile(client_factory=client_factory) + + assert first.options.client_factory() is second.options.client_factory() + assert len(_Client.created) == 1 + + def test_binding_run_agent_returns_assistant_content(): assert ( asyncio.run(_compile().run_agent("hello", InvocationMetadata())) == "response:hello" ) - assert _Client.created[0].session.prompts == ["hello"] - assert _Client.created[0].closed + assert _Client.created[0].sessions[0].prompts == ["hello"] + assert not _Client.created[0].closed -def test_provider_validates_required_options_and_annotation(): - with pytest.raises(TypeError, match="client_factory"): - provider.CopilotSdkProvider().compile_binding( - instructions="instructions", - agent_name="orders", - options={"model": "gpt-5"}, - annotation=CopilotSession, - capabilities=AgentCapabilities(), - ) +def test_provider_uses_secure_default_client_and_validates_options(monkeypatch): + monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "github-token") + monkeypatch.setenv("COPILOT_BASE_DIRECTORY", "copilot-data") + binding = provider.CopilotSdkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"model": "gpt-5"}, + annotation=CopilotSession, + capabilities=AgentCapabilities(), + ) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].client_options == { + "mode": "empty", + "github_token": "github-token", + "base_directory": "copilot-data", + "use_logged_in_user": False, + "log_level": "none", + "telemetry": None, + } with pytest.raises(TypeError, match="model option"): _compile(model="") with pytest.raises(TypeError, match="CopilotSession"): @@ -151,7 +226,7 @@ def test_provider_accepts_missing_annotation_for_durable_activity(): assert binding.agent_name == "orders" -def test_factory_and_invocation_errors_still_close_resources(monkeypatch): +def test_session_creation_error_keeps_cached_client_open(monkeypatch): binding = _compile() async def fail_create(self, **options): @@ -162,7 +237,7 @@ async def fail_create(self, **options): with pytest.raises(RuntimeError, match="session failed"): asyncio.run(binding.run_agent("hello", InvocationMetadata())) - assert _Client.created[0].closed + assert not _Client.created[0].closed def test_binding_maps_discovered_skills_and_mcp(monkeypatch): @@ -244,10 +319,10 @@ def test_mcp_credentials_require_https(monkeypatch): with pytest.raises(ValueError, match="must use HTTPS"): asyncio.run(binding.run_agent("hello", InvocationMetadata())) - assert _Client.created[0].closed + assert not _Client.created[0].closed -def test_cancellation_closes_client_and_session(monkeypatch): +def test_cancellation_closes_session_but_keeps_cached_client_open(monkeypatch): async def cancel(self, prompt): raise asyncio.CancelledError @@ -256,8 +331,8 @@ async def cancel(self, prompt): with pytest.raises(asyncio.CancelledError): asyncio.run(_compile().run_agent("hello", InvocationMetadata())) - assert _Client.created[0].closed - assert _Client.created[0].session.closed + assert not _Client.created[0].closed + assert _Client.created[0].sessions[0].closed @pytest.mark.parametrize( @@ -273,8 +348,8 @@ async def send(self, prompt): with pytest.raises(TypeError, match="final assistant message"): asyncio.run(_compile().run_agent("hello", InvocationMetadata())) - assert _Client.created[0].closed - assert _Client.created[0].session.closed + assert not _Client.created[0].closed + assert _Client.created[0].sessions[0].closed def test_binding_rejects_non_string_assistant_content(monkeypatch): @@ -290,8 +365,8 @@ async def send(self, prompt): with pytest.raises(TypeError, match="content must be a string"): asyncio.run(_compile().run_agent("hello", InvocationMetadata())) - assert _Client.created[0].closed - assert _Client.created[0].session.closed + assert not _Client.created[0].closed + assert _Client.created[0].sessions[0].closed @pytest.mark.parametrize("factory_kind", ["async_callable", "returns_awaitable"]) diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py index 45e5c1c..4101197 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py @@ -73,29 +73,25 @@ def test_direct_sample_assets_follow_discovery_conventions(): "sample_name", ["agent_samples_copilot-sdk", "agent_samples_copilot-sdk_durable"], ) -def test_sample_client_factories_use_explicit_storage(sample_name, tmp_path): - environment = os.environ.copy() - environment["PYTHONPATH"] = os.pathsep.join( - filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) - ) - environment.update( - { - "COPILOT_BASE_DIRECTORY": str(tmp_path), - "COPILOT_GITHUB_TOKEN": "factory-only-placeholder", - "COPILOT_MODEL": "gpt-5", - "INVENTORY_MCP_URL": "https://inventory.example.test/mcp", - } - ) +def test_samples_use_extension_default_client(sample_name): + source = (_SAMPLES_ROOT / sample_name / "function_app.py").read_text() - subprocess.run( - [ - sys.executable, - "-c", - "import function_app; function_app.create_copilot_client()", - ], - cwd=_SAMPLES_ROOT / sample_name, - env=environment, - check=True, - capture_output=True, - text=True, + assert "CopilotClient" not in source + assert "client_factory=" not in source + assert "app = AgentFunctionApp()" in source + + +@pytest.mark.parametrize( + "sample_name", + ["agent_samples_copilot-sdk", "agent_samples_copilot-sdk_durable"], +) +def test_sample_templates_use_standard_copilot_settings(sample_name): + settings = json.loads( + (_SAMPLES_ROOT / sample_name / "local.settings.template.json").read_text() ) + + assert { + "COPILOT_GITHUB_TOKEN", + "COPILOT_BASE_DIRECTORY", + "COPILOT_MODEL", + } <= settings["Values"].keys() From e6454139f920aa5ada225b5838fa6a86adb9c018 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 1 Oct 2026 15:50:27 -0500 Subject: [PATCH 04/11] fix: address Copilot SDK binding review feedback --- .github/skills/add-agent-binding/SKILL.md | 367 ++++++++++++++++++ .../agents/extensions/copilot_sdk/apps.py | 4 +- .../agents/extensions/copilot_sdk/provider.py | 15 +- .../pyproject.toml | 5 +- .../tests/test_apps.py | 38 ++ .../tests/test_provider.py | 55 ++- 6 files changed, 471 insertions(+), 13 deletions(-) create mode 100644 .github/skills/add-agent-binding/SKILL.md diff --git a/.github/skills/add-agent-binding/SKILL.md b/.github/skills/add-agent-binding/SKILL.md new file mode 100644 index 0000000..17306a0 --- /dev/null +++ b/.github/skills/add-agent-binding/SKILL.md @@ -0,0 +1,367 @@ +--- +name: add-agent-binding +description: 'Add a new SDK-backed Agent binding provider to azure-functions-python-extensions. USE WHEN: integrating another agent SDK, creating an AgentFunctionApp and markdown_agent decorator, injecting an SDK Agent/session type, forwarding SDK creation arguments, implementing provider lifecycle or capability adapters, adding Durable support, samples, tests, packaging, or CI wiring. NOT FOR: adding features to an existing provider without a new binding package, Connector SDK types (use add-connector-type), or application-only Agent samples.' +--- + +# Add an Agent Binding + +Use this workflow to add an SDK-backed provider package built on +`azurefunctions-agents-extensions-base`. + +The binding should let customers author a normal Azure Function while receiving +the SDK's native Agent or session type as an injected parameter. Keep SDK setup, +configuration discovery, lifecycle management, and Durable execution inside the +extension. + +## Reference Implementations + +Choose the nearest existing provider before writing code: + +| Provider | Use as a reference when | +| --- | --- | +| `azurefunctions-agents-extensions-agent-framework` | The SDK creates a fresh client and Agent for each invocation. | +| `azurefunctions-agents-extensions-copilot-sdk` | The SDK has a process-owning client that should be cached while sessions remain invocation-scoped. | +| `azurefunctions-agents-extensions-base` | The provider contract, discovery model, binding injection, and Durable bridge need clarification. | + +Do not copy a lifecycle merely because it exists. Determine what the target SDK +owns and how each object is safely closed. + +## Required Decisions + +Before editing, identify and record these decisions in the working notes or PR: + +| Decision | Questions to answer | +| --- | --- | +| Injected SDK type | What native SDK type should the Function parameter receive? Is it async-context-managed? | +| Client lifecycle | Is the client cheap and invocation-scoped, or a process/runtime owner that must be cached? Is construction thread-safe? | +| Invocation lifecycle | What fresh Agent/session/context is created per invocation, and how is it closed on success, failure, and cancellation? | +| Execution API | How does the native object execute a prompt, and how is the final text validated and returned from `run_agent()`? | +| Configuration | Which settings have secure defaults? Which require explicit environment variables or factories? | +| SDK arguments | Which `create_*` arguments should customers pass directly on `@app.markdown_agent(...)`? Which names are reserved by the extension? | +| Capabilities | Can the SDK support discovered Skills and remote HTTP MCP without changing their meaning or security? | +| Durable | Can a compiled binding execute from the shared Durable activity without relying on a live injected object? | + +If one of these is unknown, inspect the installed SDK signatures and lifecycle +documentation or run a focused probe before implementing it. + +## Package Shape + +Use the provider ID `{provider_id}` and normalized package suffix +`{provider-id}`: + +```text +azurefunctions-agents-extensions-{provider-id}/ +├── LICENSE +├── MANIFEST.in +├── README.md +├── pyproject.toml +├── azurefunctions/agents/extensions/{provider_module}/ +│ ├── __init__.py +│ ├── apps.py +│ ├── provider.py +│ └── py.typed +├── samples/ +└── tests/ +``` + +Add another module such as `options.py` only when it provides meaningful public +typing or separates substantial logic. Do not create abstractions solely to +match another provider's file count. + +## Procedure + +### 1. Define the Public Customer Experience + +Start with the intended Function code. The injected parameter should use the +SDK-native type: + +```python +from target_sdk import NativeAgent +from azurefunctions.agents.extensions.target import AgentFunctionApp + +app = AgentFunctionApp() + + +@app.route(route="orders", methods=["POST"]) +@app.markdown_agent( + arg_name="agent", + agent_name="orders", + streaming=True, + reasoning_effort="high", +) +async def process_order(req: func.HttpRequest, agent: NativeAgent) -> str: + ... +``` + +Use the SDK's names and types where practical. Avoid inventing an extension-only +configuration vocabulary for arguments the SDK already exposes. + +### 2. Register the Provider Package + +In `pyproject.toml`: + +- Name the distribution `azurefunctions-agents-extensions-{provider-id}`. +- Depend on `azurefunctions-agents-extensions-base` and the target SDK. +- Add `mcp`, `durable`, and `dev` extras only when applicable. +- Include `py.typed` as package data. +- Register a zero-argument provider factory: + +```toml +[project.entry-points."azurefunctions.agents.extensions.providers"] +{provider_id} = "azurefunctions.agents.extensions.{provider_module}.provider:create_provider" +``` + +The entry-point name, `provider_id`, and normalized distribution name must agree. + +### 3. Implement the Provider Contract + +In `provider.py`: + +1. Define a stable provider ID constant. +2. Implement an immutable compiled binding that satisfies `CompiledAgent`: + - `open_agent(invocation)` returns an async context manager yielding the + injected SDK-native object. + - `run_agent(prompt, invocation)` opens the same object, executes one prompt, + validates the SDK response, and returns text for Durable activities. +3. Implement `AgentProvider` with: + - `provider_id` + - `distribution_name` + - `supported_capabilities` + - `compile_binding(...)` +4. Validate the injected parameter annotation when one is present. Durable + compilation may provide `inspect.Signature.empty`, which must remain valid. +5. Validate factories synchronously and reject awaitable results with a clear + error rather than leaking an unawaited coroutine. +6. Reject unsupported provider options with a sorted, provider-specific error. +7. Return a zero-argument `create_provider()` factory. + +Keep compilation side-effect-free. It should validate and freeze a recipe, not +connect to networks or create invocation-scoped SDK objects. + +### 4. Design and Test Lifecycle Ownership + +Implement the lifecycle established in the Required Decisions section: + +- Use `AsyncExitStack` when several SDK, MCP, credential, or session contexts + must close together. +- Create fresh invocation state unless the SDK explicitly requires a shared + process owner. +- If a client is cached, make first creation thread-safe, validate its concrete + type, share it only at the intended app/binding scope, and keep sessions fresh. +- Close invocation objects on normal return, exception, and cancellation. +- Do not close a cached process client after every invocation. +- Document shutdown limitations if the host does not provide an awaited + application-lifetime hook. + +At minimum, tests must prove object counts, identity, entered/exited state, +concurrent first use when caching, and failure/cancellation cleanup. + +### 5. Expose SDK Creation Arguments Directly + +The decorator should accept the arguments customers would pass to the target +SDK's Agent/session creation method: + +```python +def markdown_agent( + self, + *, + arg_name: str, + agent_name: str, + client_factory: ClientFactory | None = None, + **sdk_options: Unpack[SdkCreationOptions], +) -> Callable[[_F], _F]: + ... +``` + +Follow these rules: + +- Keep `arg_name` and `agent_name` extension-owned. +- Reserve only options the extension must interpret, such as + `client_factory`, provider credentials, or custom tools. +- Forward all other supported SDK options unchanged to the SDK creation call. +- Apply customer values after extension defaults so explicit values win. +- Provide a `TypedDict` plus `Unpack` when the SDK does not publish a reusable + typed kwargs object. Match the installed SDK signature exactly. +- At runtime, compare option names with `inspect.signature(...)` or an equally + authoritative SDK surface and fail with a clear list of unsupported names. +- Do not silently drop unknown options. +- Test representative direct arguments, extension-default overrides, and an + unsupported argument. + +If an SDK argument collides with an extension-owned name, document and test the +chosen mapping. Prefer a top-level decorator argument when it is central to the +binding, while still passing the corresponding value to the SDK. + +### 6. Add `AgentFunctionApp` + +In `apps.py`: + +- Subclass `azure.functions.FunctionApp` through a provider-specific mixin. +- Configure the provider once with `configure_app(...)`. +- Keep `app_root` app-scoped; never expose it as a per-binding option. +- Put app-wide defaults on `AgentFunctionApp(...)`. +- Let decorator values override app defaults for only that binding. +- Delegate `orchestration_trigger(...)` to `durable_orchestration_trigger(...)` + when Durable is supported. +- Export only intentional public symbols from `__init__.py`. + +The provider package owns this app class. Do not require changes to +`azure.functions.FunctionApp` for a provider-specific API. + +### 7. Translate Discovered Capabilities + +Declare only capabilities the provider actually implements. Current neutral +capabilities are `skills` and `mcp`. + +For Skills: + +- Consume `SkillDefinition` paths supplied by the base package. +- Let the SDK load content when that preserves its native behavior. +- Do not rediscover or reinterpret app files in the provider. + +For MCP: + +- Consume `MCPServerDefinition` values from the base package. +- Preserve server names, remote HTTP URLs, tool allowlists, headers, and Entra + authentication semantics. +- Expand `$VAR` and `%VAR%` references at invocation time and report all missing + names clearly. +- Require HTTPS when credentials or headers leave loopback. +- Keep credentials and tokens out of logs and persisted config. +- Acquire and close credentials at the narrowest safe scope; refresh tokens per + request when the SDK transport permits it. + +If the SDK cannot preserve a capability contract, omit it from +`supported_capabilities`; the base package will reject the configuration. + +### 8. Add Durable Support + +When supported: + +- Add the base package's `durable` extra. +- Export `DurableAgentContext`. +- Add `orchestration_trigger(...)` to the provider app. +- Ensure `run_agent()` uses the same compiled instructions, options, and + capabilities as direct injection. +- Keep SDK calls in the generated activity, never in deterministic orchestrator + code. +- Test hidden activity registration, serialization, execution, failures, and + missing parameter annotations. + +Add a Durable sample with an HTTP starter, orchestrator, and Agent activity. +Run it with Core Tools and poll a real instance to a terminal state. + +### 9. Add Samples That Demonstrate the Value + +Provide a minimal non-Durable sample containing: + +- `function_app.py` +- `host.json` +- `local.settings.template.json` with placeholders, never secrets +- `requirements.txt` +- at least one raw `.agent.md` file +- Skill and `mcp.json` assets when those capabilities are supported +- a concise README with runtime provisioning and local-run steps + +Use the extension's defaults in the primary sample. Do not make customers +recreate the client factory merely to run the happy path. + +When the SDK setup is non-trivial, add a standalone direct-SDK script that +performs the same task. It should honestly show the authentication, client and +session lifecycle, instruction loading, capability wiring, option defaults, and +response validation that the binding removes. + +Add sample-indexing tests that import `function_app.py` in a subprocess and +assert the exact Function names without making live SDK calls. + +### 10. Document the Binding + +The package README must state: + +- The SDK-native type injected by the binding. +- What client is created by default and which settings it reads. +- Which object is cached and which object is fresh per invocation. +- That `@app.markdown_agent(...)` accepts SDK creation arguments directly, with + a short example such as `streaming=True` and `reasoning_effort="high"`. +- Reserved extension arguments and app-wide defaults. +- Permission/security defaults and when customers must replace them. +- Skill, MCP, Durable, authentication, and runtime-provisioning behavior. +- How to supply an advanced custom client factory. + +Favor customer behavior over internal helper type names. + +### 11. Add Tests + +Mirror the provider modules under `tests/`. Cover: + +- Public imports and package version. +- Provider entry-point discovery and metadata. +- Correct annotation acceptance and rejection. +- Required, defaulted, malformed, and unknown options. +- Direct forwarding of SDK creation arguments. +- App defaults and per-binding overrides. +- Native response extraction and malformed responses. +- Client and invocation lifecycle, including cancellation and concurrency. +- Skills and MCP mapping, allowlists, environment expansion, HTTPS rules, + authentication, and cleanup. +- Durable compilation and activity execution when supported. +- Sample indexing and standard setting names. + +Use SDK fakes for focused unit tests. Add a real local smoke test for the primary +sample and Durable workflow when credentials and emulators are available. + +### 12. Wire Build and Release Pipelines + +Add the package to all relevant matrices and jobs, not just the PR build: + +- `eng/templates/jobs/build.yml` +- `eng/templates/official/jobs/build-artifacts.yml` +- `eng/templates/official/jobs/unit-tests.yml` + +Install the editable base package before provider tests. Build both the source +distribution and wheel, and ensure package imports work from the built artifact. + +## Validation + +Run from the new package directory using its supported Python versions: + +```text +python -m pip install -U -e .[dev] +python -m flake8 azurefunctions tests samples +python -m mypy azurefunctions +python -m pytest -q --instafail tests +python -m build +python -m twine check dist/* +``` + +Adjust paths only to match established package configuration. Also run: + +- Sample indexing tests. +- A direct local invocation through Core Tools. +- A complete Durable orchestration when Durable is supported. +- `git diff --check` over the changed files. + +## PR Description + +Keep the PR summary customer-facing and concise. Include: + +1. The package and SDK being integrated. +2. The native SDK type injected into Functions. +3. The default client and client/session lifecycle. +4. That SDK creation arguments can be passed directly to + `@app.markdown_agent(...)`, with one short code example. +5. Supported Skills, MCP, Durable, samples, and validation. + +Do not lead with internal typing helpers or implementation-only classes. + +## Definition of Done + +- Provider package and entry point are discoverable. +- SDK-native injection works with a fresh invocation context. +- Client ownership and cleanup behavior are explicit and tested. +- SDK creation arguments are directly usable and unknown names fail clearly. +- Supported capabilities preserve the base package contracts. +- Direct and Durable execution share one compiled binding behavior. +- Samples show both the simple binding path and, when useful, direct SDK setup. +- README, tests, build, official artifacts, and official test pipelines are updated. +- Focused checks and relevant local end-to-end workflows pass. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py index 53fc64c..a1d095a 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py @@ -45,7 +45,7 @@ def _provider_options( if on_permission_request is not None: options["on_permission_request"] = on_permission_request if provider is not None: - options["provider"] = provider + options["session_provider"] = provider if tools is not None: options["tools"] = tools if session_options is not None: @@ -77,7 +77,7 @@ def markdown_agent( on_permission_request=on_permission_request, provider=provider, tools=tools, - session_options=session_options, + session_options=(session_options or None), ), ) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py index cd8f4f6..e691a77 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -42,7 +42,7 @@ "client_factory", "model", "on_permission_request", - "provider", + "session_provider", "session_options", "tools", } @@ -123,10 +123,13 @@ async def open_agent( client = self.options.client_factory() async with AsyncExitStack() as stack: - mcp_servers = await _build_mcp_servers( - self.capabilities.mcp_servers, - stack, - ) + if "mcp_servers" in self.options.session_options: + mcp_servers = self.options.session_options["mcp_servers"] + else: + mcp_servers = await _build_mcp_servers( + self.capabilities.mcp_servers, + stack, + ) skill_directories = _skill_directories(self.capabilities.skills) available_tools = ToolSet() if skill_directories: @@ -229,7 +232,7 @@ def compile_binding( permission_handler = options.get("on_permission_request") if permission_handler is not None and not callable(permission_handler): raise TypeError("on_permission_request must be callable") - provider = options.get("provider") + provider = options.get("session_provider") if provider is not None and not isinstance(provider, Mapping): raise TypeError("provider must be a Copilot SDK ProviderConfig mapping") diff --git a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml index fd529a6..6f06622 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml +++ b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "azurefunctions-agents-extensions-copilot-sdk" dynamic = ["version"] -requires-python = ">=3.10" +requires-python = ">=3.13" authors = [ { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, ] @@ -16,9 +16,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "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", "Operating System :: Microsoft :: Windows", diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py index 6a77cd3..b813105 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py @@ -127,3 +127,41 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): "streaming": True, "reasoning_effort": "high", } + + +def test_typed_markdown_agent_forwards_sdk_provider_without_keyword_collision( + monkeypatch, +): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", parent_decorator) + app = object.__new__(AgentFunctionApp) + sdk_provider = { + "type": "openai", + "wire_api": "responses", + "base_url": "https://models.example.test", + "api_key": "test-key", + } + + result = app.markdown_agent( + arg_name="agent", + agent_name="orders", + provider=sdk_provider, + ) + + assert result is parent_decorator.return_value + call = parent_decorator.call_args + assert call.kwargs["provider"] == "copilot_sdk" + assert call.kwargs["session_provider"] is sdk_provider + + +def test_typed_markdown_agent_omits_empty_session_options(monkeypatch): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", parent_decorator) + app = object.__new__(AgentFunctionApp) + + app.markdown_agent( + arg_name="agent", + agent_name="orders", + ) + + assert "session_options" not in parent_decorator.call_args.kwargs diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py index 2b853ba..f010352 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -134,6 +134,20 @@ def test_binding_applies_create_session_overrides(): assert options["system_message"] == {"mode": "append", "content": "extra"} +def test_binding_applies_sdk_provider(): + sdk_provider = { + "type": "openai", + "wire_api": "responses", + "base_url": "https://models.example.test", + "api_key": "test-key", + } + binding = _compile(session_provider=sdk_provider) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].session_options["provider"] is sdk_provider + + def test_provider_rejects_unknown_create_session_override(): with pytest.raises(TypeError, match="Unsupported Copilot create_session option"): _compile(session_options={"not_a_session_option": True}) @@ -283,6 +297,45 @@ async def invoke(): assert list(options["available_tools"]) == ["builtin:skill", "mcp:*"] +@pytest.mark.parametrize( + ("mcp_servers", "expected_available_tools"), + [ + ({}, []), + ( + { + "custom": { + "type": "http", + "url": "https://custom.example.test/mcp", + "tools": ["lookup"], + } + }, + ["mcp:*"], + ), + ], +) +def test_explicit_mcp_servers_replace_discovered_servers( + mcp_servers, + expected_available_tools, +): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "discovered", + MCPHTTPConfig("$MISSING_MCP_URL"), + ), + ), + ), + session_options={"mcp_servers": mcp_servers}, + ) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + options = _Client.created[0].session_options + assert options["mcp_servers"] == mcp_servers + assert list(options["available_tools"]) == expected_available_tools + + def test_binding_allowlists_explicit_python_tools(): binding = _compile( tools=Tool( @@ -403,7 +456,7 @@ async def create_client(): [ ({"unknown": True}, "Unsupported"), ({"tools": ["lookup"]}, "copilot.tools.Tool"), - ({"provider": "custom"}, "ProviderConfig"), + ({"session_provider": "custom"}, "ProviderConfig"), ], ) def test_provider_rejects_unsupported_options(overrides, message): From cc82404b0882285360f1129d8accf8e08cf718cc Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 1 Oct 2026 16:18:55 -0500 Subject: [PATCH 05/11] fix: honor effective Copilot session options --- .../agents/extensions/copilot_sdk/provider.py | 42 ++++++++++-- .../tests/test_provider.py | 65 +++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py index e691a77..4b67ab9 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -126,13 +126,35 @@ async def open_agent( if "mcp_servers" in self.options.session_options: mcp_servers = self.options.session_options["mcp_servers"] else: + disabled_mcp_servers = cast( + list[str] | None, + self.options.session_options.get("disabled_mcp_servers"), + ) + disabled_names = set(disabled_mcp_servers or ()) mcp_servers = await _build_mcp_servers( - self.capabilities.mcp_servers, + tuple( + definition + for definition in self.capabilities.mcp_servers + if definition.name not in disabled_names + ), stack, ) - skill_directories = _skill_directories(self.capabilities.skills) + skill_directories = cast( + list[str] | None, + self.options.session_options.get( + "skill_directories", + _skill_directories(self.capabilities.skills), + ), + ) + enable_skills = cast( + bool | None, + self.options.session_options.get( + "enable_skills", + bool(skill_directories), + ), + ) available_tools = ToolSet() - if skill_directories: + if enable_skills: available_tools.add_builtin("skill") if mcp_servers: available_tools.add_mcp("*") @@ -154,7 +176,7 @@ async def open_agent( "request_extensions": False, "enable_session_store": False, "included_builtin_skills": [], - "enable_skills": bool(skill_directories), + "enable_skills": enable_skills, "skill_directories": skill_directories, "mcp_servers": mcp_servers, "mcp_oauth_token_storage": "in-memory", @@ -348,12 +370,18 @@ async def _build_mcp_servers( } if ( parsed_url.scheme == "http" - and (headers or config.auth is not None) + and ( + headers + or config.auth is not None + or parsed_url.username is not None + or parsed_url.password is not None + ) and not _is_loopback_host(parsed_url.hostname) ): raise ValueError( - f"MCP server {definition.name!r} must use HTTPS when headers or auth " - "are configured; HTTP is allowed only for loopback hosts" + f"MCP server {definition.name!r} must use HTTPS when credentials, " + "headers, or auth are configured; HTTP is allowed only for loopback " + "hosts" ) if config.auth is not None: try: diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py index f010352..68ff8d6 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -336,6 +336,53 @@ def test_explicit_mcp_servers_replace_discovered_servers( assert list(options["available_tools"]) == expected_available_tools +def test_disabled_discovered_mcp_server_is_not_prepared(): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "inventory", + MCPHTTPConfig("$MISSING_INVENTORY_MCP_URL"), + ), + ), + ), + session_options={"disabled_mcp_servers": ["inventory"]}, + ) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + options = _Client.created[0].session_options + assert options["mcp_servers"] == {} + assert list(options["available_tools"]) == [] + + +@pytest.mark.parametrize( + ("available_tools", "expected_available_tools"), + [ + (None, ["builtin:skill"]), + (["builtin:custom"], ["builtin:custom"]), + ], +) +def test_explicit_skill_configuration_uses_effective_available_tools( + available_tools, + expected_available_tools, +): + session_options = { + "enable_skills": True, + "skill_directories": ["custom-skills"], + } + if available_tools is not None: + session_options["available_tools"] = available_tools + binding = _compile(session_options=session_options) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + options = _Client.created[0].session_options + assert options["enable_skills"] is True + assert options["skill_directories"] == ["custom-skills"] + assert list(options["available_tools"]) == expected_available_tools + + def test_binding_allowlists_explicit_python_tools(): binding = _compile( tools=Tool( @@ -375,6 +422,24 @@ def test_mcp_credentials_require_https(monkeypatch): assert not _Client.created[0].closed +def test_mcp_url_credentials_require_https(): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig("http://user:password@mcp.example.test/mcp"), + ), + ), + ) + ) + + with pytest.raises(ValueError, match="must use HTTPS"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + + def test_cancellation_closes_session_but_keeps_cached_client_open(monkeypatch): async def cancel(self, prompt): raise asyncio.CancelledError From ea3809ad727cc113c89469c8319226e452abf68f Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 1 Oct 2026 16:41:05 -0500 Subject: [PATCH 06/11] fix: secure Copilot MCP authentication lifecycle --- .../agents/extensions/copilot_sdk/provider.py | 12 +-- .../tests/test_provider.py | 87 ++++++++++++++++++- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py index 4b67ab9..308e65c 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import inspect import os import re @@ -375,17 +374,18 @@ async def _build_mcp_servers( or config.auth is not None or parsed_url.username is not None or parsed_url.password is not None + or bool(parsed_url.query) ) and not _is_loopback_host(parsed_url.hostname) ): raise ValueError( f"MCP server {definition.name!r} must use HTTPS when credentials, " - "headers, or auth are configured; HTTP is allowed only for loopback " - "hosts" + "query parameters, headers, or auth are configured; HTTP is allowed " + "only for loopback hosts" ) if config.auth is not None: try: - from azure.identity import DefaultAzureCredential + from azure.identity.aio import DefaultAzureCredential except ImportError as error: raise ImportError( "MCP Entra authentication is not installed. Install " @@ -406,8 +406,8 @@ async def _build_mcp_servers( credential = DefaultAzureCredential( managed_identity_client_id=client_id, ) - stack.callback(credential.close) - token = await asyncio.to_thread(credential.get_token, scope) + stack.push_async_callback(credential.close) + token = await credential.get_token(scope) headers["Authorization"] = f"Bearer {token.token}" servers[definition.name] = MCPHTTPServerConfig( diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py index 68ff8d6..f8ff950 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -440,6 +440,26 @@ def test_mcp_url_credentials_require_https(): assert not _Client.created[0].closed +def test_mcp_url_query_requires_https(): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig( + "http://mcp.example.test/mcp?access_token=secret" + ), + ), + ), + ) + ) + + with pytest.raises(ValueError, match="must use HTTPS"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + + def test_cancellation_closes_session_but_keeps_cached_client_open(monkeypatch): async def cancel(self, prompt): raise asyncio.CancelledError @@ -530,7 +550,7 @@ def test_provider_rejects_unsupported_options(overrides, message): def test_mcp_entra_token_is_injected_and_credential_is_closed(monkeypatch): - import azure.identity + import azure.identity.aio credentials = [] @@ -541,14 +561,18 @@ def __init__(self, *, managed_identity_client_id): self.closed = False credentials.append(self) - def get_token(self, scope): + async def get_token(self, scope): self.scopes.append(scope) return SimpleNamespace(token="entra-token") - def close(self): + async def close(self): self.closed = True - monkeypatch.setattr(azure.identity, "DefaultAzureCredential", FakeCredential) + monkeypatch.setattr( + azure.identity.aio, + "DefaultAzureCredential", + FakeCredential, + ) monkeypatch.setenv("MCP_SCOPE", "api://inventory/.default") monkeypatch.setenv("MCP_CLIENT_ID", "client-id") binding = _compile( @@ -583,3 +607,58 @@ async def invoke(): "headers": {"Authorization": "Bearer entra-token"}, "tools": ["*"], } + + +def test_mcp_entra_cancellation_waits_for_token_acquisition_before_close(monkeypatch): + import azure.identity.aio + + started = asyncio.Event() + token_finished = False + close_states = [] + + class BlockingAsyncCredential: + def __init__(self, *, managed_identity_client_id): + pass + + async def get_token(self, scope): + nonlocal token_finished + started.set() + try: + await asyncio.Event().wait() + finally: + token_finished = True + + async def close(self): + close_states.append(token_finished) + + monkeypatch.setattr( + azure.identity.aio, + "DefaultAzureCredential", + BlockingAsyncCredential, + ) + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "inventory", + MCPHTTPConfig( + "https://mcp.example.test", + auth=MCPAuthConfig(scope="api://inventory/.default"), + ), + ), + ), + ) + ) + + async def cancel_during_token_acquisition(): + task = asyncio.create_task( + binding.run_agent("hello", InvocationMetadata()) + ) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(cancel_during_token_acquisition()) + + assert close_states == [True] From edac0d832037fd923374af2c7aae941002d638ad Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 2 Oct 2026 09:45:38 -0500 Subject: [PATCH 07/11] fix: support evolving Copilot SDK session options --- .../README.md | 20 +++++--- .../agents/extensions/copilot_sdk/options.py | 9 +++- .../agents/extensions/copilot_sdk/provider.py | 5 ++ .../tests/test_apps.py | 3 +- .../tests/test_provider.py | 46 ++++++++++++++++++- 5 files changed, 71 insertions(+), 12 deletions(-) diff --git a/azurefunctions-agents-extensions-copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/README.md index a223f42..fccbae1 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/README.md @@ -82,7 +82,7 @@ privileged data or perform external actions. ### Customize Copilot sessions -Pass Copilot session options directly to `markdown_agent()`. The typed keyword +Pass Copilot session options directly to `markdown_agent()`. Known keyword arguments provide editor completion and static validation without string keys: ```python @@ -100,9 +100,12 @@ async def process_order( ... ``` -Unknown option names fail static validation and are also rejected when the -binding is compiled. Session options are applied after the extension defaults, -so they take precedence. This includes +The options type remains open so arguments added by newer compatible Copilot +SDK releases can be passed without an extension update. At compile time, the +extension validates every name against the installed SDK's +`CopilotClient.create_session()` signature and rejects unsupported names with a +clear error. Session options are applied after the extension defaults, so they +take precedence. This includes extension-generated values such as `system_message`, `tools`, `available_tools`, `mcp_servers`, `skill_directories`, and their enablement flags. Override those values only when intentionally replacing the Agent's @@ -224,9 +227,12 @@ Function Apps when capability sets require isolation. The extension creates one client lazily and reuses it across invocations for the lifetime of the Python worker process. Each invocation creates and closes a -fresh session. The extension replaces the session system message with the raw -Agent instructions and disables ambient Copilot configuration discovery, -session persistence, memory, telemetry, built-in Skills, and tool search. +fresh session. Default ephemeral sessions are deleted after disconnect so they +do not accumulate in the cached client's registry. Setting +`enable_session_store=True` preserves session data instead. The extension +replaces the session system message with the raw Agent instructions and disables +ambient Copilot configuration discovery, session persistence, memory, +telemetry, built-in Skills, and tool search. Explicit Python tools, discovered Skills, and discovered MCP servers remain available. diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py index 3f99a1c..bb8d7fe 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, Literal, TypedDict +from typing import Any, Literal from copilot import ToolSet from copilot.canvas import ( @@ -46,9 +46,14 @@ ToolSearchConfig, UserInputHandler, ) +from typing_extensions import TypedDict -class CopilotSessionOptions(TypedDict, total=False): +class CopilotSessionOptions( # type: ignore[call-arg] # mypy lacks PEP 728 + TypedDict, + total=False, + extra_items=object, +): """Optional arguments forwarded to ``CopilotClient.create_session``.""" session_id: str | None diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py index 308e65c..d6468e1 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -190,6 +190,11 @@ async def open_agent( client.create_session, ) session = await create_session(**create_session_options) + if create_session_options["enable_session_store"] is not True: + stack.push_async_callback( + client.delete_session, + session.session_id, + ) entered_session = await stack.enter_async_context(session) yield entered_session diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py index b813105..8ea9ee0 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py @@ -52,7 +52,8 @@ def test_typed_session_options_match_create_session(): - top_level_options ) - assert CopilotSessionOptions.__optional_keys__ == create_session_options + assert CopilotSessionOptions.__optional_keys__ <= create_session_options + assert CopilotSessionOptions.__extra_items__ is object def test_typed_agent_function_app_pins_copilot_provider(monkeypatch): diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py index f8ff950..e1d460e 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -23,7 +23,8 @@ class _Session: - def __init__(self) -> None: + def __init__(self, session_id: str) -> None: + self.session_id = session_id self.entered = False self.closed = False self.prompts: list[str] = [] @@ -47,12 +48,16 @@ async def send_and_wait(self, prompt: str): class _Client: created: list[_Client] = [] + next_session_id = 0 def __init__(self, **options) -> None: self.entered = False self.closed = False self.client_options = options self.sessions: list[_Session] = [] + self._sessions: dict[str, _Session] = {} + self.deleted_session_ids: list[str] = [] + self.deleted_closed_states: list[bool] = [] self.session_options = None self.created.append(self) @@ -64,15 +69,24 @@ async def __aexit__(self, exc_type, exc_value, traceback): self.closed = True async def create_session(self, **options): - session = _Session() + type(self).next_session_id += 1 + session_id = options.get("session_id") or f"session-{self.next_session_id}" + session = _Session(session_id) self.sessions.append(session) + self._sessions[session_id] = session self.session_options = options return session + async def delete_session(self, session_id): + session = self._sessions.pop(session_id) + self.deleted_session_ids.append(session_id) + self.deleted_closed_states.append(session.closed) + @pytest.fixture(autouse=True) def fake_client(monkeypatch): _Client.created.clear() + _Client.next_session_id = 0 monkeypatch.setattr(provider, "CopilotClient", _Client) @@ -103,6 +117,9 @@ async def invoke_twice(): assert not _Client.created[0].closed assert len(_Client.created[0].sessions) == 2 assert all(session.closed for session in _Client.created[0].sessions) + assert _Client.created[0]._sessions == {} + assert _Client.created[0].deleted_session_ids == ["session-1", "session-2"] + assert _Client.created[0].deleted_closed_states == [True, True] options = _Client.created[0].session_options assert options["model"] == "gpt-5" assert options["system_message"] == { @@ -134,6 +151,16 @@ def test_binding_applies_create_session_overrides(): assert options["system_message"] == {"mode": "append", "content": "extra"} +def test_binding_preserves_sessions_when_store_is_enabled(): + binding = _compile(session_options={"enable_session_store": True}) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + client = _Client.created[0] + assert list(client._sessions) == ["session-1"] + assert client.deleted_session_ids == [] + + def test_binding_applies_sdk_provider(): sdk_provider = { "type": "openai", @@ -153,6 +180,19 @@ def test_provider_rejects_unknown_create_session_override(): _compile(session_options={"not_a_session_option": True}) +def test_provider_accepts_option_supported_by_installed_sdk(monkeypatch): + monkeypatch.setattr( + provider, + "_CREATE_SESSION_OPTIONS", + provider._CREATE_SESSION_OPTIONS | {"future_sdk_option"}, + ) + binding = _compile(session_options={"future_sdk_option": "value"}) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].session_options["future_sdk_option"] == "value" + + def test_client_cache_is_safe_during_concurrent_first_use(): factory_calls = 0 @@ -471,6 +511,7 @@ async def cancel(self, prompt): assert not _Client.created[0].closed assert _Client.created[0].sessions[0].closed + assert _Client.created[0]._sessions == {} @pytest.mark.parametrize( @@ -488,6 +529,7 @@ async def send(self, prompt): assert not _Client.created[0].closed assert _Client.created[0].sessions[0].closed + assert _Client.created[0]._sessions == {} def test_binding_rejects_non_string_assistant_content(monkeypatch): From 024a3085bdea5286b311df7a2fcc3fd904484f86 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 9 Oct 2026 15:42:36 -0500 Subject: [PATCH 08/11] hello --- .../README.md | 10 +-- .../README.md | 15 +++-- .../agents/extensions/base/bindings.py | 21 +++--- .../tests/test_bindings.py | 25 ++++++- .../README.md | 8 ++- .../agents/extensions/copilot_sdk/apps.py | 5 +- .../agents/extensions/copilot_sdk/provider.py | 25 +++---- .../official/jobs/agent-provider-tests.yml | 37 +++++++++++ eng/templates/official/jobs/unit-tests.yml | 66 +++---------------- 9 files changed, 120 insertions(+), 92 deletions(-) create mode 100644 eng/templates/official/jobs/agent-provider-tests.yml diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index eb41a60..d0fa900 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -1,6 +1,6 @@ # Azure Functions Microsoft Agent Framework Extension -Inject Microsoft Agent Framework Agents built from raw `.agent.md` instructions +Inject Microsoft Agent Framework Agents built from raw Markdown instructions into Python Azure Functions. ## Install @@ -54,9 +54,11 @@ async def process_order(req: func.HttpRequest, agent: Agent): or modifications. One app uses the Microsoft Agent Framework provider selected by this package. -Place the complete instructions at `orders.agent.md` or -`agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime -configuration is interpreted. +Place the complete instructions at `orders.agent.md`, `orders.md`, or the same +filename under `agents/`. The `.agent.md` suffix is recommended; plain `.md` is +also supported. The file is raw UTF-8 text; no front matter or runtime +configuration is interpreted. If multiple candidates exist, lookup fails as +ambiguous. ## Skills and MCP servers diff --git a/azurefunctions-agents-extensions-base/README.md b/azurefunctions-agents-extensions-base/README.md index 06f78e2..f306bc9 100644 --- a/azurefunctions-agents-extensions-base/README.md +++ b/azurefunctions-agents-extensions-base/README.md @@ -31,18 +31,21 @@ directory; decorators cannot override it. ## Markdown lookup -An `agent_name` resolves exactly one UTF-8 file: +An `agent_name` resolves exactly one UTF-8 file from these candidates: ```text /.agent.md +/.md /agents/.agent.md +/agents/.md ``` -The entire file is passed to the provider unchanged. Front matter, YAML, -substitutions, tools, skills, MCP configuration, and history are not parsed by -this package. If both locations exist, lookup fails as ambiguous. Absolute -paths, separators, traversal components, and symlinks outside `app_root` are -rejected. +The `.agent.md` suffix is recommended because it makes Agent instructions easy +to identify, but plain `.md` is also supported. The entire file is passed to the +provider unchanged. Front matter, YAML, substitutions, tools, skills, MCP +configuration, and history are not parsed by this package. If multiple +candidates exist, lookup fails as ambiguous. Absolute paths, separators, +traversal components, and symlinks outside `app_root` are rejected. ## Skills and MCP discovery diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py index ec892b8..c877078 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -171,24 +171,27 @@ def _find_exact_file(directory: Path, expected_name: str) -> Path | None: def _resolve_instructions(app_root: Path, agent_name: str) -> str: - expected_name = f"{_validate_agent_name(agent_name)}.agent.md" + validated_name = _validate_agent_name(agent_name) + expected_names = ( + f"{validated_name}.agent.md", + f"{validated_name}.md", + ) matches = [ match - for match in ( - _find_exact_file(app_root, expected_name), - _find_exact_file(app_root / "agents", expected_name), - ) - if match is not None + for directory in (app_root, app_root / "agents") + for expected_name in expected_names + if (match := _find_exact_file(directory, expected_name)) is not None ] if not matches: + expected = " or ".join(repr(name) for name in expected_names) raise FileNotFoundError( - f"Agent {agent_name!r} was not found as {expected_name!r} in " + f"Agent {agent_name!r} was not found as {expected} in " f"{str(app_root)!r} or its 'agents' directory" ) if len(matches) > 1: + conflicts = ", ".join(repr(str(match)) for match in matches) raise ValueError( - f"Agent {agent_name!r} is ambiguous: both " - f"{str(matches[0])!r} and {str(matches[1])!r} exist" + f"Agent {agent_name!r} is ambiguous; found: {conflicts}" ) source = matches[0].resolve(strict=True) diff --git a/azurefunctions-agents-extensions-base/tests/test_bindings.py b/azurefunctions-agents-extensions-base/tests/test_bindings.py index 188df21..c4b527d 100644 --- a/azurefunctions-agents-extensions-base/tests/test_bindings.py +++ b/azurefunctions-agents-extensions-base/tests/test_bindings.py @@ -86,6 +86,29 @@ async def handler(value: str, agent: object) -> tuple[str, object]: assert provider.compiled.opened == provider.compiled.closed == 2 +@pytest.mark.parametrize("directory", ["", "agents"]) +def test_markdown_agent_accepts_plain_markdown_file( + tmp_path, + provider, + directory, +): + instructions = "Use the order API." + parent = tmp_path / directory + parent.mkdir(exist_ok=True) + (parent / "orders.md").write_text(instructions, encoding="utf-8") + + @bindings.markdown_agent( + func.FunctionApp(), + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + pass + + assert provider.compile_args["instructions"] == instructions + + def test_markdown_agent_logs_provider_usage(tmp_path, provider, caplog): (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") app = func.FunctionApp() @@ -179,7 +202,7 @@ async def handler(agent: object) -> None: def test_markdown_agent_rejects_ambiguous_files(tmp_path, provider): (tmp_path / "agents").mkdir() (tmp_path / "orders.agent.md").write_text("root", encoding="utf-8") - (tmp_path / "agents" / "orders.agent.md").write_text("nested", encoding="utf-8") + (tmp_path / "agents" / "orders.md").write_text("nested", encoding="utf-8") with pytest.raises(ValueError, match="ambiguous"): diff --git a/azurefunctions-agents-extensions-copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/README.md index fccbae1..d395fc1 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/README.md @@ -1,6 +1,6 @@ # Azure Functions GitHub Copilot SDK Extension -Inject GitHub Copilot SDK sessions built from raw `.agent.md` instructions into +Inject GitHub Copilot SDK sessions built from raw Markdown instructions into Python Azure Functions. ## Install @@ -66,8 +66,10 @@ async def process_order( `AgentFunctionApp` subclasses `azure.functions.FunctionApp` and pins every Agent binding in the app to this provider. Place the complete instructions at -`orders.agent.md` or `agents/orders.agent.md`. The file is raw UTF-8 text; no -front matter or runtime configuration is interpreted. +`orders.agent.md`, `orders.md`, or the same filename under `agents/`. The +`.agent.md` suffix is recommended; plain `.md` is also supported. The file is +raw UTF-8 text; no front matter or runtime configuration is interpreted. If +multiple candidates exist, lookup fails as ambiguous. `AgentFunctionApp()` reads its model from `COPILOT_MODEL` and defaults `on_permission_request` to `PermissionHandler.approve_all`. Pass `model=` or diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py index a1d095a..9396f62 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py @@ -21,6 +21,7 @@ COPILOT_SDK_PROVIDER_ID, ClientFactory, PermissionHandler, + _CopilotProviderOptions, _cache_client_factory, _default_client_factory, ) @@ -36,8 +37,8 @@ def _provider_options( provider: ProviderConfig | None = None, tools: Tool | Sequence[Tool] | None = None, session_options: CopilotSessionOptions | None = None, -) -> dict[str, object]: - options: dict[str, object] = {} +) -> _CopilotProviderOptions: + options: _CopilotProviderOptions = {} if client_factory is not None: options["client_factory"] = _cache_client_factory(client_factory) if model is not None: diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py index d6468e1..095fa88 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -8,7 +8,7 @@ from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from ipaddress import ip_address -from typing import Any, AsyncIterator, cast, get_origin +from typing import Any, AsyncIterator, TypedDict, cast, get_origin from urllib.parse import urlsplit from copilot import CopilotClient, ToolSet @@ -31,21 +31,24 @@ SkillDefinition, ) +from .options import CopilotSessionOptions + COPILOT_SDK_PROVIDER_ID = "copilot_sdk" ClientFactory = Callable[[], CopilotClient] PermissionHandler = Callable[..., Any] _ENV_REFERENCE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%") -_SUPPORTED_OPTIONS = frozenset( - { - "client_factory", - "model", - "on_permission_request", - "session_provider", - "session_options", - "tools", - } -) + +class _CopilotProviderOptions(TypedDict, total=False): + client_factory: ClientFactory + model: str + on_permission_request: PermissionHandler + session_provider: ProviderConfig + session_options: CopilotSessionOptions + tools: Tool | Sequence[Tool] + + +_SUPPORTED_OPTIONS = frozenset(_CopilotProviderOptions.__optional_keys__) _CREATE_SESSION_OPTIONS = frozenset( inspect.signature(CopilotClient.create_session).parameters ) - {"self"} diff --git a/eng/templates/official/jobs/agent-provider-tests.yml b/eng/templates/official/jobs/agent-provider-tests.yml new file mode 100644 index 0000000..c60cbd4 --- /dev/null +++ b/eng/templates/official/jobs/agent-provider-tests.yml @@ -0,0 +1,37 @@ +parameters: + - name: job_name + type: string + - name: extension_name + type: string + - name: extension_directory + type: string + +jobs: + - job: ${{ parameters.job_name }} + displayName: "${{ parameters.extension_name }} Extension Tests" + dependsOn: [] + strategy: + matrix: + python313: + PYTHON_VERSION: '3.13' + python314: + PYTHON_VERSION: '3.14' + condition: always() + steps: + - task: PipAuthenticate@1 + displayName: 'Pip Authenticate' + inputs: + artifactFeeds: public/upstream-public + onlyAddExtraIndex: false + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + - bash: | + python -m pip install --upgrade pip + python -m pip install -e ./azurefunctions-agents-extensions-base + cd ${{ parameters.extension_directory }} + python -m pip install -U -e .[dev,mcp] + displayName: "Install ${{ parameters.extension_name }} Dependencies" + - bash: | + python -m pytest -q --instafail ${{ parameters.extension_directory }}/tests/ + displayName: "Run ${{ parameters.extension_name }} Tests for Python $(PYTHON_VERSION)" diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index 1345099..63f7355 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -45,63 +45,17 @@ jobs: python -m pytest -q --instafail azurefunctions-agents-extensions-base/tests/ displayName: "Run Agents Base Tests for Python $(PYTHON_VERSION)" - - job: "AgentsFrameworkTests" - displayName: "Agents Framework Extension Tests" - dependsOn: [] - strategy: - matrix: - python313: - PYTHON_VERSION: '3.13' - python314: - PYTHON_VERSION: '3.14' - condition: always() - steps: - - task: PipAuthenticate@1 - displayName: 'Pip Authenticate' - inputs: - artifactFeeds: public/upstream-public - onlyAddExtraIndex: false - - task: UsePythonVersion@0 - inputs: - versionSpec: $(PYTHON_VERSION) - - bash: | - python -m pip install --upgrade pip - python -m pip install -e ./azurefunctions-agents-extensions-base - cd azurefunctions-agents-extensions-agent-framework - python -m pip install -U -e .[dev,mcp] - displayName: 'Install Agents Framework Dependencies' - - bash: | - python -m pytest -q --instafail azurefunctions-agents-extensions-agent-framework/tests/ - displayName: "Run Agents Framework Tests for Python $(PYTHON_VERSION)" + - template: /eng/templates/official/jobs/agent-provider-tests.yml@self + parameters: + job_name: AgentsFrameworkTests + extension_name: Agents Framework + extension_directory: azurefunctions-agents-extensions-agent-framework - - job: "AgentsCopilotSdkTests" - displayName: "Agents Copilot SDK Extension Tests" - dependsOn: [] - strategy: - matrix: - python313: - PYTHON_VERSION: '3.13' - python314: - PYTHON_VERSION: '3.14' - condition: always() - steps: - - task: PipAuthenticate@1 - displayName: 'Pip Authenticate' - inputs: - artifactFeeds: public/upstream-public - onlyAddExtraIndex: false - - task: UsePythonVersion@0 - inputs: - versionSpec: $(PYTHON_VERSION) - - bash: | - python -m pip install --upgrade pip - python -m pip install -e ./azurefunctions-agents-extensions-base - cd azurefunctions-agents-extensions-copilot-sdk - python -m pip install -U -e .[dev,mcp] - displayName: 'Install Agents Copilot SDK Dependencies' - - bash: | - python -m pytest -q --instafail azurefunctions-agents-extensions-copilot-sdk/tests/ - displayName: "Run Agents Copilot SDK Tests for Python $(PYTHON_VERSION)" + - template: /eng/templates/official/jobs/agent-provider-tests.yml@self + parameters: + job_name: AgentsCopilotSdkTests + extension_name: Agents Copilot SDK + extension_directory: azurefunctions-agents-extensions-copilot-sdk - job: "BaseTests" displayName: "Base Extension Tests" From c66a0a2e966221c1949d7ced32b1481f5564e7ca Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 9 Oct 2026 16:05:37 -0500 Subject: [PATCH 09/11] remove SDK argument snapshot & extension-side session option validation --- .../README.md | 28 ++-- .../agents/extensions/copilot_sdk/__init__.py | 2 - .../agents/extensions/copilot_sdk/apps.py | 10 +- .../agents/extensions/copilot_sdk/options.py | 137 ------------------ .../agents/extensions/copilot_sdk/provider.py | 28 +--- .../pyproject.toml | 1 - .../tests/test_apps.py | 22 +-- .../tests/test_imports.py | 2 +- .../tests/test_provider.py | 29 ++-- 9 files changed, 41 insertions(+), 218 deletions(-) delete mode 100644 azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py diff --git a/azurefunctions-agents-extensions-copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/README.md index d395fc1..60be650 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/README.md @@ -75,7 +75,7 @@ multiple candidates exist, lookup fails as ambiguous. `on_permission_request` to `PermissionHandler.approve_all`. Pass `model=` or `on_permission_request=` only to override those defaults. A `markdown_agent()` decorator can override `client_factory`, `model`, `on_permission_request`, -`provider`, `tools`, and any typed Copilot session option for one binding. +`provider`, `tools`, and any Copilot session option for one binding. `app_root` can be set only on `AgentFunctionApp`. `PermissionHandler.approve_all` should be used only when every exposed tool is @@ -84,8 +84,7 @@ privileged data or perform external actions. ### Customize Copilot sessions -Pass Copilot session options directly to `markdown_agent()`. Known keyword -arguments provide editor completion and static validation without string keys: +Pass Copilot session options directly to `markdown_agent()`: ```python @app.markdown_agent( @@ -102,30 +101,25 @@ async def process_order( ... ``` -The options type remains open so arguments added by newer compatible Copilot -SDK releases can be passed without an extension update. At compile time, the -extension validates every name against the installed SDK's -`CopilotClient.create_session()` signature and rejects unsupported names with a -clear error. Session options are applied after the extension defaults, so they +The extension forwards these arguments to `CopilotClient.create_session()` +without validating their names or values. The installed Copilot SDK accepts or +rejects them. Session options are applied after the extension defaults, so they take precedence. This includes extension-generated values such as `system_message`, `tools`, `available_tools`, `mcp_servers`, `skill_directories`, and their enablement flags. Override those values only when intentionally replacing the Agent's instructions or discovered capabilities. -For app-wide defaults, construct the exported typed options object: +For app-wide defaults, pass a mapping to `AgentFunctionApp`: ```python -from azurefunctions.agents.extensions.copilot_sdk import ( - AgentFunctionApp, - CopilotSessionOptions, -) +from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp app = AgentFunctionApp( - session_options=CopilotSessionOptions( - reasoning_effort="high", - streaming=True, - ), + session_options={ + "reasoning_effort": "high", + "streaming": True, + }, ) ``` diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py index 3226f59..78e5970 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py @@ -1,14 +1,12 @@ from azurefunctions.agents.extensions.base.durable import DurableAgentContext from .apps import AgentFunctionApp -from .options import CopilotSessionOptions from .provider import COPILOT_SDK_PROVIDER_ID, ClientFactory __all__ = [ "COPILOT_SDK_PROVIDER_ID", "AgentFunctionApp", "ClientFactory", - "CopilotSessionOptions", "DurableAgentContext", ] diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py index 9396f62..4eb1d01 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py @@ -1,14 +1,13 @@ from __future__ import annotations import os -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import Any, TypeVar import azure.functions as func from copilot.session import PermissionHandler as CopilotPermissionHandler from copilot.session import ProviderConfig from copilot.tools import Tool -from typing_extensions import Unpack from azurefunctions.agents.extensions.base import ( configure_app, @@ -16,7 +15,6 @@ ) from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent -from .options import CopilotSessionOptions from .provider import ( COPILOT_SDK_PROVIDER_ID, ClientFactory, @@ -36,7 +34,7 @@ def _provider_options( on_permission_request: PermissionHandler | None = None, provider: ProviderConfig | None = None, tools: Tool | Sequence[Tool] | None = None, - session_options: CopilotSessionOptions | None = None, + session_options: Mapping[str, object] | None = None, ) -> _CopilotProviderOptions: options: _CopilotProviderOptions = {} if client_factory is not None: @@ -65,7 +63,7 @@ def markdown_agent( on_permission_request: PermissionHandler | None = None, provider: ProviderConfig | None = None, tools: Tool | Sequence[Tool] | None = None, - **session_options: Unpack[CopilotSessionOptions], + **session_options: object, ) -> Callable[[_F], _F]: return base_markdown_agent( self, @@ -100,7 +98,7 @@ def __init__( ), provider: ProviderConfig | None = None, tools: Tool | Sequence[Tool] | None = None, - session_options: CopilotSessionOptions | None = None, + session_options: Mapping[str, object] | None = None, http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, ) -> None: super().__init__(http_auth_level=http_auth_level) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py deleted file mode 100644 index bb8d7fe..0000000 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/options.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from typing import Any, Literal - -from copilot import ToolSet -from copilot.canvas import ( - CanvasDeclaration, - CanvasHandler, - CanvasProviderIdentity, - ExtensionInfo, -) -from copilot.client import ( - AskUserVariant, - CapiSessionOptions, - CloudSessionOptions, - CopilotExpAssignmentResponse, - GitHubTokenProvider, - ManagedSettings, -) -from copilot.generated.rpc import RemoteSessionMode -from copilot.generated.session_events import SessionEvent -from copilot.session import ( - AutoModeSwitchHandler, - CommandDefinition, - ContextTier, - CreateSessionFsHandler, - CustomAgentConfig, - DefaultAgentConfig, - ElicitationHandler, - ExitPlanModeHandler, - GitHubMcpToolConfig, - InfiniteSessionConfig, - LargeToolOutputConfig, - McpAuthHandler, - MCPServerConfig, - MemoryConfiguration, - ModelCapabilitiesOverride, - NamedProviderConfig, - ProviderModelConfig, - ReasoningEffort, - ReasoningSummary, - SessionHooks, - SessionLimitsConfig, - SystemMessageConfig, - ToolSearchConfig, - UserInputHandler, -) -from typing_extensions import TypedDict - - -class CopilotSessionOptions( # type: ignore[call-arg] # mypy lacks PEP 728 - TypedDict, - total=False, - extra_items=object, -): - """Optional arguments forwarded to ``CopilotClient.create_session``.""" - - session_id: str | None - client_name: str | None - reasoning_effort: ReasoningEffort | None - reasoning_summary: ReasoningSummary | None - enable_experimental_mode: bool | None - context_tier: ContextTier | None - system_message: SystemMessageConfig | None - tool_search: ToolSearchConfig | None - available_tools: list[str] | ToolSet | None - excluded_tools: list[str] | ToolSet | None - on_user_input_request: UserInputHandler | None - ask_user_variant: AskUserVariant | None - hooks: SessionHooks | None - working_directory: str | None - additional_directories: list[str] | None - capi: CapiSessionOptions | None - providers: list[NamedProviderConfig] | None - models: list[ProviderModelConfig] | None - enable_session_telemetry: bool | None - enable_citations: bool | None - enable_file_change_tracking: bool | None - excluded_builtin_agents: list[str] | None - session_limits: SessionLimitsConfig | None - skip_custom_instructions: bool | None - custom_agents_local_only: bool | None - coauthor_enabled: bool | None - manage_schedule_enabled: bool | None - model_capabilities: ModelCapabilitiesOverride | None - streaming: bool | None - include_sub_agent_streaming_events: bool | None - mcp_servers: dict[str, MCPServerConfig] | None - mcp_oauth_token_storage: Literal["persistent", "in-memory"] | None - auth_client_id_metadata_url: str | None - embedding_cache_storage: Literal["persistent", "in-memory"] | None - custom_agents: list[CustomAgentConfig] | None - default_agent: DefaultAgentConfig | dict[str, Any] | None - agent: str | None - config_directory: str | None - enable_config_discovery: bool | None - skip_embedding_retrieval: bool | None - organization_custom_instructions: str | None - enable_on_demand_instruction_discovery: bool | None - enable_file_hooks: bool | None - enable_host_git_operations: bool | None - enable_session_store: bool | None - enable_skills: bool | None - included_builtin_skills: list[str] | None - skill_directories: list[str] | None - plugin_directories: list[str] | None - instruction_directories: list[str] | None - disabled_skills: list[str] | None - disabled_mcp_servers: list[str] | None - infinite_sessions: InfiniteSessionConfig | None - large_output: LargeToolOutputConfig | None - memory: MemoryConfiguration | None - on_event: Callable[[SessionEvent], None] | None - commands: list[CommandDefinition] | None - on_elicitation_request: ElicitationHandler | None - on_mcp_auth_request: McpAuthHandler | None - enable_mcp_apps: bool - on_exit_plan_mode_request: ExitPlanModeHandler | None - on_auto_mode_switch_request: AutoModeSwitchHandler | None - create_session_fs_handler: CreateSessionFsHandler | None - github_token: str | None - github_token_provider: GitHubTokenProvider | None - remote_session: RemoteSessionMode | None - cloud: CloudSessionOptions | None - canvases: list[CanvasDeclaration] | None - request_canvas_renderer: bool | None - request_extensions: bool | None - extension_sdk_path: str | None - extension_info: ExtensionInfo | None - canvas_provider: CanvasProviderIdentity | None - canvas_handler: CanvasHandler | None - feature_flags: dict[str, bool] | None - exp_assignments: CopilotExpAssignmentResponse | None - enable_managed_settings: bool | None - github_mcp_tool_config: GitHubMcpToolConfig | None - managed_settings: ManagedSettings | None diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py index 095fa88..7e1e604 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -31,8 +31,6 @@ SkillDefinition, ) -from .options import CopilotSessionOptions - COPILOT_SDK_PROVIDER_ID = "copilot_sdk" ClientFactory = Callable[[], CopilotClient] PermissionHandler = Callable[..., Any] @@ -44,14 +42,11 @@ class _CopilotProviderOptions(TypedDict, total=False): model: str on_permission_request: PermissionHandler session_provider: ProviderConfig - session_options: CopilotSessionOptions + session_options: Mapping[str, object] tools: Tool | Sequence[Tool] _SUPPORTED_OPTIONS = frozenset(_CopilotProviderOptions.__optional_keys__) -_CREATE_SESSION_OPTIONS = frozenset( - inspect.signature(CopilotClient.create_session).parameters -) - {"self"} @dataclass(frozen=True) @@ -279,8 +274,9 @@ def compile_binding( ), provider=cast(ProviderConfig | None, provider), tools=_normalize_tools(options.get("tools")), - session_options=_normalize_session_options( - options.get("session_options") + session_options=cast( + Mapping[str, object], + options.get("session_options") or {}, ), ), capabilities=capabilities, @@ -300,22 +296,6 @@ def _normalize_tools(value: object) -> tuple[Tool, ...]: return cast(tuple[Tool, ...], tools) -def _normalize_session_options(value: object) -> Mapping[str, object]: - if value is None: - return {} - if not isinstance(value, Mapping): - raise TypeError("session_options must be a mapping") - if any(not isinstance(name, str) for name in value): - raise TypeError("session_options keys must be strings") - options = cast(Mapping[str, object], value) - unknown = sorted(set(options) - _CREATE_SESSION_OPTIONS) - if unknown: - raise TypeError( - "Unsupported Copilot create_session option(s): " + ", ".join(unknown) - ) - return dict(options) - - def _skill_directories(skills: Sequence[SkillDefinition]) -> list[str]: return sorted({str(skill.path.parent) for skill in skills}) diff --git a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml index 6f06622..9b1207e 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml +++ b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml @@ -27,7 +27,6 @@ classifiers = [ dependencies = [ "azurefunctions-agents-extensions-base>=1.0.0b1", "github-copilot-sdk>=1.0.14,<2", - "typing-extensions>=4.12", ] [project.optional-dependencies] diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py index 8ea9ee0..f299955 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py @@ -4,16 +4,14 @@ from unittest.mock import Mock import azure.functions as func -from copilot import CopilotClient from copilot.session import PermissionHandler from azurefunctions.agents.extensions.copilot_sdk import ( AgentFunctionApp, - CopilotSessionOptions, apps, ) -def test_typed_api_exposes_only_v1_options(): +def test_api_exposes_extension_options_and_arbitrary_session_options(): assert list(inspect.signature(AgentFunctionApp.__init__).parameters) == [ "self", "model", @@ -42,18 +40,6 @@ def test_typed_api_exposes_only_v1_options(): .kind is inspect.Parameter.VAR_KEYWORD ) - assert "streaming" in CopilotSessionOptions.__optional_keys__ - - -def test_typed_session_options_match_create_session(): - top_level_options = {"self", "model", "on_permission_request", "provider", "tools"} - create_session_options = ( - set(inspect.signature(CopilotClient.create_session).parameters) - - top_level_options - ) - - assert CopilotSessionOptions.__optional_keys__ <= create_session_options - assert CopilotSessionOptions.__extra_items__ is object def test_typed_agent_function_app_pins_copilot_provider(monkeypatch): @@ -100,7 +86,7 @@ def test_agent_function_app_uses_function_app_directly(): assert func.FunctionApp in AgentFunctionApp.__bases__ -def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): +def test_typed_markdown_agent_forwards_arbitrary_session_options(monkeypatch): parent_decorator = Mock(return_value=object()) monkeypatch.setattr(apps, "base_markdown_agent", parent_decorator) app = object.__new__(AgentFunctionApp) @@ -112,7 +98,7 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): client_factory=factory, model="gpt-5", streaming=True, - reasoning_effort="high", + custom_session_option="value", ) assert result is parent_decorator.return_value @@ -126,7 +112,7 @@ def test_typed_markdown_agent_forwards_supported_overrides(monkeypatch): assert call.kwargs["model"] == "gpt-5" assert call.kwargs["session_options"] == { "streaming": True, - "reasoning_effort": "high", + "custom_session_option": "value", } diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py index 70554f7..67e93cf 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py @@ -7,7 +7,7 @@ def test_copilot_sdk_exports_supported_api(): from azurefunctions.agents.extensions.base.durable import DurableAgentContext assert copilot_sdk.AgentFunctionApp is not None - assert copilot_sdk.CopilotSessionOptions is not None + assert not hasattr(copilot_sdk, "CopilotSessionOptions") assert copilot_sdk.DurableAgentContext is DurableAgentContext assert copilot_sdk.COPILOT_SDK_PROVIDER_ID == "copilot_sdk" assert not hasattr(copilot_sdk, "markdown_agent") diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py index e1d460e..55fd18f 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -175,22 +175,27 @@ def test_binding_applies_sdk_provider(): assert _Client.created[0].session_options["provider"] is sdk_provider -def test_provider_rejects_unknown_create_session_override(): - with pytest.raises(TypeError, match="Unsupported Copilot create_session option"): - _compile(session_options={"not_a_session_option": True}) +def test_provider_forwards_arbitrary_create_session_options(): + custom_option = object() + binding = _compile(session_options={"custom_session_option": custom_option}) + asyncio.run(binding.run_agent("hello", InvocationMetadata())) -def test_provider_accepts_option_supported_by_installed_sdk(monkeypatch): - monkeypatch.setattr( - provider, - "_CREATE_SESSION_OPTIONS", - provider._CREATE_SESSION_OPTIONS | {"future_sdk_option"}, - ) - binding = _compile(session_options={"future_sdk_option": "value"}) + assert _Client.created[0].session_options["custom_session_option"] is custom_option - asyncio.run(binding.run_agent("hello", InvocationMetadata())) - assert _Client.created[0].session_options["future_sdk_option"] == "value" +def test_provider_defers_session_option_validation_to_client(): + class RejectingClient(_Client): + async def create_session(self, **options): + raise TypeError("Copilot SDK rejected the session options") + + binding = _compile( + client_factory=RejectingClient, + session_options={"custom_session_option": "value"}, + ) + + with pytest.raises(TypeError, match="Copilot SDK rejected"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) def test_client_cache_is_safe_during_concurrent_first_use(): From 4cdb4cf8c4f0194404fe37a25b8af6a3315ea0a1 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 29 Oct 2026 11:02:23 -0500 Subject: [PATCH 10/11] feedback --- .../tests/test_bindings.py | 15 ++++++++++++--- .../README.md | 13 +++++++------ .../pyproject.toml | 1 + .../local.settings.template.json | 2 +- .../official/jobs/agent-provider-tests.yml | 2 ++ 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/azurefunctions-agents-extensions-base/tests/test_bindings.py b/azurefunctions-agents-extensions-base/tests/test_bindings.py index c4b527d..7954a09 100644 --- a/azurefunctions-agents-extensions-base/tests/test_bindings.py +++ b/azurefunctions-agents-extensions-base/tests/test_bindings.py @@ -86,16 +86,25 @@ async def handler(value: str, agent: object) -> tuple[str, object]: assert provider.compiled.opened == provider.compiled.closed == 2 -@pytest.mark.parametrize("directory", ["", "agents"]) -def test_markdown_agent_accepts_plain_markdown_file( +@pytest.mark.parametrize( + ("directory", "filename"), + [ + ("", "orders.agent.md"), + ("", "orders.md"), + ("agents", "orders.agent.md"), + ("agents", "orders.md"), + ], +) +def test_markdown_agent_resolves_supported_instruction_paths( tmp_path, provider, directory, + filename, ): instructions = "Use the order API." parent = tmp_path / directory parent.mkdir(exist_ok=True) - (parent / "orders.md").write_text(instructions, encoding="utf-8") + (parent / filename).write_text(instructions, encoding="utf-8") @bindings.markdown_agent( func.FunctionApp(), diff --git a/azurefunctions-agents-extensions-copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/README.md index 60be650..d1be102 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/README.md +++ b/azurefunctions-agents-extensions-copilot-sdk/README.md @@ -92,7 +92,6 @@ Pass Copilot session options directly to `markdown_agent()`: agent_name="orders", reasoning_effort="high", streaming=True, - enable_session_store=True, ) async def process_order( req: func.HttpRequest, @@ -224,11 +223,13 @@ Function Apps when capability sets require isolation. The extension creates one client lazily and reuses it across invocations for the lifetime of the Python worker process. Each invocation creates and closes a fresh session. Default ephemeral sessions are deleted after disconnect so they -do not accumulate in the cached client's registry. Setting -`enable_session_store=True` preserves session data instead. The extension -replaces the session system message with the raw Agent instructions and disables -ambient Copilot configuration discovery, session persistence, memory, -telemetry, built-in Skills, and tool search. +do not accumulate in the cached client's registry. Leave +`enable_session_store` disabled: this binding creates a new session for every +invocation and does not expose a session ID or resume workflow. Enabling the +store retains each disconnected session in the cached client for the lifetime +of the worker. The extension replaces the session system message with the raw +Agent instructions and disables ambient Copilot configuration discovery, +session persistence, memory, telemetry, built-in Skills, and tool search. Explicit Python tools, discovered Skills, and discovered MCP servers remain available. diff --git a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml index 9b1207e..1bacd2c 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml +++ b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json index ccb4fba..8064466 100644 --- a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json @@ -5,6 +5,6 @@ "FUNCTIONS_WORKER_RUNTIME": "python", "COPILOT_GITHUB_TOKEN": "", "COPILOT_BASE_DIRECTORY": "", - "COPILOT_MODEL": "gpt-5" + "COPILOT_MODEL": "gpt-6" } } \ No newline at end of file diff --git a/eng/templates/official/jobs/agent-provider-tests.yml b/eng/templates/official/jobs/agent-provider-tests.yml index 7a4efa1..6f4c193 100644 --- a/eng/templates/official/jobs/agent-provider-tests.yml +++ b/eng/templates/official/jobs/agent-provider-tests.yml @@ -23,6 +23,8 @@ jobs: PYTHON_VERSION: '3.13' python314: PYTHON_VERSION: '3.14' + python315: + PYTHON_VERSION: '3.15' condition: always() steps: - task: PipAuthenticate@1 From a08ed8d2303fad9db169b7129961ef5acd500a85 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 29 Oct 2026 12:10:16 -0500 Subject: [PATCH 11/11] remove 3.15 unit tests --- eng/templates/official/jobs/agent-provider-tests.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/eng/templates/official/jobs/agent-provider-tests.yml b/eng/templates/official/jobs/agent-provider-tests.yml index 6f4c193..7a4efa1 100644 --- a/eng/templates/official/jobs/agent-provider-tests.yml +++ b/eng/templates/official/jobs/agent-provider-tests.yml @@ -23,8 +23,6 @@ jobs: PYTHON_VERSION: '3.13' python314: PYTHON_VERSION: '3.14' - python315: - PYTHON_VERSION: '3.15' condition: always() steps: - task: PipAuthenticate@1