Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/decisions/0046-python-copilot-runtime-connections.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 10 additions & 2 deletions python/packages/foundry_hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,15 +397,23 @@ 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`.

**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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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__
Expand Down
94 changes: 94 additions & 0 deletions python/packages/foundry_hosting/tests/test_invocations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions python/packages/github_copilot/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion python/packages/github_copilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Loading
Loading