From 043208241a4f54769cfe499b1b24e0c3ea720fcb Mon Sep 17 00:00:00 2001 From: Hameed Kunkanoor <41198503+Hameedkunkanoor@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:00:36 +0530 Subject: [PATCH 1/7] Python: Persist hosted MCP call/results as canonical mcp_call output (#6070) * Persist hosted MCP call/results as canonical mcp_call output - Preserve hosted MCP call/result pairs as canonical mcp_call output items - Coalesce MCP call + result in non-streaming conversion path - Keep call-id alignment for MCP tool call tracking and output mapping - Update tests and package metadata * Fix missing Mapping import in hosted responses adapter * Fix pyright unknown type in MCP output stringification * Fix typing for MCP output sequence iteration * Improve MCP output robustness and avoid eager flattening * Bump foundry_hosting to b7 and update responses dependency to b7 * Restore foundry_hosting package version to 1.0.0a260521 * Refactor hosted MCP output parsing --- .../_responses.py | 171 ++++++++++++--- .../packages/foundry_hosting/pyproject.toml | 2 +- .../foundry_hosting/tests/test_responses.py | 197 ++++++++++++++++++ 3 files changed, 338 insertions(+), 32 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 8940365930..6dc9bbd8c6 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -9,7 +9,7 @@ import logging import os import tempfile import threading -from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence +from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress from dataclasses import asdict, is_dataclass from pathlib import Path @@ -472,14 +472,12 @@ class ResponsesHostServer(ResponsesAgentServerHost): # Run the agent in non-streaming mode response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType] - for message in response.messages: - for content in message.contents: - async for item in _to_outputs( - response_event_stream, - content, - approval_storage=self._approval_storage, - ): - yield item + async for item in _to_outputs_for_messages( + response_event_stream, + response.messages, + approval_storage=self._approval_storage, + ): + yield item yield response_event_stream.emit_completed() else: if tracker is None: # pragma: no cover - defensive, set above @@ -620,10 +618,8 @@ class ResponsesHostServer(ResponsesAgentServerHost): checkpoint_storage=write_storage, ) - for message in response.messages: - for content in message.contents: - async for item in _to_outputs(response_event_stream, content): - yield item + async for item in _to_outputs_for_messages(response_event_stream, response.messages): + yield item await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name) yield response_event_stream.emit_completed() @@ -729,7 +725,7 @@ class _OutputItemTracker: yield self._fc_builder.emit_arguments_delta(args_str) elif content.type == "mcp_server_tool_call" and content.tool_name: - key = f"{content.server_name or 'default'}::{content.tool_name}" + key = content.call_id or f"{content.server_name or 'default'}::{content.tool_name}" if self._active_type != "mcp_server_tool_call" or self._active_id != key: yield from self._close() yield from self._open_mcp_call(content) @@ -738,6 +734,24 @@ class _OutputItemTracker: if self._mcp_builder is not None: yield self._mcp_builder.emit_arguments_delta(args_str) + elif ( + content.type == "mcp_server_tool_result" + and self._active_type == "mcp_server_tool_call" + and self._mcp_builder is not None + and content.call_id is not None + and content.call_id == self._mcp_builder.item_id + ): + accumulated = "".join(self._accumulated) + yield self._mcp_builder.emit_arguments_done(accumulated) + yield self._mcp_builder.emit_completed() + yield self._mcp_builder.emit_done(output=_stringify_mcp_output(content.output)) + self._mcp_builder = None + self._active_type = None + self._active_id = None + self._accumulated.clear() + self.needs_async = False + return + else: yield from self._close() self.needs_async = True @@ -777,9 +791,10 @@ class _OutputItemTracker: self._mcp_builder = self._stream.add_output_item_mcp_call( server_label=content.server_name or "default", name=content.tool_name or "", + item_id=content.call_id, ) self._active_type = "mcp_server_tool_call" - self._active_id = f"{content.server_name or 'default'}::{content.tool_name}" + self._active_id = content.call_id or f"{content.server_name or 'default'}::{content.tool_name}" yield self._mcp_builder.emit_added() def _close(self) -> Generator[ResponseStreamEvent]: @@ -927,16 +942,19 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No if item.type == "mcp_call": mcp = cast(ItemMcpToolCall, item) + contents = [ + Content.from_mcp_server_tool_call( + mcp.id, + mcp.name, + server_name=mcp.server_label, + arguments=mcp.arguments, + ) + ] + if getattr(mcp, "output", None) is not None: + contents.append(Content.from_mcp_server_tool_result(call_id=mcp.id, output=mcp.output)) return Message( role="assistant", - contents=[ - Content.from_mcp_server_tool_call( - mcp.id, - mcp.name, - server_name=mcp.server_label, - arguments=mcp.arguments, - ) - ], + contents=contents, ) if item.type == "mcp_approval_request": @@ -1197,16 +1215,19 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva if item.type == "mcp_call": mcp = cast(OutputItemMcpToolCall, item) + contents = [ + Content.from_mcp_server_tool_call( + mcp.id, + mcp.name, + server_name=mcp.server_label, + arguments=mcp.arguments, + ) + ] + if getattr(mcp, "output", None) is not None: + contents.append(Content.from_mcp_server_tool_result(call_id=mcp.id, output=mcp.output)) return Message( role="assistant", - contents=[ - Content.from_mcp_server_tool_call( - mcp.id, - mcp.name, - server_name=mcp.server_label, - arguments=mcp.arguments, - ) - ], + contents=contents, ) if item.type == "mcp_approval_request": @@ -1583,6 +1604,7 @@ async def _to_outputs( mcp_call = stream.add_output_item_mcp_call( server_label=content.server_name or "default", name=content.tool_name or "", + item_id=content.call_id, ) yield mcp_call.emit_added() async for event in mcp_call.aarguments(_arguments_to_str(content.arguments)): @@ -1657,4 +1679,91 @@ async def _to_outputs( logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.") +def _stringify_mcp_output(output: Any) -> str: + """Convert hosted MCP output payloads into the string shape expected by mcp_call.output.""" + if output is None: + return "" + if isinstance(output, str): + return output + if isinstance(output, Mapping): + text = cast(Any, output).get("text") + if isinstance(text, str): + return text + return json.dumps(output, default=str) + if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)): + parts: list[str] = [] + entries = cast(Sequence[object], output) + for entry in entries: + if isinstance(entry, Content) and entry.type == "text": + parts.append(entry.text or "") + continue + parts.append(_stringify_mcp_output(entry)) + return "".join(parts) + return str(output) + + +def _emit_completed_mcp_call( + stream: ResponseEventStream, + call_content: Content, + *, + arguments: str, + output: str, +) -> Generator[ResponseStreamEvent]: + """Emit a single completed MCP call item carrying both arguments and output.""" + mcp_call = stream.add_output_item_mcp_call( + server_label=call_content.server_name or "default", + name=call_content.tool_name or "", + item_id=call_content.call_id, + ) + yield mcp_call.emit_added() + yield mcp_call.emit_arguments_done(arguments) + yield mcp_call.emit_completed() + yield mcp_call.emit_done(output=output) + + +async def _to_outputs_for_messages( + stream: ResponseEventStream, + messages: Sequence[Message], + *, + approval_storage: ApprovalStorage | None = None, +) -> AsyncIterator[ResponseStreamEvent]: + """Convert messages to output events with hosted-MCP call/result coalescing. + + Parse once in message/content order and emit either: + - a single canonical completed ``mcp_call`` when adjacent hosted MCP + call/result content are encountered, or + - standard output items for all other content types. + """ + pending_mcp_call: Content | None = None + + for message in messages: + for content in message.contents: + if pending_mcp_call is not None: + if content.type == "mcp_server_tool_result" and content.call_id == pending_mcp_call.call_id: + for event in _emit_completed_mcp_call( + stream, + pending_mcp_call, + arguments=_arguments_to_str(pending_mcp_call.arguments), + output=_stringify_mcp_output(content.output), + ): + yield event + pending_mcp_call = None + continue + + async for event in _to_outputs(stream, pending_mcp_call, approval_storage=approval_storage): + yield event + pending_mcp_call = None + + if content.type == "mcp_server_tool_call" and content.call_id: + pending_mcp_call = content + continue + + async for event in _to_outputs(stream, content, approval_storage=approval_storage): + yield event + + if pending_mcp_call is not None: + async for event in _to_outputs(stream, pending_mcp_call, approval_storage=approval_storage): + yield event + + # endregion diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index f2d9686b8a..e9f9949a33 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.7.0,<2", "azure-ai-agentserver-core>=2.0.0b3,<3", - "azure-ai-agentserver-responses>=1.0.0b5,<2", + "azure-ai-agentserver-responses>=1.0.0b7,<2", "azure-ai-agentserver-invocations>=1.0.0b3,<2", ] diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 9358549a86..0bfff345a7 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -260,6 +260,50 @@ class TestNonStreaming: assert "function_call_output" in types assert "message" in types + async def test_hosted_mcp_call_and_result_persist_as_single_mcp_call(self) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + call_id="mcp_abc123", + tool_name="search", + server_name="api_specs", + arguments='{"q": "cats"}', + ) + ], + ), + Message( + role="tool", + contents=[ + Content.from_mcp_server_tool_result( + call_id="mcp_abc123", + output=[Content.from_text(text="found 10 cats")], + ) + ], + ), + Message(role="assistant", contents=[Content.from_text("I found 10 cats!")]), + ] + ) + ) + server = _make_server(agent) + resp = await _post(server, stream=False) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + types = [item["type"] for item in body["output"]] + assert "mcp_call" in types + assert "custom_tool_call_output" not in types + + mcp_items = [item for item in body["output"] if item["type"] == "mcp_call"] + assert len(mcp_items) == 1 + assert mcp_items[0]["id"] == "mcp_abc123" + assert mcp_items[0]["output"] == "found 10 cats" + async def test_reasoning_content(self) -> None: agent = _make_agent( response=AgentResponse( @@ -617,6 +661,53 @@ class TestStreaming: assert "response.output_item.added" in types assert "response.output_item.done" in types + async def test_mcp_tool_call_and_result_streaming_emit_single_completed_mcp_call(self) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[ + Content.from_mcp_server_tool_call( + call_id="mcp_abc123", + tool_name="search", + server_name="api_specs", + arguments='{"q":', + ) + ], + role="assistant", + ), + AgentResponseUpdate( + contents=[ + Content.from_mcp_server_tool_call( + call_id="mcp_abc123", + tool_name="search", + server_name="api_specs", + arguments=' "cats"}', + ) + ], + role="assistant", + ), + AgentResponseUpdate( + contents=[ + Content.from_mcp_server_tool_result( + call_id="mcp_abc123", + output=[Content.from_text(text="found 10 cats")], + ) + ], + role="tool", + ), + ] + ) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + done_events = [e for e in events if e["event"] == "response.output_item.done"] + assert len(done_events) == 1 + assert done_events[0]["data"]["item"]["type"] == "mcp_call" + assert done_events[0]["data"]["item"]["id"] == "mcp_abc123" + assert done_events[0]["data"]["item"]["output"] == "found 10 cats" + # endregion @@ -720,6 +811,24 @@ class TestOutputItemToMessage: assert msg.contents[0].server_name == "my_server" assert msg.contents[0].tool_name == "search" + async def test_mcp_call_with_output_reconstructs_mcp_result_content(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemMcpToolCall + + item = OutputItemMcpToolCall({ + "type": "mcp_call", + "id": "mcp-1", + "server_label": "my_server", + "name": "search", + "arguments": '{"q": "test"}', + "output": "found 10 cats", + }) + msg = await _output_item_to_message(item) + assert msg.role == "assistant" + assert len(msg.contents) == 2 + assert msg.contents[0].type == "mcp_server_tool_call" + assert msg.contents[1].type == "mcp_server_tool_result" + assert msg.contents[1].output == "found 10 cats" + async def test_mcp_approval_request(self) -> None: from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest @@ -1189,6 +1298,25 @@ class TestItemToMessage: assert msg.contents[0].server_name == "my_server" assert msg.contents[0].tool_name == "search" + async def test_mcp_call_with_output_reconstructs_mcp_result_content(self) -> None: + from azure.ai.agentserver.responses.models import ItemMcpToolCall + + item = ItemMcpToolCall({ + "type": "mcp_call", + "id": "mcp-1", + "server_label": "my_server", + "name": "search", + "arguments": '{"q": "test"}', + "output": "found 10 cats", + }) + msg = await _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert len(msg.contents) == 2 + assert msg.contents[0].type == "mcp_server_tool_call" + assert msg.contents[1].type == "mcp_server_tool_result" + assert msg.contents[1].output == "found 10 cats" + async def test_mcp_approval_request(self) -> None: from azure.ai.agentserver.responses.models import ItemMcpApprovalRequest @@ -1937,6 +2065,75 @@ class TestMultiTurnMixedContent: assert len(fc_contents) >= 1 assert fc_contents[0].name == "search" + async def test_hosted_mcp_call_round_trip_does_not_orphan_function_call_output(self) -> None: + """Turn 1 produces hosted MCP call + result, turn 2 must replay both without orphaning output.""" + agent = _make_multi_response_agent([ + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + call_id="mcp_abc123", + tool_name="search", + server_name="api_specs", + arguments='{"q": "cats"}', + ) + ], + ), + Message( + role="tool", + contents=[ + Content.from_mcp_server_tool_result( + call_id="mcp_abc123", + output=[Content.from_text(text="found 10 cats")], + ) + ], + ), + Message(role="assistant", contents=[Content.from_text("I found 10 cats!")]), + ] + ), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Here are more details")])]), + ]) + server = _make_server(agent) + + resp1 = await _post(server, input_text="Search for cats", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + + types1 = [item["type"] for item in resp1.json()["output"]] + assert "mcp_call" in types1 + assert "custom_tool_call_output" not in types1 + + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": "Tell me more", + "stream": False, + "previous_response_id": response_id, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] + mcp_call_contents = [ + c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call" + ] + mcp_result_contents = [ + c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_result" + ] + function_result_contents = [ + c for m in second_call_messages for c in m.contents if c.type == "function_result" + ] + + assert len(mcp_call_contents) >= 1 + assert len(mcp_result_contents) >= 1 + assert all((c.call_id or "") != "mcp_abc123" for c in function_result_contents) + assert any((c.call_id or "") == "mcp_abc123" for c in mcp_call_contents) + assert any((c.call_id or "") == "mcp_abc123" for c in mcp_result_contents) + async def test_multi_turn_reasoning_in_history(self) -> None: """Turn 1 produces reasoning + text, turn 2 sees them in history.""" agent = _make_multi_response_agent([ From cdc4809b8a503ec86d78a90051e5cc5d053ebf21 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:43:08 -0700 Subject: [PATCH 2/7] ci: harden Python test coverage workflow (#5982) Improve input handling and token management in the Python test coverage workflows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/python-test-coverage-report.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-test-coverage-report.yml b/.github/workflows/python-test-coverage-report.yml index f03967e72a..5637b5a2fb 100644 --- a/.github/workflows/python-test-coverage-report.yml +++ b/.github/workflows/python-test-coverage-report.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + actions: read pull-requests: write jobs: @@ -23,7 +24,7 @@ jobs: - name: Download coverage report uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + github-token: ${{ github.token }} run-id: ${{ github.event.workflow_run.id }} path: ./python merge-multiple: true @@ -38,9 +39,9 @@ jobs: echo "PR number file 'pr_number' is missing or empty" exit 1 fi - PR_NUMBER=$(head -1 pr_number | tr -dc '0-9') - if [ -z "$PR_NUMBER" ]; then - echo "PR number file 'pr_number' does not contain a valid PR number" + PR_NUMBER=$(cat pr_number) + if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::PR number file contains invalid content" exit 1 fi echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV" @@ -48,7 +49,7 @@ jobs: id: coverageComment uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0 with: - github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + github-token: ${{ github.token }} issue-number: ${{ env.PR_NUMBER }} pytest-xml-coverage-path: python/python-coverage.xml title: "Python Test Coverage Report" From 0cf48923cd6793f9da042c6b8b819f632a65957e Mon Sep 17 00:00:00 2001 From: semenshi-m Date: Tue, 2 Jun 2026 09:41:21 +0100 Subject: [PATCH 3/7] .NET: Add Hosted-ToolboxMcpSkills sample (#6175) * .NET: Add Hosted-ToolboxMcpSkills sample Adds a hosted Foundry Responses sample that discovers MCP-based skills from a Foundry Toolbox and makes them available to the agent via AgentSkillsProvider. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align README and Program.cs default model to gpt-5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify MCP skills provider log to avoid implying eager discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop redundant skills provider configured log Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Foundry Toolbox Skills tag to manifest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify BearerTokenHandler by deriving from HttpClientHandler Removes the need for an explicit InnerHandler. Enables CheckCertificateRevocationList to satisfy CA5399. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 3 + .../Hosted-ToolboxMcpSkills/.env.example | 6 + .../Hosted-ToolboxMcpSkills/Dockerfile | 26 +++++ .../Dockerfile.contributor | 18 +++ .../HostedToolboxMcpSkills.csproj | 36 ++++++ .../Hosted-ToolboxMcpSkills/Program.cs | 109 ++++++++++++++++++ .../Hosted-ToolboxMcpSkills/README.md | 103 +++++++++++++++++ .../agent.manifest.yaml | 43 +++++++ .../Hosted-ToolboxMcpSkills/agent.yaml | 14 +++ 9 files changed, 358 insertions(+) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index fbf09d442a..e395627bc9 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -344,6 +344,9 @@ + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example new file mode 100644 index 0000000000..5c312b3f8e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example @@ -0,0 +1,6 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5 +FOUNDRY_TOOLBOX_NAME= +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile new file mode 100644 index 0000000000..d04bf72711 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile @@ -0,0 +1,26 @@ +# Dockerfile for end-users consuming the Agent Framework via NuGet packages. +# +# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container, +# which only succeeds when the project references its dependencies via PackageReference (see the +# commented-out section in HostedToolboxMcpSkills.csproj). Contributors building from the +# agent-framework repository source must use Dockerfile.contributor instead because +# ProjectReference dependencies live outside this folder and cannot be restored from inside +# this build context. +# +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile.contributor new file mode 100644 index 0000000000..a01cc1e38c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile.contributor @@ -0,0 +1,18 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local source, which means a standard +# multi-stage Docker build cannot resolve dependencies outside this folder. +# Pre-publish the app targeting the container runtime and copy the output: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-toolbox-mcp-skills -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-toolbox-mcp-skills +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj new file mode 100644 index 0000000000..d4c4155baf --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + false + HostedToolboxMcpSkills + HostedToolboxMcpSkills + $(NoWarn); + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs new file mode 100644 index 0000000000..f8ca3f4991 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Hosted Toolbox MCP Skills Agent +// +// Demonstrates how to host an agent that discovers MCP-based skills from a +// Foundry Toolbox MCP endpoint and injects them as AIContextProviders using +// AgentSkillsProviderBuilder.UseMcpSkills(). +// +// Required environment variables: +// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint +// FOUNDRY_TOOLBOX_NAME - Name of the Foundry Toolbox to connect to +// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-5) + +using System.Net.Http.Headers; +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Hosted_Shared_Contributor_Setup; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using ModelContextProtocol.Client; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5"; +var toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") + ?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_NAME is not set."); + +// Build the Toolbox MCP URL from the project endpoint and toolbox name. +var toolboxMcpServerUrl = $"{projectEndpoint.TrimEnd('/')}/toolboxes/{toolboxName}/mcp?api-version=v1"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── Connect to the Foundry Toolbox MCP endpoint ───────────────────────────── +// Create an HttpClient that attaches a fresh Foundry bearer token to every request. +using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default") { CheckCertificateRevocationList = true }); + +Console.WriteLine($"Connecting to Foundry Toolbox '{toolboxName}' MCP server..."); + +await using var mcpClient = await McpClient.CreateAsync( + new HttpClientTransport( + new HttpClientTransportOptions + { + Endpoint = new Uri(toolboxMcpServerUrl), + Name = toolboxName, + TransportMode = HttpTransportMode.StreamableHttp, + AdditionalHeaders = new Dictionary + { + ["Foundry-Features"] = "Toolboxes=V1Preview", + }, + }, + httpClient)); + +// ── Configure MCP-based skills provider ────────────────────────────────────── +var skillsProvider = new AgentSkillsProviderBuilder() + .UseMcpSkills(mcpClient) + .Build(); + +// ── Create the agent ───────────────────────────────────────────────────────── +AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential) + .AsAIAgent(new ChatClientAgentOptions + { + Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-mcp-skills", + Description = "Hosted agent with MCP skills discovered from a Foundry Toolbox", + ChatOptions = new() + { + ModelId = deployment, + Instructions = "You are a helpful assistant.", + }, + AIContextProviders = [skillsProvider], + }); + +// ── Build the host ─────────────────────────────────────────────────────────── +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production. + +var app = builder.Build(); +app.MapFoundryResponses(); + +// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses +// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint). +// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path. +app.MapDevTemporaryLocalAgentEndpoint(); + +app.Run(); + +// --------------------------------------------------------------------------- +// HttpClientHandler: attaches a fresh Foundry bearer token to every request +// --------------------------------------------------------------------------- +internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : HttpClientHandler +{ + private readonly TokenRequestContext _tokenContext = new([scope]); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md new file mode 100644 index 0000000000..0c6b5ba60e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md @@ -0,0 +1,103 @@ +# Hosted-ToolboxMcpSkills + +A hosted agent that discovers **MCP-based skills from a Foundry Toolbox** and makes them available to the agent using `AgentSkillsProviderBuilder.UseMcpSkills(mcpClient)`. + +The `AgentSkillsProvider` is attached to the agent as a context provider and implements the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern. When the agent is prompted, it discovers available skills in the Foundry Toolbox via the provider: + +1. **Advertise** - skill names and descriptions are injected into the system prompt so the agent knows what is available. +2. **Load** - when the agent decides a skill is relevant, it retrieves the full skill body with detailed instructions via the provider. +3. **Read resources** - if a skill includes supplementary content (reference documents, assets), the agent reads them on demand via the provider. + +This way the full skill body and resources are only loaded when the agent actually needs them, reducing token usage. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-5`) +- A Foundry Toolbox already configured with skills provisioned +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your values: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint and toolbox name: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5 +FOUNDRY_TOOLBOX_NAME=my-toolbox +``` + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills +dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "What skills do you have available?" +``` + +## Running with Docker + +Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-toolbox-mcp-skills \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-toolbox-mcp-skills +``` + +> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration. + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "What skills do you have available?" +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml new file mode 100644 index 0000000000..2887336252 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-toolbox-mcp-skills +displayName: "Hosted Toolbox MCP Skills Agent" + +description: > + A hosted agent that discovers MCP-based skills from a Foundry Toolbox + and makes them available to the agent via the agent skills provider. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - MCP + - Model Context Protocol + - Agent Skills + - Foundry Toolbox + - Foundry Toolbox Skills + +template: + name: hosted-toolbox-mcp-skills + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: FOUNDRY_TOOLBOX_NAME + value: "{{FOUNDRY_TOOLBOX_NAME}}" +parameters: + properties: + - name: FOUNDRY_TOOLBOX_NAME + secret: false + description: Name of the Foundry Toolbox to connect to for MCP skill discovery +resources: + - kind: model + id: gpt-5 + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml new file mode 100644 index 0000000000..5f53abb2e2 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-toolbox-mcp-skills +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: FOUNDRY_TOOLBOX_NAME + value: ${FOUNDRY_TOOLBOX_NAME} From a5f355e04a4cc13da8ce8ddc13430d343decfa8d Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:29:50 +0530 Subject: [PATCH 4/7] Python: Fix OTLP HTTP base-endpoint losing /v1/{signal} auto-append (#5913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Python: Fix OTLP HTTP base-endpoint losing /v1/{signal} auto-append Per the OTel spec, OTEL_EXPORTER_OTLP_ENDPOINT is a *base* URL for HTTP — the SDK auto-appends /v1/traces, /v1/metrics, /v1/logs when it reads the env var directly. Signal-specific endpoint env vars are *full* URLs used verbatim. _get_exporters_from_env read the base endpoint and forwarded it as the constructor ``endpoint=`` argument, which the SDK always treats as a full signal URL. As a result, with OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 and HTTP protocol, the exporter sent to http://localhost:4318 instead of http://localhost:4318/v1/traces (and likewise for metrics/logs). Replicate the spec's auto-append here when falling back to the base endpoint under HTTP. gRPC behavior is unchanged. * Python: Fix mypy type errors in OTLP endpoint assignment Pre-declare traces_endpoint, metrics_endpoint, logs_endpoint as str | None before the if/else block. Mypy inferred str from the if-branch f-string assignments and then rejected the str | None expressions in the else-branch as incompatible. --- .../core/agent_framework/observability.py | 28 ++++- .../core/tests/core/test_observability.py | 109 ++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index d7734f2457..1654799d87 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -498,14 +498,34 @@ def _get_exporters_from_env( # Get base endpoint base_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") - # Get signal-specific endpoints (these override base endpoint) - traces_endpoint = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or base_endpoint - metrics_endpoint = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") or base_endpoint - logs_endpoint = os.getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") or base_endpoint + # Get signal-specific endpoints (these override base endpoint and are used verbatim) + traces_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + metrics_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") + logs_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") # Get protocol (default is grpc) protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower() + # Per the OTel spec, OTEL_EXPORTER_OTLP_ENDPOINT is a *base* URL for HTTP — the SDK + # auto-appends /v1/{traces,metrics,logs} when it reads the env var directly. The + # signal-specific endpoint env vars are *full* URLs used verbatim. Because we read + # the env vars here and forward them as the ``endpoint=`` constructor argument + # (which the SDK always treats as a full URL), we must replicate the auto-append + # ourselves for HTTP when falling back to the base endpoint. For gRPC, the base + # endpoint is used as-is. + traces_endpoint: str | None + metrics_endpoint: str | None + logs_endpoint: str | None + if protocol in ("http/protobuf", "http") and base_endpoint: + base_for_http = base_endpoint.rstrip("/") + traces_endpoint = traces_endpoint_specific or f"{base_for_http}/v1/traces" + metrics_endpoint = metrics_endpoint_specific or f"{base_for_http}/v1/metrics" + logs_endpoint = logs_endpoint_specific or f"{base_for_http}/v1/logs" + else: + traces_endpoint = traces_endpoint_specific or base_endpoint + metrics_endpoint = metrics_endpoint_specific or base_endpoint + logs_endpoint = logs_endpoint_specific or base_endpoint + # Get base headers base_headers_str = os.getenv("OTEL_EXPORTER_OTLP_HEADERS", "") base_headers = _parse_headers(base_headers_str) diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 372cb8a7dd..94d4ee3bad 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -761,6 +761,115 @@ def test_get_exporters_from_env_missing_grpc_dependency(monkeypatch): _get_exporters_from_env() +# region Test OTLP endpoint computation (base-URL auto-append for HTTP) + + +def test_get_exporters_from_env_http_base_endpoint_appends_signal_paths(monkeypatch): + """OTEL_EXPORTER_OTLP_ENDPOINT is a base URL for HTTP; SDK auto-appends + /v1/{traces,metrics,logs}. Because we read the env var and forward it as the + constructor ``endpoint=`` arg (which the SDK treats as a full URL), we must + replicate the auto-append ourselves. + """ + from unittest.mock import patch + + from agent_framework import observability + + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + for key in ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create: + observability._get_exporters_from_env() + + kwargs = create.call_args.kwargs + assert kwargs["protocol"] == "http/protobuf" + assert kwargs["traces_endpoint"] == "http://localhost:4318/v1/traces" + assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics" + assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs" + + +def test_get_exporters_from_env_http_base_endpoint_trailing_slash(monkeypatch): + """A trailing slash on the base endpoint should not produce a doubled slash.""" + from unittest.mock import patch + + from agent_framework import observability + + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + for key in ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create: + observability._get_exporters_from_env() + + kwargs = create.call_args.kwargs + assert kwargs["traces_endpoint"] == "http://localhost:4318/v1/traces" + assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics" + assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs" + + +def test_get_exporters_from_env_http_signal_specific_used_verbatim(monkeypatch): + """Signal-specific endpoint env vars are full URLs and must be used verbatim, + even when a base endpoint is also set. + """ + from unittest.mock import patch + + from agent_framework import observability + + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://traces.example.com/custom/path") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + for key in ( + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create: + observability._get_exporters_from_env() + + kwargs = create.call_args.kwargs + # Signal-specific is verbatim — no path appended + assert kwargs["traces_endpoint"] == "http://traces.example.com/custom/path" + # Others fall back to base, with path appended + assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics" + assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs" + + +def test_get_exporters_from_env_grpc_base_endpoint_unchanged(monkeypatch): + """For gRPC, the base endpoint applies to all signals as-is (no path append).""" + from unittest.mock import patch + + from agent_framework import observability + + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") + for key in ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create: + observability._get_exporters_from_env() + + kwargs = create.call_args.kwargs + assert kwargs["protocol"] == "grpc" + assert kwargs["traces_endpoint"] == "http://localhost:4317" + assert kwargs["metrics_endpoint"] == "http://localhost:4317" + assert kwargs["logs_endpoint"] == "http://localhost:4317" + + # region Test create_resource From 6de4c24fdda68bb991bf76c017618b89c6197d87 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:10:02 -0700 Subject: [PATCH 5/7] .NET: Promote Workflows.Declarative packages to stable versions (#6254) * Promote Workflows.Declarative packages to stable versions * Address PR feedback: enable package validation on GA declarative packages Both Workflows.Declarative and Workflows.Declarative.Mcp set IsReleased=true but were disabling package validation, bypassing the repo's GA convention (see dotnet/nuget/nuget-package.props which auto-enables validation when IsReleased=true). Re-enable validation by removing the local EnablePackageValidation=false overrides and pointing PackageValidationBaselineVersion at 1.8.0-rc1 (the latest published version of each package). This catches accidental breaking changes between RC and the first GA. Future GAs should bump the baseline to the previous GA version. Verified locally: dotnet build -c Release on both projects runs RunPackageValidation -> APICompat ran successfully without finding any breaking changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update statement for the baseline validation. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...rosoft.Agents.AI.Workflows.Declarative.Foundry.csproj | 2 +- .../Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj | 8 +++++--- .../Microsoft.Agents.AI.Workflows.Declarative.csproj | 9 ++++++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj index 407593536e..63c2bd30ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj @@ -1,7 +1,7 @@  - true + $(NoWarn);MEAI001;OPENAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj index bca32e93fc..00865e2fa6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj @@ -1,7 +1,7 @@ - true + true $(NoWarn);MEAI001;OPENAI001 @@ -13,9 +13,11 @@ - + - false + 1.8.0-rc1 diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index 5f8cd37505..145dbe243b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -1,7 +1,7 @@  - true + true $(NoWarn);MEAI001;OPENAI001 @@ -13,6 +13,13 @@ + + + 1.8.0-rc1 + + Microsoft Agent Framework Declarative Workflows From fa8cfb75673eb2f8f3a9870b2d95e1a435ac3dc3 Mon Sep 17 00:00:00 2001 From: Benke Qu <89610947+benke520@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:30:04 -0700 Subject: [PATCH 6/7] Python: Fix FoundryAgent stripping model from PromptAgent requests (#5526) * Fix FoundryAgent stripping model from PromptAgent requests Move run_options.pop('model', None) inside the _uses_foundry_agent_session() conditional so that model is only stripped for hosted agent sessions (where the server manages the model) and preserved for PromptAgent requests that require it in the Responses API call. Fixes #5525 * test: add coverage for resp_* continuation preserving model Adds test_raw_foundry_agent_chat_client_prepare_options_preserves_model_for_resp_continuation to explicitly verify that HostedAgent v1 / v2-no-session paths (where conversation_id starts with resp_) preserve model and previous_response_id without triggering the hosted-session gate. --------- Co-authored-by: Benke Qu Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../foundry/agent_framework_foundry/_agent.py | 2 +- .../tests/foundry/test_foundry_agent.py | 74 ++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 1e1157d05a..433380580d 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -351,6 +351,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] if _uses_foundry_agent_session(conversation_id): run_options.pop("previous_response_id", None) run_options.pop("conversation", None) + run_options.pop("model", None) extra_body["agent_session_id"] = conversation_id # Non-preview Prompt/Hosted Agent calls need agent_reference in the request body to # tell the Responses API which Foundry agent (and version) is in use, since ``model`` @@ -366,7 +367,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc] # Strip tools from request body - Foundry API rejects requests with both # agent endpoint and tools present. FunctionTools are invoked client-side # by the function invocation layer, not sent to the service. - run_options.pop("model", None) if not self.allow_preview: run_options.pop("tools", None) run_options.pop("tool_choice", None) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 44bc744f64..2cbf491c16 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -203,7 +203,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None: - """Test that _prepare_options strips model and tool-loop fields from run_options.""" + """Test that _prepare_options strips tool-loop fields but preserves model for non-session requests.""" mock_project = MagicMock() mock_openai = MagicMock() @@ -235,16 +235,49 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_ options={"tools": [my_func]}, ) - assert "model" not in result + # model is preserved for non-session (PromptAgent) requests + assert result["model"] == "gpt-4.1" assert "tools" not in result assert "tool_choice" not in result assert "parallel_tool_calls" not in result # agent_reference is required so the Responses API can resolve model server-side; see #5582. assert result == { + "model": "gpt-4.1", "extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}}, } +async def test_raw_foundry_agent_chat_client_prepare_options_strips_model_for_hosted_session() -> None: + """Test that model is stripped when using a hosted agent session (not a PromptAgent).""" + + mock_project = MagicMock() + mock_openai = MagicMock() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={ + "model": "gpt-4.1", + "previous_response_id": "resp_abc", + }, + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"conversation_id": "agent-session-123"}, + ) + + assert "model" not in result + assert "previous_response_id" not in result + assert result["extra_body"]["agent_session_id"] == "agent-session-123" + assert result["extra_body"]["agent_reference"] == {"name": "test-agent", "type": "agent_reference"} + + async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_reference_first_turn() -> None: """First-turn (no conversation_id) Prompt Agent calls must carry agent_reference in extra_body. @@ -272,7 +305,6 @@ async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_refer options={}, ) - assert "model" not in result assert result["extra_body"] == { "agent_reference": {"name": "test-agent", "type": "agent_reference", "version": "2"}, } @@ -333,7 +365,8 @@ async def test_raw_foundry_agent_chat_client_prepare_options_skips_agent_referen options={}, ) - assert "model" not in result + # model is preserved for non-session requests (platform tolerates it for hosted agents) + assert result["model"] == "gpt-4.1" # No extra_body at all is the cleanest signal — agent_reference must not be injected here. assert "extra_body" not in result @@ -363,6 +396,39 @@ async def test_raw_foundry_agent_chat_client_prepare_options_respects_caller_age assert result["extra_body"]["agent_reference"] == caller_reference +async def test_raw_foundry_agent_chat_client_prepare_options_preserves_model_for_resp_continuation() -> None: + """Test that model is preserved when conversation_id is a resp_* continuation (HostedAgent v1 / v2-no-session).""" + + mock_project = MagicMock() + mock_openai = MagicMock() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={ + "model": "gpt-4.1", + "previous_response_id": "resp_abc123", + }, + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"conversation_id": "resp_abc123"}, + ) + + # model preserved — resp_* is standard Responses API continuity, not a hosted session + assert result["model"] == "gpt-4.1" + # previous_response_id preserved — not stripped outside hosted session path + assert result["previous_response_id"] == "resp_abc123" + # no agent_session_id injected + assert "extra_body" not in result or "agent_session_id" not in result.get("extra_body", {}) + + async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None: """Test that service_session_id is forwarded as agent_session_id for hosted sessions.""" From 6086a743023c7b8a0e8ec8dde92316e42d634c09 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:30:05 -0700 Subject: [PATCH 7/7] Python: Promote agent-framework-declarative package to RC (#6256) * Promote agent-framework-declarative package to RC * Update missed package status file. --- python/PACKAGE_STATUS.md | 9 ++++++++- .../packages/core/agent_framework/_feature_stage.py | 1 + python/packages/declarative/README.md | 12 ++++++++++++ .../agent_framework_declarative/__init__.py | 13 +++++++++++++ .../agent_framework_declarative/_loader.py | 8 ++++++++ python/packages/declarative/pyproject.toml | 5 +++-- python/samples/03-workflows/declarative/README.md | 1 - python/uv.lock | 4 ++-- 8 files changed, 47 insertions(+), 6 deletions(-) diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 736a18bf1b..959fcf8008 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -27,7 +27,7 @@ Status is grouped into these buckets: | `agent-framework-claude` | `python/packages/claude` | `beta` | | `agent-framework-copilotstudio` | `python/packages/copilotstudio` | `beta` | | `agent-framework-core` | `python/packages/core` | `released` | -| `agent-framework-declarative` | `python/packages/declarative` | `beta` | +| `agent-framework-declarative` | `python/packages/declarative` | `rc` | | `agent-framework-devui` | `python/packages/devui` | `beta` | | `agent-framework-durabletask` | `python/packages/durabletask` | `beta` | | `agent-framework-foundry` | `python/packages/foundry` | `released` | @@ -58,6 +58,13 @@ listed below. ### Experimental features +#### `DECLARATIVE_AGENTS` + +- `agent-framework-declarative`: declarative agent loading APIs from + `agent_framework_declarative`, including `AgentFactory`, + `DeclarativeLoaderError`, `ProviderLookupError`, and `ProviderTypeMapping` + from `agent_framework_declarative/_loader.py` + #### `EVALS` - `agent-framework-core`: exported evaluation APIs from `agent_framework`, including diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 55d4ac1096..dfa9cb6343 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -50,6 +50,7 @@ class ExperimentalFeature(str, Enum): on enum membership or attribute presence over time. """ + DECLARATIVE_AGENTS = "DECLARATIVE_AGENTS" EVALS = "EVALS" FILE_HISTORY = "FILE_HISTORY" FIDES = "FIDES" diff --git a/python/packages/declarative/README.md b/python/packages/declarative/README.md index b4a97f049a..02c1a35472 100644 --- a/python/packages/declarative/README.md +++ b/python/packages/declarative/README.md @@ -6,6 +6,18 @@ Please install this package via pip: pip install agent-framework-declarative --pre ``` +## Release stage + +This package ships at two different stability levels: + +- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the + `_workflows` surface) are at **release-candidate** stability and may receive only + minor refinements before GA. +- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path: + `DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are + **experimental** and may change or be removed in future versions without notice. + Using any of these symbols emits an `ExperimentalWarning` on first use. + ## Declarative features The declarative packages provides support for building agents based on a declarative yaml specification. diff --git a/python/packages/declarative/agent_framework_declarative/__init__.py b/python/packages/declarative/agent_framework_declarative/__init__.py index 84bc404d5d..fd864a7068 100644 --- a/python/packages/declarative/agent_framework_declarative/__init__.py +++ b/python/packages/declarative/agent_framework_declarative/__init__.py @@ -1,5 +1,18 @@ # Copyright (c) Microsoft. All rights reserved. +"""Declarative specification support for Microsoft Agent Framework. + +Release stage: + +* The declarative-workflows surface (``WorkflowFactory``, executors, handlers, + etc.) is at release-candidate stability. +* The declarative-agents surface (``AgentFactory`` and the YAML agent + loading/parsing path: ``DeclarativeLoaderError``, ``ProviderLookupError``, + ``ProviderTypeMapping``) is *experimental* and may change or be removed in + future versions without notice. Using these symbols emits an + ``ExperimentalWarning`` on first use. +""" + from importlib import metadata from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index a9c534ee2d..4507d0112d 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -15,6 +15,10 @@ from agent_framework import ( from agent_framework import ( FunctionTool as AFFunctionTool, ) +from agent_framework._feature_stage import ( # type: ignore[reportPrivateUsage] + ExperimentalFeature, + experimental, +) from agent_framework.exceptions import AgentException from dotenv import load_dotenv @@ -43,6 +47,7 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover +@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS) class ProviderTypeMapping(TypedDict, total=True): package: str name: str @@ -118,18 +123,21 @@ PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = { } +@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS) class DeclarativeLoaderError(AgentException): """Exception raised for errors in the declarative loader.""" pass +@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS) class ProviderLookupError(DeclarativeLoaderError): """Exception raised for errors in provider type lookup.""" pass +@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS) class AgentFactory: """Factory for creating Agent instances from declarative YAML definitions. diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 0efa733daa..25f7315ebc 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260528" +version = "1.0.0rc1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -49,7 +49,8 @@ addopts = "-ra -q -r fEX" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ - "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" + "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*", + "ignore::agent_framework._feature_stage.ExperimentalWarning", ] timeout = 120 markers = [ diff --git a/python/samples/03-workflows/declarative/README.md b/python/samples/03-workflows/declarative/README.md index 1c48ef1e7a..9a24b741e7 100644 --- a/python/samples/03-workflows/declarative/README.md +++ b/python/samples/03-workflows/declarative/README.md @@ -64,7 +64,6 @@ actions: ### Agent Invocation - `InvokeAzureAgent` - Call an Azure AI agent -- `InvokePromptAgent` - Call a local prompt agent ### Tool Invocation - `InvokeFunctionTool` - Call a registered Python function diff --git a/python/uv.lock b/python/uv.lock index 5e4ae35369..1f816411e3 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -437,7 +437,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260528" +version = "1.0.0rc1" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -609,7 +609,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.0b2,<=1.0.0b2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" }, ] [[package]]