From ef857d50ac75f1fc498ede84f099c86b6b8f73f0 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 4 Nov 2026 11:14:30 +0100 Subject: [PATCH] Python: reject unsupported Copilot provider function middleware --- python/packages/github_copilot/README.md | 27 ++++ .../agent_framework_github_copilot/_agent.py | 26 +++- .../packages/github_copilot/tests/conftest.py | 145 ++++++++++++++++++ .../tests/test_github_copilot_agent.py | 102 +++++++++++- .../references/extensibility.md | 3 + .../references/integrations.md | 7 + 6 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 python/packages/github_copilot/tests/conftest.py diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 25dec937a64..6642421d3a1 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -10,6 +10,17 @@ pip install agent-framework-github-copilot The GitHub Copilot agent enables integration with GitHub Copilot, allowing you to interact with Copilot's agentic capabilities through the Agent Framework. +## Context providers and function middleware + +Context providers can contribute messages, instructions, and tools to a run. +`GitHubCopilotAgent` supports agent middleware, but the SDK owns its tool-calling loop +and does not run Agent Framework function middleware. If a context provider contributes +function middleware through `SessionContext.extend_middleware()`, the run raises +`MiddlewareException` before creating or resuming an SDK session or registering its tools. +This applies to both response modes and to `RawGitHubCopilotAgent`. + +Use `Agent` with a chat client when provider-contributed function middleware is required. + ## Tool approval (`approval_mode="always_require"`) The GitHub Copilot SDK owns the tool-calling loop for this provider, so approval for @@ -117,3 +128,19 @@ directory that defines hooks it is not loading. See [`github_copilot_with_file_hooks.py`](../../samples/02-agents/providers/github_copilot/github_copilot_with_file_hooks.py) for a runnable example. +## MCP configuration and session lifetime + +Constructor `default_options` provide SDK session defaults. Per-run `options` override +the corresponding defaults for that request, including `mcp_servers`. Reusing an +`AgentSession` with a `service_session_id` resumes SDK/CLI-owned service-session state. +Forwarding a narrower MCP configuration, such as changing a server's `tools` from +`["*"]` to `[]`, is not an authorization or revocation guarantee for capabilities +already resident in that session. Enforce authorization at the MCP service/tool boundary. + +Use a fresh service session when narrowing configured capabilities: create +`fresh_session = agent.create_session()`, then pass it to +`await agent.run("Start a new conversation", session=fresh_session, options=restricted_options)`, +where `restricted_options` contains the narrowed `mcp_servers` configuration. +The new session has no `service_session_id`; do not copy the previous service-session +state or reuse its SDK `session_id` in constructor or per-run options. A fresh session +starts a new conversation rather than continuing the previous one. diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 94633fb3018..69e5c99cfc7 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -43,6 +43,7 @@ normalize_messages, ) from agent_framework._mcp import MCPTool +from agent_framework._middleware import categorize_middleware from agent_framework._settings import load_settings from agent_framework._telemetry import mark_feature_used from agent_framework._tools import FunctionTool, ToolTypes @@ -51,7 +52,7 @@ _get_data_bytes_as_str, # pyright: ignore[reportPrivateUsage] normalize_tools, ) -from agent_framework.exceptions import AgentException, ContentError +from agent_framework.exceptions import AgentException, ContentError, MiddlewareException from agent_framework.observability import AgentTelemetryLayer from ._feature_usage import FeatureIndex @@ -891,6 +892,7 @@ def run( Raises: AgentException: If the request fails. + MiddlewareException: If a context provider contributes unsupported function middleware. """ if middleware: logger.warning( @@ -1098,6 +1100,7 @@ async def _stream_updates( Raises: AgentException: If the request fails. + MiddlewareException: If a context provider contributes unsupported function middleware. """ if not self._started: await self.start() @@ -1264,6 +1267,10 @@ async def _run_before_providers( Returns: The SessionContext with provider context populated. + + Raises: + MiddlewareException: If a context provider contributes function middleware, + which cannot be applied to the SDK-owned tool loop. """ session_context = SessionContext( session_id=session.session_id, @@ -1282,6 +1289,12 @@ async def _run_before_providers( state=session.state.setdefault(provider.source_id, {}), ) + if categorize_middleware(session_context.get_middleware())["function"]: + raise MiddlewareException( + "GitHubCopilotAgent does not support function middleware contributed by context providers. " + "Use Agent with a chat client when function middleware is required." + ) + return session_context @staticmethod @@ -1731,6 +1744,11 @@ def _build_session_kwargs( merged with the agent's tools and converted to SDK tools, and approval callbacks are turned into ``hooks``. + Forwarding options alone is not an authorization or revocation guarantee for + capabilities already resident in a resumed SDK/CLI service session. Enforce + authorization at the MCP service/tool boundary and use a fresh service session + when narrowing configured capabilities. + Args: streaming: Whether to enable streaming for the session. runtime_options: Runtime options that take precedence over default_options. @@ -1869,10 +1887,10 @@ class GitHubCopilotAgent( # type: ignore[misc] RawGitHubCopilotAgent[OptionsT], Generic[OptionsT], ): - """A GitHub Copilot Agent with full middleware and telemetry support. + """A GitHub Copilot Agent with agent middleware and telemetry support. This is the recommended agent class for most use cases. It includes - middleware support and OpenTelemetry-based telemetry for observability, + agent middleware and OpenTelemetry-based telemetry for observability, with middleware running outside the telemetry span so middleware execution time is not captured in traces. For a minimal implementation without these layers, use :class:`RawGitHubCopilotAgent`. @@ -1922,7 +1940,7 @@ def __init__( env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize a GitHub Copilot Agent with full middleware and telemetry. + """Initialize a GitHub Copilot Agent with agent middleware and telemetry. Args: instructions: System message for the agent. diff --git a/python/packages/github_copilot/tests/conftest.py b/python/packages/github_copilot/tests/conftest.py new file mode 100644 index 00000000000..79454d60410 --- /dev/null +++ b/python/packages/github_copilot/tests/conftest.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agent_framework import ( + BaseChatClient, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + Content, + ContextProvider, + FunctionInvocationContext, + FunctionInvocationLayer, + FunctionMiddleware, + FunctionTool, + Message, + MiddlewareBundle, + MiddlewareTypes, + ResponseStream, + SessionContext, + function_middleware, + tool, +) +from copilot.session_events import SessionEvent + + +@pytest.fixture +def completed_copilot_session( + mock_session: MagicMock, + assistant_message_event: SessionEvent, + session_idle_event: SessionEvent, +) -> MagicMock: + mock_session.send_and_wait.return_value = assistant_message_event + + def subscribe(handler: Callable[[SessionEvent], None]) -> Callable[[], None]: + handler(session_idle_event) + return lambda: None + + mock_session.on.side_effect = subscribe + return mock_session + + +@pytest.fixture(params=["class", "decorated", "annotated", "bundle"]) +def provider_function_middleware( + request: pytest.FixtureRequest, +) -> tuple[ContextProvider, FunctionTool, AsyncMock, AsyncMock]: + tool_body = AsyncMock() + policy = AsyncMock(side_effect=RuntimeError("Function policy denied the call")) + + @tool(approval_mode="never_require") + async def provider_tool(value: str) -> str: + """Return the supplied value.""" + await tool_body(value) + return value + + async def deny_call( + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + await policy(context) + + class DenyMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + await policy(context) + + middleware: MiddlewareTypes + if request.param == "class": + middleware = DenyMiddleware() + elif request.param == "decorated": + middleware = function_middleware(deny_call) + elif request.param == "annotated": + middleware = deny_call + else: + middleware = MiddlewareBundle([deny_call]) + + class ToolProvider(ContextProvider): + async def before_run( + self, + *, + agent: Any, + session: Any, + context: SessionContext, + state: dict[str, Any], + ) -> None: + context.extend_tools(self.source_id, [provider_tool]) + if state.get("include_middleware", True): + context.extend_middleware(self.source_id, middleware) + + return ToolProvider(source_id="tool-policy"), provider_tool, tool_body, policy + + +@pytest.fixture +def tool_calling_chat_client( + provider_function_middleware: tuple[ContextProvider, FunctionTool, AsyncMock, AsyncMock], +) -> BaseChatClient[ChatOptions[Any]]: + _, provider_tool, _, _ = provider_function_middleware + + class ToolCallingClient(FunctionInvocationLayer[ChatOptions[Any]], BaseChatClient[ChatOptions[Any]]): + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + has_result = any(content.type == "function_result" for message in messages for content in message.contents) + contents = ( + [Content.from_text("Done")] + if has_result + else [ + Content.from_function_call( + call_id="provider-call", + name=provider_tool.name, + arguments={"value": "test"}, + ) + ] + ) + + if stream: + + async def updates() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate( + role="assistant", + contents=contents, + finish_reason="stop" if has_result else "tool_calls", + ) + + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + + async def response() -> ChatResponse: + return ChatResponse(messages=[Message(role="assistant", contents=contents)]) + + return response() + + return ToolCallingClient() diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 813d5075831..d8017f8b853 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -16,20 +16,24 @@ import pytest from agent_framework import ( + Agent, AgentResponse, AgentResponseUpdate, AgentSession, BaseAgent, + BaseChatClient, + ChatOptions, Content, ContextProvider, FunctionInvocationContext, + FunctionTool, HistoryProvider, MCPStdioTool, Message, ResponseStream, tool, ) -from agent_framework.exceptions import AgentException +from agent_framework.exceptions import AgentException, MiddlewareException from copilot.session import PermissionHandler, PermissionInvocation, PreToolUseHookInput from copilot.session_events import ( AssistantUsageData, @@ -4455,6 +4459,102 @@ def load_skill(skill_name: str) -> str: tool_names = [t.name for t in call_kwargs["tools"]] assert "load_skill" in tool_names + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("agent_type", [GitHubCopilotAgent, RawGitHubCopilotAgent]) + async def test_provider_function_middleware_rejected_before_sdk_session( + self, + mock_client: MagicMock, + completed_copilot_session: MagicMock, + provider_function_middleware: tuple[ContextProvider, FunctionTool, AsyncMock, AsyncMock], + stream: bool, + resume: bool, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + ) -> None: + provider, _, tool_body, policy = provider_function_middleware + agent = agent_type(client=mock_client, context_providers=[provider]) + session = agent.create_session() + if resume: + session.service_session_id = "existing-id" + + with pytest.raises(MiddlewareException, match="does not support function middleware.*context providers"): + if stream: + await agent.run("Hello", stream=True, session=session).get_final_response() + else: + await agent.run("Hello", session=session) + + mock_client.create_session.assert_not_called() + mock_client.resume_session.assert_not_called() + completed_copilot_session.send.assert_not_called() + completed_copilot_session.send_and_wait.assert_not_called() + tool_body.assert_not_called() + policy.assert_not_called() + + @pytest.mark.parametrize("stream", [False, True]) + async def test_provider_function_middleware_checked_on_each_run( + self, + mock_client: MagicMock, + completed_copilot_session: MagicMock, + provider_function_middleware: tuple[ContextProvider, FunctionTool, AsyncMock, AsyncMock], + stream: bool, + ) -> None: + provider, provider_tool, tool_body, policy = provider_function_middleware + agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider]) + session = agent.create_session() + session.state[provider.source_id] = {"include_middleware": False} + + await agent.run("Hello", session=session) + sdk_tool = mock_client.create_session.call_args.kwargs["tools"][0] + assert sdk_tool.name == provider_tool.name + result = await sdk_tool.handler(ToolInvocation(arguments={"value": "test"})) + assert result.result_type == "success" + tool_body.assert_awaited_once_with("test") + + session.state[provider.source_id]["include_middleware"] = True + with pytest.raises(MiddlewareException, match="does not support function middleware.*context providers"): + if stream: + await agent.run("Hello again", stream=True, session=session).get_final_response() + else: + await agent.run("Hello again", session=session) + + mock_client.create_session.assert_awaited_once() + mock_client.resume_session.assert_not_called() + completed_copilot_session.send_and_wait.assert_awaited_once() + completed_copilot_session.send.assert_not_called() + tool_body.assert_awaited_once_with("test") + policy.assert_not_called() + + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + async def test_provider_function_middleware_blocks_tool_with_agent( + self, + provider_function_middleware: tuple[ContextProvider, FunctionTool, AsyncMock, AsyncMock], + tool_calling_chat_client: BaseChatClient[ChatOptions[Any]], + stream: bool, + resume: bool, + ) -> None: + provider, _, tool_body, policy = provider_function_middleware + agent = Agent(client=tool_calling_chat_client, context_providers=[provider]) + session = agent.create_session() + if resume: + session.service_session_id = "existing-id" + + if stream: + response = await agent.run("Hello", stream=True, session=session).get_final_response() + else: + response = await agent.run("Hello", session=session) + + results = [ + content + for message in response.messages + for content in message.contents + if content.type == "function_result" + ] + assert len(results) == 1 + assert "Function policy denied the call" in str(results[0].exception) + policy.assert_awaited_once() + tool_body.assert_not_called() + class TestGitHubCopilotAttachments: """Tests for forwarding inline binary message content as Copilot attachments.""" diff --git a/skills/agent-framework-builder-python/references/extensibility.md b/skills/agent-framework-builder-python/references/extensibility.md index c1dcb169609..bcb8e783d7b 100644 --- a/skills/agent-framework-builder-python/references/extensibility.md +++ b/skills/agent-framework-builder-python/references/extensibility.md @@ -72,6 +72,9 @@ Use middleware instead when the requirement is primarily execution control aroun Set `after_run_once_per_turn=True` when an `AgentLoopMiddleware` should defer `after_run` until the whole user turn is complete. Context providers may add chat or function middleware, but not agent middleware. +Check the selected agent's middleware support. `GitHubCopilotAgent` rejects provider-contributed function middleware +before creating or resuming its SDK session; use `Agent` with a chat client when that middleware is required. + For cross-session memory, include origin session attribution and enforce authorization before injecting content. Current references: diff --git a/skills/agent-framework-builder-python/references/integrations.md b/skills/agent-framework-builder-python/references/integrations.md index 6690a7d41af..e0838bcdc5e 100644 --- a/skills/agent-framework-builder-python/references/integrations.md +++ b/skills/agent-framework-builder-python/references/integrations.md @@ -44,6 +44,13 @@ client when its lifetime must extend beyond any one agent context. Do not assume For AG-UI, remember that thread and run IDs are correlation identifiers, not authentication boundaries. The application owns authorization, durable snapshots, scaling, and rendering policy. +For GitHub Copilot, construction defaults and per-run MCP options configure SDK requests; resuming an `AgentSession` +continues SDK/CLI-owned service state. Forwarding narrower options does not guarantee revocation of capabilities +already resident in that session. Enforce authorization at the MCP service/tool boundary and use +`agent.create_session()` with the narrowed options to start a fresh service session, without copying the previous +`service_session_id` or reusing its SDK `session_id`. See the +[Copilot session-lifetime guidance](https://github.com/microsoft/agent-framework/blob/main/python/packages/github_copilot/README.md#mcp-configuration-and-session-lifetime). + ## Code execution Monty and Hyperlight redact host tool exception diagnostics by default. Enable