mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f2495e196 | ||
|
|
e5d6e8ca98 | ||
|
|
b1866bd279 | ||
|
|
3e03a305f6 | ||
|
|
565c0b1623 | ||
|
|
53b0753dfb | ||
|
|
23ebfbc937 | ||
|
|
2f8fd5f82f | ||
|
|
60d5093421 | ||
|
|
d3f0c33180 | ||
|
|
97b6c9951a | ||
|
|
e35f530f2e | ||
|
|
a3bfad4791 | ||
|
|
09b3e2e4f0 | ||
|
|
55fc882ca8 | ||
|
|
c15f075412 | ||
|
|
fbcf1444ee | ||
|
|
1b7668119d | ||
|
|
fd1c66121e | ||
|
|
ded32f3ff8 | ||
|
|
e2f0bc814e | ||
|
|
6cb2289a16 | ||
|
|
2aaca50217 | ||
|
|
f74bda5a83 | ||
|
|
23d6d91c8f | ||
|
|
d5e240b375 | ||
|
|
1ca43f9643 | ||
|
|
b98880df32 | ||
|
|
c8750cbe92 | ||
|
|
394e9c1692 | ||
|
|
7e98b0cd29 | ||
|
|
f7e4143c61 | ||
|
|
d8d6ac1c59 | ||
|
|
4bd5469798 | ||
|
|
1ac68f65bf | ||
|
|
8664d19285 | ||
|
|
ce7b5b17c1 | ||
|
|
8bf4235f4e | ||
|
|
55ddd841b7 | ||
|
|
4a043c6c66 | ||
|
+6 |
3fb90a501a |
@@ -8,6 +8,10 @@ inputs:
|
||||
os:
|
||||
description: The operating system to set up
|
||||
required: true
|
||||
exclude-packages:
|
||||
description: Space-separated list of packages to exclude from uv sync
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -19,6 +23,20 @@ runs:
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ inputs.os }}-${{ inputs.python-version }}
|
||||
cache-dependency-glob: "**/uv.lock"
|
||||
- name: Exclude incompatible workspace packages
|
||||
if: ${{ inputs.exclude-packages != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
for pkg in ${{ inputs.exclude-packages }}; do
|
||||
for f in python/packages/*/pyproject.toml; do
|
||||
if grep -q "name = \"$pkg\"" "$f"; then
|
||||
pkg_dir=$(dirname "$f" | sed 's|python/||')
|
||||
echo "Excluding workspace package: $pkg ($pkg_dir)"
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
||||
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
|
||||
fi
|
||||
done
|
||||
done
|
||||
- name: Install the project
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -59,20 +59,20 @@ jobs:
|
||||
if: steps.filter.outputs.dotnet != 'true'
|
||||
run: echo "NOT dotnet file"
|
||||
|
||||
dotnet-build-and-test:
|
||||
# Build the full solution (including samples) on all TFMs. No tests.
|
||||
dotnet-build:
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.dotnetChanges == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" }
|
||||
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release }
|
||||
- { targetFramework: "net9.0", os: "windows-latest", configuration: Debug }
|
||||
- { targetFramework: "net8.0", os: "ubuntu-latest", configuration: Release }
|
||||
- { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" }
|
||||
- { targetFramework: "net472", os: "windows-latest", configuration: Release }
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -84,16 +84,6 @@ jobs:
|
||||
python
|
||||
workflow-samples
|
||||
|
||||
# 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)) }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Launching Azure Cosmos DB Emulator"
|
||||
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
|
||||
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.1.0
|
||||
with:
|
||||
@@ -140,25 +130,98 @@ jobs:
|
||||
popd
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
- name: Run Unit Tests
|
||||
shell: bash
|
||||
run: |
|
||||
export UT_PROJECTS=$(find ./dotnet -type f -name "*.UnitTests.csproj" | tr '\n' ' ')
|
||||
for project in $UT_PROJECTS; do
|
||||
# Query the project's target frameworks using MSBuild with the current configuration
|
||||
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')
|
||||
# Build src+tests only (no samples) for a single TFM and run tests.
|
||||
dotnet-test:
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.dotnetChanges == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" }
|
||||
- { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" }
|
||||
|
||||
# Check if the project supports the target framework
|
||||
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
|
||||
if [[ "${{ matrix.targetFramework }}" == "${{ env.COVERAGE_FRAMEWORK }}" ]]; then
|
||||
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --collect:"XPlat Code Coverage" --results-directory:"TestResults/Coverage/" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
|
||||
else
|
||||
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx
|
||||
fi
|
||||
else
|
||||
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
|
||||
fi
|
||||
done
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
python
|
||||
workflow-samples
|
||||
|
||||
# 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)) }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Launching Azure Cosmos DB Emulator"
|
||||
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
|
||||
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.1.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Generate test solution (no samples)
|
||||
shell: pwsh
|
||||
run: |
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
|
||||
-Solution dotnet/agent-framework-dotnet.slnx `
|
||||
-TargetFramework ${{ matrix.targetFramework }} `
|
||||
-Configuration ${{ matrix.configuration }} `
|
||||
-ExcludeSamples `
|
||||
-OutputPath dotnet/filtered.slnx `
|
||||
-Verbose
|
||||
|
||||
- name: Build src and tests
|
||||
shell: bash
|
||||
run: dotnet build dotnet/filtered.slnx -c ${{ matrix.configuration }} -f ${{ matrix.targetFramework }} --warnaserror
|
||||
|
||||
- name: Generate test-type filtered solutions
|
||||
shell: pwsh
|
||||
run: |
|
||||
$commonArgs = @{
|
||||
Solution = "dotnet/filtered.slnx"
|
||||
TargetFramework = "${{ matrix.targetFramework }}"
|
||||
Configuration = "${{ matrix.configuration }}"
|
||||
Verbose = $true
|
||||
}
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
|
||||
-TestProjectNameFilter "*UnitTests*" `
|
||||
-OutputPath dotnet/filtered-unit.slnx
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
|
||||
-TestProjectNameFilter "*IntegrationTests*" `
|
||||
-OutputPath dotnet/filtered-integration.slnx
|
||||
|
||||
- name: Run Unit Tests
|
||||
shell: pwsh
|
||||
working-directory: dotnet
|
||||
run: |
|
||||
$coverageSettings = Join-Path $PWD "tests/coverage.runsettings"
|
||||
$coverageArgs = @()
|
||||
if ("${{ matrix.targetFramework }}" -eq "${{ env.COVERAGE_FRAMEWORK }}") {
|
||||
$coverageArgs = @(
|
||||
"--coverage",
|
||||
"--coverage-output-format", "cobertura",
|
||||
"--coverage-settings", $coverageSettings,
|
||||
"--results-directory", "../TestResults/Coverage/"
|
||||
)
|
||||
}
|
||||
|
||||
dotnet test --solution ./filtered-unit.slnx `
|
||||
-f ${{ matrix.targetFramework }} `
|
||||
-c ${{ matrix.configuration }} `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--ignore-exit-code 8 `
|
||||
@coverageArgs
|
||||
env:
|
||||
# Cosmos DB Emulator connection settings
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
@@ -185,21 +248,19 @@ jobs:
|
||||
id: azure-functions-setup
|
||||
|
||||
- name: Run Integration Tests
|
||||
shell: bash
|
||||
shell: pwsh
|
||||
working-directory: dotnet
|
||||
if: github.event_name != 'pull_request' && matrix.integration-tests
|
||||
run: |
|
||||
export INTEGRATION_TEST_PROJECTS=$(find ./dotnet -type f -name "*IntegrationTests.csproj" | tr '\n' ' ')
|
||||
for project in $INTEGRATION_TEST_PROJECTS; do
|
||||
# Query the project's target frameworks using MSBuild with the current configuration
|
||||
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')
|
||||
|
||||
# Check if the project supports the target framework
|
||||
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
|
||||
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --filter "Category!=IntegrationDisabled"
|
||||
else
|
||||
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
|
||||
fi
|
||||
done
|
||||
dotnet test --solution ./filtered-integration.slnx `
|
||||
-f ${{ matrix.targetFramework }} `
|
||||
-c ${{ matrix.configuration }} `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--ignore-exit-code 8 `
|
||||
--filter-not-trait "Category=IntegrationDisabled" `
|
||||
--parallel-algorithm aggressive `
|
||||
--max-threads 2.0x
|
||||
env:
|
||||
# Cosmos DB Emulator connection settings
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
@@ -222,7 +283,7 @@ jobs:
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.1
|
||||
with:
|
||||
reports: "./TestResults/Coverage/**/coverage.cobertura.xml"
|
||||
reports: "./TestResults/Coverage/**/*.cobertura.xml"
|
||||
targetdir: "./TestResults/Reports"
|
||||
reporttypes: "HtmlInline;JsonSummary"
|
||||
|
||||
@@ -236,13 +297,13 @@ jobs:
|
||||
- name: Check coverage
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
shell: pwsh
|
||||
run: .github/workflows/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
|
||||
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
|
||||
|
||||
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
|
||||
dotnet-build-and-test-check:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
needs: [dotnet-build-and-test]
|
||||
needs: [dotnet-build, dotnet-test]
|
||||
steps:
|
||||
- name: Get Date
|
||||
shell: bash
|
||||
|
||||
@@ -86,11 +86,10 @@ jobs:
|
||||
run: docker pull mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }}
|
||||
|
||||
# This step will run dotnet format on each of the unique csproj files and fail if any changes are made
|
||||
# exclude-diagnostics should be removed after fixes for IL2026 and IL3050 are out: https://github.com/dotnet/sdk/issues/51136
|
||||
- name: Run dotnet format
|
||||
if: steps.find-csproj.outputs.csproj_files != ''
|
||||
run: |
|
||||
for csproj in ${{ steps.find-csproj.outputs.csproj_files }}; do
|
||||
echo "Running dotnet format on $csproj"
|
||||
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic --exclude-diagnostics IL2026 IL3050"
|
||||
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic"
|
||||
done
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -67,6 +67,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
|
||||
@@ -34,12 +34,13 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# Unit tests
|
||||
- name: Run all tests
|
||||
run: uv run poe all-tests
|
||||
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
|
||||
working-directory: ./python
|
||||
|
||||
# Surface failing tests
|
||||
|
||||
@@ -64,7 +64,7 @@ Approaches observed from the compared SDKs:
|
||||
| AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) |
|
||||
| OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) |
|
||||
| Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. |
|
||||
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.stream_async) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
|
||||
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/user-guide/concepts/streaming/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
|
||||
| LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
|
||||
| Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse|
|
||||
| A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) |
|
||||
@@ -496,7 +496,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|
||||
|-|-|
|
||||
| AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. |
|
||||
| Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
|
||||
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.structured_output) |
|
||||
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/user-guide/concepts/agents/structured-output/) |
|
||||
| LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response |
|
||||
| Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/input-output/structured-output/agent) at agent construction time |
|
||||
| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time |
|
||||
@@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|
||||
|-|-|
|
||||
| AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string |
|
||||
| Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) |
|
||||
| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/documentation/docs/api-reference/python/types/event_loop/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) class with options that are tied closely to LLM operations. |
|
||||
| AWS (Strands) | Exposes a `stop_reason` property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) class with options that are tied closely to LLM operations. |
|
||||
| LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
|
||||
| Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) |
|
||||
| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). |
|
||||
|
||||
@@ -1240,3 +1240,10 @@ class AttributionAwareStrategy(CompactionStrategy):
|
||||
|
||||
- [ADR-0016: Unifying Context Management with ContextPlugin](0016-python-context-middleware.md) — Parent ADR that established `ContextProvider`, `HistoryProvider`, and `AgentSession` architecture.
|
||||
- [Context Compaction Limitations Analysis](https://gist.github.com/victordibia/ec3f3baf97345f7e47da025cf55b999f) — Detailed analysis of why current architecture cannot support in-run compaction, with attempted solutions and their failure modes. Option 4 in this ADR corresponds to "Option A: Middleware Access to Mutable Message Source" from that analysis; Options 1-3 correspond to "Option B: Tool Loop Hook", adapted here to a `BaseChatClient` hook instead of `FunctionInvocationConfiguration`.
|
||||
|
||||
### Implementation Rollout Note
|
||||
|
||||
Implementation is split into two phases:
|
||||
|
||||
1. **Phase 1 (PR 1):** runtime compaction foundation in `agent_framework/_compaction.py`, in-run integration, and extensive core tests, plus in-run compaction samples (`basics`, `advanced`, `custom`).
|
||||
2. **Phase 2 (PR 2):** history/storage compaction (`upsert`-based full replacement), provider support, storage tests, and storage-focused sample (`storage`).
|
||||
|
||||
+50
-5
@@ -17,14 +17,17 @@ dotnet format # Auto-fix formatting for all projects
|
||||
|
||||
# Build/test/format a specific project (preferred for isolated/internal changes)
|
||||
dotnet build src/Microsoft.Agents.AI.<Package> --tl:off
|
||||
dotnet test tests/Microsoft.Agents.AI.<Package>.UnitTests
|
||||
dotnet test --project tests/Microsoft.Agents.AI.<Package>.UnitTests
|
||||
dotnet format src/Microsoft.Agents.AI.<Package>
|
||||
|
||||
# Run a single test
|
||||
dotnet test --filter "FullyQualifiedName~Namespace.TestClassName.TestMethodName"
|
||||
# Replace the filter values with the appropriate assembly, namespace, class, and method names for the test you want to run and use * as a wildcard elsewhere, e.g. "/*/*/HttpClientTests/GetAsync_ReturnsSuccessStatusCode"
|
||||
# Use `--ignore-exit-code 8` to avoid failing the build when no tests are found for some projects
|
||||
dotnet test --filter-query "/<assemblyFilter>/<namespaceFilter>/<classFilter>/<methodFilter>" --ignore-exit-code 8
|
||||
|
||||
# Run unit tests only
|
||||
dotnet test --filter FullyQualifiedName\~UnitTests
|
||||
# Use `--ignore-exit-code 8` to avoid failing the build when no tests are found for integration test projects
|
||||
dotnet test --filter-query "/*UnitTests*/*/*/*" --ignore-exit-code 8
|
||||
```
|
||||
|
||||
Use `--tl:off` when building to avoid flickering when running commands in the agent.
|
||||
@@ -56,7 +59,7 @@ Example: Running tests for a single project using .NET 10.
|
||||
|
||||
```bash
|
||||
# From dotnet/ directory
|
||||
dotnet test ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0
|
||||
dotnet test --project ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0
|
||||
```
|
||||
|
||||
Example: Running a single test in a specific project using .NET 10.
|
||||
@@ -64,7 +67,7 @@ Provide the full namespace, class name, and method name for the test you want to
|
||||
|
||||
```bash
|
||||
# From dotnet/ directory
|
||||
dotnet test ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0 --filter "FullyQualifiedName~Microsoft.Agents.AI.Abstractions.UnitTests.AgentRunOptionsTests.CloningConstructorCopiesProperties"
|
||||
dotnet test --project ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0 --filter-query "/*/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests/CloningConstructorCopiesProperties"
|
||||
```
|
||||
|
||||
### Multi-target framework tip
|
||||
@@ -83,3 +86,45 @@ Just remember to run `dotnet restore` after pulling changes, making changes to p
|
||||
Unit tests target both .NET Framework as well as .NET Core. When running on Linux, only the .NET Core tests can be run, as .NET Framework is not supported on Linux.
|
||||
|
||||
To run only the .NET Core tests, use the `-f net10.0` option with `dotnet test`.
|
||||
|
||||
### Microsoft Testing Platform (MTP)
|
||||
|
||||
Tests use the [Microsoft Testing Platform](https://learn.microsoft.com/dotnet/core/testing/unit-testing-platform-intro) via xUnit v3. Key differences from the legacy VSTest runner:
|
||||
|
||||
- **`dotnet test` requires `--project`** to specify a test project directly (positional arguments are no longer supported).
|
||||
- **Test output** uses the MTP format (e.g., `[✓112/x0/↓0]` progress and `Test run summary: Passed!`).
|
||||
- **TRX reports** use `--report-xunit-trx` instead of `--logger trx`.
|
||||
- **Code coverage** uses `Microsoft.Testing.Extensions.CodeCoverage` with `--coverage --coverage-output-format cobertura`.
|
||||
- **Running a test project directly** is supported via `dotnet run --project <test-project>`. This bypasses the `dotnet test` infrastructure and runs the test executable directly with the MTP command line.
|
||||
|
||||
- **Running tests across the solution** with a filter may cause some projects to match zero tests, which MTP treats as a failure (exit code 8). Use `--ignore-exit-code 8` to suppress this:
|
||||
|
||||
```bash
|
||||
# Run all unit tests across the solution, ignoring projects with no matching tests
|
||||
dotnet test --solution ./agent-framework-dotnet.slnx --no-build -f net10.0 --ignore-exit-code 8
|
||||
```
|
||||
|
||||
- **Running tests with `--solution` for a specific TFM** requires all projects in the solution to support that TFM. Not all projects target every framework (e.g., some are `net10.0`-only). Use `./dotnet/eng/scripts/New-FilteredSolution.ps1` to generate a filtered solution:
|
||||
|
||||
```powershell
|
||||
# Generate a filtered solution for net472 and run tests
|
||||
$filtered = ./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472
|
||||
dotnet test --solution $filtered --no-build -f net472 --ignore-exit-code 8
|
||||
|
||||
# Exclude samples and keep only unit test projects
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -ExcludeSamples -TestProjectNameFilter "*UnitTests*" -OutputPath dotnet/filtered-unit.slnx
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run tests via dotnet test (uses MTP under the hood)
|
||||
dotnet test --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0
|
||||
|
||||
# Run tests with code coverage (Cobertura format)
|
||||
dotnet test --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0 --coverage --coverage-output-format cobertura --coverage-settings ./tests/coverage.runsettings
|
||||
|
||||
# Run tests directly via dotnet run (MTP native command line)
|
||||
dotnet run --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0
|
||||
|
||||
# Show MTP command line help
|
||||
dotnet run --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0 -- -?
|
||||
```
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="12.3.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.4.1" />
|
||||
<PackageVersion Include="Anthropic" Version="12.8.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.4.2" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
|
||||
@@ -108,6 +108,7 @@
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.8.0" />
|
||||
<!-- Identity -->
|
||||
@@ -140,12 +141,10 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' == 'net10.0'" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageVersion Include="Moq" Version="[4.18.4]" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||
<PackageVersion Include="xretry" Version="1.9.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="xRetry.v3" Version="1.0.0-rc3" />
|
||||
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.4.1" />
|
||||
<!-- Symbols -->
|
||||
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
||||
<!-- Toolset -->
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -103,6 +104,7 @@
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithOpenAI/">
|
||||
<File Path="samples/02-agents/AgentWithOpenAI/README.md" />
|
||||
@@ -284,8 +286,11 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/HostedAgents/">
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj" />
|
||||
</Folder>
|
||||
@@ -313,7 +318,6 @@
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/.github/workflows/">
|
||||
<File Path="../.github/workflows/dotnet-build-and-test.yml" />
|
||||
<File Path="../.github/workflows/dotnet-check-coverage.ps1" />
|
||||
<File Path="../.github/workflows/dotnet-format.yml" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/demos/">
|
||||
@@ -350,6 +354,10 @@
|
||||
<File Path="eng/MSBuild/Shared.props" />
|
||||
<File Path="eng/MSBuild/Shared.targets" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/eng/scripts/">
|
||||
<File Path="eng/scripts/dotnet-check-coverage.ps1" />
|
||||
<File Path="eng/scripts/New-FilteredSolution.ps1" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/nuget/">
|
||||
<File Path="nuget/icon.png" />
|
||||
<File Path="nuget/nuget-package.props" />
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj",
|
||||
"src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj",
|
||||
"src\\Microsoft.Agents.AI.DurableTask\\Microsoft.Agents.AI.DurableTask.csproj",
|
||||
"src\\Microsoft.Agents.AI.FoundryMemory\\Microsoft.Agents.AI.FoundryMemory.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generates a filtered .slnx solution file by removing projects that don't match the specified criteria.
|
||||
|
||||
.DESCRIPTION
|
||||
Parses a .slnx solution file and applies one or more filters:
|
||||
- Removes projects that don't support the specified target framework (via MSBuild query).
|
||||
- Optionally removes all sample projects (under samples/).
|
||||
- Optionally filters test projects by name pattern (e.g., only *UnitTests*).
|
||||
Writes the filtered solution to the specified output path and prints the path.
|
||||
|
||||
.PARAMETER Solution
|
||||
Path to the source .slnx solution file.
|
||||
|
||||
.PARAMETER TargetFramework
|
||||
The target framework to filter by (e.g., net10.0, net472).
|
||||
|
||||
.PARAMETER Configuration
|
||||
Optional MSBuild configuration used when querying TargetFrameworks. Defaults to Debug.
|
||||
|
||||
.PARAMETER TestProjectNameFilter
|
||||
Optional wildcard pattern to filter test project names (e.g., *UnitTests*, *IntegrationTests*).
|
||||
When specified, only test projects whose filename matches this pattern are kept.
|
||||
|
||||
.PARAMETER ExcludeSamples
|
||||
When specified, removes all projects under the samples/ directory from the solution.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Optional output path for the filtered .slnx file. If not specified, a temp file is created.
|
||||
|
||||
.EXAMPLE
|
||||
# Generate a filtered solution and run tests
|
||||
$filtered = ./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472
|
||||
dotnet test --solution $filtered --no-build -f net472
|
||||
|
||||
.EXAMPLE
|
||||
# Generate a solution with only unit test projects
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameFilter "*UnitTests*" -OutputPath filtered-unit.slnx
|
||||
|
||||
.EXAMPLE
|
||||
# Inline usage with dotnet test (PowerShell)
|
||||
dotnet test --solution (./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472) --no-build -f net472
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Solution,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$TargetFramework,
|
||||
|
||||
[string]$Configuration = "Debug",
|
||||
|
||||
[string]$TestProjectNameFilter,
|
||||
|
||||
[switch]$ExcludeSamples,
|
||||
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Resolve the solution path
|
||||
$solutionPath = Resolve-Path $Solution
|
||||
$solutionDir = Split-Path $solutionPath -Parent
|
||||
|
||||
if (-not $OutputPath) {
|
||||
$OutputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "filtered-$(Split-Path $solutionPath -Leaf)")
|
||||
}
|
||||
|
||||
# Parse the .slnx XML
|
||||
[xml]$slnx = Get-Content $solutionPath -Raw
|
||||
|
||||
$removed = @()
|
||||
$kept = @()
|
||||
|
||||
# Remove sample projects if requested
|
||||
if ($ExcludeSamples) {
|
||||
$sampleProjects = $slnx.SelectNodes("//Project[contains(@Path, 'samples/')]")
|
||||
foreach ($proj in $sampleProjects) {
|
||||
$projRelPath = $proj.GetAttribute("Path")
|
||||
Write-Verbose "Removing (sample): $projRelPath"
|
||||
$removed += $projRelPath
|
||||
$proj.ParentNode.RemoveChild($proj) | Out-Null
|
||||
}
|
||||
Write-Host "Removed $($sampleProjects.Count) sample project(s)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Filter all remaining projects by target framework
|
||||
$allProjects = $slnx.SelectNodes("//Project")
|
||||
|
||||
foreach ($proj in $allProjects) {
|
||||
$projRelPath = $proj.GetAttribute("Path")
|
||||
$projFullPath = Join-Path $solutionDir $projRelPath
|
||||
$projFileName = Split-Path $projRelPath -Leaf
|
||||
$isTestProject = $projRelPath -like "*tests/*"
|
||||
|
||||
# Filter test projects by name pattern if specified
|
||||
if ($isTestProject -and $TestProjectNameFilter -and ($projFileName -notlike $TestProjectNameFilter)) {
|
||||
Write-Verbose "Removing (name filter): $projRelPath"
|
||||
$removed += $projRelPath
|
||||
$proj.ParentNode.RemoveChild($proj) | Out-Null
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not (Test-Path $projFullPath)) {
|
||||
Write-Verbose "Project not found, keeping in solution: $projRelPath"
|
||||
$kept += $projRelPath
|
||||
continue
|
||||
}
|
||||
|
||||
# Query the project's target frameworks using MSBuild
|
||||
$targetFrameworks = & dotnet msbuild $projFullPath -getProperty:TargetFrameworks -p:Configuration=$Configuration -nologo 2>$null
|
||||
$targetFrameworks = $targetFrameworks.Trim()
|
||||
|
||||
if ($targetFrameworks -like "*$TargetFramework*") {
|
||||
Write-Verbose "Keeping: $projRelPath (targets: $targetFrameworks)"
|
||||
$kept += $projRelPath
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Removing: $projRelPath (targets: $targetFrameworks, missing: $TargetFramework)"
|
||||
$removed += $projRelPath
|
||||
$proj.ParentNode.RemoveChild($proj) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Write the filtered solution
|
||||
$slnx.Save($OutputPath)
|
||||
|
||||
# Report results to stderr so stdout is clean for piping
|
||||
Write-Host "Filtered solution written to: $OutputPath" -ForegroundColor Green
|
||||
if ($removed.Count -gt 0) {
|
||||
Write-Host "Removed $($removed.Count) project(s):" -ForegroundColor Yellow
|
||||
foreach ($r in $removed) {
|
||||
Write-Host " - $r" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
Write-Host "Kept $($kept.Count) project(s)." -ForegroundColor Green
|
||||
|
||||
# Output the path for piping
|
||||
Write-Output $OutputPath
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"version": "10.0.200",
|
||||
"rollForward": "minor",
|
||||
"allowPrerelease": false
|
||||
},
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<RCNumber>3</RCNumber>
|
||||
<RCNumber>4</RCNumber>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260304.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260304.1</PackageVersion>
|
||||
<GitTag>1.0.0-rc3</GitTag>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260311.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260311.1</PackageVersion>
|
||||
<GitTag>1.0.0-rc4</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
@@ -27,7 +26,7 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -82,7 +82,7 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant with access to restaurant information.",
|
||||
tools: tools);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
@@ -27,7 +26,7 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -60,7 +60,7 @@ ChatClient openAIChatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
ChatClientAgent baseAgent = openAIChatClient.AsIChatClient().AsAIAgent(
|
||||
ChatClientAgent baseAgent = openAIChatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant in charge of approving expenses",
|
||||
tools: tools);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenAI.Chat;
|
||||
using RecipeAssistant;
|
||||
@@ -37,7 +36,7 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent baseAgent = chatClient.AsIChatClient().AsAIAgent(
|
||||
AIAgent baseAgent = chatClient.AsAIAgent(
|
||||
name: "RecipeAgent",
|
||||
instructions: """
|
||||
You are a helpful recipe assistant. When users ask you to create or suggest a recipe,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ChatHistoryProvider"/> that keeps a bounded window of recent messages in session state
|
||||
/// (via <see cref="InMemoryChatHistoryProvider"/>) and overflows older messages to a vector store
|
||||
/// (via <see cref="ChatHistoryMemoryProvider"/>). When providing chat history, it searches the vector
|
||||
/// store for relevant older messages and prepends them as a memory context message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only non-system messages are counted towards the session state limit and overflow mechanism. System messages are always retained in session state and are not included in the vector store.
|
||||
/// Function calls and function results are also dropped when truncation happens, both from in-memory state, and they are also not persisted to the vector store.
|
||||
/// </remarks>
|
||||
internal sealed class BoundedChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
{
|
||||
private readonly InMemoryChatHistoryProvider _chatHistoryProvider;
|
||||
private readonly ChatHistoryMemoryProvider _memoryProvider;
|
||||
private readonly TruncatingChatReducer _reducer;
|
||||
private readonly string _contextPrompt;
|
||||
private IReadOnlyList<string>? _stateKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BoundedChatHistoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxSessionMessages">The maximum number of non-system messages to keep in session state before overflowing to the vector store.</param>
|
||||
/// <param name="vectorStore">The vector store to use for storing and retrieving overflow chat history.</param>
|
||||
/// <param name="collectionName">The name of the collection for storing overflow chat history in the vector store.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
|
||||
/// <param name="stateInitializer">A delegate that initializes the memory provider state, providing the storage and search scopes.</param>
|
||||
/// <param name="contextPrompt">Optional prompt to prefix memory search results. Defaults to a standard memory context prompt.</param>
|
||||
public BoundedChatHistoryProvider(
|
||||
int maxSessionMessages,
|
||||
VectorStore vectorStore,
|
||||
string collectionName,
|
||||
int vectorDimensions,
|
||||
Func<AgentSession?, ChatHistoryMemoryProvider.State> stateInitializer,
|
||||
string? contextPrompt = null)
|
||||
{
|
||||
if (maxSessionMessages < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxSessionMessages), "maxSessionMessages must be non-negative.");
|
||||
}
|
||||
|
||||
this._reducer = new TruncatingChatReducer(maxSessionMessages);
|
||||
this._chatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = this._reducer,
|
||||
ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded,
|
||||
StorageInputRequestMessageFilter = msgs => msgs,
|
||||
});
|
||||
this._memoryProvider = new ChatHistoryMemoryProvider(
|
||||
vectorStore,
|
||||
collectionName,
|
||||
vectorDimensions,
|
||||
stateInitializer,
|
||||
options: new ChatHistoryMemoryProviderOptions
|
||||
{
|
||||
SearchInputMessageFilter = msgs => msgs,
|
||||
StorageInputRequestMessageFilter = msgs => msgs,
|
||||
});
|
||||
this._contextPrompt = contextPrompt
|
||||
?? "The following are memories from earlier in this conversation. Use them to inform your responses:";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= this._chatHistoryProvider.StateKeys.Concat(this._memoryProvider.StateKeys).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
|
||||
InvokingContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Delegate to the inner provider's full lifecycle (retrieve, filter, stamp, merge with request messages).
|
||||
var chatHistoryProviderInputContext = new InvokingContext(context.Agent, context.Session, []);
|
||||
var allMessages = await this._chatHistoryProvider.InvokingAsync(chatHistoryProviderInputContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Search the vector store for relevant older messages.
|
||||
var aiContext = new AIContext { Messages = context.RequestMessages.ToList() };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
context.Agent, context.Session, aiContext);
|
||||
|
||||
var result = await this._memoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Extract only the messages added by the memory provider (stamped with AIContextProvider source type).
|
||||
var memoryMessages = result.Messages?
|
||||
.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.AIContextProvider)
|
||||
.ToList();
|
||||
|
||||
if (memoryMessages is { Count: > 0 })
|
||||
{
|
||||
var memoryText = string.Join("\n", memoryMessages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t)));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(memoryText))
|
||||
{
|
||||
var contextMessage = new ChatMessage(ChatRole.User, $"{this._contextPrompt}\n{memoryText}");
|
||||
return new[] { contextMessage }.Concat(allMessages);
|
||||
}
|
||||
}
|
||||
|
||||
return allMessages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreChatHistoryAsync(
|
||||
InvokedContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Delegate storage to the in-memory provider. Its TruncatingChatReducer (AfterMessageAdded trigger)
|
||||
// will automatically truncate to the configured maximum and expose any removed messages.
|
||||
var innerContext = new InvokedContext(
|
||||
context.Agent, context.Session, context.RequestMessages, context.ResponseMessages!);
|
||||
await this._chatHistoryProvider.InvokedAsync(innerContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Archive any messages that the reducer removed to the vector store.
|
||||
if (this._reducer.RemovedMessages is { Count: > 0 })
|
||||
{
|
||||
var overflowContext = new AIContextProvider.InvokedContext(
|
||||
context.Agent, context.Session, this._reducer.RemovedMessages, []);
|
||||
await this._memoryProvider.InvokedAsync(overflowContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this._memoryProvider.Dispose();
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create a bounded chat history provider that keeps a configurable number of
|
||||
// recent messages in session state and automatically overflows older messages to a vector store.
|
||||
// When the agent is invoked, it searches the vector store for relevant older messages and
|
||||
// prepends them as a "memory" context message before the recent session history.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
using OpenAI.Chat;
|
||||
using SampleApp;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var credential = new DefaultAzureCredential();
|
||||
|
||||
// Create a vector store to store overflow chat messages.
|
||||
// For demonstration purposes, we are using an in-memory vector store.
|
||||
// Replace this with a persistent vector store implementation for production scenarios.
|
||||
VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions()
|
||||
{
|
||||
EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), credential)
|
||||
.GetEmbeddingClient(embeddingDeploymentName)
|
||||
.AsIEmbeddingGenerator()
|
||||
});
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
|
||||
// Create the BoundedChatHistoryProvider with a maximum of 4 non-system messages in session state.
|
||||
// It internally creates an InMemoryChatHistoryProvider with a TruncatingChatReducer and a
|
||||
// ChatHistoryMemoryProvider with the correct configuration to ensure overflow messages are
|
||||
// automatically archived to the vector store and recalled via semantic search.
|
||||
var boundedProvider = new BoundedChatHistoryProvider(
|
||||
maxSessionMessages: 4,
|
||||
vectorStore,
|
||||
collectionName: "chathistory-overflow",
|
||||
vectorDimensions: 3072,
|
||||
session => new ChatHistoryMemoryProvider.State(
|
||||
storageScope: new() { UserId = "UID1", SessionId = sessionId },
|
||||
searchScope: new() { UserId = "UID1" }));
|
||||
|
||||
// Create the agent with the bounded chat history provider.
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. Answer questions concisely." },
|
||||
Name = "Assistant",
|
||||
ChatHistoryProvider = boundedProvider,
|
||||
});
|
||||
|
||||
// Start a conversation. The first several exchanges will fill up the session state window.
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.WriteLine("--- Filling the session window (4 messages max) ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("My favorite color is blue.", session));
|
||||
Console.WriteLine(await agent.RunAsync("I have a dog named Max.", session));
|
||||
|
||||
// At this point the session state holds 4 messages (2 user + 2 assistant).
|
||||
// The next exchange will push the oldest messages into the vector store.
|
||||
Console.WriteLine("\n--- Next exchange will trigger overflow to vector store ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is the capital of France?", session));
|
||||
|
||||
// The oldest messages about favorite color have now been archived to the vector store.
|
||||
// Ask the agent something that requires recalling the overflowed information.
|
||||
Console.WriteLine("\n--- Asking about overflowed information (should recall from vector store) ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is my favorite color?", session));
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Bounded Chat History with Vector Store Overflow
|
||||
|
||||
This sample demonstrates how to create a custom `ChatHistoryProvider` that keeps a bounded window of recent messages in session state and automatically overflows older messages to a vector store. When the agent is invoked, it searches the vector store for relevant older messages and prepends them as memory context.
|
||||
|
||||
## Concepts
|
||||
|
||||
- **`TruncatingChatReducer`**: A custom `IChatReducer` that keeps the most recent N messages and exposes removed messages via a `RemovedMessages` property.
|
||||
- **`BoundedChatHistoryProvider`**: A custom `ChatHistoryProvider` that composes:
|
||||
- `InMemoryChatHistoryProvider` for fast session-state storage (bounded by the reducer)
|
||||
- `ChatHistoryMemoryProvider` for vector-store overflow and semantic search of older messages
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure OpenAI resource with:
|
||||
- A chat deployment (e.g., `gpt-4o-mini`)
|
||||
- An embedding deployment (e.g., `text-embedding-3-large`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | *(required)* |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Chat model deployment name | `gpt-4o-mini` |
|
||||
| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Embedding model deployment name | `text-embedding-3-large` |
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
1. The agent starts a conversation with a bounded session window of 4 non-system, non-function messages (i.e., user/assistant turns). System messages are always preserved, and function call/result messages are truncated and not preserved.
|
||||
2. As messages accumulate beyond the limit, the `TruncatingChatReducer` removes the oldest messages.
|
||||
3. The `BoundedChatHistoryProvider` detects the removed messages and stores them in a vector store via `ChatHistoryMemoryProvider`.
|
||||
4. On subsequent invocations, the provider searches the vector store for relevant older messages and prepends them as memory context, allowing the agent to recall information from earlier in the conversation.
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// A truncating chat reducer that keeps the most recent messages up to a configured maximum,
|
||||
/// preserving any leading system message. Removed messages are exposed via <see cref="RemovedMessages"/>
|
||||
/// so that a caller can archive them (e.g. to a vector store).
|
||||
/// </summary>
|
||||
internal sealed class TruncatingChatReducer : IChatReducer
|
||||
{
|
||||
private readonly int _maxMessages;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncatingChatReducer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxMessages">The maximum number of non-system messages to retain.</param>
|
||||
public TruncatingChatReducer(int maxMessages)
|
||||
{
|
||||
this._maxMessages = maxMessages > 0 ? maxMessages : throw new ArgumentOutOfRangeException(nameof(maxMessages));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages that were removed during the most recent call to <see cref="ReduceAsync"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> RemovedMessages { get; private set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = messages ?? throw new ArgumentNullException(nameof(messages));
|
||||
|
||||
ChatMessage? systemMessage = null;
|
||||
Queue<ChatMessage> retained = new(capacity: this._maxMessages);
|
||||
List<ChatMessage> removed = [];
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
// Preserve the first system message outside the counting window.
|
||||
systemMessage ??= message;
|
||||
}
|
||||
else if (!message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent))
|
||||
{
|
||||
if (retained.Count >= this._maxMessages)
|
||||
{
|
||||
removed.Add(retained.Dequeue());
|
||||
}
|
||||
|
||||
retained.Enqueue(message);
|
||||
}
|
||||
}
|
||||
|
||||
this.RemovedMessages = removed;
|
||||
|
||||
IEnumerable<ChatMessage> result = systemMessage is not null
|
||||
? new[] { systemMessage }.Concat(retained)
|
||||
: retained;
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,6 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|
||||
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|
||||
|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.|
|
||||
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|
||||
|
||||
> **See also**: [Memory Search with Foundry Agents](../FoundryAgents/FoundryAgents_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry Agents.
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a CompactionProvider with a compaction pipeline
|
||||
// as an AIContextProvider for an agent's in-run context management. The pipeline chains multiple
|
||||
// compaction strategies from gentle to aggressive:
|
||||
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
|
||||
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
|
||||
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
|
||||
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create a chat client for the agent and a separate one for the summarization strategy.
|
||||
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
|
||||
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Define a tool the agent can use, so we can see tool-result compaction in action.
|
||||
[Description("Look up the current price of a product by name.")]
|
||||
static string LookupPrice([Description("The product name to look up.")] string productName) =>
|
||||
productName.ToUpperInvariant() switch
|
||||
{
|
||||
"LAPTOP" => "The laptop costs $999.99.",
|
||||
"KEYBOARD" => "The keyboard costs $79.99.",
|
||||
"MOUSE" => "The mouse costs $29.99.",
|
||||
_ => $"Sorry, I don't have pricing for '{productName}'."
|
||||
};
|
||||
|
||||
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
|
||||
PipelineCompactionStrategy compactionPipeline =
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries
|
||||
new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7)),
|
||||
|
||||
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
|
||||
|
||||
// 3. Aggressive: keep only the last N user turns and their responses
|
||||
new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
|
||||
|
||||
// 4. Emergency: drop oldest groups until under the token budget
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
|
||||
|
||||
// Create the agent with a CompactionProvider that uses the compaction pipeline.
|
||||
AIAgent agent =
|
||||
agentChatClient
|
||||
.AsBuilder()
|
||||
// Note: Adding the CompactionProvider at the builder level means it will be applied to all agents
|
||||
// built from this builder and will manage context for both agent messages and tool calls.
|
||||
.UseAIContextProviders(new CompactionProvider(compactionPipeline))
|
||||
.BuildAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ShoppingAssistant",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful, but long winded, shopping assistant.
|
||||
Help the user look up prices and compare products.
|
||||
When responding, Be sure to be extra descriptive and use as
|
||||
many words as possible without sounding ridiculous.
|
||||
""",
|
||||
Tools = [AIFunctionFactory.Create(LookupPrice)]
|
||||
},
|
||||
// Note: AIContextProviders may be specified here instead of ChatClientBuilder.UseAIContextProviders.
|
||||
// Specifying compaction at the agent level skips compaction in the function calling loop.
|
||||
//AIContextProviders = [new CompactionProvider(compactionPipeline)]
|
||||
});
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Helper to print chat history size
|
||||
void PrintChatHistory()
|
||||
{
|
||||
if (session.TryGetInMemoryChatHistory(out var history))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\n[Messages: #{history.Count}]\n");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// Run a multi-turn conversation with tool calls to exercise the pipeline.
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the price of a laptop?",
|
||||
"How about a keyboard?",
|
||||
"And a mouse?",
|
||||
"Which product is the cheapest?",
|
||||
"Can you compare the laptop and the keyboard for me?",
|
||||
"What was the first product I asked about?",
|
||||
"Thank you!",
|
||||
];
|
||||
|
||||
foreach (string prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[Agent] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(await agent.RunAsync(prompt, session));
|
||||
|
||||
PrintChatHistory();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Compaction Pipeline
|
||||
|
||||
This sample demonstrates how to use a `CompactionProvider` with a `PipelineCompactionStrategy` to manage long conversation histories in a token-efficient way. The pipeline chains four compaction strategies, ordered from gentle to aggressive, so that the least disruptive strategy runs first and more aggressive strategies only activate when necessary.
|
||||
|
||||
## What This Sample Shows
|
||||
|
||||
- **`CompactionProvider`** — an `AIContextProvider` that applies a compaction strategy before each agent invocation, keeping only the most relevant messages within the model's context window
|
||||
- **`PipelineCompactionStrategy`** — chains multiple compaction strategies into an ordered pipeline; each strategy evaluates its own trigger independently and operates on the output of the previous one
|
||||
- **`ToolResultCompactionStrategy`** — collapses older tool-call groups into concise inline summaries, activated by a message-count trigger
|
||||
- **`SummarizationCompactionStrategy`** — uses an LLM to compress older conversation spans into a single summary message, activated by a token-count trigger
|
||||
- **`SlidingWindowCompactionStrategy`** — retains only the most recent N user turns and their responses, activated by a turn-count trigger
|
||||
- **`TruncationCompactionStrategy`** — emergency backstop that drops the oldest groups until the conversation fits within a hard token budget
|
||||
- **`CompactionTriggers`** — factory methods (`MessagesExceed`, `TokensExceed`, `TurnsExceed`, `GroupsExceed`, `HasToolCalls`, `All`, `Any`) that control when each strategy activates
|
||||
|
||||
## Concepts
|
||||
|
||||
### Message groups
|
||||
|
||||
The compaction engine organizes messages into atomic *groups* that are treated as indivisible units during compaction. A group is either:
|
||||
|
||||
| Group kind | Contents |
|
||||
|---|---|
|
||||
| `System` | System prompt message(s) |
|
||||
| `User` | A single user message |
|
||||
| `ToolCall` | One assistant message with tool calls + the matching tool result messages |
|
||||
| `AssistantText` | A single assistant text-only message |
|
||||
| `Summary` | One or more messages summarizing earlier conversation spans, produced by compaction strategies |
|
||||
|
||||
`Summary` groups (`CompactionGroupKind.Summary`) are created by compaction strategies (for example, `SummarizationCompactionStrategy`) and do not originate directly from user or assistant messages.
|
||||
Strategies exclude entire groups rather than individual messages, preserving the tool-call/result pairing required by most model APIs.
|
||||
|
||||
### Compaction triggers
|
||||
|
||||
A `CompactionTrigger` is a predicate evaluated against the current `MessageIndex`. When the trigger fires, the strategy performs compaction; when it does not fire, the strategy is skipped. Available triggers are:
|
||||
|
||||
| Trigger | Activates when… |
|
||||
|---|---|
|
||||
| `CompactionTriggers.Always` | Always (unconditional) |
|
||||
| `CompactionTriggers.Never` | Never (disabled) |
|
||||
| `CompactionTriggers.MessagesExceed(n)` | Included message count > n |
|
||||
| `CompactionTriggers.TokensExceed(n)` | Included token count > n |
|
||||
| `CompactionTriggers.TurnsExceed(n)` | Included user-turn count > n |
|
||||
| `CompactionTriggers.GroupsExceed(n)` | Included group count > n |
|
||||
| `CompactionTriggers.HasToolCalls()` | At least one included tool-call group exists |
|
||||
| `CompactionTriggers.All(...)` | All supplied triggers fire (logical AND) |
|
||||
| `CompactionTriggers.Any(...)` | Any supplied trigger fires (logical OR) |
|
||||
|
||||
### Pipeline ordering
|
||||
|
||||
Order strategies from **least aggressive** to **most aggressive**. The pipeline runs every strategy whose trigger is met. Earlier strategies reduce the conversation gently so that later, more destructive strategies may not need to activate at all.
|
||||
|
||||
```
|
||||
1. ToolResultCompactionStrategy – gentle: replaces verbose tool results with a short label
|
||||
2. SummarizationCompactionStrategy – moderate: LLM-summarizes older turns
|
||||
3. SlidingWindowCompactionStrategy – aggressive: drops turns beyond the window
|
||||
4. TruncationCompactionStrategy – emergency: hard token-budget enforcement
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure OpenAI service endpoint and model deployment
|
||||
- Azure CLI installed and authenticated
|
||||
|
||||
**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
The sample runs a seven-turn shopping-assistant conversation with tool calls. After each turn it prints the full message count so you can observe the pipeline compaction doesn't alter the source conversation.
|
||||
|
||||
Each of the four compaction strategies has a deliberately low threshold so that it activates during the short demonstration conversation. In a production scenario you would raise the thresholds to match your model's context window and cost requirements.
|
||||
|
||||
## Customizing the Pipeline
|
||||
|
||||
### Using a single strategy
|
||||
|
||||
If you only need one compaction strategy, pass it directly to `CompactionProvider` without wrapping it in a pipeline:
|
||||
|
||||
```csharp
|
||||
CompactionProvider provider =
|
||||
new(new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(20)));
|
||||
```
|
||||
|
||||
### Ad-hoc compaction outside the provider pipeline
|
||||
|
||||
`CompactionProvider.CompactAsync` applies a strategy to an arbitrary list of messages without an active agent session:
|
||||
|
||||
```csharp
|
||||
IEnumerable<ChatMessage> compacted = await CompactionProvider.CompactAsync(
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(8000)),
|
||||
existingMessages);
|
||||
```
|
||||
|
||||
### Using a different model for summarization
|
||||
|
||||
The `SummarizationCompactionStrategy` accepts any `IChatClient`. Use a smaller, cheaper model to reduce summarization cost:
|
||||
|
||||
```csharp
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient();
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(4000))
|
||||
```
|
||||
|
||||
### Registering through `ChatClientAgentOptions`
|
||||
|
||||
`CompactionProvider` can also be specified directly on `ChatClientAgentOptions` instead of calling `UseAIContextProviders` on the `ChatClientBuilder`:
|
||||
|
||||
```csharp
|
||||
AIAgent agent = agentChatClient
|
||||
.AsBuilder()
|
||||
.BuildAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [new CompactionProvider(compactionPipeline)]
|
||||
});
|
||||
```
|
||||
|
||||
This places the compaction provider at the agent level instead of the chat client level, which allows you to use different compaction strategies for different agents that share the same chat client.
|
||||
|
||||
> Note: In this mode the `CompactionProvider` is not engaged during the tool calling loop. Agent-level `AIContextProviders` run before chat history is stored, so any synthetic summary messages produced by `CompactionProvider` can become part of the persisted history when using `ChatHistoryProvider`. If you want to compact only the request context while preserving the original stored history, register `CompactionProvider` on the `ChatClientBuilder` via `UseAIContextProviders(...)` instead of on `ChatClientAgentOptions`.
|
||||
@@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|
||||
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|
||||
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+72
-56
@@ -12,11 +12,8 @@ using OpenAI.Responses;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// Memory store configuration
|
||||
// NOTE: Memory stores must be created beforehand via Azure Portal or Python SDK.
|
||||
// The .NET SDK currently only supports using existing memory stores with agents.
|
||||
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? throw new InvalidOperationException("AZURE_AI_MEMORY_STORE_ID is not set.");
|
||||
string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
|
||||
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? $"foundry-memory-sample-{Guid.NewGuid():N}";
|
||||
|
||||
const string AgentInstructions = """
|
||||
You are a helpful assistant that remembers past conversations.
|
||||
@@ -32,71 +29,57 @@ const string AgentNameNative = "MemorySearchAgent-NATIVE";
|
||||
string userScope = $"user_{Environment.MachineName}";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
DefaultAzureCredential credential = new();
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
// Ensure the memory store exists and has memories to retrieve.
|
||||
await EnsureMemoryStoreAsync();
|
||||
|
||||
// Create the Memory Search tool configuration
|
||||
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope)
|
||||
{
|
||||
// Optional: Configure how quickly new memories are indexed (in seconds)
|
||||
UpdateDelay = 1,
|
||||
|
||||
// Optional: Configure search behavior
|
||||
SearchOptions = new MemorySearchToolOptions
|
||||
{
|
||||
// Additional search options can be configured here if needed
|
||||
}
|
||||
};
|
||||
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelay = 0 };
|
||||
|
||||
// Create agent using Option 1 (MEAI) or Option 2 (Native SDK)
|
||||
AIAgent agent = await CreateAgentWithMEAI();
|
||||
// AIAgent agent = await CreateAgentWithNativeSDK();
|
||||
|
||||
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
|
||||
|
||||
// Conversation 1: Share some personal information
|
||||
Console.WriteLine("User: My name is Alice and I love programming in C#.");
|
||||
AgentResponse response1 = await agent.RunAsync("My name is Alice and I love programming in C#.");
|
||||
Console.WriteLine($"Agent: {response1.Messages.LastOrDefault()?.Text}\n");
|
||||
|
||||
// Allow time for memory to be indexed
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Conversation 2: Test if the agent remembers
|
||||
Console.WriteLine("User: What's my name and what programming language do I prefer?");
|
||||
AgentResponse response2 = await agent.RunAsync("What's my name and what programming language do I prefer?");
|
||||
Console.WriteLine($"Agent: {response2.Messages.LastOrDefault()?.Text}\n");
|
||||
|
||||
// Inspect memory search results if available in raw response items
|
||||
// Note: Memory search tool call results appear as AgentResponseItem types
|
||||
foreach (var message in response2.Messages)
|
||||
try
|
||||
{
|
||||
if (message.RawRepresentation is AgentResponseItem agentResponseItem &&
|
||||
agentResponseItem is MemorySearchToolCallResponseItem memorySearchResult)
|
||||
{
|
||||
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
|
||||
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
|
||||
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
|
||||
|
||||
foreach (var result in memorySearchResult.Results)
|
||||
// The agent uses the memory search tool to recall stored information.
|
||||
Console.WriteLine("User: What's my name and what programming language do I prefer?");
|
||||
AgentResponse response = await agent.RunAsync("What's my name and what programming language do I prefer?");
|
||||
Console.WriteLine($"Agent: {response.Messages.LastOrDefault()?.Text}\n");
|
||||
|
||||
// Inspect memory search results if available in raw response items.
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
if (message.RawRepresentation is MemorySearchToolCallResponseItem memorySearchResult)
|
||||
{
|
||||
var memoryItem = result.MemoryItem;
|
||||
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
|
||||
Console.WriteLine($" Scope: {memoryItem.Scope}");
|
||||
Console.WriteLine($" Content: {memoryItem.Content}");
|
||||
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
|
||||
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
|
||||
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
|
||||
|
||||
foreach (var result in memorySearchResult.Results)
|
||||
{
|
||||
var memoryItem = result.MemoryItem;
|
||||
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
|
||||
Console.WriteLine($" Scope: {memoryItem.Scope}");
|
||||
Console.WriteLine($" Content: {memoryItem.Content}");
|
||||
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup: Delete the agent and memory store.
|
||||
Console.WriteLine("\nCleaning up...");
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
Console.WriteLine("Agent deleted.");
|
||||
await aiProjectClient.MemoryStores.DeleteMemoryStoreAsync(memoryStoreName);
|
||||
Console.WriteLine("Memory store deleted.");
|
||||
}
|
||||
|
||||
// Cleanup: Delete the agent (memory store persists and should be cleaned up separately if needed)
|
||||
Console.WriteLine("\nCleaning up agent...");
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
Console.WriteLine("Agent deleted successfully.");
|
||||
|
||||
// NOTE: Memory stores are long-lived resources and are NOT deleted with the agent.
|
||||
// To delete a memory store, use the Azure Portal or Python SDK:
|
||||
// await project_client.memory_stores.delete(memory_store.name)
|
||||
|
||||
// --- Agent Creation Options ---
|
||||
#pragma warning disable CS8321 // Local function is declared but never used
|
||||
|
||||
// Option 1 - Using MemorySearchTool wrapped as MEAI AITool
|
||||
@@ -122,3 +105,36 @@ async Task<AIAgent> CreateAgentWithNativeSDK()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Helpers — kept at the bottom so the main agent flow above stays clean.
|
||||
async Task EnsureMemoryStoreAsync()
|
||||
{
|
||||
Console.WriteLine($"Creating memory store '{memoryStoreName}'...");
|
||||
try
|
||||
{
|
||||
await aiProjectClient.MemoryStores.GetMemoryStoreAsync(memoryStoreName);
|
||||
Console.WriteLine("Memory store already exists.");
|
||||
}
|
||||
catch (System.ClientModel.ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
MemoryStoreDefaultDefinition definition = new(deploymentName, embeddingModelName);
|
||||
await aiProjectClient.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, "Sample memory store for Memory Search demo");
|
||||
Console.WriteLine("Memory store created.");
|
||||
}
|
||||
|
||||
Console.WriteLine("Storing memories from a prior conversation...");
|
||||
MemoryUpdateOptions memoryOptions = new(userScope) { UpdateDelay = 0 };
|
||||
memoryOptions.Items.Add(ResponseItem.CreateUserMessageItem("My name is Alice and I love programming in C#."));
|
||||
|
||||
MemoryUpdateResult updateResult = await aiProjectClient.MemoryStores.WaitForMemoriesUpdateAsync(
|
||||
memoryStoreName: memoryStoreName,
|
||||
options: memoryOptions,
|
||||
pollingInterval: 500);
|
||||
|
||||
if (updateResult.Status == MemoryStoreUpdateStatus.Failed)
|
||||
{
|
||||
throw new InvalidOperationException($"Memory update failed: {updateResult.ErrorDetails}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Memory update completed (status: {updateResult.Status}).\n");
|
||||
}
|
||||
|
||||
+8
-8
@@ -10,7 +10,7 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ChatClient = OpenAI.Chat.ChatClient;
|
||||
using OpenAI.Chat;
|
||||
|
||||
namespace AGUIDojoServer;
|
||||
|
||||
@@ -36,7 +36,7 @@ internal static class ChatClientAgentFactory
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
return chatClient.AsIChatClient().AsAIAgent(
|
||||
return chatClient.AsAIAgent(
|
||||
name: "AgenticChat",
|
||||
description: "A simple chat agent using Azure OpenAI");
|
||||
}
|
||||
@@ -45,7 +45,7 @@ internal static class ChatClientAgentFactory
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
return chatClient.AsIChatClient().AsAIAgent(
|
||||
return chatClient.AsAIAgent(
|
||||
name: "BackendToolRenderer",
|
||||
description: "An agent that can render backend tools using Azure OpenAI",
|
||||
tools: [AIFunctionFactory.Create(
|
||||
@@ -59,7 +59,7 @@ internal static class ChatClientAgentFactory
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
return chatClient.AsIChatClient().AsAIAgent(
|
||||
return chatClient.AsAIAgent(
|
||||
name: "HumanInTheLoopAgent",
|
||||
description: "An agent that involves human feedback in its decision-making process using Azure OpenAI");
|
||||
}
|
||||
@@ -68,7 +68,7 @@ internal static class ChatClientAgentFactory
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
return chatClient.AsIChatClient().AsAIAgent(
|
||||
return chatClient.AsAIAgent(
|
||||
name: "ToolBasedGenerativeUIAgent",
|
||||
description: "An agent that uses tools to generate user interfaces using Azure OpenAI");
|
||||
}
|
||||
@@ -76,7 +76,7 @@ internal static class ChatClientAgentFactory
|
||||
public static AIAgent CreateAgenticUI(JsonSerializerOptions options)
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
var baseAgent = chatClient.AsIChatClient().AsAIAgent(new ChatClientAgentOptions
|
||||
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "AgenticUIAgent",
|
||||
Description = "An agent that generates agentic user interfaces using Azure OpenAI",
|
||||
@@ -119,7 +119,7 @@ internal static class ChatClientAgentFactory
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
var baseAgent = chatClient.AsIChatClient().AsAIAgent(
|
||||
var baseAgent = chatClient.AsAIAgent(
|
||||
name: "SharedStateAgent",
|
||||
description: "An agent that demonstrates shared state patterns using Azure OpenAI");
|
||||
|
||||
@@ -130,7 +130,7 @@ internal static class ChatClientAgentFactory
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
var baseAgent = chatClient.AsIChatClient().AsAIAgent(new ChatClientAgentOptions
|
||||
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "PredictiveStateUpdatesAgent",
|
||||
Description = "An agent that demonstrates predictive state updates using Azure OpenAI",
|
||||
|
||||
@@ -74,7 +74,7 @@ AzureOpenAIClient azureOpenAIClient = new AzureOpenAIClient(
|
||||
ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName);
|
||||
|
||||
// Create AI agent
|
||||
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
name: "ChatAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
@@ -162,7 +162,7 @@ dotnet run
|
||||
Edit the instructions in `Server/Program.cs`:
|
||||
|
||||
```csharp
|
||||
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
name: "ChatAssistant",
|
||||
instructions: "You are a helpful coding assistant specializing in C# and .NET.");
|
||||
```
|
||||
|
||||
@@ -6,7 +6,6 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
@@ -28,7 +27,7 @@ AzureOpenAIClient azureOpenAIClient = new(
|
||||
|
||||
ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName);
|
||||
|
||||
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
name: "ChatAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -75,16 +76,20 @@ string apiKey = builder.Configuration["OPENAI_API_KEY"]
|
||||
?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable.");
|
||||
string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini";
|
||||
|
||||
// Here we are using Singleton lifetime, since none of the services, function tools and user context classes in the sample have state that are per request.
|
||||
// You should evaluate the appropriate lifetime for your own services and tools based on their behavior and dependencies.
|
||||
// E.g. if any of the service instances or tools maintain state that is specific to a user, and each request may be from a different user,
|
||||
// you should use Scoped lifetime instead, so that a new instance is created for each request.
|
||||
// Note that if you use Scoped lifetime for any dependencies, you must also use Scoped lifetime for any class that uses it, including the agent itself.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<IUserContext, KeycloakUserContext>();
|
||||
builder.Services.AddScoped<ExpenseService>();
|
||||
builder.Services.AddScoped<AIAgent>(sp =>
|
||||
builder.Services.AddSingleton<IUserContext, KeycloakUserContext>();
|
||||
builder.Services.AddSingleton<ExpenseService>();
|
||||
builder.Services.AddSingleton<AIAgent>(sp =>
|
||||
{
|
||||
var expenseService = sp.GetRequiredService<ExpenseService>();
|
||||
|
||||
return new OpenAIClient(apiKey)
|
||||
.GetChatClient(model)
|
||||
.AsIChatClient()
|
||||
.AsAIAgent(
|
||||
name: "ExpenseApprovalAgent",
|
||||
instructions: "You are an expense approval assistant. You can list pending expenses "
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -27,43 +27,73 @@ public interface IUserContext
|
||||
/// Keycloak uses <c>sub</c> for the user ID, <c>preferred_username</c>
|
||||
/// for the login name, <c>given_name</c>/<c>family_name</c> for the
|
||||
/// display name, and <c>scope</c> (space-delimited) for granted scopes.
|
||||
/// Registered as a scoped service so it is resolved once per request.
|
||||
/// Registered as a singleton — claims are parsed once per request and
|
||||
/// cached in <see cref="HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
public sealed class KeycloakUserContext : IUserContext
|
||||
{
|
||||
public string UserId { get; }
|
||||
private static readonly object s_cacheKey = new();
|
||||
|
||||
public string UserName { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public IReadOnlySet<string> Scopes { get; }
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user?.FindFirstValue("sub")
|
||||
?? "anonymous";
|
||||
public string UserId => this.GetOrCreateCachedInfo().UserId;
|
||||
|
||||
this.UserName = user?.FindFirstValue("preferred_username")
|
||||
?? user?.FindFirstValue(ClaimTypes.Name)
|
||||
?? "unknown";
|
||||
public string UserName => this.GetOrCreateCachedInfo().UserName;
|
||||
|
||||
public string DisplayName => this.GetOrCreateCachedInfo().DisplayName;
|
||||
|
||||
public IReadOnlySet<string> Scopes => this.GetOrCreateCachedInfo().Scopes;
|
||||
|
||||
private CachedUserInfo GetOrCreateCachedInfo()
|
||||
{
|
||||
HttpContext? httpContext = this._httpContextAccessor.HttpContext;
|
||||
if (httpContext is not null && httpContext.Items.TryGetValue(s_cacheKey, out object? cached) && cached is CachedUserInfo info)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
|
||||
info = ParseClaims(httpContext?.User);
|
||||
|
||||
if (httpContext is not null)
|
||||
{
|
||||
httpContext.Items[s_cacheKey] = info;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static CachedUserInfo ParseClaims(ClaimsPrincipal? user)
|
||||
{
|
||||
string userId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user?.FindFirstValue("sub")
|
||||
?? "anonymous";
|
||||
|
||||
string userName = user?.FindFirstValue("preferred_username")
|
||||
?? user?.FindFirstValue(ClaimTypes.Name)
|
||||
?? "unknown";
|
||||
|
||||
string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName);
|
||||
string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname);
|
||||
this.DisplayName = (givenName, familyName) switch
|
||||
string displayName = (givenName, familyName) switch
|
||||
{
|
||||
(not null, not null) => $"{givenName} {familyName}",
|
||||
(not null, null) => givenName,
|
||||
(null, not null) => familyName,
|
||||
_ => this.UserName,
|
||||
_ => userName,
|
||||
};
|
||||
|
||||
string? scopeClaim = user?.FindFirstValue("scope");
|
||||
this.Scopes = scopeClaim is not null
|
||||
IReadOnlySet<string> scopes = scopeClaim is not null
|
||||
? new HashSet<string>(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new CachedUserInfo(userId, userName, displayName, scopes);
|
||||
}
|
||||
|
||||
private sealed record CachedUserInfo(string UserId, string UserName, string DisplayName, IReadOnlySet<string> Scopes);
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -36,11 +36,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -11,9 +11,10 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
[Description("Get the weather for a given location.")]
|
||||
static string GetWeather([Description("The location to get the weather for.")] string location)
|
||||
@@ -22,17 +23,19 @@ static string GetWeather([Description("The location to get the weather for.")] s
|
||||
// Create the chat client and agent.
|
||||
// Note: ApprovalRequiredAIFunction wraps the tool to require user approval before invocation.
|
||||
// User should reply with 'approve' or 'reject' when prompted.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.CreateAIAgent(
|
||||
.AsAIAgent(
|
||||
instructions: "You are a helpful assistant",
|
||||
tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]
|
||||
);
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
var threadRepository = new InMemoryAgentThreadRepository(agent);
|
||||
InMemoryAgentThreadRepository threadRepository = new(agent);
|
||||
await agent.RunAIAgentAsync(telemetrySourceName: "Agents", threadRepository: threadRepository);
|
||||
|
||||
+2
-2
@@ -35,10 +35,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.6" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -9,9 +9,10 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// Create an MCP tool that can be called without approval.
|
||||
AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
@@ -28,8 +29,7 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetResponsesClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.CreateAIAgent(
|
||||
.AsAIAgent(
|
||||
instructions: "You answer questions by searching the Microsoft Learn content only.",
|
||||
name: "MicrosoftLearnAgent",
|
||||
tools: [mcpTool]);
|
||||
|
||||
@@ -18,7 +18,7 @@ Before running this sample, ensure you have:
|
||||
2. A deployment of a chat model (e.g., gpt-4o-mini)
|
||||
3. Azure CLI installed and authenticated
|
||||
|
||||
**Note**: This sample uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
|
||||
**Note**: This sample uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
+2
-2
@@ -36,11 +36,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -15,21 +15,21 @@ using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
Console.WriteLine($"Project Endpoint: {endpoint}");
|
||||
Console.WriteLine($"Model Deployment: {deploymentName}");
|
||||
|
||||
var seattleHotels = new[]
|
||||
{
|
||||
Hotel[] seattleHotels =
|
||||
[
|
||||
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
|
||||
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
|
||||
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
|
||||
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
|
||||
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
|
||||
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
|
||||
};
|
||||
];
|
||||
|
||||
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
|
||||
string GetAvailableHotels(
|
||||
@@ -54,21 +54,21 @@ string GetAvailableHotels(
|
||||
return "Error: Check-out date must be after check-in date.";
|
||||
}
|
||||
|
||||
var nights = (checkOut - checkIn).Days;
|
||||
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
|
||||
int nights = (checkOut - checkIn).Days;
|
||||
List<Hotel> availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
|
||||
|
||||
if (availableHotels.Count == 0)
|
||||
{
|
||||
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
|
||||
}
|
||||
|
||||
var result = new StringBuilder();
|
||||
StringBuilder result = new();
|
||||
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
|
||||
result.AppendLine();
|
||||
|
||||
foreach (var hotel in availableHotels)
|
||||
foreach (Hotel hotel in availableHotels)
|
||||
{
|
||||
var totalCost = hotel.PricePerNight * nights;
|
||||
int totalCost = hotel.PricePerNight * nights;
|
||||
result.AppendLine($"**{hotel.Name}**");
|
||||
result.AppendLine($" Location: {hotel.Location}");
|
||||
result.AppendLine($" Rating: {hotel.Rating}/5");
|
||||
@@ -84,7 +84,10 @@ string GetAvailableHotels(
|
||||
}
|
||||
}
|
||||
|
||||
var credential = new AzureCliCredential();
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
DefaultAzureCredential credential = new();
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!);
|
||||
@@ -96,14 +99,14 @@ if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is
|
||||
openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}");
|
||||
Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}");
|
||||
|
||||
var chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
|
||||
IChatClient chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
|
||||
.Build();
|
||||
|
||||
var agent = new ChatClientAgent(chatClient,
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "SeattleHotelAgent",
|
||||
instructions: """
|
||||
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
|
||||
|
||||
+2
-3
@@ -35,11 +35,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -11,8 +11,8 @@ using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
@@ -28,13 +28,13 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
},
|
||||
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)]
|
||||
});
|
||||
|
||||
await agent.RunAIAgentAsync();
|
||||
|
||||
@@ -35,11 +35,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -9,13 +9,16 @@ using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
|
||||
string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
|
||||
|
||||
var credential = new AzureCliCredential();
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
DefaultAzureCredential credential = new();
|
||||
|
||||
var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
|
||||
IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
@@ -23,7 +26,7 @@ var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
|
||||
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
|
||||
.Build();
|
||||
|
||||
var agent = new ChatClientAgent(chatClient,
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "AgentWithTools",
|
||||
instructions: @"You are a helpful assistant with access to tools for fetching Microsoft documentation.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Key features:
|
||||
|
||||
- Configuring Foundry tools using `UseFoundryTools` with MCP and code interpreter
|
||||
- Connecting to an external MCP tool via a Foundry project connection
|
||||
- Using `AzureCliCredential` for Azure authentication
|
||||
- Using `DefaultAzureCredential` for Azure authentication
|
||||
- OpenTelemetry instrumentation for both the chat client and agent
|
||||
|
||||
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
|
||||
@@ -36,7 +36,7 @@ $env:MCP_TOOL_CONNECTION_ID="SampleMCPTool"
|
||||
|
||||
## How It Works
|
||||
|
||||
1. An `AzureOpenAIClient` is created with `AzureCliCredential` and used to get a chat client
|
||||
1. An `AzureOpenAIClient` is created with `DefaultAzureCredential` and used to get a chat client
|
||||
2. The chat client is wrapped with `UseFoundryTools` which registers two Foundry tool types:
|
||||
- **MCP connection**: Connects to an external MCP server (Microsoft Learn) via the project connection name, providing documentation fetch and search capabilities
|
||||
- **Code interpreter**: Allows the agent to execute code snippets when needed
|
||||
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -35,11 +35,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -12,8 +12,8 @@ using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
@@ -32,9 +32,9 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
|
||||
.AddEdge(frenchAgent, spanishAgent)
|
||||
.AddEdge(spanishAgent, englishAgent)
|
||||
.Build()
|
||||
.AsAgent();
|
||||
.AsAIAgent();
|
||||
|
||||
await agent.RunAIAgentAsync();
|
||||
|
||||
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
|
||||
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
|
||||
static AIAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
|
||||
chatClient.AsAIAgent($"You are a translation assistant that translates the provided text to {targetLanguage}.");
|
||||
|
||||
@@ -19,7 +19,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
**Note**: This demo uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Before running any sample, ensure you have:
|
||||
|
||||
### Authenticate with Azure CLI
|
||||
|
||||
All samples use `AzureCliCredential` for authentication. Make sure you're logged in:
|
||||
All samples use `DefaultAzureCredential` for authentication, which automatically probes multiple credential sources (environment variables, managed identity, Azure CLI, etc.). For local development, the simplest approach is to authenticate via Azure CLI:
|
||||
|
||||
```powershell
|
||||
az login
|
||||
|
||||
@@ -127,6 +127,7 @@ public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
RawRepresentation = message,
|
||||
Messages = [message.ToChatMessage()],
|
||||
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
|
||||
@@ -141,6 +142,7 @@ public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = agentTask.Id,
|
||||
FinishReason = MapTaskStateToFinishReason(agentTask.Status.State),
|
||||
RawRepresentation = agentTask,
|
||||
Messages = agentTask.ToChatMessages() ?? [],
|
||||
ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State),
|
||||
@@ -328,6 +330,7 @@ public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
RawRepresentation = message,
|
||||
Role = ChatRole.Assistant,
|
||||
MessageId = message.MessageId,
|
||||
@@ -342,6 +345,7 @@ public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = task.Id,
|
||||
FinishReason = MapTaskStateToFinishReason(task.Status.State),
|
||||
RawRepresentation = task,
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = task.ToAIContents(),
|
||||
@@ -365,7 +369,16 @@ public sealed class A2AAgent : AIAgent
|
||||
responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents();
|
||||
responseUpdate.RawRepresentation = artifactUpdateEvent;
|
||||
}
|
||||
else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent)
|
||||
{
|
||||
responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State);
|
||||
}
|
||||
|
||||
return responseUpdate;
|
||||
}
|
||||
|
||||
private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state)
|
||||
{
|
||||
return state == TaskState.Completed ? ChatFinishReason.Stop : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,19 @@ namespace Microsoft.Agents.AI;
|
||||
/// <see cref="AIAgent"/> serves as the foundational class for implementing AI agents that can participate in conversations
|
||||
/// and process user requests. An agent instance may participate in multiple concurrent conversations, and each conversation
|
||||
/// may involve multiple agents working together.
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> An <see cref="AIAgent"/> orchestrates data flow across trust boundaries —
|
||||
/// messages are sent to external AI services, context providers, chat history stores, and function tools. Agent Framework
|
||||
/// passes messages through as-is without validation or sanitization. Developers must be aware that:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>User-supplied messages may contain prompt injection attempts designed to manipulate LLM behavior.</description></item>
|
||||
/// <item><description>LLM responses should be treated as untrusted output — they may contain hallucinations, malicious payloads (e.g., scripts, SQL),
|
||||
/// or content influenced by indirect prompt injection. Always validate and sanitize LLM output before rendering in HTML, executing as code,
|
||||
/// or using in database queries.</description></item>
|
||||
/// <item><description>Messages with different roles carry different trust levels: <c>system</c> messages have the highest trust and must be developer-controlled;
|
||||
/// <c>user</c>, <c>assistant</c>, and <c>tool</c> messages should be treated as untrusted.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public abstract partial class AIAgent
|
||||
@@ -165,6 +178,11 @@ public abstract partial class AIAgent
|
||||
/// This method enables saving conversation sessions to persistent storage,
|
||||
/// allowing conversations to resume across application restarts or be migrated between
|
||||
/// different agent instances. Use <see cref="DeserializeSessionAsync"/> to restore the session.
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Serialized sessions may contain conversation content, session identifiers,
|
||||
/// and other potentially sensitive data including PII. Ensure that serialized session data is stored securely with
|
||||
/// appropriate access controls and encryption at rest.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<JsonElement> SerializeSessionAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> this.SerializeSessionCoreAsync(session, jsonSerializerOptions, cancellationToken);
|
||||
@@ -194,6 +212,12 @@ public abstract partial class AIAgent
|
||||
/// This method enables restoration of conversation sessions from previously saved state,
|
||||
/// allowing conversations to resume across application restarts or be migrated between
|
||||
/// different agent instances.
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Restoring a session from an untrusted source is equivalent to accepting untrusted input.
|
||||
/// Serialized sessions may contain conversation content, session identifiers, and potentially sensitive data. A compromised
|
||||
/// storage backend could alter message roles to escalate trust, or inject adversarial content that influences LLM behavior.
|
||||
/// Treat serialized session data as sensitive and ensure it is stored and transmitted securely.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> this.DeserializeSessionCoreAsync(serializedState, jsonSerializerOptions, cancellationToken);
|
||||
@@ -301,6 +325,11 @@ public abstract partial class AIAgent
|
||||
/// The messages are processed in the order provided and become part of the conversation history.
|
||||
/// The agent's response will also be added to <paramref name="session"/> if one is provided.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Agent Framework does not validate or sanitize message content — it is passed through
|
||||
/// to the underlying AI service as-is. If input messages include untrusted user content, developers should be aware of prompt injection risks.
|
||||
/// System-role messages must be developer-controlled and should never contain end-user input.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task<AgentResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
@@ -426,6 +455,11 @@ public abstract partial class AIAgent
|
||||
/// Each <see cref="AgentResponseUpdate"/> represents a portion of the complete response, allowing consumers
|
||||
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Agent Framework does not validate or sanitize message content — it is passed through
|
||||
/// to the underlying AI service as-is. If input messages include untrusted user content, developers should be aware of prompt injection risks.
|
||||
/// System-role messages must be developer-controlled and should never contain end-user input.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
|
||||
@@ -28,6 +28,14 @@ namespace Microsoft.Agents.AI;
|
||||
/// <see cref="InvokingAsync"/> to provide context, and optionally called at the end of invocation via
|
||||
/// <see cref="InvokedAsync"/> to process results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> Context providers may inject messages with any role, including <c>system</c>, which
|
||||
/// has the highest trust level and directly shapes LLM behavior. Developers must ensure that all providers attached to an agent
|
||||
/// are trusted. Agent Framework does not validate or filter the data returned by providers — it is accepted as-is and merged into
|
||||
/// the request context. If a provider retrieves data from an external source (e.g., a vector database or memory service), be aware
|
||||
/// that a compromised data source could introduce adversarial content designed to manipulate LLM behavior via indirect prompt injection.
|
||||
/// Implementers should validate and sanitize data retrieved from external sources before returning it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class AIContextProvider
|
||||
{
|
||||
@@ -96,6 +104,11 @@ public abstract class AIContextProvider
|
||||
/// <item><description>Injecting contextual messages from conversation history</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Data retrieved from external sources (e.g., vector databases, memory services, or
|
||||
/// knowledge bases) may contain adversarial content designed to influence LLM behavior via indirect prompt injection.
|
||||
/// Implementers should validate data integrity and consider the trustworthiness of the data source.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
|
||||
@@ -195,6 +208,11 @@ public abstract class AIContextProvider
|
||||
/// In contrast with <see cref="InvokingCoreAsync"/>, this method only returns additional context to be merged with the input,
|
||||
/// while <see cref="InvokingCoreAsync"/> is responsible for returning the full merged <see cref="AIContext"/> for the invocation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Any messages, tools, or instructions returned by this method will be merged into the
|
||||
/// AI request context. If data is retrieved from external or untrusted sources, implementers should validate and sanitize it
|
||||
/// to prevent indirect prompt injection attacks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
@@ -299,6 +317,10 @@ public abstract class AIContextProvider
|
||||
/// <para>
|
||||
/// The default implementation of <see cref="InvokedCoreAsync"/> only calls this method if the invocation succeeded.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Messages being processed/stored may contain PII and sensitive conversation content.
|
||||
/// Implementers should ensure appropriate encryption at rest and access controls for the storage backend.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected virtual ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
|
||||
default;
|
||||
|
||||
@@ -61,6 +61,7 @@ public class AgentResponse
|
||||
|
||||
this.AdditionalProperties = response.AdditionalProperties;
|
||||
this.CreatedAt = response.CreatedAt;
|
||||
this.FinishReason = response.FinishReason;
|
||||
this.Messages = response.Messages;
|
||||
this.RawRepresentation = response;
|
||||
this.ResponseId = response.ResponseId;
|
||||
@@ -84,6 +85,7 @@ public class AgentResponse
|
||||
|
||||
this.AdditionalProperties = response.AdditionalProperties;
|
||||
this.CreatedAt = response.CreatedAt;
|
||||
this.FinishReason = response.FinishReason;
|
||||
this.Messages = response.Messages;
|
||||
this.RawRepresentation = response;
|
||||
this.ResponseId = response.ResponseId;
|
||||
@@ -190,6 +192,21 @@ public class AgentResponse
|
||||
/// </remarks>
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the reason for the agent response finishing.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A <see cref="ChatFinishReason"/> value indicating why the response finished (e.g., stop, length, content filter, tool calls),
|
||||
/// or <see langword="null"/> if the finish reason is not available.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property is particularly useful for detecting non-normal completions, such as content filtering
|
||||
/// or token limit truncation, which may require special handling by the caller.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ChatFinishReason? FinishReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the resource usage information for generating this response.
|
||||
/// </summary>
|
||||
@@ -276,6 +293,7 @@ public class AgentResponse
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
Role = message.Role,
|
||||
|
||||
FinishReason = this.FinishReason,
|
||||
AgentId = this.AgentId,
|
||||
ResponseId = this.ResponseId,
|
||||
MessageId = message.MessageId,
|
||||
|
||||
@@ -38,6 +38,7 @@ public static class AgentResponseExtensions
|
||||
{
|
||||
AdditionalProperties = response.AdditionalProperties,
|
||||
CreatedAt = response.CreatedAt,
|
||||
FinishReason = response.FinishReason,
|
||||
Messages = response.Messages,
|
||||
RawRepresentation = response,
|
||||
ResponseId = response.ResponseId,
|
||||
@@ -71,6 +72,7 @@ public static class AgentResponseExtensions
|
||||
AuthorName = responseUpdate.AuthorName,
|
||||
Contents = responseUpdate.Contents,
|
||||
CreatedAt = responseUpdate.CreatedAt,
|
||||
FinishReason = responseUpdate.FinishReason,
|
||||
MessageId = responseUpdate.MessageId,
|
||||
RawRepresentation = responseUpdate,
|
||||
ResponseId = responseUpdate.ResponseId,
|
||||
|
||||
@@ -70,6 +70,7 @@ public class AgentResponseUpdate
|
||||
this.AuthorName = chatResponseUpdate.AuthorName;
|
||||
this.Contents = chatResponseUpdate.Contents;
|
||||
this.CreatedAt = chatResponseUpdate.CreatedAt;
|
||||
this.FinishReason = chatResponseUpdate.FinishReason;
|
||||
this.MessageId = chatResponseUpdate.MessageId;
|
||||
this.RawRepresentation = chatResponseUpdate;
|
||||
this.ResponseId = chatResponseUpdate.ResponseId;
|
||||
@@ -153,6 +154,15 @@ public class AgentResponseUpdate
|
||||
/// </remarks>
|
||||
public ResponseContinuationToken? ContinuationToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the reason for the agent response finishing.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A <see cref="ChatFinishReason"/> value indicating why the response finished (e.g., stop, length, content filter, tool calls),
|
||||
/// or <see langword="null"/> if the finish reason is not available or not yet determined (mid-stream).
|
||||
/// </value>
|
||||
public ChatFinishReason? FinishReason { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => this.Text;
|
||||
|
||||
|
||||
@@ -42,6 +42,15 @@ namespace Microsoft.Agents.AI;
|
||||
/// <see cref="JsonElement"/> and the <see cref="AIAgent.DeserializeSessionAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/> method
|
||||
/// can be used to deserialize the session.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> Serialized sessions may contain conversation content, session identifiers,
|
||||
/// and other potentially sensitive data including PII. Developers should:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Treat serialized session data as sensitive and store it securely with appropriate access controls and encryption at rest.</description></item>
|
||||
/// <item><description>Treat restoring a session from an untrusted source as equivalent to accepting untrusted input. A compromised storage backend
|
||||
/// could alter message roles to escalate trust, or inject adversarial content that influences LLM behavior.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <seealso cref="AIAgent"/>
|
||||
/// <seealso cref="AIAgent.CreateSessionAsync(System.Threading.CancellationToken)"/>
|
||||
@@ -67,6 +76,11 @@ public abstract class AgentSession
|
||||
/// <summary>
|
||||
/// Gets any arbitrary state associated with this session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Data stored in the <see cref="StateBag"/> will be included when the session is serialized.
|
||||
/// Avoid storing secrets, credentials, or highly sensitive data in the state bag without appropriate encryption,
|
||||
/// as this data may be persisted to external storage.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("stateBag")]
|
||||
public AgentSessionStateBag StateBag { get; protected set; } = new();
|
||||
|
||||
|
||||
@@ -37,6 +37,14 @@ namespace Microsoft.Agents.AI;
|
||||
/// A <see cref="ChatHistoryProvider"/> is only relevant for scenarios where the underlying AI service that the agent is using
|
||||
/// does not use in-service chat history storage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> Agent Framework does not validate or filter the messages returned by the provider
|
||||
/// during load — they are accepted as-is and treated identically to user-supplied messages. Implementers must ensure that only
|
||||
/// trusted data is returned. If the underlying storage is compromised, adversarial content could influence LLM behavior via
|
||||
/// indirect prompt injection — for example, injected messages could alter the conversation context or impersonate different roles.
|
||||
/// Messages stored in chat history may contain PII and sensitive conversation content; implementers should consider encryption
|
||||
/// at rest and appropriate access controls for the storage backend.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class ChatHistoryProvider
|
||||
{
|
||||
@@ -159,6 +167,11 @@ public abstract class ChatHistoryProvider
|
||||
/// Messages are returned in chronological order to maintain proper conversation flow and context for the agent.
|
||||
/// The oldest messages appear first in the collection, followed by more recent messages.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Messages loaded from storage should be treated with the same caution as user-supplied
|
||||
/// messages. A compromised storage backend could alter message roles to escalate trust (e.g., changing <c>user</c> messages to
|
||||
/// <c>system</c> messages) or inject adversarial content that influences LLM behavior.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
@@ -273,6 +286,10 @@ public abstract class ChatHistoryProvider
|
||||
/// <para>
|
||||
/// The default implementation of <see cref="InvokedCoreAsync"/> only calls this method if the invocation succeeded.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Messages being stored may contain PII and sensitive conversation content.
|
||||
/// Implementers should ensure appropriate encryption at rest and access controls for the storage backend.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
|
||||
default;
|
||||
|
||||
@@ -79,20 +79,21 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
|
||||
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
Throw.IfNull(messages);
|
||||
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
State state = this._sessionState.GetOrInitializeState(session);
|
||||
state.Messages = messages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
// Apply pre-retrieval reduction if configured
|
||||
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return state.Messages;
|
||||
@@ -101,7 +102,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
// Add request and response messages to the provider
|
||||
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
|
||||
@@ -109,10 +110,16 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
// Apply pre-write reduction strategy if configured
|
||||
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -17,6 +17,24 @@ namespace Microsoft.Agents.AI;
|
||||
/// <summary>
|
||||
/// Provides a Cosmos DB implementation of the <see cref="ChatHistoryProvider"/> abstract class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><strong>PII and sensitive data:</strong> Chat history stored in Cosmos DB may contain PII, sensitive conversation
|
||||
/// content, and system instructions. Ensure the Cosmos DB account is configured with appropriate access controls, encryption at rest,
|
||||
/// and network security (e.g., private endpoints, virtual network rules). The <see cref="MessageTtlSeconds"/> property can be used to
|
||||
/// automatically expire messages and limit data retention.</description></item>
|
||||
/// <item><description><strong>Compromised store risks:</strong> Agent Framework does not validate or filter messages loaded from the
|
||||
/// store — they are accepted as-is. If the Cosmos DB store is compromised, adversarial content could be injected into the conversation
|
||||
/// context, potentially influencing LLM behavior via indirect prompt injection. Altered message roles (e.g., changing <c>user</c> to
|
||||
/// <c>system</c>) could escalate trust levels.</description></item>
|
||||
/// <item><description><strong>Authentication:</strong> Agent Framework does not manage authentication or encryption for the Cosmos DB
|
||||
/// connection — these are the responsibility of the <see cref="CosmosClient"/> configuration. Use managed identity
|
||||
/// or token-based authentication where possible, and avoid embedding connection strings with keys in source code.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RequiresUnreferencedCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
<PropertyGroup>
|
||||
<!-- Disable packing until we are ready to release this as a nuget -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
|
||||
+1
-3
@@ -72,9 +72,7 @@ internal static class AIAgentChatCompletionsProcessor
|
||||
|
||||
await foreach (var agentResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken))
|
||||
{
|
||||
var finishReason = (agentResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate)
|
||||
? chatResponseUpdate.FinishReason.ToString()
|
||||
: "stop";
|
||||
var finishReason = agentResponseUpdate.FinishReason?.ToString() ?? "stop";
|
||||
|
||||
var choiceChunks = new List<ChatCompletionChoiceChunk>();
|
||||
CompletionUsage? usageDetails = null;
|
||||
|
||||
+1
-3
@@ -34,9 +34,7 @@ internal static class AgentResponseExtensions
|
||||
var chatCompletionChoices = new List<ChatCompletionChoice>();
|
||||
var index = 0;
|
||||
|
||||
var finishReason = (agentResponse.RawRepresentation is ChatResponse { FinishReason: not null } chatResponse)
|
||||
? chatResponse.FinishReason.ToString()
|
||||
: "stop"; // "stop" is a natural stop point; returning this by-default
|
||||
var finishReason = agentResponse.FinishReason?.ToString() ?? ChatFinishReason.Stop.Value; // "stop" is a natural stop point; returning this by-default
|
||||
|
||||
foreach (var message in agentResponse.Messages)
|
||||
{
|
||||
|
||||
@@ -19,9 +19,10 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
@@ -30,7 +31,7 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
var chatClient = sp.GetRequiredService<IChatClient>();
|
||||
var tools = sp.GetKeyedServices<AITool>(name).ToList();
|
||||
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
|
||||
});
|
||||
}, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -40,9 +41,10 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
@@ -50,7 +52,7 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
{
|
||||
var tools = sp.GetKeyedServices<AITool>(name).ToList();
|
||||
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
|
||||
});
|
||||
}, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,9 +62,10 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
@@ -71,7 +74,7 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
|
||||
var tools = sp.GetKeyedServices<AITool>(name).ToList();
|
||||
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
|
||||
});
|
||||
}, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -82,9 +85,10 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="description">A description of the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
@@ -93,7 +97,7 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
|
||||
var tools = sp.GetKeyedServices<AITool>(name).ToList();
|
||||
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description, tools: tools);
|
||||
});
|
||||
}, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,15 +106,16 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns <see langword="null"/> or an agent whose <see cref="AIAgent.Name"/> does not match <paramref name="name"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNull(name);
|
||||
Throw.IfNull(createAgentDelegate);
|
||||
services.AddKeyedSingleton(name, (sp, key) =>
|
||||
services.AddKeyedService(name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
@@ -122,8 +127,18 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
}
|
||||
|
||||
return agent;
|
||||
});
|
||||
}, lifetime);
|
||||
|
||||
return new HostedAgentBuilder(name, services);
|
||||
return new HostedAgentBuilder(name, services, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a keyed service with the specified lifetime.
|
||||
/// </summary>
|
||||
internal static void AddKeyedService<T>(this IServiceCollection services, object? serviceKey, Func<IServiceProvider, object?, T> factory, ServiceLifetime lifetime)
|
||||
where T : class
|
||||
{
|
||||
var descriptor = new ServiceDescriptor(typeof(T), serviceKey, (sp, key) => factory(sp, key), lifetime);
|
||||
services.Add(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -18,12 +19,13 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
return builder.Services.AddAIAgent(name, instructions);
|
||||
return builder.Services.AddAIAgent(name, instructions, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -33,13 +35,14 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
return builder.Services.AddAIAgent(name, instructions, chatClient);
|
||||
return builder.Services.AddAIAgent(name, instructions, chatClient, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,13 +53,14 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="description">A description of the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey);
|
||||
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -66,12 +70,13 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey);
|
||||
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,12 +85,13 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
return builder.Services.AddAIAgent(name, createAgentDelegate);
|
||||
return builder.Services.AddAIAgent(name, createAgentDelegate, lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,20 @@ public static class HostApplicationBuilderWorkflowExtensions
|
||||
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
|
||||
/// <param name="name">The unique name for the workflow.</param>
|
||||
/// <param name="createWorkflowDelegate">A factory function that creates the <see cref="Workflow"/> instance. The function receives the service provider and workflow name as parameters.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the workflow registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createWorkflowDelegate"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is empty.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name.
|
||||
/// </exception>
|
||||
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate)
|
||||
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
Throw.IfNull(createWorkflowDelegate);
|
||||
|
||||
builder.Services.AddKeyedSingleton(name, (sp, key) =>
|
||||
builder.Services.AddKeyedService(name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
@@ -43,7 +44,7 @@ public static class HostApplicationBuilderWorkflowExtensions
|
||||
}
|
||||
|
||||
return workflow;
|
||||
});
|
||||
}, lifetime);
|
||||
|
||||
return new HostedWorkflowBuilder(name, builder);
|
||||
}
|
||||
|
||||
@@ -9,15 +9,17 @@ internal sealed class HostedAgentBuilder : IHostedAgentBuilder
|
||||
{
|
||||
public string Name { get; }
|
||||
public IServiceCollection ServiceCollection { get; }
|
||||
public ServiceLifetime Lifetime { get; }
|
||||
|
||||
public HostedAgentBuilder(string name, IHostApplicationBuilder builder)
|
||||
: this(name, builder.Services)
|
||||
public HostedAgentBuilder(string name, IHostApplicationBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
: this(name, builder.Services, lifetime)
|
||||
{
|
||||
}
|
||||
|
||||
public HostedAgentBuilder(string name, IServiceCollection serviceCollection)
|
||||
public HostedAgentBuilder(string name, IServiceCollection serviceCollection, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
this.Name = name;
|
||||
this.ServiceCollection = serviceCollection;
|
||||
this.Lifetime = lifetime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,17 +42,19 @@ public static class HostedAgentBuilderExtensions
|
||||
/// <param name="builder">The host agent builder to configure.</param>
|
||||
/// <param name="createAgentSessionStore">A factory function that creates an agent session store instance using the provided service provider and agent
|
||||
/// name.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
|
||||
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
|
||||
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore)
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, key) =>
|
||||
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
Throw.IfNullOrEmpty(keyString);
|
||||
return createAgentSessionStore(sp, keyString) ??
|
||||
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
|
||||
});
|
||||
}, lifetime);
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -98,13 +100,39 @@ public static class HostedAgentBuilderExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The hosted agent builder.</param>
|
||||
/// <param name="factory">A factory function that creates a AI tool using the provided service provider.</param>
|
||||
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory)
|
||||
/// <param name="lifetime">The DI service lifetime for the tool registration. If <see langword="null"/>, the agent's lifetime is used.</param>
|
||||
/// <returns>The same <see cref="IHostedAgentBuilder"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="factory"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the effective tool lifetime is shorter than the agent's lifetime, which would cause a captive dependency.
|
||||
/// For example, a singleton agent cannot use scoped or transient tools.
|
||||
/// </exception>
|
||||
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory, ServiceLifetime? lifetime = null)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(factory);
|
||||
|
||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, name) => factory(sp));
|
||||
var effectiveLifetime = lifetime ?? builder.Lifetime;
|
||||
ValidateToolLifetime(builder.Lifetime, effectiveLifetime);
|
||||
|
||||
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, name) => factory(sp), effectiveLifetime);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the tool lifetime is compatible with the agent lifetime.
|
||||
/// A tool's lifetime must be at least as long as the agent's lifetime to prevent captive dependency issues.
|
||||
/// </summary>
|
||||
internal static void ValidateToolLifetime(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
|
||||
{
|
||||
// ServiceLifetime enum: Singleton=0, Scoped=1, Transient=2
|
||||
// A higher value means a shorter lifetime.
|
||||
if (toolLifetime > agentLifetime)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A tool with lifetime '{toolLifetime}' cannot be registered for an agent with lifetime '{agentLifetime}'. " +
|
||||
"The tool's lifetime must be at least as long as the agent's lifetime to avoid captive dependency issues.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,24 @@ public static class HostedWorkflowBuilderExtensions
|
||||
/// Registers the workflow as an AI agent in the dependency injection container.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
|
||||
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder)
|
||||
=> builder.AddAsAIAgent(name: null);
|
||||
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
=> builder.AddAsAIAgent(name: null, lifetime: lifetime);
|
||||
|
||||
/// <summary>
|
||||
/// Registers the workflow as an AI agent in the dependency injection container.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
|
||||
/// <param name="name">The optional name for the AI agent. If not specified, the workflow name is used.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
|
||||
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name)
|
||||
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
var workflowName = builder.Name;
|
||||
var agentName = name ?? workflowName;
|
||||
|
||||
return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) =>
|
||||
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key));
|
||||
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key), lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,9 @@ public interface IHostedAgentBuilder
|
||||
/// Gets the service collection for configuration.
|
||||
/// </summary>
|
||||
IServiceCollection ServiceCollection { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the DI service lifetime used for the agent registration.
|
||||
/// </summary>
|
||||
ServiceLifetime Lifetime { get; }
|
||||
}
|
||||
|
||||
@@ -13,16 +13,38 @@ using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mem0;
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names - Microsoft.Extensions.Logging.LogLevel.Trace doesn't get found in net472 when removing the namespace.
|
||||
/// <summary>
|
||||
/// Provides a Mem0 backed <see cref="MessageAIContextProvider"/> that persists conversation messages as memories
|
||||
/// and retrieves related memories to augment the agent invocation context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The provider stores user, assistant and system messages as Mem0 memories and retrieves relevant memories
|
||||
/// for new invocations using a semantic search endpoint. Retrieved memories are injected as user messages
|
||||
/// to the model, prefixed by a configurable context prompt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><strong>External service trust:</strong> This provider communicates with an external Mem0 service over HTTP.
|
||||
/// Agent Framework does not manage authentication, encryption, or connection details for this service — these are the responsibility
|
||||
/// of the <see cref="HttpClient"/> configuration. Ensure the HTTP client is configured with appropriate authentication
|
||||
/// and uses HTTPS to protect data in transit.</description></item>
|
||||
/// <item><description><strong>PII and sensitive data:</strong> Conversation messages (including user inputs, LLM responses, and system
|
||||
/// instructions) are sent to the external Mem0 service for storage. These messages may contain PII or sensitive information.
|
||||
/// Ensure the Mem0 service is configured with appropriate data retention policies and access controls.</description></item>
|
||||
/// <item><description><strong>Indirect prompt injection:</strong> Memories retrieved from the Mem0 service are injected into the LLM
|
||||
/// context as user messages. If the memory store is compromised, adversarial content could influence LLM behavior. The data
|
||||
/// returned from the service is accepted as-is without validation or sanitization.</description></item>
|
||||
/// <item><description><strong>Trace logging:</strong> When <see cref="Microsoft.Extensions.Logging.LogLevel.Trace"/> is enabled,
|
||||
/// full memory content (including search queries and results) may be logged. This data may contain PII and should not be enabled
|
||||
/// in production environments.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Mem0Provider : MessageAIContextProvider
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
{
|
||||
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
@@ -133,7 +134,25 @@ internal sealed class ExecutorProtocol(MessageRouter router, ISet<Type> sendType
|
||||
|
||||
public bool CanHandle(Type type) => router.CanHandle(type);
|
||||
|
||||
public bool CanOutput(Type type) => this._yieldTypes.Contains(new(type));
|
||||
private readonly ConcurrentDictionary<Type, bool> _canOutputCache = new();
|
||||
|
||||
public bool CanOutput(Type type)
|
||||
{
|
||||
return this._canOutputCache.GetOrAdd(type, this.CanOutputCore);
|
||||
}
|
||||
|
||||
private bool CanOutputCore(Type type)
|
||||
{
|
||||
foreach (TypeId yieldType in this._yieldTypes)
|
||||
{
|
||||
if (yieldType.IsMatchPolymorphic(type))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public ProtocolDescriptor Describe() => new(this.Router.IncomingTypes, yieldTypes, sendTypes, this.Router.HasCatchAll);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ internal sealed class MessageMerger
|
||||
List<ChatMessage> messages = [];
|
||||
Dictionary<string, AgentResponse> responses = [];
|
||||
HashSet<string> agentIds = [];
|
||||
HashSet<ChatFinishReason> finishReasons = [];
|
||||
|
||||
foreach (string responseId in this._mergeStates.Keys)
|
||||
{
|
||||
@@ -156,6 +157,11 @@ internal sealed class MessageMerger
|
||||
createdTimes.Add(response.CreatedAt.Value);
|
||||
}
|
||||
|
||||
if (response.FinishReason.HasValue)
|
||||
{
|
||||
finishReasons.Add(response.FinishReason.Value);
|
||||
}
|
||||
|
||||
usage = MergeUsage(usage, response.Usage);
|
||||
additionalProperties = MergeProperties(additionalProperties, response.AdditionalProperties);
|
||||
}
|
||||
@@ -182,6 +188,7 @@ internal sealed class MessageMerger
|
||||
AgentId = primaryAgentId
|
||||
?? primaryAgentName
|
||||
?? (agentIds.Count == 1 ? agentIds.First() : null),
|
||||
FinishReason = finishReasons.Count == 1 ? finishReasons.First() : null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Usage = usage,
|
||||
AdditionalProperties = additionalProperties
|
||||
@@ -207,6 +214,7 @@ internal sealed class MessageMerger
|
||||
AgentId = incoming.AgentId ?? current.AgentId,
|
||||
AdditionalProperties = MergeProperties(current.AdditionalProperties, incoming.AdditionalProperties),
|
||||
CreatedAt = incoming.CreatedAt ?? current.CreatedAt,
|
||||
FinishReason = incoming.FinishReason ?? current.FinishReason,
|
||||
Messages = current.Messages.Concat(incoming.Messages).ToList(),
|
||||
ResponseId = current.ResponseId,
|
||||
RawRepresentation = rawRepresentation,
|
||||
|
||||
@@ -17,6 +17,25 @@ namespace Microsoft.Agents.AI;
|
||||
/// <summary>
|
||||
/// Provides an <see cref="AIAgent"/> that delegates to an <see cref="IChatClient"/> implementation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> The <see cref="ChatClientAgent"/> orchestrates data flow across trust boundaries.
|
||||
/// The underlying AI service is an external endpoint and LLM responses should be treated as untrusted output. Developers should be aware of:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><strong>Hallucination:</strong> LLMs may generate plausible-sounding but factually incorrect information.
|
||||
/// Do not treat LLM output as authoritative without verification.</description></item>
|
||||
/// <item><description><strong>Indirect prompt injection:</strong> Data retrieved by tools, AI context providers, or chat history providers may
|
||||
/// contain adversarial content designed to influence LLM behavior or exfiltrate data through tool calls.</description></item>
|
||||
/// <item><description><strong>Malicious payloads:</strong> LLM output may contain content that is harmful if rendered or executed without
|
||||
/// sanitization — for example, HTML/JavaScript for cross-site scripting, SQL for injection, or shell commands.</description></item>
|
||||
/// <item><description><strong>Tool invocation:</strong> By default, all tools provided to the agent are invoked without user approval.
|
||||
/// The AI selects which functions to call and with what arguments. Function arguments should be treated as untrusted input.
|
||||
/// Developers should require explicit approval for tools with side effects, data sensitivity, or irreversibility.</description></item>
|
||||
/// </list>
|
||||
/// Developers should validate and sanitize LLM output before rendering it in HTML, executing it as code, using it in database queries,
|
||||
/// or passing it to any security-sensitive context. Apply defense-in-depth by combining tool approval requirements with output validation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
private readonly ChatClientAgentOptions? _agentOptions;
|
||||
@@ -44,6 +63,9 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// Optional collection of tools that the agent can invoke during conversations.
|
||||
/// These tools augment any tools that may be provided to the agent via <see cref="ChatOptions.Tools"/> when
|
||||
/// the agent is run.
|
||||
/// By default, all provided tools are invoked without user approval. The AI selects which functions to call and chooses
|
||||
/// the arguments — these arguments should be treated as untrusted input. Developers should require explicit approval
|
||||
/// for tools that have side effects, access sensitive data, or perform irreversible operations.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
|
||||
@@ -55,7 +55,7 @@ public static class ChatClientExtensions
|
||||
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
_ = chatBuilder.Use((innerClient, services) =>
|
||||
chatBuilder.Use((innerClient, services) =>
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Content-based equality comparison for <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
internal static class ChatMessageContentEquality
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether two <see cref="ChatMessage"/> instances represent the same message by content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When both messages define a <see cref="ChatMessage.MessageId"/>, identity is determined solely
|
||||
/// by that identifier. Otherwise, the comparison falls through to <see cref="ChatMessage.Role"/>,
|
||||
/// <see cref="ChatMessage.AuthorName"/>, and each item in <see cref="ChatMessage.Contents"/>.
|
||||
/// </remarks>
|
||||
internal static bool ContentEquals(this ChatMessage? message, ChatMessage? other)
|
||||
{
|
||||
if (ReferenceEquals(message, other))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message is null || other is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A matching MessageId is sufficient.
|
||||
if (message.MessageId is not null && other.MessageId is not null)
|
||||
{
|
||||
return string.Equals(message.MessageId, other.MessageId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (message.Role != other.Role)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(message.AuthorName, other.AuthorName, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ContentsEqual(message.Contents, other.Contents);
|
||||
}
|
||||
|
||||
private static bool ContentsEqual(IList<AIContent> left, IList<AIContent> right)
|
||||
{
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < left.Count; i++)
|
||||
{
|
||||
if (!ContentItemEquals(left[i], right[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ContentItemEquals(AIContent left, AIContent right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left.GetType() != right.GetType())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return (left, right) switch
|
||||
{
|
||||
(TextContent a, TextContent b) => TextContentEquals(a, b),
|
||||
(TextReasoningContent a, TextReasoningContent b) => TextReasoningContentEquals(a, b),
|
||||
(DataContent a, DataContent b) => DataContentEquals(a, b),
|
||||
(UriContent a, UriContent b) => UriContentEquals(a, b),
|
||||
(ErrorContent a, ErrorContent b) => ErrorContentEquals(a, b),
|
||||
(FunctionCallContent a, FunctionCallContent b) => FunctionCallContentEquals(a, b),
|
||||
(FunctionResultContent a, FunctionResultContent b) => FunctionResultContentEquals(a, b),
|
||||
(HostedFileContent a, HostedFileContent b) => HostedFileContentEquals(a, b),
|
||||
(AIContent a, AIContent b) => a.GetType() == b.GetType(),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TextContentEquals(TextContent a, TextContent b) =>
|
||||
string.Equals(a.Text, b.Text, StringComparison.Ordinal);
|
||||
|
||||
private static bool TextReasoningContentEquals(TextReasoningContent a, TextReasoningContent b) =>
|
||||
string.Equals(a.Text, b.Text, StringComparison.Ordinal) &&
|
||||
string.Equals(a.ProtectedData, b.ProtectedData, StringComparison.Ordinal);
|
||||
|
||||
private static bool DataContentEquals(DataContent a, DataContent b) =>
|
||||
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
|
||||
string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
|
||||
a.Data.Span.SequenceEqual(b.Data.Span);
|
||||
|
||||
private static bool UriContentEquals(UriContent a, UriContent b) =>
|
||||
Equals(a.Uri, b.Uri) &&
|
||||
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal);
|
||||
|
||||
private static bool ErrorContentEquals(ErrorContent a, ErrorContent b) =>
|
||||
string.Equals(a.Message, b.Message, StringComparison.Ordinal) &&
|
||||
string.Equals(a.ErrorCode, b.ErrorCode, StringComparison.Ordinal) &&
|
||||
Equals(a.Details, b.Details);
|
||||
|
||||
private static bool FunctionCallContentEquals(FunctionCallContent a, FunctionCallContent b) =>
|
||||
string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
|
||||
string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
|
||||
ArgumentsEqual(a.Arguments, b.Arguments);
|
||||
|
||||
private static bool FunctionResultContentEquals(FunctionResultContent a, FunctionResultContent b) =>
|
||||
string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
|
||||
Equals(a.Result, b.Result);
|
||||
|
||||
private static bool ArgumentsEqual(IDictionary<string, object?>? left, IDictionary<string, object?>? right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left is null || right is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, object?> entry in left)
|
||||
{
|
||||
if (!right.TryGetValue(entry.Key, out object? value) || !Equals(entry.Value, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HostedFileContentEquals(HostedFileContent a, HostedFileContent b) =>
|
||||
string.Equals(a.FileId, b.FileId, StringComparison.Ordinal) &&
|
||||
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
|
||||
string.Equals(a.Name, b.Name, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that delegates to an <see cref="IChatReducer"/> to reduce the conversation's
|
||||
/// included messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy bridges the <see cref="IChatReducer"/> abstraction from <c>Microsoft.Extensions.AI</c>
|
||||
/// into the compaction pipeline. It collects the currently included messages from the
|
||||
/// <see cref="CompactionMessageIndex"/>, passes them to the reducer, and rebuilds the index from the
|
||||
/// reduced message list when the reducer produces fewer messages.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> controls when reduction is attempted.
|
||||
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or message thresholds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use this strategy when you have an existing <see cref="IChatReducer"/> implementation
|
||||
/// (such as <c>MessageCountingChatReducer</c>) and want to apply it as part of a
|
||||
/// <see cref="CompactionStrategy"/> pipeline or as an in-run compaction strategy.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class ChatReducerCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatReducer">
|
||||
/// The <see cref="IChatReducer"/> that performs the message reduction.
|
||||
/// </param>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger trigger)
|
||||
: base(trigger)
|
||||
{
|
||||
this.ChatReducer = Throw.IfNull(chatReducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat reducer used to reduce messages.
|
||||
/// </summary>
|
||||
public IChatReducer ChatReducer { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// No need to short-circuit on empty conversations, this is handled by <see cref="CompactionStrategy.CompactAsync"/>.
|
||||
List<ChatMessage> includedMessages = [.. index.GetIncludedMessages()];
|
||||
|
||||
IEnumerable<ChatMessage> reduced = await this.ChatReducer.ReduceAsync(includedMessages, cancellationToken).ConfigureAwait(false);
|
||||
IList<ChatMessage> reducedMessages = reduced as IList<ChatMessage> ?? [.. reduced];
|
||||
|
||||
if (reducedMessages.Count >= includedMessages.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rebuild the index from the reduced messages
|
||||
CompactionMessageIndex rebuilt = CompactionMessageIndex.Create(reducedMessages, index.Tokenizer);
|
||||
index.Groups.Clear();
|
||||
foreach (CompactionMessageGroup group in rebuilt.Groups)
|
||||
{
|
||||
index.Groups.Add(group);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of a <see cref="CompactionMessageGroup"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Message groups are used to classify logically related messages that must be kept together
|
||||
/// during compaction operations. For example, an assistant message containing tool calls
|
||||
/// and its corresponding tool result messages form an atomic <see cref="ToolCall"/> group.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public enum CompactionGroupKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A system message group containing one or more system messages.
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// A user message group containing a single user message.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An assistant message group containing a single assistant text response (no tool calls).
|
||||
/// </summary>
|
||||
AssistantText,
|
||||
|
||||
/// <summary>
|
||||
/// An atomic tool call group containing an assistant message with tool calls
|
||||
/// followed by the corresponding tool result messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This group must be treated as an atomic unit during compaction. Removing the assistant
|
||||
/// message without its tool results (or vice versa) will cause LLM API errors.
|
||||
/// </remarks>
|
||||
ToolCall,
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names
|
||||
/// <summary>
|
||||
/// A summary message group produced by a compaction strategy (e.g., <c>SummarizationCompactionStrategy</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Summary groups replace previously compacted messages with a condensed representation.
|
||||
/// They are identified by the <see cref="CompactionMessageGroup.SummaryPropertyKey"/> metadata entry
|
||||
/// on the underlying <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </remarks>
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
Summary,
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for logging compaction diagnostics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
|
||||
/// generate logging code at compile time to achieve optimized code.
|
||||
/// </remarks>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static partial class CompactionLogMessages
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs when compaction is skipped because the trigger condition was not met.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Trace,
|
||||
Message = "Compaction skipped for {StrategyName}: trigger condition not met or insufficient groups.")]
|
||||
public static partial void LogCompactionSkipped(
|
||||
this ILogger logger,
|
||||
string strategyName);
|
||||
|
||||
/// <summary>
|
||||
/// Logs compaction completion with before/after metrics.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Compaction completed: {StrategyName} in {DurationMs}ms — Messages {BeforeMessages}→{AfterMessages}, Groups {BeforeGroups}→{AfterGroups}, Tokens {BeforeTokens}→{AfterTokens}")]
|
||||
public static partial void LogCompactionCompleted(
|
||||
this ILogger logger,
|
||||
string strategyName,
|
||||
long durationMs,
|
||||
int beforeMessages,
|
||||
int afterMessages,
|
||||
int beforeGroups,
|
||||
int afterGroups,
|
||||
int beforeTokens,
|
||||
int afterTokens);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when the compaction provider skips compaction.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Trace,
|
||||
Message = "CompactionProvider skipped: {Reason}.")]
|
||||
public static partial void LogCompactionProviderSkipped(
|
||||
this ILogger logger,
|
||||
string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when the compaction provider begins applying a compaction strategy.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "CompactionProvider applying compaction to {MessageCount} messages using {StrategyName}.")]
|
||||
public static partial void LogCompactionProviderApplying(
|
||||
this ILogger logger,
|
||||
int messageCount,
|
||||
string strategyName);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when the compaction provider has applied compaction with result metrics.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "CompactionProvider compaction applied: messages {BeforeMessages}→{AfterMessages}.")]
|
||||
public static partial void LogCompactionProviderApplied(
|
||||
this ILogger logger,
|
||||
int beforeMessages,
|
||||
int afterMessages);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when a summarization LLM call is starting.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Summarization starting for {GroupCount} groups ({MessageCount} messages) using {ChatClientType}.")]
|
||||
public static partial void LogSummarizationStarting(
|
||||
this ILogger logger,
|
||||
int groupCount,
|
||||
int messageCount,
|
||||
string chatClientType);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when a summarization LLM call has completed.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Summarization completed: summary length {SummaryLength} characters, inserted at index {InsertIndex}.")]
|
||||
public static partial void LogSummarizationCompleted(
|
||||
this ILogger logger,
|
||||
int summaryLength,
|
||||
int insertIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when a summarization LLM call fails and groups are restored.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Summarization failed for {GroupCount} groups; restoring excluded groups and continuing without compaction. Error: {ErrorMessage}")]
|
||||
public static partial void LogSummarizationFailed(
|
||||
this ILogger logger,
|
||||
int groupCount,
|
||||
string errorMessage);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a logical group of <see cref="ChatMessage"/> instances that must be kept or removed together during compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Message groups ensure atomic preservation of related messages. For example, an assistant message
|
||||
/// containing tool calls and its corresponding tool result messages form a <see cref="CompactionGroupKind.ToolCall"/>
|
||||
/// group — removing one without the other would cause LLM API errors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Groups also support exclusion semantics: a group can be marked as excluded (with an optional reason)
|
||||
/// to indicate it should not be included in the messages sent to the model, while still being preserved
|
||||
/// for diagnostics, storage, or later re-inclusion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each group tracks its <see cref="MessageCount"/>, <see cref="ByteCount"/>, and <see cref="TokenCount"/>
|
||||
/// so that <see cref="CompactionMessageIndex"/> can efficiently aggregate totals across all or only included groups.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class CompactionMessageGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="ChatMessage.AdditionalProperties"/> key used to identify a message as a compaction summary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When this key is present with a value of <see langword="true"/>, the message is classified as
|
||||
/// <see cref="CompactionGroupKind.Summary"/> by <see cref="CompactionMessageIndex.Create"/>.
|
||||
/// </remarks>
|
||||
public static readonly string SummaryPropertyKey = "_is_summary";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionMessageGroup"/> class.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in this group. The list is captured as a read-only snapshot.</param>
|
||||
/// <param name="byteCount">The total UTF-8 byte count of the text content in the messages.</param>
|
||||
/// <param name="tokenCount">The token count for the messages, computed by a tokenizer or estimated.</param>
|
||||
/// <param name="turnIndex">
|
||||
/// The user turn this group belongs to, or <see langword="null"/> for <see cref="CompactionGroupKind.System"/>.
|
||||
/// </param>
|
||||
[JsonConstructor]
|
||||
internal CompactionMessageGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int byteCount, int tokenCount, int? turnIndex = null)
|
||||
{
|
||||
this.Kind = kind;
|
||||
this.Messages = messages;
|
||||
this.MessageCount = messages.Count;
|
||||
this.ByteCount = byteCount;
|
||||
this.TokenCount = tokenCount;
|
||||
this.TurnIndex = turnIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of this message group.
|
||||
/// </summary>
|
||||
public CompactionGroupKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages in this group.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> Messages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of messages in this group.
|
||||
/// </summary>
|
||||
public int MessageCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count of the text content in this group's messages.
|
||||
/// </summary>
|
||||
public int ByteCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the estimated or actual token count for this group's messages.
|
||||
/// </summary>
|
||||
public int TokenCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets user turn index this group belongs to, or <see langword="null"/> for groups
|
||||
/// that precede the first user message (e.g., system messages). A turn index of 0
|
||||
/// corresponds with any non-system message that precedes the first user message,
|
||||
/// turn index 1 corresponds with the first user message and its subsequent non-user
|
||||
/// messages, and so on...
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A turn starts with a <see cref="CompactionGroupKind.User"/> group and includes all subsequent
|
||||
/// non-user, non-system groups until the next user group or end of conversation. System messages
|
||||
/// (<see cref="CompactionGroupKind.System"/>) are always assigned a <see langword="null"/> turn index
|
||||
/// since they never belong to a user turn.
|
||||
/// </remarks>
|
||||
public int? TurnIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this group is excluded from the projected message list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
|
||||
/// but are not included when calling <see cref="CompactionMessageIndex.GetIncludedMessages"/>.
|
||||
/// </remarks>
|
||||
public bool IsExcluded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional reason explaining why this group was excluded.
|
||||
/// </summary>
|
||||
public string? ExcludeReason { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A collection of <see cref="CompactionMessageGroup"/> instances and derived metrics based on a flat list of <see cref="ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="CompactionMessageIndex"/> provides structural grouping of messages into logical <see cref="CompactionMessageGroup"/> units. Individual
|
||||
/// groups can be marked as excluded without being removed, allowing compaction strategies to toggle visibility while preserving
|
||||
/// the full history for diagnostics or storage. Metrics are provided both including and excluding excluded groups,
|
||||
/// allowing strategies to make informed decisions based on the impact of potential exclusions.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class CompactionMessageIndex
|
||||
{
|
||||
private int _currentTurn;
|
||||
private ChatMessage? _lastProcessedMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of message groups in this collection.
|
||||
/// </summary>
|
||||
public IList<CompactionMessageGroup> Groups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tokenizer used for computing token counts, or <see langword="null"/> if token counts are estimated.
|
||||
/// </summary>
|
||||
public Tokenizer? Tokenizer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionMessageIndex"/> class with the specified groups.
|
||||
/// </summary>
|
||||
/// <param name="groups">The message groups.</param>
|
||||
/// <param name="tokenizer">An optional tokenizer retained for computing token counts when adding new groups.</param>
|
||||
public CompactionMessageIndex(IList<CompactionMessageGroup> groups, Tokenizer? tokenizer = null)
|
||||
{
|
||||
this.Groups = Throw.IfNull(groups, nameof(groups));
|
||||
this.Tokenizer = tokenizer;
|
||||
|
||||
// Restore turn counter and last processed message from the groups
|
||||
for (int index = groups.Count - 1; index >= 0; --index)
|
||||
{
|
||||
if (this._lastProcessedMessage is null && this.Groups[index].Kind != CompactionGroupKind.Summary)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> groupMessages = this.Groups[index].Messages;
|
||||
this._lastProcessedMessage = groupMessages[^1];
|
||||
}
|
||||
|
||||
if (this.Groups[index].TurnIndex.HasValue)
|
||||
{
|
||||
this._currentTurn = this.Groups[index].TurnIndex!.Value;
|
||||
|
||||
// Both values restored — no need to keep scanning
|
||||
if (this._lastProcessedMessage is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="CompactionMessageIndex"/> from a flat list of <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to group.</param>
|
||||
/// <param name="tokenizer">
|
||||
/// An optional <see cref="Tokenizer"/> for computing token counts on each group.
|
||||
/// When <see langword="null"/>, token counts are estimated as <c>ByteCount / 4</c>.
|
||||
/// </param>
|
||||
/// <returns>A new <see cref="CompactionMessageIndex"/> with messages organized into logical groups.</returns>
|
||||
/// <remarks>
|
||||
/// The grouping algorithm:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>System messages become <see cref="CompactionGroupKind.System"/> groups.</description></item>
|
||||
/// <item><description>User messages become <see cref="CompactionGroupKind.User"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages with tool calls, followed by their corresponding tool result messages, become <see cref="CompactionGroupKind.ToolCall"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages marked with <see cref="CompactionMessageGroup.SummaryPropertyKey"/> become <see cref="CompactionGroupKind.Summary"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages without tool calls become <see cref="CompactionGroupKind.AssistantText"/> groups.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static CompactionMessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
|
||||
{
|
||||
CompactionMessageIndex instance = new([], tokenizer);
|
||||
instance.AppendFromMessages(messages, 0);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incrementally updates the groups with new messages from the conversation.
|
||||
/// </summary>
|
||||
/// <param name="allMessages">
|
||||
/// The full list of messages for the conversation. This must be the same list (or a replacement with the same
|
||||
/// prefix) that was used to create or last update this instance.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Uses equality on the last processed message to detect changes. Only the messages after that position are
|
||||
/// processed and appended as new groups. Existing groups and their compaction state (exclusions) are preserved.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the last processed message is not found (e.g., the message list was replaced entirely
|
||||
/// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the last message in <paramref name="allMessages"/> matches the last
|
||||
/// processed message, no work is performed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Update(IList<ChatMessage> allMessages)
|
||||
{
|
||||
if (allMessages.Count == 0)
|
||||
{
|
||||
this.Groups.Clear();
|
||||
this._currentTurn = 0;
|
||||
this._lastProcessedMessage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// If the last message is unchanged and the list hasn't shrunk, there is nothing new to process.
|
||||
if (this._lastProcessedMessage is not null &&
|
||||
allMessages.Count >= this.RawMessageCount &&
|
||||
allMessages[allMessages.Count - 1].ContentEquals(this._lastProcessedMessage))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk backwards to locate where we left off.
|
||||
int foundIndex = -1;
|
||||
if (this._lastProcessedMessage is not null)
|
||||
{
|
||||
for (int i = allMessages.Count - 1; i >= 0; --i)
|
||||
{
|
||||
if (allMessages[i].ContentEquals(this._lastProcessedMessage))
|
||||
{
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundIndex < 0)
|
||||
{
|
||||
// Last processed message not found — total rebuild.
|
||||
this.Groups.Clear();
|
||||
this._currentTurn = 0;
|
||||
this.AppendFromMessages(allMessages, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard against a sliding window that removed messages from the front:
|
||||
// the number of messages up to (and including) the found position must
|
||||
// match the number of messages already represented by existing groups.
|
||||
if (foundIndex + 1 < this.RawMessageCount)
|
||||
{
|
||||
// Front of the message list was trimmed — rebuild.
|
||||
this.Groups.Clear();
|
||||
this._currentTurn = 0;
|
||||
this.AppendFromMessages(allMessages, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process only the delta messages.
|
||||
this.AppendFromMessages(allMessages, foundIndex + 1);
|
||||
}
|
||||
|
||||
private void AppendFromMessages(IList<ChatMessage> messages, int startIndex)
|
||||
{
|
||||
int index = startIndex;
|
||||
|
||||
while (index < messages.Count)
|
||||
{
|
||||
ChatMessage message = messages[index];
|
||||
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
// System messages are not part of any turn
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.User)
|
||||
{
|
||||
this._currentTurn++;
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
|
||||
{
|
||||
List<ChatMessage> groupMessages = [message];
|
||||
index++;
|
||||
|
||||
// Collect all subsequent tool result messages and reasoning-only assistant messages
|
||||
while (index < messages.Count &&
|
||||
(messages[index].Role == ChatRole.Tool ||
|
||||
(messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index]))))
|
||||
{
|
||||
groupMessages.Add(messages[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
|
||||
{
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message))
|
||||
{
|
||||
// Reasoning-only assistant messages that precede a tool-call assistant message
|
||||
// are part of the same atomic tool-call group. Look ahead past consecutive
|
||||
// reasoning messages to find a possible tool-call message.
|
||||
int lookahead = index + 1;
|
||||
while (lookahead < messages.Count &&
|
||||
messages[lookahead].Role == ChatRole.Assistant &&
|
||||
HasOnlyReasoning(messages[lookahead]))
|
||||
{
|
||||
lookahead++;
|
||||
}
|
||||
|
||||
if (lookahead < messages.Count && messages[lookahead].Role == ChatRole.Assistant && HasToolCalls(messages[lookahead]))
|
||||
{
|
||||
// Group all reasoning messages + the tool-call message together
|
||||
List<ChatMessage> groupMessages = [];
|
||||
for (int j = index; j <= lookahead; j++)
|
||||
{
|
||||
groupMessages.Add(messages[j]);
|
||||
}
|
||||
|
||||
index = lookahead + 1;
|
||||
|
||||
// Collect all subsequent tool result messages and reasoning-only assistant messages
|
||||
while (index < messages.Count &&
|
||||
(messages[index].Role == ChatRole.Tool ||
|
||||
(messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index]))))
|
||||
{
|
||||
groupMessages.Add(messages[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.Count > 0)
|
||||
{
|
||||
this._lastProcessedMessage = messages[^1];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="CompactionMessageGroup"/> with byte and token counts computed using this collection's
|
||||
/// <see cref="Tokenizer"/>, and adds it to the <see cref="Groups"/> list at the specified index.
|
||||
/// </summary>
|
||||
/// <param name="index">The zero-based index at which the group should be inserted.</param>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in the group.</param>
|
||||
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
|
||||
/// <returns>The newly created <see cref="CompactionMessageGroup"/>.</returns>
|
||||
public CompactionMessageGroup InsertGroup(int index, CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Insert(index, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="CompactionMessageGroup"/> with byte and token counts computed using this collection's
|
||||
/// <see cref="Tokenizer"/>, and appends it to the end of the <see cref="Groups"/> list.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in the group.</param>
|
||||
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
|
||||
/// <returns>The newly created <see cref="CompactionMessageGroup"/>.</returns>
|
||||
public CompactionMessageGroup AddGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the messages from groups that are not excluded.
|
||||
/// </summary>
|
||||
/// <returns>A list of <see cref="ChatMessage"/> instances from included groups, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetIncludedMessages() =>
|
||||
this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all messages from all groups, including excluded ones.
|
||||
/// </summary>
|
||||
/// <returns>A list of all <see cref="ChatMessage"/> instances, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetAllMessages() => this.Groups.SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalGroupCount => this.Groups.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalMessageCount => this.Groups.Sum(group => group.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalByteCount => this.Groups.Sum(group => group.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalTokenCount => this.Groups.Sum(group => group.TokenCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups that are not excluded.
|
||||
/// </summary>
|
||||
public int IncludedGroupCount => this.Groups.Count(group => !group.IsExcluded);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedMessageCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedByteCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedTokenCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of user turns across all groups (including those with excluded groups).
|
||||
/// </summary>
|
||||
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of user turns that have at least one non-excluded group.
|
||||
/// </summary>
|
||||
public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups across all included (non-excluded) groups that are not <see cref="CompactionGroupKind.System"/>.
|
||||
/// </summary>
|
||||
public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of original messages (that are not summaries).
|
||||
/// </summary>
|
||||
public int RawMessageCount => this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all groups that belong to the specified user turn.
|
||||
/// </summary>
|
||||
/// <param name="turnIndex">The desired turn index.</param>
|
||||
/// <returns>The groups belonging to the turn, in order.</returns>
|
||||
public IEnumerable<CompactionMessageGroup> GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the UTF-8 byte count for a set of messages across all content types.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute byte count for.</param>
|
||||
/// <returns>The total UTF-8 byte count of all message content.</returns>
|
||||
internal static int ComputeByteCount(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
IList<AIContent> contents = messages[i].Contents;
|
||||
for (int j = 0; j < contents.Count; j++)
|
||||
{
|
||||
total += ComputeContentByteCount(contents[j]);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the token count for a set of messages using the specified tokenizer.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute token count for.</param>
|
||||
/// <param name="tokenizer">The tokenizer to use for counting tokens.</param>
|
||||
/// <returns>The total token count across all message content.</returns>
|
||||
/// <remarks>
|
||||
/// Text-bearing content (<see cref="TextContent"/> and <see cref="TextReasoningContent"/>)
|
||||
/// is tokenized directly. All other content types estimate tokens as <c>byteCount / 4</c>.
|
||||
/// </remarks>
|
||||
internal static int ComputeTokenCount(IReadOnlyList<ChatMessage> messages, Tokenizer tokenizer)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
IList<AIContent> contents = messages[i].Contents;
|
||||
for (int j = 0; j < contents.Count; j++)
|
||||
{
|
||||
AIContent content = contents[j];
|
||||
switch (content)
|
||||
{
|
||||
case TextContent text:
|
||||
if (text.Text is { Length: > 0 } t)
|
||||
{
|
||||
total += tokenizer.CountTokens(t);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case TextReasoningContent reasoning:
|
||||
if (reasoning.Text is { Length: > 0 } rt)
|
||||
{
|
||||
total += tokenizer.CountTokens(rt);
|
||||
}
|
||||
|
||||
if (reasoning.ProtectedData is { Length: > 0 } pd)
|
||||
{
|
||||
total += tokenizer.CountTokens(pd);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
total += ComputeContentByteCount(content) / 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static int ComputeContentByteCount(AIContent content)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case TextContent text:
|
||||
return GetStringByteCount(text.Text);
|
||||
|
||||
case TextReasoningContent reasoning:
|
||||
return GetStringByteCount(reasoning.Text) + GetStringByteCount(reasoning.ProtectedData);
|
||||
|
||||
case DataContent data:
|
||||
return data.Data.Length + GetStringByteCount(data.MediaType) + GetStringByteCount(data.Name);
|
||||
|
||||
case UriContent uri:
|
||||
return (uri.Uri is Uri uriValue ? GetStringByteCount(uriValue.OriginalString) : 0) + GetStringByteCount(uri.MediaType);
|
||||
|
||||
case FunctionCallContent call:
|
||||
int callBytes = GetStringByteCount(call.CallId) + GetStringByteCount(call.Name);
|
||||
if (call.Arguments is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, object?> arg in call.Arguments)
|
||||
{
|
||||
callBytes += GetStringByteCount(arg.Key);
|
||||
callBytes += GetStringByteCount(arg.Value?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
return callBytes;
|
||||
|
||||
case FunctionResultContent result:
|
||||
return GetStringByteCount(result.CallId) + GetStringByteCount(result.Result?.ToString());
|
||||
|
||||
case ErrorContent error:
|
||||
return GetStringByteCount(error.Message) + GetStringByteCount(error.ErrorCode) + GetStringByteCount(error.Details);
|
||||
|
||||
case HostedFileContent file:
|
||||
return GetStringByteCount(file.FileId) + GetStringByteCount(file.MediaType) + GetStringByteCount(file.Name);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetStringByteCount(string? value) =>
|
||||
value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0;
|
||||
|
||||
private static CompactionMessageGroup CreateGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, Tokenizer? tokenizer, int? turnIndex)
|
||||
{
|
||||
int byteCount = ComputeByteCount(messages);
|
||||
int tokenCount = tokenizer is not null
|
||||
? ComputeTokenCount(messages, tokenizer)
|
||||
: byteCount / 4;
|
||||
|
||||
return new CompactionMessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
|
||||
}
|
||||
|
||||
private static bool HasToolCalls(ChatMessage message)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasOnlyReasoning(ChatMessage message) =>
|
||||
message.Contents.All(content => content is TextReasoningContent);
|
||||
|
||||
private static bool IsSummaryMessage(ChatMessage message) =>
|
||||
message.AdditionalProperties?.TryGetValue(CompactionMessageGroup.SummaryPropertyKey, out object? value) is true
|
||||
&& value is true;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="AIContextProvider"/> that applies a <see cref="CompactionStrategy"/> to compact
|
||||
/// the message list before each agent invocation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider performs in-run compaction by organizing messages into atomic groups (preserving
|
||||
/// tool-call/result pairings) before applying compaction logic. Only included messages are forwarded
|
||||
/// to the agent's underlying chat client.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionProvider"/> can be added to an agent's context provider pipeline
|
||||
/// via <see cref="ChatClientAgentOptions.AIContextProviders"/> or via <c>UseAIContextProviders</c>
|
||||
/// on a <see cref="ChatClientBuilder"/> or <see cref="AIAgentBuilder"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class CompactionProvider : AIContextProvider
|
||||
{
|
||||
private readonly CompactionStrategy _compactionStrategy;
|
||||
private readonly ProviderSessionState<State> _sessionState;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="compactionStrategy">The compaction strategy to apply before each invocation.</param>
|
||||
/// <param name="stateKey">
|
||||
/// An optional key used to store the provider state in the <see cref="AgentSession.StateBag"/>. Provide
|
||||
/// an explicit value if configuring multiple agents with different compaction strategies that will interact
|
||||
/// in the same session.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// An optional <see cref="ILoggerFactory"/> used to create a logger for provider diagnostics.
|
||||
/// When <see langword="null"/>, logging is disabled.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="compactionStrategy"/> is <see langword="null"/>.</exception>
|
||||
public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._compactionStrategy = Throw.IfNull(compactionStrategy);
|
||||
stateKey ??= this._compactionStrategy.GetType().Name;
|
||||
this.StateKeys = [stateKey];
|
||||
this._sessionState = new ProviderSessionState<State>(
|
||||
_ => new State(),
|
||||
stateKey,
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
this._loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies compaction strategy to the provided message list and returns the compacted messages.
|
||||
/// This can be used for ad-hoc compaction outside of the provider pipeline.
|
||||
/// </summary>
|
||||
/// <param name="compactionStrategy">The compaction strategy to apply before each invocation.</param>
|
||||
/// <param name="messages">The messages to compact</param>
|
||||
/// <param name="logger">An optional <see cref="ILogger"/> for emitting compaction diagnostics.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>An enumeration of the compacted <see cref="ChatMessage"/> instances.</returns>
|
||||
public static async Task<IEnumerable<ChatMessage>> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable<ChatMessage> messages, ILogger? logger = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(compactionStrategy);
|
||||
Throw.IfNull(messages);
|
||||
|
||||
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages];
|
||||
CompactionMessageIndex messageIndex = CompactionMessageIndex.Create(messageList);
|
||||
|
||||
await compactionStrategy.CompactAsync(messageIndex, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return messageIndex.GetIncludedMessages();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the compaction strategy to the accumulated message list before forwarding it to the agent.
|
||||
/// </summary>
|
||||
/// <param name="context">Contains the request context including all accumulated messages.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains an <see cref="AIContext"/>
|
||||
/// with the compacted message list.
|
||||
/// </returns>
|
||||
protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.CompactionProviderInvoke);
|
||||
|
||||
ILoggerFactory loggerFactory = this.GetLoggerFactory(context.Agent);
|
||||
ILogger logger = loggerFactory.CreateLogger<CompactionProvider>();
|
||||
|
||||
AgentSession? session = context.Session;
|
||||
IEnumerable<ChatMessage>? allMessages = context.AIContext.Messages;
|
||||
|
||||
if (session is null || allMessages is null)
|
||||
{
|
||||
logger.LogCompactionProviderSkipped("no session or no messages");
|
||||
return context.AIContext;
|
||||
}
|
||||
|
||||
ChatClientAgentSession? chatClientSession = session.GetService<ChatClientAgentSession>();
|
||||
if (chatClientSession is not null &&
|
||||
!string.IsNullOrWhiteSpace(chatClientSession.ConversationId))
|
||||
{
|
||||
logger.LogCompactionProviderSkipped("session managed by remote service");
|
||||
return context.AIContext;
|
||||
}
|
||||
|
||||
List<ChatMessage> messageList = allMessages as List<ChatMessage> ?? [.. allMessages];
|
||||
|
||||
State state = this._sessionState.GetOrInitializeState(session);
|
||||
|
||||
CompactionMessageIndex messageIndex;
|
||||
if (state.MessageGroups.Count > 0)
|
||||
{
|
||||
// Update existing index with any new messages appended since the last call.
|
||||
messageIndex = new([.. state.MessageGroups]);
|
||||
messageIndex.Update(messageList);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First pass — initialize the message index from scratch.
|
||||
messageIndex = CompactionMessageIndex.Create(messageList);
|
||||
}
|
||||
|
||||
string strategyName = this._compactionStrategy.GetType().Name;
|
||||
int beforeMessages = messageIndex.IncludedMessageCount;
|
||||
logger.LogCompactionProviderApplying(beforeMessages, strategyName);
|
||||
|
||||
// Apply compaction
|
||||
await this._compactionStrategy.CompactAsync(
|
||||
messageIndex,
|
||||
loggerFactory.CreateLogger(this._compactionStrategy.GetType()),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int afterMessages = messageIndex.IncludedMessageCount;
|
||||
if (afterMessages < beforeMessages)
|
||||
{
|
||||
logger.LogCompactionProviderApplied(beforeMessages, afterMessages);
|
||||
}
|
||||
|
||||
// Persist the index
|
||||
state.MessageGroups.Clear();
|
||||
state.MessageGroups.AddRange(messageIndex.Groups);
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Instructions = context.AIContext.Instructions,
|
||||
Messages = messageIndex.GetIncludedMessages(),
|
||||
Tools = context.AIContext.Tools
|
||||
};
|
||||
}
|
||||
|
||||
private ILoggerFactory GetLoggerFactory(AIAgent agent) =>
|
||||
this._loggerFactory ??
|
||||
agent.GetService<IChatClient>()?.GetService<ILoggerFactory>() ??
|
||||
NullLoggerFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the persisted state of a <see cref="CompactionProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
internal sealed class State
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message index groups used for incremental compaction updates.
|
||||
/// </summary>
|
||||
[JsonPropertyName("messagegroups")]
|
||||
public List<CompactionMessageGroup> MessageGroups { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for strategies that compact a <see cref="CompactionMessageIndex"/> to reduce context size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Compaction strategies operate on <see cref="CompactionMessageIndex"/> instances, which organize messages
|
||||
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
|
||||
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every strategy requires a <see cref="CompactionTrigger"/> that determines whether compaction should
|
||||
/// proceed based on current <see cref="CompactionMessageIndex"/> metrics (token count, message count, turn count, etc.).
|
||||
/// The base class evaluates this trigger at the start of <see cref="CompactAsync"/> and skips compaction when
|
||||
/// the trigger returns <see langword="false"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An optional <b>target</b> condition controls when compaction stops. Strategies incrementally exclude
|
||||
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
|
||||
/// <see langword="true"/>. When no target is specified, it defaults to the inverse of the trigger —
|
||||
/// meaning compaction stops when the trigger condition would no longer fire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Strategies can be applied at three lifecycle points:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
|
||||
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
|
||||
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="CompactionMessageIndex"/>
|
||||
/// via <see cref="PipelineCompactionStrategy"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
|
||||
/// this predicate after each incremental exclusion and stop when it returns <see langword="true"/>.
|
||||
/// When <see langword="null"/>, defaults to the inverse of the <paramref name="trigger"/> — compaction
|
||||
/// stops as soon as the trigger condition would no longer fire.
|
||||
/// </param>
|
||||
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
|
||||
{
|
||||
this.Trigger = Throw.IfNull(trigger);
|
||||
this.Target = target ?? (index => !trigger(index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the trigger predicate that controls when compaction proceeds.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Trigger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target predicate that controls when compaction stops.
|
||||
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns <see langword="true"/>.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Target { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies the strategy-specific compaction logic to the specified message index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is called by <see cref="CompactAsync"/> only when the <see cref="Trigger"/>
|
||||
/// returns <see langword="true"/>. Implementations do not need to evaluate the trigger or
|
||||
/// report metrics — the base class handles both. Implementations should use <see cref="Target"/>
|
||||
/// to determine when to stop compacting incrementally.
|
||||
/// </remarks>
|
||||
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> for emitting compaction diagnostics.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task whose result is <see langword="true"/> if any compaction was performed, <see langword="false"/> otherwise.</returns>
|
||||
protected abstract ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
|
||||
/// <see cref="CompactCoreAsync"/> and reports compaction metrics.
|
||||
/// </summary>
|
||||
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="logger">An optional <see cref="ILogger"/> for emitting compaction diagnostics. When <see langword="null"/>, logging is disabled.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
|
||||
public async ValueTask<bool> CompactAsync(CompactionMessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string strategyName = this.GetType().Name;
|
||||
logger ??= NullLogger.Instance;
|
||||
|
||||
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Compact);
|
||||
activity?.SetTag(CompactionTelemetry.Tags.Strategy, strategyName);
|
||||
|
||||
if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index))
|
||||
{
|
||||
activity?.SetTag(CompactionTelemetry.Tags.Triggered, false);
|
||||
logger.LogCompactionSkipped(strategyName);
|
||||
return false;
|
||||
}
|
||||
|
||||
activity?.SetTag(CompactionTelemetry.Tags.Triggered, true);
|
||||
|
||||
int beforeTokens = index.IncludedTokenCount;
|
||||
int beforeGroups = index.IncludedGroupCount;
|
||||
int beforeMessages = index.IncludedMessageCount;
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
bool compacted = await this.CompactCoreAsync(index, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
activity?.SetTag(CompactionTelemetry.Tags.Compacted, compacted);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
activity?
|
||||
.SetTag(CompactionTelemetry.Tags.BeforeTokens, beforeTokens)
|
||||
.SetTag(CompactionTelemetry.Tags.AfterTokens, index.IncludedTokenCount)
|
||||
.SetTag(CompactionTelemetry.Tags.BeforeMessages, beforeMessages)
|
||||
.SetTag(CompactionTelemetry.Tags.AfterMessages, index.IncludedMessageCount)
|
||||
.SetTag(CompactionTelemetry.Tags.BeforeGroups, beforeGroups)
|
||||
.SetTag(CompactionTelemetry.Tags.AfterGroups, index.IncludedGroupCount)
|
||||
.SetTag(CompactionTelemetry.Tags.DurationMs, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
logger.LogCompactionCompleted(
|
||||
strategyName,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
beforeMessages,
|
||||
index.IncludedMessageCount,
|
||||
beforeGroups,
|
||||
index.IncludedGroupCount,
|
||||
beforeTokens,
|
||||
index.IncludedTokenCount);
|
||||
}
|
||||
|
||||
return compacted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the provided value is not a negative number.
|
||||
/// </summary>
|
||||
/// <param name="value">The target value.</param>
|
||||
/// <returns>0 if negative; otherwise the value</returns>
|
||||
protected static int EnsureNonNegative(int value) => Math.Max(0, value);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Provides shared telemetry infrastructure for compaction operations.
|
||||
/// </summary>
|
||||
internal static class CompactionTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="ActivitySource"/> used to create activities for compaction operations.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(OpenTelemetryConsts.DefaultSourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Activity names used by compaction tracing.
|
||||
/// </summary>
|
||||
public static class ActivityNames
|
||||
{
|
||||
public const string Compact = "compaction.compact";
|
||||
public const string CompactionProviderInvoke = "compaction.provider.invoke";
|
||||
public const string Summarize = "compaction.summarize";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag names used on compaction activities.
|
||||
/// </summary>
|
||||
public static class Tags
|
||||
{
|
||||
public const string Strategy = "compaction.strategy";
|
||||
public const string Triggered = "compaction.triggered";
|
||||
public const string Compacted = "compaction.compacted";
|
||||
public const string BeforeTokens = "compaction.before.tokens";
|
||||
public const string AfterTokens = "compaction.after.tokens";
|
||||
public const string BeforeMessages = "compaction.before.messages";
|
||||
public const string AfterMessages = "compaction.after.messages";
|
||||
public const string BeforeGroups = "compaction.before.groups";
|
||||
public const string AfterGroups = "compaction.after.groups";
|
||||
public const string DurationMs = "compaction.duration_ms";
|
||||
public const string GroupsSummarized = "compaction.groups_summarized";
|
||||
public const string SummaryLength = "compaction.summary_length";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a condition based on <see cref="CompactionMessageIndex"/> metrics used by a <see cref="CompactionStrategy"/>
|
||||
/// to determine when to trigger compaction and when the target compaction threshold has been met.
|
||||
/// </summary>
|
||||
/// <param name="index">An index over conversation messages that provides group, token, message, and turn metrics.</param>
|
||||
/// <returns><see langword="true"/> to indicate the condition has been met; otherwise <see langword="false"/>.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public delegate bool CompactionTrigger(CompactionMessageIndex index);
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Factory to create <see cref="CompactionTrigger"/> predicates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <see cref="CompactionTrigger"/> defines a condition based on <see cref="CompactionMessageIndex"/> metrics used
|
||||
/// by a <see cref="CompactionStrategy"/> to determine when to trigger compaction and when the target
|
||||
/// compaction threshold has been met.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Combine triggers with <see cref="All"/> or <see cref="Any"/> for compound conditions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static class CompactionTriggers
|
||||
{
|
||||
/// <summary>
|
||||
/// Always trigger, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Always =
|
||||
_ => true;
|
||||
|
||||
/// <summary>
|
||||
/// Never trigger, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Never =
|
||||
_ => false;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included token count is below the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The token threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
|
||||
public static CompactionTrigger TokensBelow(int maxTokens) =>
|
||||
index => index.IncludedTokenCount < maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The token threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
|
||||
public static CompactionTrigger TokensExceed(int maxTokens) =>
|
||||
index => index.IncludedTokenCount > maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included message count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxMessages">The message threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included message count.</returns>
|
||||
public static CompactionTrigger MessagesExceed(int maxMessages) =>
|
||||
index => index.IncludedMessageCount > maxMessages;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTurns">The turn threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included turn count.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A user turn starts with a <see cref="CompactionGroupKind.User"/> group and includes all subsequent
|
||||
/// non-user, non-system groups until the next user group or end of conversation. Each group is assigned
|
||||
/// a <see cref="CompactionMessageGroup.TurnIndex"/> indicating which user turn it belongs to.
|
||||
/// System messages (<see cref="CompactionGroupKind.System"/>) are always assigned a <see langword="null"/>
|
||||
/// <see cref="CompactionMessageGroup.TurnIndex"/> since they never belong to a user turn.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The turn count is the number of distinct values defined by <see cref="CompactionMessageGroup.TurnIndex"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static CompactionTrigger TurnsExceed(int maxTurns) =>
|
||||
index => index.IncludedTurnCount > maxTurns;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included group count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxGroups">The group threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included group count.</returns>
|
||||
public static CompactionTrigger GroupsExceed(int maxGroups) =>
|
||||
index => index.IncludedGroupCount > maxGroups;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included message index contains at least one
|
||||
/// non-excluded <see cref="CompactionGroupKind.ToolCall"/> group.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included tool call presence.</returns>
|
||||
public static CompactionTrigger HasToolCalls() =>
|
||||
index => index.Groups.Any(g => !g.IsExcluded && g.Kind == CompactionGroupKind.ToolCall);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compound trigger that fires only when <b>all</b> of the specified triggers fire.
|
||||
/// </summary>
|
||||
/// <param name="triggers">The triggers to combine with logical AND.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that requires all conditions to be met.</returns>
|
||||
public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
|
||||
index =>
|
||||
{
|
||||
for (int i = 0; i < triggers.Length; i++)
|
||||
{
|
||||
if (!triggers[i](index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compound trigger that fires when <b>any</b> of the specified triggers fire.
|
||||
/// </summary>
|
||||
/// <param name="triggers">The triggers to combine with logical OR.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that requires at least one condition to be met.</returns>
|
||||
public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
|
||||
index =>
|
||||
{
|
||||
for (int i = 0; i < triggers.Length; i++)
|
||||
{
|
||||
if (triggers[i](index))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that executes a sequential pipeline of <see cref="CompactionStrategy"/> instances
|
||||
/// against the same <see cref="CompactionMessageIndex"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
|
||||
/// such as summarizing older messages first and then truncating to fit a token budget.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pipeline itself always executes while each child strategy evaluates its own
|
||||
/// <see cref="CompactionStrategy.Trigger"/> independently to decide whether it should compact.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class PipelineCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategies">The ordered sequence of strategies to execute.</param>
|
||||
public PipelineCompactionStrategy(params IEnumerable<CompactionStrategy> strategies)
|
||||
: base(CompactionTriggers.Always)
|
||||
{
|
||||
this.Strategies = [.. Throw.IfNull(strategies)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of strategies in this pipeline.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CompactionStrategy> Strategies { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
bool anyCompacted = false;
|
||||
|
||||
foreach (CompactionStrategy strategy in this.Strategies)
|
||||
{
|
||||
bool compacted = await strategy.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
anyCompacted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return anyCompacted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest user turns and their associated response groups
|
||||
/// to bound conversation length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy always preserves system messages. It identifies user turns in the
|
||||
/// conversation (via <see cref="CompactionMessageGroup.TurnIndex"/>) and excludes the oldest turns
|
||||
/// one at a time until the <see cref="CompactionStrategy.Target"/> condition is met.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreservedTurns"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedTurns"/> turns
|
||||
/// (by <see cref="CompactionMessageGroup.TurnIndex"/>). Groups with a <see cref="CompactionMessageGroup.TurnIndex"/>
|
||||
/// of <c>0</c> or <see langword="null"/> are always preserved regardless of this setting.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This strategy is more predictable than token-based truncation for bounding conversation
|
||||
/// length, since it operates on logical turn boundaries rather than estimated token counts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent turns to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// Use <see cref="CompactionTriggers.TurnsExceed"/> for turn-based thresholds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreservedTurns">
|
||||
/// The minimum number of most-recent turns (by <see cref="CompactionMessageGroup.TurnIndex"/>) to preserve.
|
||||
/// This is a hard floor — compaction will not exclude turns within this range, regardless of the target condition.
|
||||
/// Groups with <see cref="CompactionMessageGroup.TurnIndex"/> of <c>0</c> or <see langword="null"/> are always preserved.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreservedTurns = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreservedTurns = EnsureNonNegative(minimumPreservedTurns);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent turns (by <see cref="CompactionMessageGroup.TurnIndex"/>) that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// Groups with <see cref="CompactionMessageGroup.TurnIndex"/> of <c>0</c> or <see langword="null"/> are always preserved
|
||||
/// independently of this value.
|
||||
/// </summary>
|
||||
public int MinimumPreservedTurns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// Forward pass: pre-index non-system included groups by TurnIndex.
|
||||
Dictionary<int, List<int>> turnGroups = [];
|
||||
List<int> turnOrder = [];
|
||||
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
CompactionMessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && group.TurnIndex is int turnIndex)
|
||||
{
|
||||
if (!turnGroups.TryGetValue(turnIndex, out List<int>? indices))
|
||||
{
|
||||
indices = [];
|
||||
turnGroups[turnIndex] = indices;
|
||||
turnOrder.Add(turnIndex);
|
||||
}
|
||||
|
||||
indices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward pass: identify protected turns by TurnIndex.
|
||||
// TurnIndex = 0 is always protected (non-system messages before first user message).
|
||||
// TurnIndex = null is always protected (system messages, already excluded from turn tracking).
|
||||
HashSet<int> protectedTurnIndices = [];
|
||||
if (turnGroups.ContainsKey(0))
|
||||
{
|
||||
protectedTurnIndices.Add(0);
|
||||
}
|
||||
|
||||
// Protect the last MinimumPreservedTurns distinct turns.
|
||||
int turnsToProtect = Math.Min(this.MinimumPreservedTurns, turnOrder.Count);
|
||||
for (int i = turnOrder.Count - turnsToProtect; i < turnOrder.Count; i++)
|
||||
{
|
||||
protectedTurnIndices.Add(turnOrder[i]);
|
||||
}
|
||||
|
||||
// Exclude turns oldest-first, skipping protected turns, checking target after each turn.
|
||||
bool compacted = false;
|
||||
|
||||
for (int t = 0; t < turnOrder.Count; t++)
|
||||
{
|
||||
int currentTurnIndex = turnOrder[t];
|
||||
if (protectedTurnIndices.Contains(currentTurnIndex))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
List<int> groupIndices = turnGroups[currentTurnIndex];
|
||||
for (int g = 0; g < groupIndices.Count; g++)
|
||||
{
|
||||
int idx = groupIndices[g];
|
||||
index.Groups[idx].IsExcluded = true;
|
||||
index.Groups[idx].ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
|
||||
}
|
||||
|
||||
compacted = true;
|
||||
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ValueTask<bool>(compacted);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user