diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md new file mode 100644 index 00000000000..4c728e22551 --- /dev/null +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -0,0 +1,63 @@ +--- +status: proposed +contact: rogerbarreto +date: 2026-10-22 +deciders: rogerbarreto, eavanvalkenburg, westey-m +--- + +# Let the Copilot agent own runtime connection configuration + +## Context and Problem Statement + +The Invocations host passes application-parsed options to agents. For the Copilot +agent, `provider` and `mcp_servers` are serializable SDK connection configurations, +not Python client objects. Forwarding unfiltered request options can therefore +change the connections selected for a session. + +The same option names can have different meanings for other agent implementations. +An Invocations-wide denylist also rejects connection settings produced by trusted +application parsers and option hooks. The compatibility concern is tracked in +[PR #9611](https://github.com/microsoft/agent-framework/pull/9611). + +## Decision Drivers + +- Keep application-owned connections protected by default. +- Preserve deliberate runtime connection configuration through explicit opt-in. +- Keep ordinary per-run generation options and agent defaults available. +- Apply the policy to direct calls and hosting without provider-specific host coupling. + +## Considered Options + +- Reserve connection names in Invocations. This rejects input early, but changes + every hosted agent's contract and also blocks trusted parser and hook output. +- Disable all runtime options. This is simple, but unnecessarily removes model + and timeout overrides. +- Keep runtime connections enabled by default. This preserves compatibility, but + leaves applications forwarding unfiltered options without default protection. +- Make the Copilot agent reject runtime connections by default with constructor + opt-in. This scopes the policy to the component interpreting the configuration, + but still requires an explicit migration for existing Copilot runtime callers. + +## Decision Outcome + +Choose the agent-owned policy. Both `GitHubCopilotAgent` and +`RawGitHubCopilotAgent` expose `allow_runtime_connection_overrides=False`. +They reject per-run `provider` and `mcp_servers`, including empty or null values, +before SDK startup and again before assembling SDK session configuration. +Application `default_options` remain allowed, and ordinary runtime options retain +their existing behavior. The constructor flag is never read from run options or +forwarded to the SDK. + +Trusted applications may opt in to preserve runtime connection precedence over +defaults. This is an intentional behavioral compatibility change: existing callers +must move connections into defaults or enable the constructor flag. + +Invocations no longer reserves these connection names for every agent. It reports +the existing `AgentInvalidRequestException` as a sanitized HTTP 400 or typed SSE +client error, while retaining generic provider-failure sanitization and legacy wire +behavior. Other agents retain responsibility for their own option semantics. +An updated host alone does not protect older Copilot package versions; release and +deployment must include the updated agent policy. + +The proposed deciders include Python code owners; maintainer and engineering-management +approval is still required. diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 3a352eba761..e1d3424b8ee 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -397,6 +397,12 @@ runtime options. It also rejects agent execution controls: `additional_function_ arguments belong in developer-configured agent defaults or middleware/factories, not in request options or hook output. A hook may strip denied caller fields before validation; allowed generation and provider-specific options remain available. These restrictions apply to both wire formats and every `unsupported_options` policy. +Connection option names such as `provider` and `mcp_servers` are not reserved host-wide. +The hosted agent owns their policy. The updated `GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject those runtime +options by default, while preserving connections in `default_options`. Trusted applications can enable them with the +constructor-only `allow_runtime_connection_overrides=True` flag and filter request input through `prepare_options`. +Update the Copilot package as well as the host to obtain that default-deny policy; older Copilot versions retain their +existing runtime connection behavior. When an agent cannot accept runtime options, `unsupported_options="warn"` (default) logs and ignores them; `"ignore"` silently drops them and `"error"` rejects them. For request-scoped factories, unsupported options discovered after streaming starts are reported as an SSE `error` event with `status: 400`. @@ -404,8 +410,10 @@ discovered after streaming starts are reported as an SSE `error` event with `sta **New default:** non-streaming success is JSON `{"response": "..."}`. Streaming success is real SSE `event: delta` with `{"text": "..."}`, followed by `event: done` with the platform sandbox `session_id`. The `done` event is sent only after the final MAF `ResponseStream` is finalized and the session is persisted. -Client validation errors return JSON HTTP 400 before streaming where possible. Provider errors are logged and -sanitized as JSON HTTP 500 or an SSE `error`; a cross-process ETag conflict is JSON HTTP 409 or an SSE `error` +Client validation errors return JSON HTTP 400 before streaming where possible. An agent's `AgentInvalidRequestException` +is reported as a sanitized JSON HTTP 400 or SSE `error` with `code: "invalid_request"` and `status: 400`. +Provider errors are logged and sanitized as JSON HTTP 500 or an SSE `error`; a cross-process ETag conflict is +JSON HTTP 409 or an SSE `error` with `code: "session_conflict"` and `status: 409`. A stream may emit deltas before an error. The host serializes same-session requests in one process, but a CAS conflict can still follow external tool effects in separate processes; it does not guarantee exactly-once execution. See the diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 6838e601f5e..2c7e534614b 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -35,6 +35,7 @@ WorkflowExecutor, ) from agent_framework._telemetry import mark_feature_used +from agent_framework.exceptions import AgentInvalidRequestException from anyio import CancelScope from azure.ai.agentserver.core import ( FoundryAgentRequestContext, @@ -251,6 +252,8 @@ def _sse(event: str, data: Mapping[str, Any]) -> str: def _invocation_failure(exc: Exception) -> tuple[str, int, str]: cause: BaseException | None = exc while cause is not None: + if isinstance(cause, AgentInvalidRequestException): + return "The agent rejected the invocation request.", 400, "invalid_request" if isinstance(cause, (FoundryStorageConflictError, FoundryStoragePreconditionError)): return "Another request advanced this agent session; reload before retrying.", 409, "session_conflict" cause = cause.__cause__ diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 184dc536b14..52f2b001066 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -944,6 +944,100 @@ def create_host() -> InvocationsHostServer: assert agent.calls == [] assert provider.contexts == [] + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("source", ["request", "parser", "hook"]) + @pytest.mark.parametrize( + "options", + [ + {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + ], + ) + async def test_connection_options_are_governed_by_the_agent( + self, stream: bool, source: str, options: dict[str, Any] + ) -> None: + agent = _make_agent(response_text="ok", stream_texts=["ok"]) + server = InvocationsHostServer( + agent, + agent_session_store_provider=_SessionStoreProvider(_mock_session_store()), + parse_request=(lambda _request: InvocationRun(messages="hi", options=options, stream=stream)) + if source == "parser" + else None, + prepare_options=(lambda _request, _options: options) if source == "hook" else None, + ) + with _request_context(session_id="connection-options"): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "hi", "stream": stream, "options": options if source == "request" else {}}) + ) + assert await _success_text(response) == "ok" + + assert agent.calls[0]["options"] == options + + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("agent_type", ["RawGitHubCopilotAgent", "GitHubCopilotAgent"]) + @pytest.mark.parametrize( + "options", + [ + {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + ], + ) + async def test_http_route_reports_copilot_connection_policy_rejections_as_client_errors( + self, stream: bool, agent_type: str, options: dict[str, Any] + ) -> None: + copilot_agents = pytest.importorskip("agent_framework_github_copilot") + sdk_client = MagicMock() + sdk_client.start = AsyncMock() + sdk_client.create_session = AsyncMock() + sdk_client.resume_session = AsyncMock() + agent = getattr(copilot_agents, agent_type)(client=sdk_client) + server = InvocationsHostServer(agent, agent_session_store_provider=_SessionStoreProvider(_mock_session_store())) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: + response = await client.post( + "/invocations", + params={"agent_session_id": f"connection-policy-{uuid.uuid4().hex}"}, + json={"message": "hi", "stream": stream, "options": options}, + ) + + if stream: + assert response.status_code == 200 + assert _parse_sse_events(response.text) == [ + ( + "error", + { + "message": "The agent rejected the invocation request.", + "code": "invalid_request", + "status": 400, + }, + ) + ] + else: + assert response.status_code == 400 + assert response.json() == {"error": "The agent rejected the invocation request."} + sdk_client.start.assert_not_awaited() + sdk_client.create_session.assert_not_awaited() + sdk_client.resume_session.assert_not_awaited() + + @pytest.mark.parametrize("stream", [False, True]) + async def test_generation_options_preserve_application_connection_defaults(self, stream: bool) -> None: + agent = _make_agent(response_text="ok", stream_texts=["ok"]) + agent.default_options = { + "mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + "provider": {"type": "openai", "base_url": "https://example.com/api"}, + } + server = InvocationsHostServer(agent) + with _request_context(session_id="generation-options"): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "hi", "stream": stream, "options": {"model": "request-model"}}) + ) + assert await _success_text(response) == "ok" + + assert agent.calls[0]["options"] == {"model": "request-model"} + assert agent.default_options == { + "mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + "provider": {"type": "openai", "base_url": "https://example.com/api"}, + } + @pytest.mark.parametrize("result", [None, [], {1: "invalid"}, {"session_id": "forged"}]) async def test_options_hook_must_return_safe_string_keyed_mapping(self, result: Any) -> None: agent = _make_agent(response_text="ok") diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index 8f594e1c73b..953ecc2a0aa 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -27,6 +27,13 @@ from agent_framework_github_copilot import GitHubCopilotAgent ## Session Option Defaults +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run `provider` and +`mcp_servers` by default. Application defaults remain supported; runtime connection +configuration requires the constructor-only `allow_runtime_connection_overrides=True` +flag. Validate before SDK startup and again when assembling session kwargs so +creation and resumption share the policy. Ordinary runtime options keep their +existing precedence. + `_build_session_kwargs` forwards options to the SDK verbatim except for a few keys that get an explicit default: `on_permission_request` (deny-all) and `enable_file_hooks` (`False`, so a session behaves the same way in every working directory). Callers opt in through diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 1dbdbe05a46..b5b40569ce0 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -10,6 +10,42 @@ 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. +## Runtime connection configuration + +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` default to +`allow_runtime_connection_overrides=False`. Per-run `provider` and `mcp_servers` +options raise `AgentInvalidRequestException` before the agent starts the SDK client, +creates a session, or resumes one. This includes supplying a connection when no +default is configured, and clearing a configuration with an empty object or `None`. +Ordinary runtime options, such as `model` and `timeout`, remain supported. + +Configure application-owned connections in `default_options`: + +```python +from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions + +agent = GitHubCopilotAgent( + default_options=GitHubCopilotOptions( + provider={"type": "openai", "base_url": "https://application.example/v1"}, + ), +) +``` + +**Migration:** existing code passing `provider` or `mcp_servers` through +`run(options=...)` must move those settings to `default_options`, or explicitly +enable trusted runtime connection configuration: + +```python +agent = GitHubCopilotAgent(allow_runtime_connection_overrides=True) +``` + +With opt-in, runtime connection options retain their existing precedence over +defaults. The flag is a constructor argument, not an SDK session option; it cannot +be enabled through the incoming options mapping. Apply the same policy to both +streaming and non-streaming calls. Applications opting in are responsible for +validating or filtering connection settings received over HTTP or from other +untrusted inputs. + ## Tool approval (`approval_mode="always_require"`) The GitHub Copilot SDK owns the tool-calling loop for this provider, so approval for @@ -115,4 +151,3 @@ To make the default visible rather than silent, the agent logs a warning through 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. - 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 64a9f125e42..c648a705812 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -51,7 +51,7 @@ _get_data_bytes_as_str, # pyright: ignore[reportPrivateUsage] normalize_tools, ) -from agent_framework.exceptions import AgentException, ContentError +from agent_framework.exceptions import AgentException, AgentInvalidRequestException, ContentError from agent_framework.observability import AgentTelemetryLayer from ._feature_usage import FeatureIndex @@ -504,7 +504,8 @@ class GitHubCopilotOptions(TypedDict, total=False): ``reasoning_effort``, ``context_tier``, ``enable_citations``, ``available_tools``, ``memory``, ...) may also be supplied and is forwarded verbatim to the SDK. An unrecognized parameter name surfaces as a ``TypeError`` from the SDK, so typos are - caught rather than silently ignored. + caught rather than silently ignored. Per-run ``provider`` and ``mcp_servers`` require + constructor opt-in through ``allow_runtime_connection_overrides=True``. """ system_message: SystemMessageConfig @@ -661,6 +662,7 @@ def __init__( middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsT | None = None, + allow_runtime_connection_overrides: bool = False, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -681,12 +683,17 @@ def __init__( or tool definition dicts. These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. + allow_runtime_connection_overrides: Allow per-run `provider` and `mcp_servers` configuration. + Defaults to False. Configure application-owned connections through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. Raises: ValueError: If required configuration is missing or invalid. """ + if not isinstance(allow_runtime_connection_overrides, bool): + raise TypeError("allow_runtime_connection_overrides must be a bool.") + super().__init__( id=id, name=name, @@ -697,6 +704,7 @@ def __init__( self._client = client self._owns_client = client is None + self._allow_runtime_connection_overrides = allow_runtime_connection_overrides # Parse options opts: dict[str, Any] = dict(default_options) if default_options else {} @@ -894,6 +902,7 @@ def run( When stream=True: A ResponseStream of AgentResponseUpdate items. Raises: + AgentInvalidRequestException: If runtime connection configuration is supplied without constructor opt-in. AgentException: If the request fails. """ if middleware: @@ -966,13 +975,15 @@ async def _run_impl( function_invocation_kwargs: Mapping[str, Any] | None = None, ) -> AgentResponse: """Non-streaming implementation of run.""" + opts: dict[str, Any] = dict(options) if options else {} + self._validate_runtime_connection_options(opts) + if not self._started: await self.start() if not session: session = self.create_session() - opts: dict[str, Any] = dict(options) if options else {} if "on_function_approval" in opts: raise ValueError( "on_function_approval is a security-sensitive option and must be set " @@ -1103,13 +1114,15 @@ async def _stream_updates( Raises: AgentException: If the request fails. """ + opts: dict[str, Any] = dict(options) if options else {} + self._validate_runtime_connection_options(opts) + if not self._started: await self.start() if not session: session = self.create_session() - opts: dict[str, Any] = dict(options) if options else {} if "on_function_approval" in opts: raise ValueError( "on_function_approval is a security-sensitive option and must be set " @@ -1714,6 +1727,17 @@ def _warn_once_about_unloaded_file_hooks(self, session_working_directory: str | working_directory / ".github" / "hooks", ) + def _validate_runtime_connection_options(self, options: Mapping[str, Any]) -> None: + if self._allow_runtime_connection_overrides: + return + reserved = {"mcp_servers", "provider"}.intersection(options) + if reserved: + raise AgentInvalidRequestException( + f"Runtime connection options are disabled: {', '.join(sorted(reserved))}. " + "Configure connections through default_options or set allow_runtime_connection_overrides=True " + "when constructing the agent." + ) + def _build_session_kwargs( self, streaming: bool, @@ -1725,11 +1749,11 @@ def _build_session_kwargs( """Assemble keyword arguments for ``create_session`` / ``resume_session``. Options are layered: the agent's ``default_options`` first, then per-run - ``runtime_options`` which override them. Every key is forwarded verbatim to + ``runtime_options`` which override them. Every accepted key is forwarded verbatim to the Copilot SDK, so any ``create_session`` parameter is supported without a dedicated mapping here (an unknown name surfaces as a ``TypeError`` from the - SDK). A few keys are handled specially because they need a specific default or - transforming: ``on_permission_request`` defaults to denying all requests and is + SDK). Runtime connection options require constructor opt-in. A few keys need + specific defaults or transformations: ``on_permission_request`` defaults to denying all requests and is wrapped so under-specified ``approve-for-session`` decisions are scoped to the request that triggered them, ``enable_file_hooks`` defaults to off, ``tools`` are merged with the agent's tools and converted to SDK tools, and approval callbacks @@ -1748,6 +1772,7 @@ def _build_session_kwargs( The keyword arguments to splat into the SDK session factory. """ opts = runtime_options or {} + self._validate_runtime_connection_options(opts) # Passthrough layer: agent defaults first, per-run options override. kwargs: dict[str, Any] = {**self._default_options, **opts} @@ -1923,6 +1948,7 @@ def __init__( middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsT | None = None, + allow_runtime_connection_overrides: bool = False, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -1943,6 +1969,8 @@ def __init__( These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. + allow_runtime_connection_overrides: Allow per-run `provider` and `mcp_servers` configuration. + Defaults to False. Configure application-owned connections through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. """ @@ -1956,6 +1984,7 @@ def __init__( middleware=middleware, tools=tools, default_options=default_options, + allow_runtime_connection_overrides=allow_runtime_connection_overrides, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) 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 175398a3d23..e87db6e0e4d 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -32,9 +32,10 @@ MCPStdioTool, Message, ResponseStream, + SessionStore, tool, ) -from agent_framework.exceptions import AgentException +from agent_framework.exceptions import AgentException, AgentInvalidRequestException from copilot.session import PermissionHandler, PermissionInvocation, PreToolUseHookInput from copilot.session_events import ( AssistantUsageData, @@ -1647,6 +1648,178 @@ async def test_disabled_skills_override_on_resumed_session( assert config["disabled_skills"] == ["override-skill"] +class TestGitHubCopilotAgentRuntimeConnections: + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("configured_connections", [False, True]) + @pytest.mark.parametrize( + "options", + [ + {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + ], + ) + async def test_run_rejects_runtime_connections_by_default( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + configured_connections: bool, + options: GitHubCopilotOptions, + mock_client: MagicMock, + ) -> None: + agent = agent_type(client=mock_client, default_options=options if configured_connections else None) + session = AgentSession(service_session_id="existing-session" if resume else None) + + with pytest.raises(AgentInvalidRequestException, match="allow_runtime_connection_overrides=True"): + if stream: + async for _ in agent.run("Hello", session=session, options=options, stream=True): + pass + else: + await agent.run("Hello", session=session, options=options) + + mock_client.start.assert_not_awaited() + mock_client.create_session.assert_not_awaited() + mock_client.resume_session.assert_not_awaited() + assert agent.default_options == (options if configured_connections else {}) + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("runtime_connections", [False, True]) + async def test_run_preserves_defaults_and_supports_explicit_runtime_opt_in( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + runtime_connections: bool, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + defaults: GitHubCopilotOptions = { + "provider": {"type": "azure", "base_url": "https://application.example/api"}, + "mcp_servers": {"application": {"type": "http", "url": "https://application.example/mcp", "tools": ["*"]}}, + } + options: GitHubCopilotOptions = {"model": "runtime-model", "timeout": 3} + if runtime_connections: + options["provider"] = {"type": "openai", "base_url": "https://runtime.example/api"} + options["mcp_servers"] = {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}} + + def mock_on(handler: Any) -> Any: + handler(assistant_delta_event) + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + mock_session.send_and_wait.return_value = assistant_message_event + agent = agent_type( + client=mock_client, + default_options=defaults, + allow_runtime_connection_overrides=runtime_connections, + ) + session = AgentSession(service_session_id="existing-session" if resume else None) + if stream: + updates = [update async for update in agent.run("Hello", session=session, options=options, stream=True)] + assert "".join(update.text for update in updates) == "Hello" + else: + response = await agent.run("Hello", session=session, options=options) + assert response.text == "Test response" + + config = (mock_client.resume_session if resume else mock_client.create_session).call_args.kwargs + assert config["provider"] == (options["provider"] if runtime_connections else defaults["provider"]) + assert config["mcp_servers"] == (options["mcp_servers"] if runtime_connections else defaults["mcp_servers"]) + assert config["model"] == "runtime-model" + assert "allow_runtime_connection_overrides" not in config + assert agent.default_options == defaults + + async def test_runtime_options_cannot_enable_connection_overrides(self, mock_client: MagicMock) -> None: + agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent(client=mock_client) + with pytest.raises(AgentInvalidRequestException, match="provider"): + await agent.run( + "Hello", + options=cast(GitHubCopilotOptions, {"provider": {}, "allow_runtime_connection_overrides": True}), + ) + mock_client.create_session.assert_not_awaited() + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + async def test_invocations_preserves_explicit_runtime_connection_opt_in( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + monkeypatch: pytest.MonkeyPatch, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + hosting = pytest.importorskip("agent_framework_foundry_hosting") + import httpx + + # Host construction configures process-wide SDK telemetry, so isolate it as the Foundry tests do. + monkeypatch.setattr("azure.ai.agentserver.core._tracing._configure_tracing", lambda **kwargs: None) + + def mock_on(handler: Any) -> Any: + handler(assistant_delta_event) + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + mock_session.send_and_wait.return_value = assistant_message_event + store = MagicMock(spec=SessionStore) + store.get.return_value = None + store_provider = MagicMock(spec=hosting.StoreProvider) + store_provider.get_store.return_value = store + agent = agent_type(client=mock_client, allow_runtime_connection_overrides=True) + server = hosting.InvocationsHostServer(agent, agent_session_store_provider=store_provider) + options: GitHubCopilotOptions = { + "provider": {"type": "openai", "base_url": "https://runtime.example/api"}, + "mcp_servers": {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}}, + } + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: + response = await client.post( + "/invocations", + params={"agent_session_id": f"trusted-connections-{uuid4().hex}"}, + json={"message": "Hello", "stream": stream, "options": options}, + ) + + assert response.status_code == 200 + if stream: + assert 'event: delta\ndata: {"text": "Hello"}' in response.text + assert "event: done" in response.text + assert "event: error" not in response.text + else: + assert response.json() == {"response": "Test response"} + config = mock_client.create_session.call_args.kwargs + assert config["provider"] == options["provider"] + assert config["mcp_servers"] == options["mcp_servers"] + store.set.assert_awaited_once() + + @pytest.mark.parametrize( + "options", [{"provider": None}, {"provider": {}}, {"mcp_servers": None}, {"mcp_servers": {}}] + ) + async def test_empty_runtime_connection_options_are_rejected( + self, options: dict[str, Any], mock_client: MagicMock + ) -> None: + agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent(client=mock_client) + with pytest.raises(AgentInvalidRequestException, match="Runtime connection options are disabled"): + await agent.run("Hello", options=cast(GitHubCopilotOptions, options)) + mock_client.start.assert_not_awaited() + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("value", [None, 1, "false"]) + def test_connection_override_policy_requires_a_boolean( + self, agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], value: Any + ) -> None: + with pytest.raises(TypeError, match="allow_runtime_connection_overrides must be a bool"): + agent_type(allow_runtime_connection_overrides=value) + + class TestGitHubCopilotAgentMCPServers: """Test cases for MCP server configuration.""" @@ -1849,6 +2022,7 @@ async def test_runtime_provider_takes_precedence( agent = GitHubCopilotAgent( client=mock_client, default_options=copilot_options({"provider": default_provider}), + allow_runtime_connection_overrides=True, ) await agent.start()