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()