From b298113d15cf5c7e8a7299991cea98ef0400dbbf Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Mon, 1 Jun 2026 11:43:45 -0700 Subject: [PATCH] .NET - Fix missing id on function_call_output in Foundry Hosting (#6246) * Fix missing id on function_call_output in Foundry Hosting The Foundry storage layer was rejecting responses with "ID cannot be null or empty (Parameter 'id')" because function_call_output items emitted by OutputConverter had no id on the wire. OutputItemFunctionToolCallOutput's public ctor only sets CallId and Output; Id is read-only and only the SDK's internal ctor populates it. OutputItemBuilder.ApplyAutoStamps fills ResponseId and AgentReference but not Id, so the itemId passed to AddOutputItem(itemId) was used only for event sequencing and the serialized item went out with id=null. Switch to stream.OutputItemFunctionCallOutput(callId, output), the SDK convenience method that uses the internal ctor and stamps the id. Add a regression test asserting the added/done events carry a non-empty matching Id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: free disk space and relocate NuGet cache on ubuntu runners The ubuntu-latest dotnet-build/test jobs were hitting No space left on device because the runner image only ships ~14 GB free on /. The full multi-TFM build plus the dotnet pack + console-app install-check exhausts that easily. Add a reusable composite action .github/actions/free-runner-disk-space that runs on Linux runners only and: * removes pre-installed toolchains we never use here (Android SDK, GHC/Haskell, CodeQL, PyPy, Ruby, Go, boost, vcpkg, etc.), prunes docker images, and disables swap (reclaims ~25-30 GB on /) * relocates the NuGet package cache to /mnt/nuget via NUGET_PACKAGES env, since /mnt has ~75 GB free on hosted runners Wire the action into the four ubuntu-touching jobs in dotnet-build-and-test.yml (dotnet-build, dotnet-test, dotnet-foundry-hosted-it, dotnet-test-functions). The action self-guards with runner.os == 'Linux' so the matrix legs that run on windows are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode <25218250+alliscode@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actions/free-runner-disk-space/action.yml | 64 +++++++++++++++++++ .github/workflows/dotnet-build-and-test.yml | 12 ++++ .../OutputConverter.cs | 17 +++-- .../OutputConverterTests.cs | 29 +++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 .github/actions/free-runner-disk-space/action.yml diff --git a/.github/actions/free-runner-disk-space/action.yml b/.github/actions/free-runner-disk-space/action.yml new file mode 100644 index 0000000000..c534b1c2d8 --- /dev/null +++ b/.github/actions/free-runner-disk-space/action.yml @@ -0,0 +1,64 @@ +name: Free runner disk space +description: | + Reclaims disk space on GitHub-hosted Ubuntu runners by removing + pre-installed toolchains we do not use (Android SDK, GHC/Haskell, + CodeQL bundle), Docker images, and swap. Also relocates the + NuGet package cache to /mnt (which has ~75 GB free vs ~14 GB + on /). No-op on non-Linux runners. + +runs: + using: composite + steps: + - name: Free disk space (Linux only) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + echo "::group::Disk usage before cleanup" + df -h / + echo "::endgroup::" + + # Remove pre-installed toolchains we never use on this repo's + # dotnet/python jobs. These reclaim ~25-30 GB on ubuntu-latest. + sudo rm -rf \ + /usr/local/lib/android \ + /usr/share/dotnet/sdk/NuGetFallbackFolder \ + /opt/ghc \ + /usr/local/.ghcup \ + /opt/hostedtoolcache/CodeQL \ + /opt/hostedtoolcache/PyPy \ + /opt/hostedtoolcache/Ruby \ + /opt/hostedtoolcache/go \ + /usr/local/share/boost \ + /usr/local/share/powershell \ + /usr/local/share/chromium \ + /usr/local/share/vcpkg \ + /usr/local/lib/heroku \ + "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/PyPy" \ + "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/Ruby" \ + "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/go" || true + + # Drop docker images shipped on the runner; jobs that need + # docker pull what they need fresh. + if command -v docker >/dev/null 2>&1; then + sudo docker image prune --all --force >/dev/null 2>&1 || true + fi + + # Disable swap to free its backing file. + sudo swapoff -a || true + sudo rm -f /mnt/swapfile /swapfile || true + + echo "::group::Disk usage after cleanup" + df -h / + echo "::endgroup::" + + - name: Relocate NuGet package cache to /mnt (Linux only) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + sudo mkdir -p /mnt/nuget + sudo chown -R "$USER":"$USER" /mnt/nuget + echo "NUGET_PACKAGES=/mnt/nuget" >> "$GITHUB_ENV" + echo "Relocated NuGet package cache to /mnt/nuget" + df -h /mnt || true diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 8fe1fbf176..6582240a5c 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -121,6 +121,9 @@ jobs: python declarative-agents + - name: Free runner disk space + uses: ./.github/actions/free-runner-disk-space + - name: Setup dotnet uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: @@ -191,6 +194,9 @@ jobs: python declarative-agents + - name: Free runner disk space + uses: ./.github/actions/free-runner-disk-space + # Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened) - name: Start Azure Cosmos DB Emulator if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }} @@ -365,6 +371,9 @@ jobs: dotnet python + - name: Free runner disk space + uses: ./.github/actions/free-runner-disk-space + - name: Setup dotnet uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: @@ -452,6 +461,9 @@ jobs: python declarative-agents + - name: Free runner disk space + uses: ./.github/actions/free-runner-disk-space + - name: Setup dotnet uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index fe16edeb3b..1006f0fdb4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -281,14 +281,19 @@ internal static class OutputConverter var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result); - var itemId = GenerateItemId("fc"); - var outputItem = new OutputItemFunctionToolCallOutput( + // Use the SDK's convenience method so the OutputItemFunctionToolCallOutput + // is constructed with a populated Id. The public OutputItemFunctionToolCallOutput + // ctor only sets CallId/Output (Id is read-only), and AddOutputItem+EmitAdded + // does not auto-stamp Id — only ResponseId/AgentReference. Without this, the + // serialized item arrives at the Foundry storage layer with id=null and is + // rejected with "ID cannot be null or empty (Parameter 'id')". + foreach (var evt in stream.OutputItemFunctionCallOutput( functionResult.CallId, - BinaryData.FromString(outputText)); + BinaryData.FromString(outputText))) + { + yield return evt; + } - var outputBuilder = stream.AddOutputItem(itemId); - yield return outputBuilder.EmitAdded(outputItem); - yield return outputBuilder.EmitDone(outputItem); break; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index 4103517a10..f8cc4402ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -704,6 +704,35 @@ public class OutputConverterTests Assert.Equal("[{\"id\":1}]", inner); } + // K-06e: Regression — the OutputItemFunctionToolCallOutput must have a populated Id + // and a matching wire id on the added/done events. The Foundry storage layer extracts + // a partition id from this field and throws "ID cannot be null or empty (Parameter 'id')" + // when it is missing. + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionResult_OutputItemHasIdAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + var added = Assert.Single(events.OfType()); + var done = Assert.Single(events.OfType()); + + var addedOutput = Assert.IsType(added.Item); + var doneOutput = Assert.IsType(done.Item); + + Assert.False(string.IsNullOrEmpty(addedOutput.Id)); + Assert.False(string.IsNullOrEmpty(doneOutput.Id)); + Assert.Equal(addedOutput.Id, doneOutput.Id); + Assert.Equal("call_1", addedOutput.CallId); + Assert.Equal("call_1", doneOutput.CallId); + } + // L-01 [Fact] public async Task ConvertUpdatesToEventsAsync_ExecutorInvokedEvent_EmitsWorkflowActionItemAsync()