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..72edc2c3287 --- /dev/null +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -0,0 +1,75 @@ +--- +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 settings, MCP settings, custom-agent MCP settings, session +credentials, authentication controls, and discovery roots are SDK session +configuration. 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 configuration, MCP configuration, session credentials, +authentication controls, and discovery roots, including empty or null values, before +SDK startup and again before assembling SDK session configuration. Application +`default_options` remain allowed, runtime custom agents without MCP configuration +remain supported, and ordinary runtime options retain their existing behavior. The +constructor flag is never read from run options or forwarded to the SDK. + +The agents expose a side-effect-free `validate_run_options` preflight. Invocations +uses it after resolving the request-scoped agent and before entering its async +context or accessing session storage. Direct runs still validate at entry, and +session assembly validates again. + +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. + +The package bounds `github-copilot-sdk` at 1.0.25 because it is the latest +reviewed session configuration surface. Widening the bound requires classifying new +`create_session` and `resume_session` parameters and adding regression coverage before +publication. + +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..1070f6b0b60 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -397,6 +397,14 @@ 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. Agents may expose `validate_run_options(options)` for a side-effect-free +preflight before the host enters a request-scoped agent context or reads or writes session storage. The updated +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` use this preflight to reject runtime connections 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 +412,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..e82038deae8 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,12 +252,22 @@ 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__ return "Agent invocation failed.", 500, "invocation_failed" +def _contains_exception(cause: BaseException | None, exception_type: type[BaseException]) -> bool: + while cause is not None: + if isinstance(cause, exception_type): + return True + cause = cause.__cause__ + return False + + def _workflow_failure(exc: Exception) -> tuple[str, int, str]: if isinstance(exc, _WorkflowOutputError): return str(exc), 500, "invalid_workflow_output" @@ -517,12 +528,13 @@ def _warn_legacy_workflow(self) -> None: logger.warning("DEPRECATION: %s", message) @asynccontextmanager - async def _request_agent(self) -> AsyncGenerator[SupportsAgentRun]: + async def _request_agent(self, options: Mapping[str, Any]) -> AsyncGenerator[SupportsAgentRun]: if self._agent is None: raise RuntimeError("No agent is configured for Invocations.") agent = await resolve_agent(self._agent) if isinstance(agent, WorkflowAgent): self._warn_legacy_workflow() + await self._validate_agent_options(agent, options) async with AsyncExitStack() as resources: if self._owns_request_agent and isinstance(agent, AbstractAsyncContextManager): await resources.enter_async_context(agent) @@ -555,17 +567,18 @@ async def _request_session( yield session finally: failure = sys.exc_info()[1] - try: - await store.set(session_id, session) - except Exception as exc: - logger.exception("Failed to persist invocation session") - if failure is None: - raise - if isinstance(failure, Exception): - raise RuntimeError( - f"Invocation failed: {str(failure) or type(failure).__name__}; " - f"session persistence also failed: {str(exc) or type(exc).__name__}" - ) from exc + if not _contains_exception(failure, AgentInvalidRequestException): + try: + await store.set(session_id, session) + except Exception as exc: + logger.exception("Failed to persist invocation session") + if failure is None: + raise + if isinstance(failure, Exception): + raise RuntimeError( + f"Invocation failed: {str(failure) or type(failure).__name__}; " + f"session persistence also failed: {str(exc) or type(exc).__name__}" + ) from exc async def _parse(self, request: Request) -> InvocationRun | WorkflowTurn[Any]: if self._parse_request is not None: @@ -623,6 +636,15 @@ def _agent_kwargs(self, agent: SupportsAgentRun, options: dict[str, Any]) -> dic return {} return {"options": options} + @staticmethod + async def _validate_agent_options(agent: SupportsAgentRun, options: Mapping[str, Any]) -> None: + validator = getattr(agent, "validate_run_options", None) + if not callable(validator): + return + result = validator(options) + if inspect.isawaitable(result): + await result + @staticmethod async def _close_interrupted_stream(stream: object) -> None: if isinstance(stream, ResponseStream): @@ -669,7 +691,7 @@ async def stream_response() -> AsyncGenerator[str]: token: Token[FoundryAgentRequestContext] | None = set_request_context(context) try: lock = self._session_locks.setdefault(partition_key, asyncio.Lock()) - async with lock, self._request_agent() as agent: + async with lock, self._request_agent(options) as agent: run_kwargs = self._agent_kwargs(agent, options) async with self._request_session(partition_key, context, hosted_scope=hosted_scope) as session: stream = agent.run(parsed.messages, session=session, stream=True, **run_kwargs) @@ -728,7 +750,7 @@ async def stream_response() -> AsyncGenerator[str]: try: lock = self._session_locks.setdefault(partition_key, asyncio.Lock()) - async with lock, self._request_agent() as agent: + async with lock, self._request_agent(options) as agent: run_kwargs = self._agent_kwargs(agent, options) async with self._request_session(partition_key, context, hosted_scope=hosted_scope) as session: response = await agent.run(parsed.messages, session=session, **run_kwargs) diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 184dc536b14..c86e37c5dce 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -67,6 +67,36 @@ pytestmark = pytest.mark.filterwarnings("ignore:.*SessionStore is experimental.*") +_COPILOT_RUNTIME_CONNECTION_OPTIONS: list[dict[str, Any]] = [ + {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + { + "providers": [{"name": "runtime", "type": "openai", "base_url": "https://example.com/api"}], + "models": [{"id": "runtime-model", "provider": "runtime"}], + "model": "runtime/runtime-model", + }, + {"plugin_directories": ["C:\\caller-plugin"]}, + {"agent_directories": ["C:\\caller-agents"]}, + {"config_directory": "C:\\caller-config"}, + {"enable_config_discovery": True}, + {"working_directory": "C:\\caller-workspace"}, + {"github_token": "caller-token"}, + {"auth_client_id_metadata_url": "https://caller.example/oauth-client.json"}, + {"mcp_oauth_token_storage": "persistent"}, + {"github_mcp_tool_config": {"enable_all_tools": True}}, + {"disabled_mcp_servers": ["application"]}, + {"capi": {"enable_web_socket_responses": False}}, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + { + "custom_agents": [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": {"runtime": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + } + ] + }, +] + # region Helpers @@ -944,6 +974,106 @@ 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", _COPILOT_RUNTIME_CONNECTION_OPTIONS) + 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("resume", [False, True]) + @pytest.mark.parametrize("source", ["request", "parser", "hook"]) + @pytest.mark.parametrize("agent_source", ["instance", "factory"]) + @pytest.mark.parametrize("agent_type", ["RawGitHubCopilotAgent", "GitHubCopilotAgent"]) + @pytest.mark.parametrize("options", _COPILOT_RUNTIME_CONNECTION_OPTIONS) + async def test_http_route_reports_copilot_connection_policy_rejections_as_client_errors( + self, stream: bool, resume: bool, source: str, agent_source: str, 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(side_effect=AssertionError("create_session must not be called")) + sdk_client.resume_session = AsyncMock(side_effect=AssertionError("resume_session must not be called")) + agent = getattr(copilot_agents, agent_type)(client=sdk_client) + store = _mock_session_store() + if resume: + store.get.return_value = AgentSession( + session_id="connection-policy", + service_session_id="existing-session", + ) + server = InvocationsHostServer( + agent if agent_source == "instance" else lambda: agent, + agent_session_store_provider=_SessionStoreProvider(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, + ) + 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 source == "request" else {}}, + ) + + 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() + store.get.assert_not_awaited() + store.set.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..589a261c044 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -27,6 +27,23 @@ from agent_framework_github_copilot import GitHubCopilotAgent ## Session Option Defaults +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run provider and MCP +configuration, session credentials, authentication controls, and discovery roots 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. Runtime custom agents without MCP configuration and ordinary runtime +options keep their existing behavior. + +The `github-copilot-sdk` upper bound reflects the latest reviewed session-option +surface. Before widening it, compare `create_session` and `resume_session` parameters +against `_RUNTIME_CONNECTION_OPTION_NAMES`, add red/green coverage for new connection +paths, and preserve the constructor opt-in. + +`validate_run_options` is the side-effect-free hosting preflight. Keep it aligned +with validation at the start of direct streaming and non-streaming runs and with the +second validation in `_build_session_kwargs`. + `_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..f934480f6a8 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -10,6 +10,54 @@ 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 configuration, +session credentials, authentication controls, and discovery roots 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`. Runtime +custom agents without `mcp_servers` remain supported. Ordinary runtime options, +such as `model` and `timeout`, remain supported. + +The Invocations host calls `validate_run_options` before loading session storage, so +rejected hosted options do not read or write an agent session. + +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 configuration, MCP configuration, +session credentials, authentication controls, or discovery roots 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, including replacement custom-agent configurations. 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. + +The package accepts Copilot SDK versions through 1.0.25, the latest session +configuration surface reviewed for this boundary. A later SDK requires an Agent +Framework update that classifies any new session options before widening the bound. + ## Tool approval (`approval_mode="always_require"`) The GitHub Copilot SDK owns the tool-calling loop for this provider, so approval for @@ -115,4 +163,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..4c42749a577 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 @@ -207,6 +207,25 @@ def _provider_service_session_state_keys(agent: object, client: object | None) - # Entries disappear once no run holds or awaits the lock. _SESSION_RUN_LOCKS: weakref.WeakValueDictionary[tuple[int, str], asyncio.Lock] = weakref.WeakValueDictionary() +_RUNTIME_CONNECTION_OPTION_NAMES = frozenset({ + "agent_directories", + "auth_client_id_metadata_url", + "capi", + "config_directory", + "disabled_mcp_servers", + "enable_config_discovery", + "github_mcp_tool_config", + "github_token", + "github_token_provider", + "mcp_oauth_token_storage", + "mcp_servers", + "on_mcp_auth_request", + "plugin_directories", + "provider", + "providers", + "working_directory", +}) + _MCP_TOOL_MESSAGE = ( "MCP server '{name}' cannot be passed to GitHubCopilotAgent as a tool: the Copilot SDK " "connects to MCP servers itself, so a framework-managed MCPTool would keep none of its " @@ -504,7 +523,9 @@ 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 configuration, MCP configuration, + session credentials, authentication controls, and discovery roots require constructor + opt-in through ``allow_runtime_connection_overrides=True``. """ system_message: SystemMessageConfig @@ -661,6 +682,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 +703,18 @@ 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 configuration, MCP + configuration, session credentials, authentication controls, and discovery roots. + Defaults to False. Configure application-owned connections through `default_options`. 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 +725,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 +923,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 +996,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 +1135,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 +1748,32 @@ 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 = set(_RUNTIME_CONNECTION_OPTION_NAMES.intersection(options)) + custom_agents = options.get("custom_agents") + if ( + isinstance(custom_agents, Sequence) + and not isinstance(custom_agents, (str, bytes, bytearray)) + and any( + "mcp_servers" in cast(Mapping[object, object], custom_agent) + for custom_agent in cast(Sequence[object], custom_agents) + if isinstance(custom_agent, Mapping) + ) + ): + reserved.add("custom_agents[].mcp_servers") + 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 validate_run_options(self, options: Mapping[str, Any] | None = None) -> None: + """Validate per-run options without starting the SDK client or opening a session.""" + self._validate_runtime_connection_options(options or {}) + def _build_session_kwargs( self, streaming: bool, @@ -1725,11 +1785,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 +1808,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 +1984,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 +2005,9 @@ 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 configuration, MCP + configuration, session credentials, authentication controls, and discovery roots. + Defaults to False. Configure application-owned connections through `default_options`. 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 +2021,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/pyproject.toml b/python/packages/github_copilot/pyproject.toml index caa0cb7706e..8cea775272c 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.22.0,<2", - "github-copilot-sdk>=1.0.16,<2.0; python_version >= '3.11'", + "github-copilot-sdk>=1.0.16,<=1.0.25; python_version >= '3.11'", ] [tool.uv] 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..e35f7803c81 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, @@ -69,6 +70,16 @@ def pre_tool_use_input(tool_name: str) -> PreToolUseHookInput: } +def test_copilot_sdk_dependency_stops_at_latest_reviewed_version() -> None: + dependency = next( + line.strip() + for line in (Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8").splitlines() + if line.strip().startswith('"github-copilot-sdk') + ) + + assert "<=1.0.25" in dependency + + def shell_request( command_identifiers: Sequence[str], can_offer_session_approval: bool = True ) -> PermissionRequestShell: @@ -1647,6 +1658,532 @@ 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"}}, + { + "providers": [{"name": "runtime", "type": "openai", "base_url": "https://example.com/api"}], + "models": [{"id": "runtime-model", "provider": "runtime"}], + "model": "runtime/runtime-model", + }, + {"plugin_directories": ["C:\\caller-plugin"]}, + {"agent_directories": ["C:\\caller-agents"]}, + {"config_directory": "C:\\caller-config"}, + {"enable_config_discovery": True}, + {"working_directory": "C:\\caller-workspace"}, + {"github_token": "caller-token"}, + {"github_token_provider": lambda _args: {"kind": "cancelled"}}, + {"auth_client_id_metadata_url": "https://caller.example/oauth-client.json"}, + {"mcp_oauth_token_storage": "persistent"}, + {"on_mcp_auth_request": lambda _request: {"kind": "cancelled"}}, + {"github_mcp_tool_config": {"enable_all_tools": True}}, + {"disabled_mcp_servers": ["application"]}, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + { + "custom_agents": [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": {"runtime": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + } + ] + }, + {"capi": {"enable_web_socket_responses": False}}, + ], + ) + 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_mode", ["defaults", "custom_agent", "connections"]) + async def test_run_preserves_defaults_and_supports_explicit_runtime_opt_in( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + runtime_mode: str, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + application_custom_agents: list[dict[str, Any]] = [ + { + "name": "application", + "prompt": "Use the application MCP server.", + "mcp_servers": { + "application": { + "type": "http", + "url": "https://application.example/custom-agent-mcp", + "tools": ["*"], + } + }, + } + ] + defaults = cast( + GitHubCopilotOptions, + { + "provider": {"type": "azure", "base_url": "https://application.example/api"}, + "mcp_servers": { + "application": {"type": "http", "url": "https://application.example/mcp", "tools": ["*"]} + }, + "custom_agents": application_custom_agents, + }, + ) + runtime_custom_agents: list[dict[str, Any]] | None = None + if runtime_mode == "connections": + runtime_custom_agents = [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": { + "runtime": { + "type": "http", + "url": "https://runtime.example/custom-agent-mcp", + "tools": ["*"], + } + }, + } + ] + options = cast( + GitHubCopilotOptions, + { + "model": "runtime-model", + "timeout": 3, + "provider": {"type": "openai", "base_url": "https://runtime.example/api"}, + "mcp_servers": {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}}, + "custom_agents": runtime_custom_agents, + }, + ) + elif runtime_mode == "custom_agent": + runtime_custom_agents = [{"name": "runtime", "prompt": "Use only built-in tools."}] + options = cast( + GitHubCopilotOptions, + { + "model": "runtime-model", + "timeout": 3, + "custom_agents": runtime_custom_agents, + }, + ) + else: + options = GitHubCopilotOptions(model="runtime-model", timeout=3) + + 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_mode == "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_mode == "connections" else defaults["provider"]) + assert config["mcp_servers"] == ( + options["mcp_servers"] if runtime_mode == "connections" else defaults["mcp_servers"] + ) + assert config["custom_agents"] == (runtime_custom_agents or application_custom_agents) + assert config["model"] == "runtime-model" + assert "allow_runtime_connection_overrides" not in config + assert agent.default_options == defaults + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("source", ["defaults", "runtime"]) + async def test_named_provider_defaults_and_opt_in_are_forwarded( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + source: str, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + providers = [{"name": "runtime", "type": "openai", "base_url": "https://runtime.example/api"}] + models = [{"id": "runtime-model", "provider": "runtime"}] + connections = cast( + GitHubCopilotOptions, + { + "providers": providers, + "models": models, + "model": "runtime/runtime-model", + }, + ) + + 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=connections if source == "defaults" else None, + allow_runtime_connection_overrides=source == "runtime", + ) + 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=connections if source == "runtime" else None, + stream=True, + ) + ] + assert "".join(update.text for update in updates) == "Hello" + else: + response = await agent.run( + "Hello", + session=session, + options=connections if source == "runtime" else None, + ) + assert response.text == "Test response" + + config = (mock_client.resume_session if resume else mock_client.create_session).call_args.kwargs + assert config["providers"] == providers + assert config["models"] == models + assert config["model"] == "runtime/runtime-model" + assert agent.default_options == (connections if source == "defaults" else {}) + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("source", ["defaults", "runtime"]) + @pytest.mark.parametrize( + "connections", + [ + {"plugin_directories": ["C:\\trusted-plugin"]}, + {"config_directory": "C:\\trusted-config"}, + {"enable_config_discovery": True}, + {"working_directory": "C:\\trusted-workspace"}, + {"github_token": "trusted-token"}, + {"github_token_provider": lambda _args: {"kind": "cancelled"}}, + {"auth_client_id_metadata_url": "https://trusted.example/oauth-client.json"}, + {"mcp_oauth_token_storage": "persistent"}, + {"on_mcp_auth_request": lambda _request: {"kind": "cancelled"}}, + {"github_mcp_tool_config": {"enable_all_tools": True}}, + {"disabled_mcp_servers": ["application"]}, + {"capi": {"enable_web_socket_responses": False}}, + ], + ) + async def test_runtime_connection_defaults_and_opt_in_are_forwarded( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + source: str, + connections: dict[str, Any], + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> 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 + agent = agent_type( + client=mock_client, + default_options=cast(GitHubCopilotOptions, connections) if source == "defaults" else None, + allow_runtime_connection_overrides=source == "runtime", + ) + session = AgentSession(service_session_id="existing-session" if resume else None) + options = cast(GitHubCopilotOptions, connections) if source == "runtime" 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 + for key, value in connections.items(): + assert config[key] == value + assert agent.default_options == (connections if source == "defaults" else {}) + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + async def test_newer_sdk_runtime_connection_opt_in_is_forwarded( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + mock_session.send_and_wait.return_value = assistant_message_event + client = MagicMock() + client.start = AsyncMock() + client.create_session = AsyncMock(return_value=mock_session) + client.resume_session = AsyncMock(return_value=mock_session) + agent = agent_type(client=client, allow_runtime_connection_overrides=True) + options = cast(GitHubCopilotOptions, {"agent_directories": ["C:\\trusted-agents"]}) + + response = await agent.run("Hello", session=AgentSession(), options=options) + + assert response.text == "Test response" + assert client.create_session.call_args.kwargs["agent_directories"] == ["C:\\trusted-agents"] + + 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) + runtime_custom_agents: list[dict[str, Any]] = [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": { + "runtime": { + "type": "http", + "url": "https://runtime.example/custom-agent-mcp", + "tools": ["*"], + } + }, + } + ] + options: GitHubCopilotOptions = { + "provider": {"type": "openai", "base_url": "https://runtime.example/api"}, + "mcp_servers": {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}}, + } + options = cast( + GitHubCopilotOptions, + { + **options, + "custom_agents": runtime_custom_agents, + }, + ) + 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"] + assert config["custom_agents"] == runtime_custom_agents + store.set.assert_awaited_once() + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("source", ["request", "parser", "hook"]) + @pytest.mark.parametrize("agent_source", ["instance", "factory"]) + async def test_invocations_preserves_named_provider_opt_in( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + source: str, + agent_source: str, + 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 + + 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 = ( + AgentSession(session_id="trusted-named-provider", service_session_id="existing-session") if resume else 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) + providers = [{"name": "runtime", "type": "openai", "base_url": "https://runtime.example/api"}] + models = [{"id": "runtime-model", "provider": "runtime"}] + options = cast( + GitHubCopilotOptions, + { + "providers": providers, + "models": models, + "model": "runtime/runtime-model", + }, + ) + server = hosting.InvocationsHostServer( + agent if agent_source == "instance" else lambda: agent, + agent_session_store_provider=store_provider, + parse_request=(lambda _request: hosting.InvocationRun(messages="Hello", options=options, stream=stream)) + if source == "parser" + else None, + prepare_options=(lambda _request, _options: options) if source == "hook" else None, + ) + 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-named-provider-{uuid4().hex}"}, + json={"message": "Hello", "stream": stream, "options": options if source == "request" else {}}, + ) + + 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.resume_session if resume else mock_client.create_session).call_args.kwargs + assert config["providers"] == providers + assert config["models"] == models + assert config["model"] == "runtime/runtime-model" + store.set.assert_awaited_once() + + @pytest.mark.parametrize( + "options", + [ + {"provider": None}, + {"provider": {}}, + {"providers": None}, + {"providers": []}, + {"plugin_directories": []}, + {"agent_directories": []}, + {"config_directory": None}, + {"enable_config_discovery": None}, + {"working_directory": None}, + {"github_token": None}, + {"github_token_provider": None}, + {"auth_client_id_metadata_url": None}, + {"mcp_oauth_token_storage": None}, + {"on_mcp_auth_request": None}, + {"github_mcp_tool_config": {}}, + {"disabled_mcp_servers": []}, + {"capi": {}}, + { + "providers": [{"name": "runtime", "type": "openai", "base_url": "https://runtime.example/api"}], + "models": [{"id": "runtime-model", "provider": "runtime"}], + "model": "runtime-model", + }, + {"mcp_servers": None}, + {"mcp_servers": {}}, + {"custom_agents": [{"name": "runtime", "prompt": "Empty MCP", "mcp_servers": None}]}, + {"custom_agents": [{"name": "runtime", "prompt": "Empty MCP", "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 +2386,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() diff --git a/python/uv.lock b/python/uv.lock index 6571372d261..f7f7ea4fe04 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -738,7 +738,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.16,<2.0" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.16,<=1.0.25" }, ] [[package]]