Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3daed114ee | ||
|
|
5e097276a0 | ||
|
|
383d551b86 | ||
|
|
2a345e5d3b | ||
|
|
632f67b92e | ||
|
|
5e6eb6f121 | ||
|
|
dbfacbfc4a | ||
|
|
29cec0d27b | ||
|
|
96d242fa7f | ||
|
|
9486c76ef8 | ||
|
|
caa75f7cdd | ||
|
|
cfb033e5d4 | ||
|
|
d222079df9 | ||
|
|
e89e745bc0 | ||
|
|
bad05a2bdc | ||
|
|
7e0767a0a0 | ||
|
|
af772997af | ||
|
|
b343625c1f | ||
|
|
9bc7b27813 | ||
|
|
6a2efeae7c | ||
|
|
6169df04cb | ||
|
|
331201294b | ||
|
|
fa9e086576 | ||
|
|
dcc218dbac | ||
|
|
6bd2cfec03 | ||
|
|
ab8ba8fc61 | ||
|
|
9cafd7e58b | ||
|
|
d5335fbeae | ||
|
|
bf4ad48cf2 | ||
|
|
01fc518b29 | ||
|
|
f3c3efed43 | ||
|
|
bbccb7c28c | ||
|
|
dbc312a78a | ||
|
|
bb9ed63a34 | ||
|
|
6b94315161 | ||
|
|
bc0e65d716 | ||
|
|
4268080c20 | ||
|
|
fe08574a7c | ||
|
|
f970a699d8 | ||
|
|
f29bae8fbc |
@@ -474,6 +474,45 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# GitHub Copilot integration tests
|
||||
python-tests-github-copilot:
|
||||
name: Python Integration Tests - GitHub Copilot
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (GitHub Copilot integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/github_copilot/tests
|
||||
-m integration
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
@@ -490,6 +529,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -553,7 +593,8 @@ jobs:
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
@@ -85,6 +86,8 @@ jobs:
|
||||
- 'python/packages/foundry_hosting/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
github_copilot:
|
||||
- 'python/packages/github_copilot/**'
|
||||
# run only if 'python' files were changed
|
||||
- name: python tests
|
||||
if: steps.filter.outputs.python == 'true'
|
||||
@@ -658,6 +661,58 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# GitHub Copilot integration tests
|
||||
python-tests-github-copilot:
|
||||
name: Python Tests - GitHub Copilot Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.githubCopilotChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (GitHub Copilot integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/github_copilot/tests
|
||||
-m integration
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: GitHub Copilot integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
@@ -674,6 +729,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -735,6 +791,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
|
||||
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,55 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
|
||||
<path d="M163.006 36.0262C155.129 31.4785 145.424 31.4759 137.533 36.0284L104.002 55.3877C107.838 53.2763 112.07 52.2274 116.296 52.2248C120.633 52.2231 124.976 53.3247 128.871 55.5411L174.091 81.6479C175.11 82.2362 175.738 83.3236 175.738 84.5004V151.263C175.738 152.716 177.313 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.7299C209.704 68.6342 204.855 60.227 196.967 55.6697L190.983 52.2141C190.65 52.0008 190.313 51.7921 189.969 51.5933L163.006 36.0262Z" fill="url(#paint0_linear_481_4810)"/>
|
||||
<path d="M163.006 36.0262C155.129 31.4785 145.424 31.4759 137.533 36.0284L104.002 55.3877C107.838 53.2763 112.07 52.2274 116.296 52.2248C120.633 52.2231 124.976 53.3247 128.871 55.5411L174.091 81.6479C175.11 82.2362 175.738 83.3236 175.738 84.5004V151.263C175.738 152.716 177.313 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.7299C209.704 68.6342 204.855 60.227 196.967 55.6697L190.983 52.2141C190.65 52.0008 190.313 51.7921 189.969 51.5933L163.006 36.0262Z" fill="url(#paint1_linear_481_4810)"/>
|
||||
<path d="M103.548 55.6397L103.557 55.6451L104.002 55.3877C103.851 55.471 103.698 55.5531 103.548 55.6397Z" fill="url(#paint2_linear_481_4810)"/>
|
||||
<path d="M103.548 55.6397L103.557 55.6451L104.002 55.3877C103.851 55.471 103.698 55.5531 103.548 55.6397Z" fill="url(#paint3_linear_481_4810)"/>
|
||||
<path d="M116.308 52.2209C111.903 52.2271 107.507 53.3498 103.561 55.6366C95.6702 60.1891 90.8239 68.6019 90.8231 77.6986L90.8223 167.846C90.8222 169.786 92.871 171.041 94.599 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9894C124.778 58.6476 129.49 55.9209 133.25 58.0698L128.879 55.5453C124.976 53.3242 120.645 52.2192 116.308 52.2209Z" fill="url(#paint4_linear_481_4810)"/>
|
||||
<path d="M95.3068 221.682C103.184 226.23 112.889 226.232 120.78 221.68L154.311 202.32C150.475 204.432 146.243 205.481 142.018 205.483C137.68 205.485 133.337 204.383 129.442 202.167L84.2226 176.06C83.2035 175.472 82.5757 174.384 82.5757 173.208L82.5757 106.445C82.5757 104.992 81 104.087 79.7451 104.813L61.3465 115.428C53.4682 119.976 48.6091 128.382 48.6089 137.487L48.6089 179.978C48.6089 189.074 53.4585 197.481 61.3465 202.038L67.3303 205.494C67.6629 205.707 68.0002 205.916 68.3446 206.115L95.3068 221.682Z" fill="url(#paint5_linear_481_4810)"/>
|
||||
<path d="M95.3068 221.682C103.184 226.23 112.889 226.232 120.78 221.68L154.311 202.32C150.475 204.432 146.243 205.481 142.018 205.483C137.68 205.485 133.337 204.383 129.442 202.167L84.2226 176.06C83.2035 175.472 82.5757 174.384 82.5757 173.208L82.5757 106.445C82.5757 104.992 81 104.087 79.7451 104.813L61.3465 115.428C53.4682 119.976 48.6091 128.382 48.6089 137.487L48.6089 179.978C48.6089 189.074 53.4585 197.481 61.3465 202.038L67.3303 205.494C67.6629 205.707 68.0002 205.916 68.3446 206.115L95.3068 221.682Z" fill="url(#paint6_linear_481_4810)"/>
|
||||
<path d="M154.765 202.068L154.756 202.063L154.311 202.32C154.463 202.237 154.615 202.155 154.765 202.068Z" fill="url(#paint7_linear_481_4810)"/>
|
||||
<path d="M154.765 202.068L154.756 202.063L154.311 202.32C154.463 202.237 154.615 202.155 154.765 202.068Z" fill="url(#paint8_linear_481_4810)"/>
|
||||
<path d="M142.003 205.487C146.408 205.481 150.805 204.358 154.751 202.071C162.641 197.519 167.488 189.106 167.488 180.009L167.489 89.8618C167.489 87.9222 165.44 86.667 163.712 87.5479L154.788 92.0972C141.739 98.7494 133.523 112.159 133.523 126.806L133.523 194.719C133.533 199.06 128.821 201.787 125.061 199.638L129.432 202.163C133.336 204.384 137.666 205.489 142.003 205.487Z" fill="url(#paint9_linear_481_4810)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_481_4810" x1="207.413" y1="61.882" x2="148.999" y2="163.067" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9189F7"/>
|
||||
<stop offset="1" stop-color="#4135E9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_481_4810" x1="188.235" y1="204.189" x2="264.21" y2="152.592" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#7274FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_481_4810" x1="207.413" y1="61.882" x2="148.999" y2="163.067" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9189F7"/>
|
||||
<stop offset="1" stop-color="#4135E9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_481_4810" x1="188.235" y1="204.189" x2="264.21" y2="152.592" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#7274FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_481_4810" x1="93.1761" y1="128.826" x2="66.2399" y2="104.746" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.25" stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#2C08AC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_481_4810" x1="50.9004" y1="195.826" x2="109.315" y2="94.6412" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9189F7"/>
|
||||
<stop offset="1" stop-color="#4135E9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint6_linear_481_4810" x1="70.0787" y1="53.5188" x2="-5.89657" y2="105.116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#7274FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint7_linear_481_4810" x1="50.9004" y1="195.826" x2="109.315" y2="94.6412" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9189F7"/>
|
||||
<stop offset="1" stop-color="#4135E9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint8_linear_481_4810" x1="70.0787" y1="53.5188" x2="-5.89657" y2="105.116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#7274FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint9_linear_481_4810" x1="165.135" y1="128.882" x2="192.072" y2="152.962" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.25" stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#2C08AC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
|
||||
<path d="M167.489 89.8618C167.489 87.9224 165.441 86.667 163.713 87.5473L154.788 92.0971C141.739 98.7493 133.523 112.159 133.523 126.806V194.718C133.533 199.053 128.837 201.777 125.08 199.647L84.2227 176.06C83.2036 175.472 82.5762 174.384 82.5762 173.207V106.445C82.5759 104.992 80.9999 104.087 79.7451 104.813L61.3467 115.428C53.4685 119.976 48.6096 128.382 48.6094 137.487V179.978C48.6094 189.074 53.4588 197.481 61.3467 202.039L67.3301 205.494C67.6627 205.707 68.0004 205.916 68.3447 206.115L95.3066 221.682C103.184 226.23 112.889 226.232 120.779 221.679L154.312 202.32C154.271 202.342 154.23 202.362 154.189 202.384C154.283 202.333 154.375 202.281 154.468 202.229L154.312 202.32C154.463 202.237 154.615 202.154 154.765 202.068L154.762 202.065C162.646 197.511 167.487 189.102 167.488 180.009L167.489 89.8618Z" fill="black"/>
|
||||
<path d="M163.007 36.0259C155.13 31.4781 145.424 31.4763 137.533 36.0288L104.002 55.3882C104.029 55.3732 104.057 55.359 104.084 55.3442C104.02 55.3794 103.956 55.4159 103.893 55.4516L104.002 55.3882C103.851 55.4714 103.699 55.5536 103.549 55.6401L103.552 55.6411C95.6664 60.1948 90.8241 68.6053 90.8232 77.6987L90.8223 167.846C90.8223 169.786 92.8707 171.04 94.5986 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9897C124.778 58.6479 129.49 55.9211 133.25 58.0698L174.091 81.6479C175.11 82.2363 175.737 83.3238 175.737 84.5005V151.263C175.738 152.716 177.314 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.73C209.704 68.6343 204.855 60.2267 196.967 55.6694L190.982 52.2143C190.65 52.0011 190.313 51.792 189.969 51.5932L163.007 36.0259Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="black"/>
|
||||
<path d="M167.489 89.8618C167.489 87.9224 165.441 86.667 163.713 87.5473L154.788 92.0971C141.739 98.7493 133.523 112.159 133.523 126.806V194.718C133.533 199.053 128.837 201.777 125.08 199.647L84.2227 176.06C83.2036 175.472 82.5762 174.384 82.5762 173.207V106.445C82.5759 104.992 80.9999 104.087 79.7451 104.813L61.3467 115.428C53.4685 119.976 48.6096 128.382 48.6094 137.487V179.978C48.6094 189.074 53.4588 197.481 61.3467 202.039L67.3301 205.494C67.6627 205.707 68.0004 205.916 68.3447 206.115L95.3066 221.682C103.184 226.23 112.889 226.232 120.779 221.679L154.312 202.32C154.271 202.342 154.23 202.362 154.189 202.384C154.283 202.333 154.375 202.281 154.468 202.229L154.312 202.32C154.463 202.237 154.615 202.154 154.765 202.068L154.762 202.065C162.646 197.511 167.487 189.102 167.488 180.009L167.489 89.8618Z" fill="white"/>
|
||||
<path d="M163.007 36.0259C155.13 31.4781 145.424 31.4763 137.533 36.0288L104.002 55.3882C104.029 55.3732 104.057 55.359 104.084 55.3442C104.02 55.3794 103.956 55.4159 103.893 55.4516L104.002 55.3882C103.851 55.4714 103.699 55.5536 103.549 55.6401L103.552 55.6411C95.6664 60.1948 90.8241 68.6053 90.8232 77.6987L90.8223 167.846C90.8223 169.786 92.8707 171.04 94.5986 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9897C124.778 58.6479 129.49 55.9211 133.25 58.0698L174.091 81.6479C175.11 82.2363 175.737 83.3238 175.737 84.5005V151.263C175.738 152.716 177.314 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.73C209.704 68.6343 204.855 60.2267 196.967 55.6694L190.982 52.2143C190.65 52.0011 190.313 51.792 189.969 51.5932L163.007 36.0259Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
|
||||
<path d="M48.6094 179.978V137.487C48.6096 128.382 53.4685 119.976 61.3467 115.428L79.7451 104.813C80.9999 104.087 82.5759 104.992 82.5762 106.445V173.207C82.5762 174.384 83.2036 175.472 84.2227 176.06L125.08 199.647C128.837 201.777 133.533 199.053 133.523 194.718V126.806C133.523 112.388 141.484 99.1684 154.18 92.4135L154.788 92.0971L163.713 87.5473C165.441 86.667 167.489 87.9224 167.489 89.8618L167.488 180.009L167.474 180.86C167.181 189.624 162.399 197.653 154.762 202.065L154.765 202.068C154.615 202.154 154.463 202.237 154.312 202.32L154.468 202.229C154.375 202.281 154.283 202.333 154.189 202.384C154.23 202.362 154.271 202.342 154.312 202.32L120.779 221.679L120.034 222.092C112.534 226.093 103.539 226.092 96.0508 222.095L95.3066 221.682L68.3447 206.115C68.0004 205.916 67.6627 205.707 67.3301 205.494L61.3467 202.039C53.7053 197.624 48.9149 189.595 48.623 180.829L48.6094 179.978ZM175.737 84.5005C175.737 83.3974 175.186 82.3728 174.277 81.7641L174.091 81.6479L133.25 58.0698C129.49 55.9211 124.778 58.6479 124.788 62.9897V130.902L124.782 131.587C124.53 145.966 116.369 159.063 103.523 165.611L94.5986 170.16L94.4355 170.236C92.799 170.938 90.9459 169.803 90.8281 168.026L90.8223 167.846L90.8232 77.6987C90.8241 68.6053 95.6664 60.1948 103.552 55.6411L103.549 55.6401C103.699 55.5536 103.851 55.4714 104.002 55.3882L103.893 55.4516C103.956 55.4159 104.02 55.3794 104.084 55.3442C104.057 55.359 104.029 55.3732 104.002 55.3882L137.533 36.0288C145.424 31.4763 155.13 31.4781 163.007 36.0259L189.969 51.5932C190.313 51.792 190.65 52.0011 190.982 52.2143L196.967 55.6694C204.855 60.2267 209.704 68.6343 209.704 77.73V120.221L209.689 121.073C209.397 129.848 204.599 137.874 196.967 142.28L178.568 152.895C177.314 153.621 175.738 152.716 175.737 151.263V84.5005ZM176.814 149.866L176.819 149.864L176.832 149.856C176.826 149.859 176.82 149.862 176.814 149.866ZM137.023 194.71L137.019 195.038C136.802 201.878 129.341 206.087 123.354 202.692L123.33 202.678L82.4727 179.091C80.3698 177.877 79.0762 175.634 79.0762 173.207V109.239L63.0957 118.458C56.5124 122.259 52.3745 129.183 52.1221 136.752L52.1094 137.487V179.978C52.1094 187.823 56.2909 195.075 63.0957 199.007H63.0967L69.0801 202.462L69.1504 202.503L69.2188 202.547C69.5212 202.741 69.8111 202.92 70.0947 203.083L97.0566 218.651L97.6982 219.007C104.372 222.569 112.434 222.453 119.029 218.648L152.514 199.316L152.512 199.312C152.581 199.274 152.65 199.236 152.752 199.178L152.753 199.181C152.775 199.169 152.796 199.158 152.815 199.147L153.011 199.035C159.81 195.107 163.987 187.854 163.988 180.009V91.3344L156.378 95.2153C144.501 101.27 137.023 113.475 137.023 126.806V194.71ZM94.3223 166.372L101.934 162.493C113.811 156.438 121.288 144.233 121.288 130.902V62.9897C121.278 55.9521 128.901 51.5532 134.986 55.0307L135 55.0385L175.841 78.6167L176.036 78.7339C178.023 79.9714 179.237 82.15 179.237 84.5005V148.468L195.217 139.249C202.013 135.326 206.204 128.075 206.204 120.221V77.73C206.204 69.8848 202.022 62.6318 195.217 58.6997L189.232 55.2456L189.162 55.2046L189.094 55.1606C188.788 54.9649 188.5 54.787 188.219 54.6245L161.257 39.0571C154.463 35.135 146.091 35.1311 139.282 39.0591L139.283 39.06L105.765 58.4106L105.767 58.4135C105.713 58.443 105.723 58.4383 105.602 58.5063L105.6 58.5034C105.535 58.5391 105.481 58.5691 105.433 58.5962L105.302 58.6723C98.5016 62.5995 94.324 69.8532 94.3232 77.6987L94.3223 166.372Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="black"/>
|
||||
<path d="M48.6094 179.978V137.487C48.6096 128.382 53.4685 119.976 61.3467 115.428L79.7451 104.813C80.9999 104.087 82.5759 104.992 82.5762 106.445V173.207C82.5762 174.384 83.2036 175.472 84.2227 176.06L125.08 199.647C128.837 201.777 133.533 199.053 133.523 194.718V126.806C133.523 112.388 141.484 99.1684 154.18 92.4135L154.788 92.0971L163.713 87.5473C165.441 86.667 167.489 87.9224 167.489 89.8618L167.488 180.009L167.474 180.86C167.181 189.624 162.399 197.653 154.762 202.065L154.765 202.068C154.615 202.154 154.463 202.237 154.312 202.32L154.468 202.229C154.375 202.281 154.283 202.333 154.189 202.384C154.23 202.362 154.271 202.342 154.312 202.32L120.779 221.679L120.034 222.092C112.534 226.093 103.539 226.092 96.0508 222.095L95.3066 221.682L68.3447 206.115C68.0004 205.916 67.6627 205.707 67.3301 205.494L61.3467 202.039C53.7053 197.624 48.9149 189.595 48.623 180.829L48.6094 179.978ZM175.737 84.5005C175.737 83.3974 175.186 82.3728 174.277 81.7641L174.091 81.6479L133.25 58.0698C129.49 55.9211 124.778 58.6479 124.788 62.9897V130.902L124.782 131.587C124.53 145.966 116.369 159.063 103.523 165.611L94.5986 170.16L94.4355 170.236C92.799 170.938 90.9459 169.803 90.8281 168.026L90.8223 167.846L90.8232 77.6987C90.8241 68.6053 95.6664 60.1948 103.552 55.6411L103.549 55.6401C103.699 55.5536 103.851 55.4714 104.002 55.3882L103.893 55.4516C103.956 55.4159 104.02 55.3794 104.084 55.3442C104.057 55.359 104.029 55.3732 104.002 55.3882L137.533 36.0288C145.424 31.4763 155.13 31.4781 163.007 36.0259L189.969 51.5932C190.313 51.792 190.65 52.0011 190.982 52.2143L196.967 55.6694C204.855 60.2267 209.704 68.6343 209.704 77.73V120.221L209.689 121.073C209.397 129.848 204.599 137.874 196.967 142.28L178.568 152.895C177.314 153.621 175.738 152.716 175.737 151.263V84.5005ZM176.814 149.866L176.819 149.864L176.832 149.856C176.826 149.859 176.82 149.862 176.814 149.866ZM137.023 194.71L137.019 195.038C136.802 201.878 129.341 206.087 123.354 202.692L123.33 202.678L82.4727 179.091C80.3698 177.877 79.0762 175.634 79.0762 173.207V109.239L63.0957 118.458C56.5124 122.259 52.3745 129.183 52.1221 136.752L52.1094 137.487V179.978C52.1094 187.823 56.2909 195.075 63.0957 199.007H63.0967L69.0801 202.462L69.1504 202.503L69.2188 202.547C69.5212 202.741 69.8111 202.92 70.0947 203.083L97.0566 218.651L97.6982 219.007C104.372 222.569 112.434 222.453 119.029 218.648L152.514 199.316L152.512 199.312C152.581 199.274 152.65 199.236 152.752 199.178L152.753 199.181C152.775 199.169 152.796 199.158 152.815 199.147L153.011 199.035C159.81 195.107 163.987 187.854 163.988 180.009V91.3344L156.378 95.2153C144.501 101.27 137.023 113.475 137.023 126.806V194.71ZM94.3223 166.372L101.934 162.493C113.811 156.438 121.288 144.233 121.288 130.902V62.9897C121.278 55.9521 128.901 51.5532 134.986 55.0307L135 55.0385L175.841 78.6167L176.036 78.7339C178.023 79.9714 179.237 82.15 179.237 84.5005V148.468L195.217 139.249C202.013 135.326 206.204 128.075 206.204 120.221V77.73C206.204 69.8848 202.022 62.6318 195.217 58.6997L189.232 55.2456L189.162 55.2046L189.094 55.1606C188.788 54.9649 188.5 54.787 188.219 54.6245L161.257 39.0571C154.463 35.135 146.091 35.1311 139.282 39.0591L139.283 39.06L105.765 58.4106L105.767 58.4135C105.713 58.443 105.723 58.4383 105.602 58.5063L105.6 58.5034C105.535 58.5391 105.481 58.5691 105.433 58.5962L105.302 58.6723C98.5016 62.5995 94.324 69.8532 94.3232 77.6987L94.3223 166.372Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 1.5 MiB |
@@ -99,7 +99,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
@@ -109,7 +109,7 @@
|
||||
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<!-- Hyperlight -->
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
|
||||
// This sample shows how to create a GitHub Copilot agent with shell command permissions.
|
||||
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
// Permission handler that prompts the user for approval
|
||||
static Task<PermissionRequestResult> PromptPermission(PermissionRequest request, PermissionInvocation invocation)
|
||||
static Task<PermissionDecision> PromptPermission(PermissionRequest request, PermissionInvocation invocation)
|
||||
{
|
||||
Console.WriteLine($"\n[Permission Request: {request.Kind}]");
|
||||
Console.Write("Approve? (y/n): ");
|
||||
|
||||
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
PermissionRequestResultKind kind = input is "Y" or "YES"
|
||||
? PermissionRequestResultKind.Approved
|
||||
: PermissionRequestResultKind.Rejected;
|
||||
PermissionDecision decision = input is "Y" or "YES"
|
||||
? PermissionDecision.ApproveOnce()
|
||||
: PermissionDecision.Reject();
|
||||
|
||||
return Task.FromResult(new PermissionRequestResult { Kind = kind });
|
||||
return Task.FromResult(decision);
|
||||
}
|
||||
|
||||
// Create and start a Copilot client
|
||||
|
||||
@@ -36,7 +36,7 @@ dotnet run
|
||||
You can customize the agent by providing additional configuration:
|
||||
|
||||
```csharp
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
// Create and start a Copilot client
|
||||
|
||||
@@ -79,8 +79,10 @@ AIAgent agent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
|
||||
@@ -44,8 +44,10 @@ AIAgent webSearchAgent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "WebSearchAgent",
|
||||
Description = "An agent that can search the web to find information.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
@@ -92,8 +94,10 @@ AIAgent parentAgent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "StockPriceResearcher",
|
||||
Description = "An agent that researches stock prices using background agents.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
|
||||
@@ -68,8 +68,10 @@ AIAgent agent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "DataAnalyst",
|
||||
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
|
||||
@@ -89,8 +89,10 @@ AIAgent agent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "CodeExecutionAgent",
|
||||
Description = "A technical assistant with sandboxed code execution and skill-based workflows.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
|
||||
@@ -71,6 +71,34 @@ curl -X POST http://localhost:8088/invocations \
|
||||
-d "Hello from Docker!"
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-invocations-echo-agent && cd hosted-invocations-echo-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-invocations-echo-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -107,3 +107,29 @@ azd env set SKILL_NAMES "support-style,escalation-policy"
|
||||
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup.
|
||||
|
||||
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills.
|
||||
|
||||
### Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-agent-skills && cd hosted-agent-skills
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-agent-skills
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
@@ -174,6 +174,34 @@ The model receives the top three search results as additional context and cites
|
||||
|
||||
Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-azure-search-rag && cd hosted-azure-search-rag
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-azure-search-rag
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -104,6 +104,32 @@ curl -X POST http://localhost:8088/responses \
|
||||
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-chat-client-agent && cd hosted-chat-client-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-chat-client-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -112,6 +112,34 @@ docker run --rm -p 8088:8088 \
|
||||
|
||||
The bundled `resources/` folder is part of the published output and ships inside the image.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-files && cd hosted-files
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-files
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj).
|
||||
|
||||
@@ -107,6 +107,32 @@ curl -X POST http://localhost:8088/responses \
|
||||
-d '{"input": "Hello!", "model": "<your-agent-name>"}'
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-foundry-agent && cd hosted-foundry-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-foundry-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedFoundryAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -108,6 +108,34 @@ The agent has a single tool `GetAvailableHotels` defined as a C# method with `[D
|
||||
|
||||
The tool searches a mock database of 6 Seattle hotels and returns formatted results with name, location, rating, and pricing.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-local-tools && cd hosted-local-tools
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-local-tools
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -78,6 +78,34 @@ docker run --rm -p 8088:8088 \
|
||||
hosted-mcp-tools
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir mcp-tools && cd mcp-tools
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME mcp-tools
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -139,6 +139,34 @@ The script publishes the project, builds the image, runs the container with two
|
||||
`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each
|
||||
user only sees their own memories, and exits non-zero on failure.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-memory-agent && cd hosted-memory-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-memory-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the
|
||||
|
||||
@@ -104,6 +104,34 @@ docker run --rm -p 8088:8088 \
|
||||
|
||||
Once deployed, telemetry flows to the Application Insights instance attached to your Foundry project. In the Foundry UI, the **Traces** tab next to **Playground** lists conversations and lets you drill into the span tree for any request.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-observability && cd hosted-observability
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-observability
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -111,6 +111,34 @@ The `TextSearchProvider` runs a mock search **before each model invocation**:
|
||||
|
||||
The model receives the search results as additional context and cites the source in its response. In production, replace `MockSearchAsync` with a call to Azure AI Search or your preferred search provider.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-text-rag && cd hosted-text-rag
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-text-rag
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedTextRag.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -98,6 +98,34 @@ Using the Azure Developer CLI:
|
||||
azd ai agent invoke --local "What skills do you have available?"
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-toolbox-mcp-skills && cd hosted-toolbox-mcp-skills
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-toolbox-mcp-skills
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-5
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -121,6 +121,34 @@ User message
|
||||
|
||||
The triage agent receives every message and hands off to the appropriate specialist. Specialists route back to the triage agent after responding, allowing for multi-turn conversations.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir triage-workflow && cd triage-workflow
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME triage-workflow
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -5,7 +5,7 @@ A hosted agent that demonstrates **multi-agent workflow orchestration**. Three t
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `hosted-workflow-simple`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
@@ -22,7 +22,7 @@ Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=hosted-workflow-simple
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
@@ -104,6 +104,34 @@ Input text
|
||||
|
||||
Each agent in the chain receives the output of the previous agent. The final result demonstrates how meaning is preserved (or subtly shifted) through multiple translation hops.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-workflows && cd hosted-workflows
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-workflow-simple
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME hosted-workflow-simple
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowSimple.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -44,18 +44,33 @@ public static class HostedFoundryMemoryProviderScopes
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
|
||||
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
|
||||
/// only to the same user within the same conversation.
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, composing
|
||||
/// <see cref="HostedSessionContext.UserId"/> and <see cref="HostedSessionContext.ChatId"/> into a
|
||||
/// single delimiter-safe partition key. Use this when memories should be visible only to the same
|
||||
/// user within the same conversation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both identity values are opaque strings that may contain any characters, including the <c>:</c>
|
||||
/// delimiter. To keep the composite key injective (so two distinct (user, chat) pairs can never
|
||||
/// collide), each part is escaped (<c>\</c> becomes <c>\\</c>, then <c>:</c> becomes <c>\:</c>) before
|
||||
/// being joined with a <c>::</c> separator.
|
||||
/// </remarks>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
|
||||
session =>
|
||||
{
|
||||
var ctx = GetRequiredHostedContext(session);
|
||||
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
|
||||
return new FoundryMemoryProvider.State(
|
||||
new FoundryMemoryProviderScope($"{EscapeScopePart(ctx.UserId)}::{EscapeScopePart(ctx.ChatId)}"));
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in a scope part so that distinct (user, chat) pairs produce distinct
|
||||
/// composite scope keys. Backslashes are escaped first (<c>\</c> becomes <c>\\</c>), then colons
|
||||
/// (<c>:</c> becomes <c>\:</c>), ensuring the <c>{user}::{chat}</c> format is unambiguous.
|
||||
/// </summary>
|
||||
private static string EscapeScopePart(string part) => part.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
|
||||
session?.GetHostedContext()
|
||||
?? throw new InvalidOperationException(
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Agents.AI.GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace GitHub.Copilot.SDK;
|
||||
namespace GitHub.Copilot;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="CopilotClient"/>
|
||||
|
||||
@@ -9,7 +9,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -169,7 +169,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
|
||||
|
||||
// Subscribe to session events
|
||||
using IDisposable subscription = copilotSession.On(evt =>
|
||||
using IDisposable subscription = copilotSession.On<SessionEvent>(evt =>
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string prompt = string.Join("\n", messages.Select(m => m.Text));
|
||||
|
||||
// Handle DataContent as attachments
|
||||
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
(List<AttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -262,10 +262,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
private async Task EnsureClientStartedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._copilotClient.State != ConnectionState.Connected)
|
||||
{
|
||||
await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private ResumeSessionConfig CreateResumeConfig()
|
||||
@@ -275,36 +272,18 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new instance
|
||||
/// with <see cref="SessionConfig.Streaming"/> set to <c>true</c>.
|
||||
/// with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
|
||||
/// </summary>
|
||||
internal static SessionConfig CopySessionConfig(SessionConfig source)
|
||||
{
|
||||
return new SessionConfig
|
||||
{
|
||||
Model = source.Model,
|
||||
ReasoningEffort = source.ReasoningEffort,
|
||||
Tools = source.Tools,
|
||||
SystemMessage = source.SystemMessage,
|
||||
AvailableTools = source.AvailableTools,
|
||||
ExcludedTools = source.ExcludedTools,
|
||||
Provider = source.Provider,
|
||||
OnPermissionRequest = source.OnPermissionRequest,
|
||||
OnUserInputRequest = source.OnUserInputRequest,
|
||||
Hooks = source.Hooks,
|
||||
WorkingDirectory = source.WorkingDirectory,
|
||||
ConfigDir = source.ConfigDir,
|
||||
McpServers = source.McpServers,
|
||||
CustomAgents = source.CustomAgents,
|
||||
SkillDirectories = source.SkillDirectories,
|
||||
DisabledSkills = source.DisabledSkills,
|
||||
InfiniteSessions = source.InfiniteSessions,
|
||||
Streaming = true
|
||||
};
|
||||
SessionConfig copy = source.Clone();
|
||||
copy.Streaming = true;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new
|
||||
/// <see cref="ResumeSessionConfig"/> with <see cref="ResumeSessionConfig.Streaming"/> set to <c>true</c>.
|
||||
/// <see cref="ResumeSessionConfig"/> with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
|
||||
/// </summary>
|
||||
internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? source)
|
||||
{
|
||||
@@ -321,7 +300,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
OnUserInputRequest = source?.OnUserInputRequest,
|
||||
Hooks = source?.Hooks,
|
||||
WorkingDirectory = source?.WorkingDirectory,
|
||||
ConfigDir = source?.ConfigDir,
|
||||
ConfigDirectory = source?.ConfigDirectory,
|
||||
McpServers = source?.McpServers,
|
||||
CustomAgents = source?.CustomAgents,
|
||||
SkillDirectories = source?.SkillDirectories,
|
||||
@@ -394,10 +373,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
AdditionalPropertiesDictionary<long>? additionalCounts = null;
|
||||
|
||||
if (usageEvent.Data.CacheWriteTokens is double cacheWriteTokens)
|
||||
if (usageEvent.Data.CacheWriteTokens is long cacheWriteTokens)
|
||||
{
|
||||
additionalCounts ??= [];
|
||||
additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = (long)cacheWriteTokens;
|
||||
additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = cacheWriteTokens;
|
||||
}
|
||||
|
||||
if (usageEvent.Data.Cost is double cost)
|
||||
@@ -406,10 +385,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
additionalCounts[nameof(AssistantUsageData.Cost)] = (long)cost;
|
||||
}
|
||||
|
||||
if (usageEvent.Data.Duration is double duration)
|
||||
if (usageEvent.Data.Duration is TimeSpan duration)
|
||||
{
|
||||
additionalCounts ??= [];
|
||||
additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration;
|
||||
additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration.TotalMilliseconds;
|
||||
}
|
||||
|
||||
return additionalCounts;
|
||||
@@ -432,7 +411,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
private static SessionConfig? GetSessionConfig(IList<AITool>? tools, string? instructions)
|
||||
{
|
||||
List<AIFunction>? mappedTools = tools is { Count: > 0 } ? tools.OfType<AIFunction>().ToList() : null;
|
||||
List<AIFunctionDeclaration>? mappedTools = tools is { Count: > 0 } ? tools.OfType<AIFunctionDeclaration>().ToList() : null;
|
||||
SystemMessageConfig? systemMessage = instructions is not null ? new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = instructions } : null;
|
||||
|
||||
if (mappedTools is null && systemMessage is null)
|
||||
@@ -443,11 +422,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
|
||||
}
|
||||
|
||||
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
private static async Task<(List<AttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<UserMessageAttachmentFile>? attachments = null;
|
||||
List<AttachmentFile>? attachments = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
@@ -461,7 +440,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageAttachmentFile
|
||||
attachments.Add(new AttachmentFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -16,23 +16,16 @@ public static class ChatClientHarnessExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="HarnessAgent"/> that wraps this <see cref="IChatClient"/> with a pre-configured
|
||||
/// pipeline including function invocation, per-service-call chat history persistence, and in-loop compaction.
|
||||
/// pipeline including function invocation, per-service-call chat history persistence, optional in-loop compaction, and a rich set
|
||||
/// of default context providers and agent decorators.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">
|
||||
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
|
||||
/// </param>
|
||||
/// <param name="maxContextWindowTokens">
|
||||
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="maxOutputTokens">
|
||||
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options for the agent, including instructions override, tools,
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// additional context providers, chat history provider, and compaction settings.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings with compaction disabled.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
@@ -43,10 +36,8 @@ public static class ChatClientHarnessExtensions
|
||||
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
|
||||
public static HarnessAgent AsHarnessAgent(
|
||||
this IChatClient chatClient,
|
||||
int maxContextWindowTokens,
|
||||
int maxOutputTokens,
|
||||
HarnessAgentOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
IServiceProvider? services = null) =>
|
||||
new(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||
new(chatClient, options, loggerFactory, services);
|
||||
}
|
||||
|
||||
@@ -18,50 +18,65 @@ namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A pre-configured <see cref="DelegatingAIAgent"/> that wraps a <see cref="ChatClientAgent"/> with
|
||||
/// function invocation, per-service-call chat history persistence, in-loop compaction, and a rich set
|
||||
/// function invocation, per-service-call chat history persistence, optional in-loop compaction, and a rich set
|
||||
/// of default context providers and agent decorators.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="HarnessAgent"/> assembles the following pipeline from a caller-supplied <see cref="IChatClient"/>:
|
||||
/// <see cref="HarnessAgent"/> provides an opinionated, batteries-included agent suitable for
|
||||
/// interactive agentic scenarios such as research, coding, data analysis, and general task automation.
|
||||
/// It assembles a full pipeline from a caller-supplied <see cref="IChatClient"/> so that callers
|
||||
/// only need to configure the parts they want to customize.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Chat client pipeline (inner to outer):</strong>
|
||||
/// <list type="number">
|
||||
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation.</description></item>
|
||||
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream.</description></item>
|
||||
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop.</description></item>
|
||||
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window.</description></item>
|
||||
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation with configurable iteration limits.</description></item>
|
||||
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream (e.g., for user interrupts).</description></item>
|
||||
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop, enabling crash recovery and history inspection.</description></item>
|
||||
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window. Only included when <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> and <see cref="HarnessAgentOptions.MaxOutputTokens"/> are both provided.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// By default, the following context providers are included (each can be disabled via <see cref="HarnessAgentOptions"/>):
|
||||
/// <strong>Context providers (each enabled by default, individually disableable via <see cref="HarnessAgentOptions"/>):</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="TodoProvider"/> — todo list management.</description></item>
|
||||
/// <item><description><see cref="AgentModeProvider"/> — agent mode tracking (plan/execute).</description></item>
|
||||
/// <item><description><see cref="FileMemoryProvider"/> — file-based session memory.</description></item>
|
||||
/// <item><description><see cref="FileAccessProvider"/> — shared file access.</description></item>
|
||||
/// <item><description><see cref="AgentSkillsProvider"/> — skill discovery and loading.</description></item>
|
||||
/// <item><description><see cref="TodoProvider"/> — persistent todo list that the agent uses to track multi-step plans. Disable with <see cref="HarnessAgentOptions.DisableTodoProvider"/>.</description></item>
|
||||
/// <item><description><see cref="AgentModeProvider"/> — mode tracking (e.g., "plan" vs "execute") that the agent uses to structure its work. Disable with <see cref="HarnessAgentOptions.DisableAgentModeProvider"/>.</description></item>
|
||||
/// <item><description><see cref="FileMemoryProvider"/> — file-based session memory allowing the agent to persist notes and artifacts across turns. Disable with <see cref="HarnessAgentOptions.DisableFileMemory"/>.</description></item>
|
||||
/// <item><description><see cref="FileAccessProvider"/> — shared file access providing read/write tools for a working directory. Disable with <see cref="HarnessAgentOptions.DisableFileAccess"/>.</description></item>
|
||||
/// <item><description><see cref="AgentSkillsProvider"/> — discovers and loads skill definitions from the file system, enabling dynamic tool sets. Disable with <see cref="HarnessAgentOptions.DisableAgentSkillsProvider"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The agent is also wrapped with the following decorators by default (each can be disabled):
|
||||
/// <strong>Optional context providers (enabled via <see cref="HarnessAgentOptions"/>):</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules.</description></item>
|
||||
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation.</description></item>
|
||||
/// <item><description><see cref="BackgroundAgentsProvider"/> — enables delegation to background agents for parallel work. Enable by setting <see cref="HarnessAgentOptions.BackgroundAgents"/>.</description></item>
|
||||
/// <item><description><c>ShellEnvironmentProvider</c> — injects OS/shell/CWD information and a shell execution tool. Enable by setting <c>HarnessAgentOptions.ShellExecutor</c> (.NET only).</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A <see cref="HostedWebSearchTool"/> is added to the chat options by default (can be disabled via
|
||||
/// <see cref="HarnessAgentOptions.DisableWebSearch"/>).
|
||||
/// <strong>Agent decorators (each enabled by default, individually disableable):</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules enabling safe unattended execution. Disable with <see cref="HarnessAgentOptions.DisableToolApproval"/>.</description></item>
|
||||
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation following semantic conventions for generative AI. Disable with <see cref="HarnessAgentOptions.DisableOpenTelemetry"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The underlying <see cref="ChatClientAgent"/> is configured with
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and
|
||||
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> set to <see langword="true"/>
|
||||
/// to match the manually-assembled pipeline.
|
||||
/// <strong>Default tools:</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="HostedWebSearchTool"/> — a hosted web search tool added to chat options by default. Disable with <see cref="HarnessAgentOptions.DisableWebSearch"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When no <see cref="HarnessAgentOptions.ChatHistoryProvider"/> is supplied, the agent defaults to an
|
||||
/// <see cref="InMemoryChatHistoryProvider"/> whose chat reducer applies the same compaction strategy,
|
||||
/// keeping in-memory history from growing unboundedly across sessions.
|
||||
/// <strong>Chat history:</strong> When no <see cref="HarnessAgentOptions.ChatHistoryProvider"/> is supplied,
|
||||
/// the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>. If compaction is enabled, the provider
|
||||
/// is configured with a compaction-based chat reducer to keep in-memory history bounded. Otherwise, no reducer
|
||||
/// is applied.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Default instructions:</strong> The agent includes built-in system instructions (<see cref="DefaultInstructions"/>)
|
||||
/// that guide general tool usage and reasoning patterns. These can be overridden via <see cref="HarnessAgentOptions.HarnessInstructions"/>
|
||||
/// and combined with agent-specific instructions via <see cref="ChatOptions.Instructions"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
@@ -90,21 +105,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// </summary>
|
||||
/// <param name="chatClient">
|
||||
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
|
||||
/// The agent wraps this client in a function-invocation, per-service-call persistence,
|
||||
/// and compaction pipeline automatically.
|
||||
/// </param>
|
||||
/// <param name="maxContextWindowTokens">
|
||||
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="maxOutputTokens">
|
||||
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy and to limit the model's output.
|
||||
/// The agent wraps this client in a function-invocation and per-service-call persistence pipeline.
|
||||
/// When compaction is enabled via <paramref name="options"/>, a compaction decorator is also added.
|
||||
/// </param>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options for the agent, including instructions override, tools,
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// additional context providers, chat history provider, and compaction settings.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings with compaction disabled.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
@@ -116,29 +123,28 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// <paramref name="chatClient"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
||||
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
|
||||
/// <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> is not positive, or
|
||||
/// <see cref="HarnessAgentOptions.MaxOutputTokens"/> is negative or greater than or equal to
|
||||
/// <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> (when both are provided).
|
||||
/// </exception>
|
||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
|
||||
public HarnessAgent(IChatClient chatClient, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
|
||||
: base(BuildAgent(
|
||||
Throw.IfNull(chatClient),
|
||||
maxContextWindowTokens,
|
||||
maxOutputTokens,
|
||||
options,
|
||||
loggerFactory,
|
||||
services))
|
||||
{
|
||||
}
|
||||
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
{
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, options, loggerFactory, services);
|
||||
|
||||
AIAgentBuilder builder = innerAgent.AsBuilder();
|
||||
|
||||
if (options?.DisableToolApproval is not true)
|
||||
{
|
||||
builder.UseToolApproval();
|
||||
builder.UseToolApproval(options?.ToolApprovalAgentOptions);
|
||||
}
|
||||
|
||||
if (options?.DisableOpenTelemetry is not true)
|
||||
@@ -149,17 +155,35 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
return builder.Build(services);
|
||||
}
|
||||
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
{
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: maxContextWindowTokens,
|
||||
maxOutputTokens: maxOutputTokens);
|
||||
// Determine compaction strategy:
|
||||
// 1. DisableCompaction = true → no compaction
|
||||
// 2. Custom CompactionStrategy provided → use it (ignore token params)
|
||||
// 3. Both token params provided → build default ContextWindowCompactionStrategy
|
||||
// 4. Otherwise → no compaction
|
||||
CompactionStrategy? compactionStrategy = null;
|
||||
if (options?.DisableCompaction is not true)
|
||||
{
|
||||
if (options?.CompactionStrategy is CompactionStrategy customStrategy)
|
||||
{
|
||||
compactionStrategy = customStrategy;
|
||||
}
|
||||
else if (options?.MaxContextWindowTokens is int maxCtx && options?.MaxOutputTokens is int maxOut)
|
||||
{
|
||||
compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: maxCtx,
|
||||
maxOutputTokens: maxOut);
|
||||
}
|
||||
}
|
||||
|
||||
ChatHistoryProvider chatHistoryProvider = options?.ChatHistoryProvider
|
||||
?? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
});
|
||||
?? (compactionStrategy is not null
|
||||
? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
})
|
||||
: new InMemoryChatHistoryProvider());
|
||||
|
||||
string harnessInstructions = options?.HarnessInstructions ?? DefaultInstructions;
|
||||
string? agentInstructions = options?.ChatOptions?.Instructions;
|
||||
@@ -172,20 +196,34 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
(false, false) => $"{harnessInstructions}\n\n{agentInstructions}",
|
||||
};
|
||||
|
||||
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
|
||||
ChatOptions chatOptions = BuildChatOptions(options, instructions, options?.MaxOutputTokens);
|
||||
|
||||
var compactionProvider = new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory);
|
||||
CompactionProvider? compactionProvider = compactionStrategy is not null
|
||||
? new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory)
|
||||
: null;
|
||||
|
||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
|
||||
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
ChatClientBuilder chatClientBuilder = chatClient.AsBuilder();
|
||||
|
||||
if (options?.DisableNonApprovalRequiredFunctionBypassing is not true)
|
||||
{
|
||||
chatClientBuilder.UseNonApprovalRequiredFunctionBypassing();
|
||||
}
|
||||
|
||||
ChatClientBuilder pipeline = chatClientBuilder
|
||||
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
|
||||
: null)
|
||||
.UseMessageInjection()
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(compactionProvider)
|
||||
.UsePerServiceCallChatHistoryPersistence();
|
||||
|
||||
if (compactionProvider is not null)
|
||||
{
|
||||
pipeline = pipeline.UseAIContextProviders(compactionProvider);
|
||||
}
|
||||
|
||||
return pipeline
|
||||
.BuildAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = options?.Id,
|
||||
@@ -203,11 +241,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
services);
|
||||
}
|
||||
|
||||
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
|
||||
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int? maxOutputTokens)
|
||||
{
|
||||
ChatOptions result = options?.ChatOptions?.Clone() ?? new ChatOptions();
|
||||
result.Instructions = instructions;
|
||||
result.MaxOutputTokens ??= maxOutputTokens;
|
||||
|
||||
if (maxOutputTokens.HasValue)
|
||||
{
|
||||
result.MaxOutputTokens ??= maxOutputTokens.Value;
|
||||
}
|
||||
|
||||
if (options?.DisableWebSearch is not true)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
@@ -31,6 +32,68 @@ public sealed class HarnessAgentOptions
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When both <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/> are provided (and no
|
||||
/// custom <see cref="CompactionStrategy"/> is set), a default <see cref="ContextWindowCompactionStrategy"/>
|
||||
/// is constructed from these values to prevent function-invocation loops from overflowing the context window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ignored when <see cref="CompactionStrategy"/> is provided or when <see cref="DisableCompaction"/> is
|
||||
/// <see langword="true"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int? MaxContextWindowTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, this value is used as the default for <see cref="ChatOptions"/>.<see cref="ChatOptions.MaxOutputTokens"/>
|
||||
/// when not explicitly configured.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For compaction purposes, this value is used together with <see cref="MaxContextWindowTokens"/> to construct a
|
||||
/// default <see cref="ContextWindowCompactionStrategy"/> — but only when no custom <see cref="CompactionStrategy"/>
|
||||
/// is provided and <see cref="DisableCompaction"/> is <see langword="false"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int? MaxOutputTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a custom <see cref="Compaction.CompactionStrategy"/> to use for in-loop context-window compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When provided, this strategy is used directly and <see cref="MaxContextWindowTokens"/> and
|
||||
/// <see cref="MaxOutputTokens"/> are ignored for compaction purposes (<see cref="MaxOutputTokens"/> is still
|
||||
/// used as the default for <see cref="ChatOptions"/>.<see cref="ChatOptions.MaxOutputTokens"/> if set).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see langword="null"/> and both <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/>
|
||||
/// are provided, a default <see cref="ContextWindowCompactionStrategy"/> is constructed from those values.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This property is ignored when <see cref="DisableCompaction"/> is <see langword="true"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public CompactionStrategy? CompactionStrategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether in-loop compaction is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="true"/>, compaction is disabled regardless of <see cref="CompactionStrategy"/>,
|
||||
/// <see cref="MaxContextWindowTokens"/>, or <see cref="MaxOutputTokens"/> settings. No
|
||||
/// <see cref="CompactionProvider"/> is added to the chat client pipeline, and the default
|
||||
/// <see cref="InMemoryChatHistoryProvider"/> is configured without a chat reducer.
|
||||
/// </remarks>
|
||||
public bool DisableCompaction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional chat options such as tools for the agent to use.
|
||||
/// </summary>
|
||||
@@ -68,9 +131,9 @@ public sealed class HarnessAgentOptions
|
||||
/// Gets or sets the <see cref="ChatHistoryProvider"/> to use for storing chat history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>
|
||||
/// configured with a compaction-based chat reducer derived from the <c>maxContextWindowTokens</c>
|
||||
/// and <c>maxOutputTokens</c> constructor parameters of <see cref="HarnessAgent"/>.
|
||||
/// When <see langword="null"/>, the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// If <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/> are both provided,
|
||||
/// the default provider is configured with a compaction-based chat reducer; otherwise, no reducer is applied.
|
||||
/// </remarks>
|
||||
public ChatHistoryProvider? ChatHistoryProvider { get; set; }
|
||||
|
||||
@@ -101,6 +164,29 @@ public sealed class HarnessAgentOptions
|
||||
/// </remarks>
|
||||
public bool DisableToolApproval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the options for the <see cref="ToolApprovalAgent"/> middleware.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, the <see cref="ToolApprovalAgent"/> uses default settings.
|
||||
/// This property has no effect when <see cref="DisableToolApproval"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public ToolApprovalAgentOptions? ToolApprovalAgentOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether bypassing of approval requests for tools that do not
|
||||
/// require approval is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), the underlying chat client pipeline includes the decorator
|
||||
/// added by <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/> above the
|
||||
/// function invocation middleware.
|
||||
/// This stores automatically approved function calls for tools that do not require approval in the session
|
||||
/// state when they are returned alongside tools that do, so that only tools that truly require human
|
||||
/// approval are surfaced to the caller.
|
||||
/// </remarks>
|
||||
public bool DisableNonApprovalRequiredFunctionBypassing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
|
||||
/// </summary>
|
||||
|
||||
@@ -21,6 +21,18 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// from the ambient <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>
|
||||
/// must uniquely identify the principal within the served population. Display names, usernames, email
|
||||
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless the
|
||||
/// host can prove their uniqueness across all callers: two distinct principals that share the same value
|
||||
/// would receive the same isolation key and could read or overwrite one another's persisted sessions.
|
||||
/// The default claim type is <see cref="ClaimTypes.NameIdentifier"/>, a stable unique subject identifier
|
||||
/// that is typically populated from the OpenID Connect <c>sub</c> claim via the default JWT inbound claim
|
||||
/// mapping (note that this differs from Entra's object identifier <c>oid</c> claim; override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
|
||||
/// provider maps a different claim).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
@@ -60,18 +72,24 @@ public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProv
|
||||
/// <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 the value of the
|
||||
/// configured claim type from the current user's identity, or <see langword="null"/> if the claim
|
||||
/// is not present or the HTTP context is unavailable.
|
||||
/// configured claim type from the current user's identity, or <see langword="null"/> if the HTTP
|
||||
/// context is unavailable, the user is not authenticated, or the claim is not present.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
|
||||
/// of the specified type exist, the first match is returned.
|
||||
/// This method only reads claims from an authenticated principal: if the current request has no
|
||||
/// authenticated user, it returns <see langword="null"/> rather than trusting claims on an
|
||||
/// unauthenticated identity. The claim value is retrieved from <c>HttpContext.User.Claims</c>; if
|
||||
/// multiple claims of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Claim? claim = this._httpContextAccessor?
|
||||
.HttpContext?
|
||||
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
ClaimsPrincipal? user = this._httpContextAccessor?.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return new ValueTask<string?>((string?)null);
|
||||
}
|
||||
|
||||
Claim? claim = user?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
|
||||
@@ -14,17 +14,30 @@ public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// Defaults to <see cref="ClaimTypes.NameIdentifier"/>, which corresponds to a stable, unique
|
||||
/// subject identifier for the authenticated principal. For OpenID Connect tokens (including those
|
||||
/// issued by Microsoft Entra ID), this is typically populated from the <c>sub</c> claim via the
|
||||
/// default JWT inbound claim mapping. Note that <c>sub</c> is distinct from Entra's object
|
||||
/// identifier (<c>oid</c>) claim; if you require the <c>oid</c> claim, or your provider does not map
|
||||
/// a unique identifier onto <see cref="ClaimTypes.NameIdentifier"/>, override <see cref="ClaimType"/>
|
||||
/// with the appropriate claim type.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> The configured claim must uniquely identify the principal
|
||||
/// within the served population. Display names (<see cref="ClaimsIdentity.DefaultNameClaimType"/>
|
||||
/// / <see cref="ClaimTypes.Name"/>), usernames, email aliases, and other mutable or non-unique
|
||||
/// claims are <strong>unsafe</strong> isolation keys unless the host can prove their uniqueness
|
||||
/// across all callers. Two distinct principals that share the same value for a non-unique claim
|
||||
/// would receive the same session-isolation key and could read or overwrite one another's
|
||||
/// persisted sessions. Only override this value with a claim that is guaranteed unique and stable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
|
||||
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider</description></item>
|
||||
/// <item><description>A composite of tenant and subject identifiers — required for multi-tenant hosts where the subject is only unique per tenant</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider, provided they are unique and stable</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
public string ClaimType { get; set; } = ClaimTypes.NameIdentifier;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -19,8 +20,28 @@ public static class ServiceCollectionExtensions
|
||||
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
|
||||
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <paramref name="options"/> is not supplied, the isolation key is derived from the
|
||||
/// <see cref="ClaimTypes.NameIdentifier"/> claim, a stable unique subject identifier. For OpenID
|
||||
/// Connect tokens (including Microsoft Entra ID), this is typically mapped from the <c>sub</c> claim
|
||||
/// by the default JWT inbound claim mapping. Authentication schemes that do not project a unique
|
||||
/// identifier onto <see cref="ClaimTypes.NameIdentifier"/> (or hosts that require a different claim
|
||||
/// such as Entra's <c>oid</c>) should override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
|
||||
/// absent, which causes strict-mode session stores to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> If you override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
|
||||
/// uniquely identify the principal within the served population. Display names, usernames, email
|
||||
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless
|
||||
/// the host can prove their uniqueness across all callers, because distinct principals that share the
|
||||
/// same claim value would receive the same isolation key and could access one another's sessions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IServiceCollection UseClaimsBasedSessionIsolation(
|
||||
this IServiceCollection services,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview;
|
||||
@@ -16,6 +20,7 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
{
|
||||
private readonly IChannelHandler _channelHandler;
|
||||
private readonly IPurviewClient _purviewClient;
|
||||
private readonly ICacheProvider _cacheProvider;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,12 +28,14 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
/// </summary>
|
||||
/// <param name="channelHandler">The channel handler used to manage job channels.</param>
|
||||
/// <param name="purviewClient">The Purview client used to send requests to Purview.</param>
|
||||
/// <param name="cacheProvider">The cache provider used to store protection scopes results.</param>
|
||||
/// <param name="logger">The logger used to log information about background jobs.</param>
|
||||
/// <param name="purviewSettings">The settings used to configure Purview client behavior.</param>
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings)
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ICacheProvider cacheProvider, ILogger logger, PurviewSettings purviewSettings)
|
||||
{
|
||||
this._channelHandler = channelHandler;
|
||||
this._purviewClient = purviewClient;
|
||||
this._cacheProvider = cacheProvider;
|
||||
this._logger = logger;
|
||||
|
||||
for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++)
|
||||
@@ -67,6 +74,28 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
break;
|
||||
case ContentActivityJob contentActivityJob:
|
||||
_ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case ScopeRetrievalJob scopeRetrievalJob:
|
||||
try
|
||||
{
|
||||
ProtectionScopesResponse response = await this._purviewClient.GetProtectionScopesAsync(scopeRetrievalJob.Request, CancellationToken.None).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(scopeRetrievalJob.CacheKey, response, CancellationToken.None).ConfigureAwait(false);
|
||||
(bool shouldProcess, List<DlpActionInfo> _, ExecutionMode _) = ScopedContentProcessor.CheckApplicableScopes(scopeRetrievalJob.ProcessContentRequest, response);
|
||||
if (!shouldProcess)
|
||||
{
|
||||
ProcessContentRequest pcRequest = scopeRetrievalJob.ProcessContentRequest;
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
this._channelHandler.QueueJob(new ContentActivityJob(caRequest));
|
||||
}
|
||||
}
|
||||
catch (PurviewPaymentRequiredException ex)
|
||||
{
|
||||
await this._cacheProvider.SetAsync(
|
||||
new PaymentRequiredCacheKey(scopeRetrievalJob.Request.TenantId),
|
||||
new PaymentRequiredCacheEntry(ex.Message),
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Cached tenant-level payment required state.
|
||||
/// </summary>
|
||||
internal sealed class PaymentRequiredCacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="PaymentRequiredCacheEntry"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The payment required error message.</param>
|
||||
public PaymentRequiredCacheEntry(string? message)
|
||||
{
|
||||
this.Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The payment required error message.
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Common;
|
||||
|
||||
/// <summary>
|
||||
/// A cache key for tenant-level payment required state.
|
||||
/// </summary>
|
||||
internal sealed class PaymentRequiredCacheKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="PaymentRequiredCacheKey"/>.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The id of the tenant.</param>
|
||||
public PaymentRequiredCacheKey(string tenantId)
|
||||
{
|
||||
this.TenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The id of the tenant.
|
||||
/// </summary>
|
||||
public string TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Class representing a job that refreshes the protection scopes cache in the background.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by the parallel protection scopes retrieval path to warm the cache without blocking the
|
||||
/// foreground ProcessContent call.
|
||||
/// </remarks>
|
||||
internal sealed class ScopeRetrievalJob : BackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScopeRetrievalJob"/> class.
|
||||
/// </summary>
|
||||
/// <param name="request">The protection scopes request to send to Purview.</param>
|
||||
/// <param name="cacheKey">The cache key used to store the response.</param>
|
||||
/// <param name="processContentRequest">The original process content request that triggered scope retrieval.</param>
|
||||
public ScopeRetrievalJob(ProtectionScopesRequest request, ProtectionScopesCacheKey cacheKey, ProcessContentRequest processContentRequest)
|
||||
{
|
||||
this.Request = request;
|
||||
this.CacheKey = cacheKey;
|
||||
this.ProcessContentRequest = processContentRequest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the protection scopes request.
|
||||
/// </summary>
|
||||
public ProtectionScopesRequest Request { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache key used to store the response.
|
||||
/// </summary>
|
||||
public ProtectionScopesCacheKey CacheKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original process content request that triggered scope retrieval.
|
||||
/// </summary>
|
||||
public ProcessContentRequest ProcessContentRequest { get; }
|
||||
}
|
||||
@@ -53,4 +53,10 @@ internal sealed class ProcessContentRequest
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal string? ScopeIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the ProcessContent request should ask the service for inline evaluation.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal bool ProcessInline { get; set; }
|
||||
}
|
||||
|
||||
@@ -130,6 +130,11 @@ internal sealed class PurviewClient : IPurviewClient
|
||||
message.Headers.Add("If-None-Match", request.ScopeIdentifier);
|
||||
}
|
||||
|
||||
if (request.ProcessInline)
|
||||
{
|
||||
message.Headers.Add("Prefer", "evaluateInline");
|
||||
}
|
||||
|
||||
string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest)));
|
||||
message.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
|
||||
@@ -218,8 +218,8 @@ The policy logic is identical; the only difference is the hook point in the pipe
|
||||
|
||||
The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user.
|
||||
|
||||
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls.
|
||||
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit.
|
||||
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. When a lookup is not cached, the middleware will refresh it in a background worker so the foreground ProcessContent request does not have to wait.
|
||||
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. Payment Required responses from background scope lookups are cached at the tenant level so subsequent requests for the tenant short-circuit.
|
||||
|
||||
## Exceptions
|
||||
| Exception | Scenario |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
@@ -193,43 +194,60 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId);
|
||||
|
||||
PaymentRequiredCacheEntry? cachedPaymentRequired = await this._cacheProvider.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
|
||||
new PaymentRequiredCacheKey(pcRequest.TenantId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (cachedPaymentRequired != null)
|
||||
{
|
||||
throw new PurviewPaymentRequiredException(cachedPaymentRequired.Message ?? "Payment required");
|
||||
}
|
||||
|
||||
ProtectionScopesCacheKey cacheKey = new(psRequest);
|
||||
|
||||
ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ProtectionScopesResponse psResponse;
|
||||
|
||||
if (cacheResponse != null)
|
||||
{
|
||||
psResponse = cacheResponse;
|
||||
}
|
||||
else
|
||||
{
|
||||
psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false);
|
||||
return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest));
|
||||
}
|
||||
catch (PurviewJobException)
|
||||
{
|
||||
// QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent.
|
||||
}
|
||||
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply locally-cached protection scopes to the request and dispatch ProcessContent appropriately.
|
||||
/// </summary>
|
||||
private async Task<ProcessContentResponse> ProcessWithCachedScopesAsync(
|
||||
ProcessContentRequest pcRequest,
|
||||
ProtectionScopesResponse psResponse,
|
||||
ProtectionScopesCacheKey cacheKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier;
|
||||
|
||||
(bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse);
|
||||
|
||||
if (shouldProcess)
|
||||
{
|
||||
pcRequest.ProcessInline = executionMode == ExecutionMode.EvaluateInline;
|
||||
|
||||
if (executionMode == ExecutionMode.EvaluateOffline)
|
||||
{
|
||||
this._channelHandler.QueueJob(new ProcessContentJob(pcRequest));
|
||||
return new ProcessContentResponse();
|
||||
}
|
||||
|
||||
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
|
||||
{
|
||||
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
|
||||
return pcResponse;
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
@@ -238,6 +256,30 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
return new ProcessContentResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call ProcessContent and invalidate the protection scopes cache when the response indicates the cached scopes are stale.
|
||||
/// </summary>
|
||||
private async Task<ProcessContentResponse> CallProcessContentAsync(
|
||||
ProcessContentRequest pcRequest,
|
||||
ProtectionScopesCacheKey cacheKey,
|
||||
List<DlpActionInfo>? dlpActions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pcRequest.ScopeIdentifier != null && pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
|
||||
{
|
||||
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (dlpActions?.Count > 0)
|
||||
{
|
||||
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dedupe policy actions received from the service.
|
||||
/// </summary>
|
||||
@@ -248,9 +290,21 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
if (actionInfos?.Count > 0)
|
||||
{
|
||||
pcResponse.PolicyActions = pcResponse.PolicyActions is null ?
|
||||
actionInfos :
|
||||
[.. pcResponse.PolicyActions, .. actionInfos];
|
||||
List<DlpActionInfo> combinedActions = [];
|
||||
HashSet<(DlpAction Action, RestrictionAction? RestrictionAction)> seenActions = [];
|
||||
IEnumerable<DlpActionInfo> allActions = pcResponse.PolicyActions is null
|
||||
? actionInfos
|
||||
: pcResponse.PolicyActions.Concat(actionInfos);
|
||||
|
||||
foreach (DlpActionInfo actionInfo in allActions)
|
||||
{
|
||||
if (seenActions.Add((actionInfo.Action, actionInfo.RestrictionAction)))
|
||||
{
|
||||
combinedActions.Add(actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
pcResponse.PolicyActions = combinedActions;
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
@@ -262,7 +316,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
/// <param name="pcRequest">The process content request.</param>
|
||||
/// <param name="psResponse">The protection scopes response that was returned for the process content request.</param>
|
||||
/// <returns>A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request.</returns>
|
||||
private static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
internal static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
{
|
||||
ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity);
|
||||
|
||||
@@ -284,7 +338,11 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
|
||||
foreach (var location in scope.Locations ?? Array.Empty<PolicyLocation>())
|
||||
{
|
||||
locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase);
|
||||
if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
locationMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (activityMatch && locationMatch)
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace Microsoft.Agents.AI.Purview.Serialization;
|
||||
[JsonSerializable(typeof(ContentActivitiesRequest))]
|
||||
[JsonSerializable(typeof(ContentActivitiesResponse))]
|
||||
[JsonSerializable(typeof(ProtectionScopesCacheKey))]
|
||||
[JsonSerializable(typeof(PaymentRequiredCacheKey))]
|
||||
[JsonSerializable(typeof(PaymentRequiredCacheEntry))]
|
||||
internal sealed partial class SourceGenerationContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -49,7 +49,7 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(this.Model.Items);
|
||||
if (expressionResult.Value is TableDataValue tableValue)
|
||||
{
|
||||
this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())];
|
||||
this._values = [.. tableValue.Values.Select(ToLoopValue)];
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -99,6 +99,15 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
}
|
||||
}
|
||||
|
||||
// Power Fx wraps scalar array literals (`=[1, 2, 3]`) as `Table({Value: 1}, ...)`. Unwrap that single-column
|
||||
// `Value`-record shape so `Local.LoopValue` is the scalar; multi-field and other shapes pass through unchanged.
|
||||
private static FormulaValue ToLoopValue(DataValue value) =>
|
||||
value is RecordDataValue record
|
||||
&& record.Properties.Count == 1
|
||||
&& record.Properties.TryGetValue("Value", out DataValue? singleColumn)
|
||||
? singleColumn.ToFormula()
|
||||
: value.ToFormula();
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the iteration cursor (<see cref="_index"/>), the materialized item snapshot
|
||||
|
||||
@@ -5,4 +5,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
internal static class MagenticConstants
|
||||
{
|
||||
public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey);
|
||||
public const string CurrentSpeakerStateKey = nameof(CurrentSpeakerStateKey);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
private MagenticTaskContext? _taskContext;
|
||||
private PortBinding? _planReviewPort;
|
||||
private string? _currentSpeakerExecutorId;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
@@ -196,15 +197,46 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
else
|
||||
{
|
||||
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan).
|
||||
// Capture the participant's reply into the manager-visible chat history so the progress ledger can see it.
|
||||
if (messages is { Count: > 0 })
|
||||
{
|
||||
// Capture the participant's reply into the manager-visible chat history so the progress ledger can see it.
|
||||
this._taskContext.ChatHistory.AddRange(messages);
|
||||
|
||||
// Share the reply with the other participants except the replier
|
||||
await this.BroadcastReplyToOtherParticipantsAsync(messages, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a participant's reply to every other participant so they share the running conversation.
|
||||
/// The messages are buffered (no <see cref="TurnToken"/> is sent) - they only become context for the participant's next turn.
|
||||
/// </summary>
|
||||
private ValueTask BroadcastReplyToOtherParticipantsAsync(
|
||||
List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// Without a known current speaker we cannot exclude the reply's author, so skip the broadcast
|
||||
// rather than risk echoing the reply back to its own author. This covers the window after a
|
||||
// checkpoint restore but before any delegation has set the current speaker.
|
||||
if (string.IsNullOrEmpty(this._currentSpeakerExecutorId))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
List<Task>? sendTasks = null;
|
||||
foreach (AIAgent agent in team)
|
||||
{
|
||||
string executorId = AIAgentHostExecutor.IdFor(agent);
|
||||
if (string.Equals(executorId, this._currentSpeakerExecutorId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
(sendTasks ??= []).Add(context.SendMessageAsync(messages, executorId, cancellationToken).AsTask());
|
||||
}
|
||||
return sendTasks is null ? default : new ValueTask(Task.WhenAll(sendTasks));
|
||||
}
|
||||
|
||||
private ChatMessage? _fullTaskLedgerMessage;
|
||||
private ValueTask DelegateToTeamAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -287,15 +319,18 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
return;
|
||||
}
|
||||
|
||||
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(taskContext.ProgressLedger.InstructionOrQuestion))
|
||||
{
|
||||
ChatMessage instruction = new(ChatRole.Assistant, taskContext.ProgressLedger.InstructionOrQuestion);
|
||||
taskContext.ChatHistory.Add(instruction);
|
||||
|
||||
await context.SendMessageAsync(instruction, cancellationToken).ConfigureAwait(false);
|
||||
// Target the instruction at the chosen speaker only.
|
||||
await context.SendMessageAsync(instruction, nextExecutorId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
|
||||
this._currentSpeakerExecutorId = nextExecutorId;
|
||||
await context.SendMessageAsync(new TurnToken(taskContext.EmitUpdateEvents), nextExecutorId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -303,6 +338,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
{
|
||||
bool wasStalled = taskContext.IsStalled;
|
||||
taskContext.Reset();
|
||||
this._currentSpeakerExecutorId = null;
|
||||
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false);
|
||||
@@ -313,9 +349,9 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
List<ChatMessage> messages = [await this._manager.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false)];
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
taskContext.IsTerminated = true;
|
||||
this._currentSpeakerExecutorId = null;
|
||||
}
|
||||
|
||||
private const string CurrentTurnEmitUpdateEventsKey = nameof(CurrentTurnEmitUpdateEventsKey);
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Task contextStateTask = this._taskContext == null
|
||||
@@ -325,14 +361,21 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
cancellationToken: cancellationToken)
|
||||
.AsTask();
|
||||
|
||||
Task currentSpeakerTask = context.QueueStateUpdateAsync(MagenticConstants.CurrentSpeakerStateKey,
|
||||
this._currentSpeakerExecutorId,
|
||||
cancellationToken: cancellationToken)
|
||||
.AsTask();
|
||||
|
||||
await Task.WhenAll(base.OnCheckpointingAsync(context, cancellationToken).AsTask(),
|
||||
contextStateTask).ConfigureAwait(false);
|
||||
contextStateTask,
|
||||
currentSpeakerTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), LoadContextStateAsync())
|
||||
.ConfigureAwait(false);
|
||||
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(),
|
||||
LoadContextStateAsync(),
|
||||
LoadCurrentSpeakerAsync()).ConfigureAwait(false);
|
||||
|
||||
async Task LoadContextStateAsync()
|
||||
{
|
||||
@@ -344,5 +387,11 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
this._taskContext = new MagenticTaskContext(state, team, limits, []);
|
||||
}
|
||||
}
|
||||
|
||||
async Task LoadCurrentSpeakerAsync()
|
||||
{
|
||||
this._currentSpeakerExecutorId = await context.ReadStateAsync<string?>(MagenticConstants.CurrentSpeakerStateKey, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ namespace Microsoft.Agents.AI;
|
||||
/// </remarks>
|
||||
public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
private const string AGUIProviderName = "ag-ui";
|
||||
|
||||
private readonly ChatClientAgentOptions? _agentOptions;
|
||||
private readonly HashSet<string> _aiContextProviderStateKeys;
|
||||
private readonly AIAgentMetadata _agentMetadata;
|
||||
@@ -562,6 +564,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
requestChatOptions.ModelId ??= this._agentOptions.ChatOptions.ModelId;
|
||||
requestChatOptions.PresencePenalty ??= this._agentOptions.ChatOptions.PresencePenalty;
|
||||
requestChatOptions.ResponseFormat ??= this._agentOptions.ChatOptions.ResponseFormat;
|
||||
requestChatOptions.Reasoning ??= this._agentOptions.ChatOptions.Reasoning;
|
||||
requestChatOptions.Seed ??= this._agentOptions.ChatOptions.Seed;
|
||||
requestChatOptions.Temperature ??= this._agentOptions.ChatOptions.Temperature;
|
||||
requestChatOptions.TopP ??= this._agentOptions.ChatOptions.TopP;
|
||||
@@ -815,7 +818,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(responseConversationId))
|
||||
{
|
||||
if (this._agentOptions?.ChatHistoryProvider is not null)
|
||||
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) && this._agentOptions?.ChatHistoryProvider is not null)
|
||||
{
|
||||
// The agent has a ChatHistoryProvider configured, but the service returned a conversation id,
|
||||
// meaning the service manages chat history server-side. Both cannot be used simultaneously.
|
||||
@@ -929,6 +932,9 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAGUIProviderName(string? providerName) =>
|
||||
string.Equals(providerName, AGUIProviderName, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that <see cref="AIAgent.CurrentRunContext"/> contains the resolved session.
|
||||
/// </summary>
|
||||
@@ -976,12 +982,17 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions)
|
||||
{
|
||||
ChatHistoryProvider? provider = chatOptions?.ConversationId is null ? this.ChatHistoryProvider : null;
|
||||
ChatHistoryProvider? provider =
|
||||
chatOptions?.ConversationId is null || IsAGUIProviderName(this._agentMetadata.ProviderName)
|
||||
? this.ChatHistoryProvider
|
||||
: null;
|
||||
|
||||
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
|
||||
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
|
||||
{
|
||||
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true && string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
|
||||
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) &&
|
||||
this._agentOptions?.ThrowOnChatHistoryProviderConflict is true &&
|
||||
string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
|
||||
|
||||
@@ -181,6 +181,36 @@ public sealed class ChatClientAgentOptions
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public bool EnableMessageInjection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to store automatically approved function calls in the session state
|
||||
/// for tools that do not require approval when they are returned alongside tools that do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="FunctionInvokingChatClient"/> has an all-or-nothing behavior for approvals: when any tool
|
||||
/// in a response is an <see cref="ApprovalRequiredAIFunction"/>, it converts all <see cref="FunctionCallContent"/>
|
||||
/// items to <see cref="ToolApprovalRequestContent"/>, even for tools that do not require approval.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Setting this property to <see langword="true"/> injects an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/>
|
||||
/// decorator above <see cref="FunctionInvokingChatClient"/> in the pipeline. This decorator identifies approval
|
||||
/// requests for non-approval-required tools, removes them from the response, and stores them in the session.
|
||||
/// On the next request, the stored items are automatically re-injected as approved, so the caller only needs
|
||||
/// to handle approval requests for tools that truly require human approval.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
||||
/// When using a custom chat client stack, you can add an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/>
|
||||
/// manually via the <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/>
|
||||
/// extension method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </value>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public bool EnableNonApprovalRequiredFunctionBypassing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
|
||||
/// </summary>
|
||||
@@ -199,5 +229,6 @@ public sealed class ChatClientAgentOptions
|
||||
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
|
||||
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
|
||||
EnableMessageInjection = this.EnableMessageInjection,
|
||||
EnableNonApprovalRequiredFunctionBypassing = this.EnableNonApprovalRequiredFunctionBypassing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,4 +148,35 @@ public static class ChatClientBuilderExtensions
|
||||
{
|
||||
return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/> to the chat client pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This decorator should be positioned above the <see cref="FunctionInvokingChatClient"/> in the pipeline
|
||||
/// so that it can intercept approval requests for tools that do not require approval. When
|
||||
/// <see cref="FunctionInvokingChatClient"/> converts all function calls to approval requests (because at
|
||||
/// least one tool requires approval), this decorator removes the requests for non-approval-required tools,
|
||||
/// stores them in the session, and automatically re-injects them as approved on the next request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This extension method is intended for use with custom chat client stacks when
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
||||
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
|
||||
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
|
||||
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/> is <see langword="true"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> with
|
||||
/// an active session, and will throw an exception if used in any other stack.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static ChatClientBuilder UseNonApprovalRequiredFunctionBypassing(this ChatClientBuilder builder)
|
||||
{
|
||||
return builder.Use(innerClient => new NonApprovalRequiredFunctionBypassingChatClient(innerClient));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,17 @@ public static class ChatClientExtensions
|
||||
{
|
||||
var chatBuilder = chatClient.AsBuilder();
|
||||
|
||||
// NonApprovalRequiredFunctionBypassingChatClient is registered before FunctionInvokingChatClient so that
|
||||
// it sits above FICC in the pipeline. ChatClientBuilder.Build applies factories in reverse order,
|
||||
// making the first Use() call outermost. By adding this decorator first, the resulting pipeline is:
|
||||
// NonApprovalRequiredFunctionBypassingChatClient → FunctionInvokingChatClient → ChatHistoryPersistingChatClient → leaf IChatClient
|
||||
// This allows the decorator to intercept FICC's responses and remove approval requests for tools
|
||||
// that don't actually require approval, storing them for automatic re-injection on the next request.
|
||||
if (options?.EnableNonApprovalRequiredFunctionBypassing is true)
|
||||
{
|
||||
chatBuilder.Use(innerClient => new NonApprovalRequiredFunctionBypassingChatClient(innerClient));
|
||||
}
|
||||
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
chatBuilder.Use((innerClient, services) =>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating chat client that automatically removes <see cref="ToolApprovalRequestContent"/> for tools
|
||||
/// that do not actually require approval, storing auto-approved results in the session for transparent
|
||||
/// re-injection on the next request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="FunctionInvokingChatClient"/> has an all-or-nothing behavior for approvals: when any tool
|
||||
/// in a response is an <see cref="ApprovalRequiredAIFunction"/>, it converts all <see cref="FunctionCallContent"/>
|
||||
/// items to <see cref="ToolApprovalRequestContent"/> — even for tools that do not require approval. This
|
||||
/// decorator sits above <see cref="FunctionInvokingChatClient"/> in the pipeline and transparently handles
|
||||
/// the non-approval-required items so callers only see approval requests for tools that truly need them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On outbound responses, the decorator identifies <see cref="ToolApprovalRequestContent"/> items for tools
|
||||
/// that are not wrapped in <see cref="ApprovalRequiredAIFunction"/>, removes them from the response, and
|
||||
/// stores them in the session's <see cref="AgentSessionStateBag"/>. On the next inbound request, the stored
|
||||
/// items are re-injected as pre-approved <see cref="ToolApprovalResponseContent"/> so that
|
||||
/// <see cref="FunctionInvokingChatClient"/> can process them alongside the caller's human-approved responses.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator requires an active <see cref="AIAgent.CurrentRunContext"/> with a non-null
|
||||
/// <see cref="AgentRunContext.Session"/>. An <see cref="InvalidOperationException"/> is thrown if no
|
||||
/// run context or session is available.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class NonApprovalRequiredFunctionBypassingChatClient : DelegatingChatClient
|
||||
{
|
||||
/// <summary>
|
||||
/// The key used in <see cref="AgentSessionStateBag"/> to store pending auto-approved function calls
|
||||
/// between agent runs.
|
||||
/// </summary>
|
||||
internal const string StateBagKey = "_autoApprovedFunctionCalls";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NonApprovalRequiredFunctionBypassingChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The underlying chat client (typically a <see cref="FunctionInvokingChatClient"/>).</param>
|
||||
public NonApprovalRequiredFunctionBypassingChatClient(IChatClient innerClient)
|
||||
: base(innerClient)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = GetRequiredSession();
|
||||
var autoApprovableNames = this.GetAutoApprovableToolNames(options);
|
||||
|
||||
messages = InjectPendingAutoApprovals(messages, session);
|
||||
|
||||
var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
RemoveAutoApprovedFromMessages(response.Messages, autoApprovableNames, session);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = GetRequiredSession();
|
||||
var autoApprovableNames = this.GetAutoApprovableToolNames(options);
|
||||
|
||||
messages = InjectPendingAutoApprovals(messages, session);
|
||||
List<ToolApprovalRequestContent>? autoApproved = null;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (FilterUpdateContents(update, autoApprovableNames, ref autoApproved))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (autoApproved is { Count: > 0 })
|
||||
{
|
||||
session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current <see cref="AgentSession"/> from the ambient run context.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">No run context or session is available.</exception>
|
||||
private static AgentSession GetRequiredSession()
|
||||
{
|
||||
var runContext = AIAgent.CurrentRunContext
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(NonApprovalRequiredFunctionBypassingChatClient)} can only be used within the context of a running AIAgent. " +
|
||||
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
|
||||
|
||||
return runContext.Session
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(NonApprovalRequiredFunctionBypassingChatClient)} requires a session. " +
|
||||
"Ensure the agent has a resolved session before invoking the chat client.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the session for stored auto-approvals from a previous turn and injects them as
|
||||
/// a user message containing <see cref="ToolApprovalResponseContent"/> items appended to the input messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All stored requests are unconditionally injected as approved responses regardless of whether the
|
||||
/// tool set has changed, because the LLM requires a complete set of tool call responses for a prior turn.
|
||||
/// </remarks>
|
||||
private static IEnumerable<ChatMessage> InjectPendingAutoApprovals(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession session)
|
||||
{
|
||||
if (!session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
StateBagKey,
|
||||
out var pendingRequests,
|
||||
AgentJsonUtilities.DefaultOptions)
|
||||
|| pendingRequests is not { Count: > 0 })
|
||||
{
|
||||
return messages;
|
||||
}
|
||||
|
||||
session.StateBag.TryRemoveValue(StateBagKey);
|
||||
|
||||
List<AIContent> approvalResponses = [];
|
||||
foreach (var request in pendingRequests)
|
||||
{
|
||||
approvalResponses.Add(request.CreateResponse(approved: true));
|
||||
}
|
||||
|
||||
var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
|
||||
return messages.Concat([userMessage]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a set of tool names that do not require approval and can be auto-approved,
|
||||
/// by checking all available tools from <see cref="ChatOptions.Tools"/> and
|
||||
/// <see cref="FunctionInvokingChatClient.AdditionalTools"/>.
|
||||
/// </summary>
|
||||
private HashSet<string> GetAutoApprovableToolNames(ChatOptions? options)
|
||||
{
|
||||
var ficc = this.GetService<FunctionInvokingChatClient>();
|
||||
|
||||
var allTools = (options?.Tools ?? Enumerable.Empty<AITool>())
|
||||
.Concat(ficc?.AdditionalTools ?? Enumerable.Empty<AITool>());
|
||||
|
||||
return new HashSet<string>(
|
||||
allTools
|
||||
.OfType<AIFunction>()
|
||||
.Where(static f => f.GetService<ApprovalRequiredAIFunction>() is null)
|
||||
.Select(static f => f.Name),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a <see cref="ToolApprovalRequestContent"/> can be auto-approved because
|
||||
/// the underlying tool is not an <see cref="ApprovalRequiredAIFunction"/>.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the approval request is for a known tool that does not require approval
|
||||
/// and can be auto-approved; <see langword="false"/> otherwise.
|
||||
/// </returns>
|
||||
private static bool IsAutoApprovable(ToolApprovalRequestContent approval, HashSet<string> autoApprovableNames)
|
||||
{
|
||||
if (approval.ToolCall is not FunctionCallContent fcc)
|
||||
{
|
||||
// Non-function tool calls cannot be auto-approved.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-approve only if the tool is known and explicitly does NOT require approval.
|
||||
// Unknown tools are not in the set and are treated as approval-required (safe default).
|
||||
return autoApprovableNames.Contains(fcc.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans response messages for auto-approvable <see cref="ToolApprovalRequestContent"/> items,
|
||||
/// removes them from the messages, and stores them in the session for the next request.
|
||||
/// </summary>
|
||||
private static void RemoveAutoApprovedFromMessages(
|
||||
IList<ChatMessage> messages,
|
||||
HashSet<string> autoApprovableNames,
|
||||
AgentSession session)
|
||||
{
|
||||
List<ToolApprovalRequestContent>? autoApproved = null;
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
for (int i = message.Contents.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (message.Contents[i] is ToolApprovalRequestContent approval
|
||||
&& IsAutoApprovable(approval, autoApprovableNames))
|
||||
{
|
||||
(autoApproved ??= []).Add(approval);
|
||||
message.Contents.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove messages that are now empty after filtering.
|
||||
for (int i = messages.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (messages[i].Contents.Count == 0)
|
||||
{
|
||||
messages.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoApproved is { Count: > 0 })
|
||||
{
|
||||
session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters auto-approvable <see cref="ToolApprovalRequestContent"/> items from a streaming update's
|
||||
/// contents, collecting them for later storage.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the update should be yielded (has remaining content or had no
|
||||
/// approval content to begin with); <see langword="false"/> if the update is now empty and
|
||||
/// should be skipped.
|
||||
/// </returns>
|
||||
private static bool FilterUpdateContents(
|
||||
ChatResponseUpdate update,
|
||||
HashSet<string> autoApprovableNames,
|
||||
ref List<ToolApprovalRequestContent>? autoApproved)
|
||||
{
|
||||
bool hasApprovalContent = false;
|
||||
List<AIContent> filteredContents = [];
|
||||
bool removedAny = false;
|
||||
|
||||
for (int i = 0; i < update.Contents.Count; i++)
|
||||
{
|
||||
var content = update.Contents[i];
|
||||
|
||||
if (content is ToolApprovalRequestContent approval)
|
||||
{
|
||||
hasApprovalContent = true;
|
||||
|
||||
if (IsAutoApprovable(approval, autoApprovableNames))
|
||||
{
|
||||
(autoApproved ??= []).Add(approval);
|
||||
removedAny = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
filteredContents.Add(content);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
filteredContents.Add(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (removedAny)
|
||||
{
|
||||
update.Contents = filteredContents;
|
||||
}
|
||||
|
||||
// Yield the update unless it was purely auto-approvable approval content (now empty).
|
||||
return update.Contents.Count > 0 || !hasApprovalContent;
|
||||
}
|
||||
}
|
||||
@@ -51,20 +51,22 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly ProviderSessionState<ToolApprovalState> _sessionState;
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
private readonly Func<FunctionCallContent, ValueTask<bool>>[]? _autoApprovalRules;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolApprovalAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerAgent">The underlying agent to delegate to.</param>
|
||||
/// <param name="jsonSerializerOptions">
|
||||
/// Optional <see cref="JsonSerializerOptions"/> used for serializing argument values when storing rules
|
||||
/// and for persisting state. When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
|
||||
/// <param name="options">
|
||||
/// Optional <see cref="ToolApprovalAgentOptions"/> for configuring serialization and auto-approval rules.
|
||||
/// When <see langword="null"/>, default settings are used.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
|
||||
public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
public ToolApprovalAgent(AIAgent innerAgent, ToolApprovalAgentOptions? options = null)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
this._autoApprovalRules = options?.AutoApprovalRules?.ToArray();
|
||||
this._sessionState = new ProviderSessionState<ToolApprovalState>(
|
||||
_ => new ToolApprovalState(),
|
||||
"toolApprovalState",
|
||||
@@ -79,7 +81,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests.
|
||||
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
|
||||
var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false);
|
||||
|
||||
if (nextQueuedItem is not null)
|
||||
{
|
||||
@@ -98,7 +100,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
var response = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Classify approval requests: auto-approve matching, queue excess, keep first unapproved.
|
||||
bool allAutoApproved = this.ProcessAndQueueOutboundApprovalRequests(response.Messages, state, session);
|
||||
bool allAutoApproved = await this.ProcessAndQueueOutboundApprovalRequestsAsync(response.Messages, state, session).ConfigureAwait(false);
|
||||
|
||||
if (!allAutoApproved)
|
||||
{
|
||||
@@ -119,7 +121,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests.
|
||||
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
|
||||
var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false);
|
||||
|
||||
if (nextQueuedItem is not null)
|
||||
{
|
||||
@@ -197,7 +199,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 4. Classify the collected approval requests against standing rules.
|
||||
// 4. Classify the collected approval requests against standing rules and auto-approval rules.
|
||||
List<ToolApprovalRequestContent> unapproved = [];
|
||||
foreach (var tarc in streamedApprovalRequests)
|
||||
{
|
||||
@@ -206,6 +208,11 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
state.CollectedApprovalResponses.Add(
|
||||
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
|
||||
}
|
||||
else if (await this.MatchesAutoApprovalRuleAsync(tarc).ConfigureAwait(false))
|
||||
{
|
||||
state.CollectedApprovalResponses.Add(
|
||||
tarc.CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
|
||||
}
|
||||
else
|
||||
{
|
||||
unapproved.Add(tarc);
|
||||
@@ -291,9 +298,9 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluates queued approval requests against current rules and auto-approves any that now match.
|
||||
/// Re-evaluates queued approval requests against current rules and auto-approval rules, and auto-approves any that now match.
|
||||
/// </summary>
|
||||
private void DrainAutoApprovableFromQueue(ToolApprovalState state)
|
||||
private async ValueTask DrainAutoApprovableFromQueueAsync(ToolApprovalState state)
|
||||
{
|
||||
for (int i = state.QueuedApprovalRequests.Count - 1; i >= 0; i--)
|
||||
{
|
||||
@@ -303,6 +310,12 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
|
||||
state.QueuedApprovalRequests.RemoveAt(i);
|
||||
}
|
||||
else if (await this.MatchesAutoApprovalRuleAsync(state.QueuedApprovalRequests[i]).ConfigureAwait(false))
|
||||
{
|
||||
state.CollectedApprovalResponses.Add(
|
||||
state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
|
||||
state.QueuedApprovalRequests.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,8 +331,8 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
/// A tuple of (state, processed caller messages, next queued item or <see langword="null"/> if the queue is resolved).
|
||||
/// When the returned item is non-null, the caller should return/yield it without calling the inner agent.
|
||||
/// </returns>
|
||||
private (ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)
|
||||
PrepareInboundMessages(IEnumerable<ChatMessage> messages, AgentSession? session)
|
||||
private async ValueTask<(ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)>
|
||||
PrepareInboundMessagesAsync(IEnumerable<ChatMessage> messages, AgentSession? session)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
|
||||
@@ -337,7 +350,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
|
||||
// Re-evaluate remaining queued items — the caller may have added new rules
|
||||
// (e.g., "always approve this tool") that resolve additional items.
|
||||
this.DrainAutoApprovableFromQueue(state);
|
||||
await this.DrainAutoApprovableFromQueueAsync(state).ConfigureAwait(false);
|
||||
|
||||
if (state.QueuedApprovalRequests.Count > 0)
|
||||
{
|
||||
@@ -386,15 +399,18 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
/// <see langword="true"/> if all TARc items were auto-approved (caller should re-invoke the inner agent);
|
||||
/// <see langword="false"/> otherwise.
|
||||
/// </returns>
|
||||
private bool ProcessAndQueueOutboundApprovalRequests(
|
||||
private async ValueTask<bool> ProcessAndQueueOutboundApprovalRequestsAsync(
|
||||
IList<ChatMessage> responseMessages,
|
||||
ToolApprovalState state,
|
||||
AgentSession? session)
|
||||
{
|
||||
// Pass 1: Scan all response messages and classify each approval request as
|
||||
// auto-approved (matches a standing rule) or unapproved (needs caller decision).
|
||||
var autoApproved = new List<ToolApprovalRequestContent>();
|
||||
// Pass 1: Scan all response messages and classify each approval request.
|
||||
// Auto-approved requests (matching a standing rule or auto-approval rule) have their
|
||||
// responses collected immediately, preserving the original request order, and are
|
||||
// marked for removal. Unapproved requests are collected for the caller to decide.
|
||||
var toRemove = new HashSet<ToolApprovalRequestContent>();
|
||||
var unapproved = new List<ToolApprovalRequestContent>();
|
||||
int autoApprovedCount = 0;
|
||||
|
||||
foreach (var message in responseMessages)
|
||||
{
|
||||
@@ -404,7 +420,17 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
{
|
||||
if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions))
|
||||
{
|
||||
autoApproved.Add(tarc);
|
||||
state.CollectedApprovalResponses.Add(
|
||||
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
|
||||
toRemove.Add(tarc);
|
||||
autoApprovedCount++;
|
||||
}
|
||||
else if (await this.MatchesAutoApprovalRuleAsync(tarc).ConfigureAwait(false))
|
||||
{
|
||||
state.CollectedApprovalResponses.Add(
|
||||
tarc.CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
|
||||
toRemove.Add(tarc);
|
||||
autoApprovedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -415,18 +441,12 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
// Nothing to process: no auto-approved items and at most one unapproved (no queueing needed).
|
||||
if (autoApproved.Count == 0 && unapproved.Count <= 1)
|
||||
// No responses were collected above in this case, so state is unmodified and safe to leave.
|
||||
if (autoApprovedCount == 0 && unapproved.Count <= 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store auto-approved responses for later injection into the inner agent.
|
||||
foreach (var tarc in autoApproved)
|
||||
{
|
||||
state.CollectedApprovalResponses.Add(
|
||||
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
|
||||
}
|
||||
|
||||
// If every approval request was auto-approved, strip them all and signal the caller
|
||||
// to re-invoke the inner agent immediately with the collected responses.
|
||||
if (unapproved.Count == 0)
|
||||
@@ -439,14 +459,10 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
// Pass 2: Keep only the first unapproved request in the response (for the caller to decide).
|
||||
// Queue the remaining unapproved requests for subsequent one-at-a-time delivery.
|
||||
// Remove all auto-approved and queued items from the response messages.
|
||||
var toRemove = new HashSet<ToolApprovalRequestContent>(autoApproved);
|
||||
if (unapproved.Count > 1)
|
||||
for (int i = 1; i < unapproved.Count; i++)
|
||||
{
|
||||
for (int i = 1; i < unapproved.Count; i++)
|
||||
{
|
||||
toRemove.Add(unapproved[i]);
|
||||
state.QueuedApprovalRequests.Add(unapproved[i]);
|
||||
}
|
||||
toRemove.Add(unapproved[i]);
|
||||
state.QueuedApprovalRequests.Add(unapproved[i]);
|
||||
}
|
||||
|
||||
// Walk messages in reverse and strip marked items.
|
||||
@@ -663,8 +679,36 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares stored rule arguments against actual function call arguments for an exact match.
|
||||
/// Checks whether a <see cref="ToolApprovalRequestContent"/> is approved by any of the configured
|
||||
/// auto-approval rules (heuristic functions).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if any auto-approval rule returns <see langword="true"/> for the function call;
|
||||
/// <see langword="false"/> if no rules are configured, the request is not a function call, or no rule approves it.
|
||||
/// </returns>
|
||||
private async ValueTask<bool> MatchesAutoApprovalRuleAsync(ToolApprovalRequestContent request)
|
||||
{
|
||||
if (this._autoApprovalRules is not { Length: > 0 })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.ToolCall is not FunctionCallContent functionCall)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var rule in this._autoApprovalRules)
|
||||
{
|
||||
if (await rule(functionCall).ConfigureAwait(false))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ArgumentsMatch(IDictionary<string, string> ruleArguments, IDictionary<string, object?>? callArguments, JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
if (callArguments is null)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -17,9 +16,9 @@ public static class ToolApprovalAgentBuilderExtensions
|
||||
/// Adds tool approval middleware to the agent pipeline, enabling "don't ask again" approval behavior.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which tool approval support will be added.</param>
|
||||
/// <param name="jsonSerializerOptions">
|
||||
/// Optional <see cref="JsonSerializerOptions"/> used for serializing argument values when storing rules
|
||||
/// and for persisting state. When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
|
||||
/// <param name="options">
|
||||
/// Optional <see cref="ToolApprovalAgentOptions"/> for configuring serialization and auto-approval rules.
|
||||
/// When <see langword="null"/>, default settings are used.
|
||||
/// </param>
|
||||
/// <returns>The <see cref="AIAgentBuilder"/> with tool approval middleware added, enabling method chaining.</returns>
|
||||
/// <exception cref="System.ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
|
||||
@@ -32,6 +31,6 @@ public static class ToolApprovalAgentBuilderExtensions
|
||||
/// </remarks>
|
||||
public static AIAgentBuilder UseToolApproval(
|
||||
this AIAgentBuilder builder,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, jsonSerializerOptions));
|
||||
ToolApprovalAgentOptions? options = null)
|
||||
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, options));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring the <see cref="ToolApprovalAgent"/> middleware.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public class ToolApprovalAgentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="System.Text.Json.JsonSerializerOptions"/> used for serializing argument values
|
||||
/// when storing rules and for persisting state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
|
||||
/// </remarks>
|
||||
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a collection of heuristic functions that can automatically approve function calls
|
||||
/// that would otherwise require user approval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each function receives a <see cref="FunctionCallContent"/> representing the tool call that requires approval
|
||||
/// and returns a <see cref="ValueTask{Boolean}"/> that resolves to <see langword="true"/> to auto-approve
|
||||
/// the call, or <see langword="false"/> to continue evaluating the next rule.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Auto-approval rules are evaluated after standing rules (derived from prior user approvals) but before
|
||||
/// prompting the user. Rules are evaluated in order; the first rule returning <see langword="true"/>
|
||||
/// causes the function call to be auto-approved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IEnumerable<Func<FunctionCallContent, ValueTask<bool>>>? AutoApprovalRules { get; set; }
|
||||
}
|
||||
@@ -49,14 +49,13 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns the raw SKILL.md content. When the skill has scripts, a
|
||||
/// <c><scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts></c>
|
||||
/// block is appended with a per-script entry describing the expected argument format.
|
||||
/// <c><script_schemas></c> block is appended describing the argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var content = this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
return new(content);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ public abstract class AgentClassSkill<
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts));
|
||||
}
|
||||
|
||||
@@ -147,11 +146,17 @@ public abstract class AgentClassSkill<
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default implementation returns resources discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for members annotated with <see cref="AgentSkillResourceAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference resources by name in the skill's instructions or in other resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
||||
|
||||
@@ -159,11 +164,17 @@ public abstract class AgentClassSkill<
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default implementation returns scripts discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for methods annotated with <see cref="AgentSkillScriptAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only script parameter schemas are included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference scripts by name in the skill's instructions or in a resource.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
||||
|
||||
@@ -184,6 +195,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill resource backed by a static value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -194,6 +209,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill resource backed by a delegate that produces a dynamic value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -208,6 +227,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill script backed by a delegate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the script's parameter schema is included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -115,6 +115,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// <summary>
|
||||
/// Registers a static resource with this skill.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -129,6 +133,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// Registers a dynamic resource with this skill, backed by a C# delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -147,6 +155,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// Registers a script with this skill, backed by a C# delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the script's parameter schema is included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
|
||||
@@ -12,19 +12,17 @@ namespace Microsoft.Agents.AI;
|
||||
internal static class AgentInlineSkillContentBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the complete skill content containing name, description, instructions, resources, and scripts.
|
||||
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
|
||||
/// </summary>
|
||||
/// <param name="name">The skill name.</param>
|
||||
/// <param name="description">The skill description.</param>
|
||||
/// <param name="instructions">The raw instructions text.</param>
|
||||
/// <param name="resources">Optional resources associated with the skill.</param>
|
||||
/// <param name="scripts">Optional scripts associated with the skill.</param>
|
||||
/// <returns>An XML-structured content string.</returns>
|
||||
public static string Build(
|
||||
string name,
|
||||
string description,
|
||||
string instructions,
|
||||
IReadOnlyList<AgentSkillResource>? resources,
|
||||
IReadOnlyList<AgentSkillScript>? scripts)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(name);
|
||||
@@ -39,41 +37,24 @@ internal static class AgentInlineSkillContentBuilder
|
||||
.Append(EscapeXmlString(instructions))
|
||||
.Append("\n</instructions>");
|
||||
|
||||
if (resources is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n<resources>\n");
|
||||
foreach (var resource in resources)
|
||||
{
|
||||
if (resource.Description is not null)
|
||||
{
|
||||
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\" description=\"{EscapeXmlString(resource.Description)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</resources>");
|
||||
}
|
||||
|
||||
if (scripts is { Count: > 0 })
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildScriptsBlock(scripts));
|
||||
sb.Append(BuildScriptSchemasBlock(scripts));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c><scripts>...</scripts></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><script name="..."></c> element with optional
|
||||
/// <c>description</c> attribute and <c><parameters_schema></c> child element.
|
||||
/// Builds a <c><script_schemas>...</script_schemas></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><schema script="..."></c> element containing only
|
||||
/// the parameter schema. This block serves as a reference for the model to know how to
|
||||
/// format arguments when calling scripts, not as a discovery mechanism.
|
||||
/// </summary>
|
||||
/// <param name="scripts">The scripts to include in the block.</param>
|
||||
/// <returns>An XML string starting with <c>\n<scripts></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
/// <returns>An XML string starting with <c>\n<script_schemas></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptSchemasBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
{
|
||||
_ = Throw.IfNull(scripts);
|
||||
|
||||
@@ -83,32 +64,23 @@ internal static class AgentInlineSkillContentBuilder
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("\n<scripts>\n");
|
||||
sb.Append("\n<script_schemas>\n");
|
||||
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parametersSchema = script.ParametersSchema;
|
||||
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
if (parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(script.Description is not null
|
||||
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
|
||||
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
|
||||
if (parametersSchema is not null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
}
|
||||
|
||||
sb.Append(" </script>\n");
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</scripts>");
|
||||
sb.Append("</script_schemas>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -243,6 +243,46 @@ public sealed class AGUIAgentTests
|
||||
Assert.Contains(updates, u => u.Text == "Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithSession_SendsFullHistoryAfterThreadIdIsSetAsync()
|
||||
{
|
||||
// Arrange
|
||||
var captureHandler = new StateCapturingTestDelegatingHandler();
|
||||
captureHandler.AddResponse(
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "First response" },
|
||||
new TextMessageEndEvent { MessageId = "msg1" },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
]);
|
||||
captureHandler.AddResponse(
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
|
||||
new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg2", Delta = "Second response" },
|
||||
new TextMessageEndEvent { MessageId = "msg2" },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
|
||||
]);
|
||||
using HttpClient httpClient = new(captureHandler);
|
||||
|
||||
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
|
||||
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "First")], session))
|
||||
{
|
||||
}
|
||||
|
||||
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Second")], session))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal([1, 3], captureHandler.CapturedMessageCounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeserializeSession_WithValidState_ReturnsChatClientAgentSessionAsync()
|
||||
{
|
||||
@@ -1686,10 +1726,12 @@ internal sealed class CapturingTestDelegatingHandler : DelegatingHandler
|
||||
internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responseFactories = new();
|
||||
private readonly List<int> _capturedMessageCounts = [];
|
||||
|
||||
public bool RequestWasMade { get; private set; }
|
||||
public JsonElement? CapturedState { get; private set; }
|
||||
public int CapturedMessageCount { get; private set; }
|
||||
public IReadOnlyList<int> CapturedMessageCounts => this._capturedMessageCounts;
|
||||
|
||||
public void AddResponse(BaseEvent[] events)
|
||||
{
|
||||
@@ -1714,6 +1756,7 @@ internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
|
||||
this.CapturedState = input.State;
|
||||
}
|
||||
this.CapturedMessageCount = input.Messages.Count();
|
||||
this._capturedMessageCounts.Add(this.CapturedMessageCount);
|
||||
}
|
||||
|
||||
if (this._responseFactories.Count == 0)
|
||||
|
||||
@@ -182,7 +182,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
}
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
public async Task WorkflowEventsSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -278,7 +278,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
public async Task WorkflowSharedStateSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -376,7 +376,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
public async Task SubWorkflowsSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -452,7 +452,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
public async Task WorkflowHITLSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
|
||||
@@ -43,7 +43,7 @@ public class HostedFoundryMemoryProviderScopesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_ComposesUserAndChatWithColon()
|
||||
public void PerUserAndChat_ComposesUserAndChatWithEscapedSeparator()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(TestUserId, TestChatId);
|
||||
@@ -54,7 +54,51 @@ public class HostedFoundryMemoryProviderScopesTests
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal($"{TestUserId}:{TestChatId}", state.Scope.Scope);
|
||||
Assert.Equal($"{TestUserId}::{TestChatId}", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_EscapesColonsInUserAndChat()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession("alice:finance", "q2:final");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert - colons inside each part are escaped as \: , parts joined with ::
|
||||
Assert.Equal(@"alice\:finance::q2\:final", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_EscapesBackslashesInUserAndChat()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(@"alice\corp", @"chat\1");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert - backslashes escaped first as \\ , parts joined with ::
|
||||
Assert.Equal(@"alice\\corp::chat\\1", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_DistinctContextsDoNotCollide()
|
||||
{
|
||||
// Arrange - two distinct (UserId, ChatId) pairs that collide under raw-colon composition.
|
||||
var sessionA = CreateTaggedSession("alice:finance", "q2");
|
||||
var sessionB = CreateTaggedSession("alice", "finance:q2");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var scopeA = initializer(sessionA).Scope.Scope;
|
||||
var scopeB = initializer(sessionB).Scope.Scope;
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(scopeA, scopeB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -4,7 +4,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests;
|
||||
@@ -13,8 +14,8 @@ public class GitHubCopilotAgentTests
|
||||
{
|
||||
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
|
||||
|
||||
private static Task<PermissionRequestResult> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
|
||||
private static Task<PermissionDecision> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
|
||||
@@ -16,7 +16,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithAllParameters_ReturnsGitHubCopilotAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
|
||||
const string TestId = "test-agent-id";
|
||||
const string TestName = "Test Agent";
|
||||
@@ -37,7 +37,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithMinimalParameters_ReturnsGitHubCopilotAgent()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
|
||||
// Act
|
||||
var agent = copilotClient.AsAIAgent(ownsClient: false, tools: null);
|
||||
@@ -61,7 +61,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithOwnsClient_ReturnsAgentThatOwnsClient()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
|
||||
// Act
|
||||
var agent = copilotClient.AsAIAgent(ownsClient: true, tools: null);
|
||||
@@ -75,7 +75,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithTools_ReturnsAgentWithTools()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
List<AITool> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
|
||||
// Act
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
|
||||
@@ -17,7 +18,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithCopilotClient_InitializesPropertiesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
const string TestId = "test-id";
|
||||
const string TestName = "test-name";
|
||||
const string TestDescription = "test-description";
|
||||
@@ -42,7 +43,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithDefaultParameters_UsesBaseProperties()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
|
||||
// Act
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
@@ -58,7 +59,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public async Task CreateSessionAsync_ReturnsGitHubCopilotAgentSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
// Act
|
||||
@@ -73,7 +74,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public async Task CreateSessionAsync_WithSessionId_ReturnsSessionWithSessionIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
const string TestSessionId = "test-session-id";
|
||||
|
||||
@@ -90,7 +91,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithTools_InitializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
List<AITool> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
|
||||
// Act
|
||||
@@ -105,12 +106,12 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void CopySessionConfig_CopiesAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
List<AIFunction> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
List<AIFunctionDeclaration> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
var hooks = new SessionHooks();
|
||||
var infiniteSessions = new InfiniteSessionConfig();
|
||||
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
|
||||
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
|
||||
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> permissionHandler = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>> userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
|
||||
|
||||
var source = new SessionConfig
|
||||
@@ -122,7 +123,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
AvailableTools = ["tool1", "tool2"],
|
||||
ExcludedTools = ["tool3"],
|
||||
WorkingDirectory = "/workspace",
|
||||
ConfigDir = "/config",
|
||||
ConfigDirectory = "/config",
|
||||
Hooks = hooks,
|
||||
InfiniteSessions = infiniteSessions,
|
||||
OnPermissionRequest = permissionHandler,
|
||||
@@ -137,17 +138,15 @@ public sealed class GitHubCopilotAgentTests
|
||||
// Assert
|
||||
Assert.Equal("gpt-4o", result.Model);
|
||||
Assert.Equal("high", result.ReasoningEffort);
|
||||
Assert.Same(tools, result.Tools);
|
||||
Assert.Same(systemMessage, result.SystemMessage);
|
||||
Assert.Equal(systemMessage, result.SystemMessage);
|
||||
Assert.Equal(new List<string> { "tool1", "tool2" }, result.AvailableTools);
|
||||
Assert.Equal(new List<string> { "tool3" }, result.ExcludedTools);
|
||||
Assert.Equal("/workspace", result.WorkingDirectory);
|
||||
Assert.Equal("/config", result.ConfigDir);
|
||||
Assert.Equal("/config", result.ConfigDirectory);
|
||||
Assert.Same(hooks, result.Hooks);
|
||||
Assert.Same(infiniteSessions, result.InfiniteSessions);
|
||||
Assert.Same(permissionHandler, result.OnPermissionRequest);
|
||||
Assert.Same(userInputHandler, result.OnUserInputRequest);
|
||||
Assert.Same(mcpServers, result.McpServers);
|
||||
Assert.Equal(new List<string> { "skill1" }, result.DisabledSkills);
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
@@ -156,12 +155,12 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void CopyResumeSessionConfig_CopiesAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
List<AIFunction> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
List<AIFunctionDeclaration> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
var hooks = new SessionHooks();
|
||||
var infiniteSessions = new InfiniteSessionConfig();
|
||||
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
|
||||
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
|
||||
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> permissionHandler = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>> userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
|
||||
|
||||
var source = new SessionConfig
|
||||
@@ -173,7 +172,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
AvailableTools = ["tool1", "tool2"],
|
||||
ExcludedTools = ["tool3"],
|
||||
WorkingDirectory = "/workspace",
|
||||
ConfigDir = "/config",
|
||||
ConfigDirectory = "/config",
|
||||
Hooks = hooks,
|
||||
InfiniteSessions = infiniteSessions,
|
||||
OnPermissionRequest = permissionHandler,
|
||||
@@ -193,7 +192,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Assert.Equal(new List<string> { "tool1", "tool2" }, result.AvailableTools);
|
||||
Assert.Equal(new List<string> { "tool3" }, result.ExcludedTools);
|
||||
Assert.Equal("/workspace", result.WorkingDirectory);
|
||||
Assert.Equal("/config", result.ConfigDir);
|
||||
Assert.Equal("/config", result.ConfigDirectory);
|
||||
Assert.Same(hooks, result.Hooks);
|
||||
Assert.Same(infiniteSessions, result.InfiniteSessions);
|
||||
Assert.Same(permissionHandler, result.OnPermissionRequest);
|
||||
@@ -218,7 +217,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Assert.Null(result.OnUserInputRequest);
|
||||
Assert.Null(result.Hooks);
|
||||
Assert.Null(result.WorkingDirectory);
|
||||
Assert.Null(result.ConfigDir);
|
||||
Assert.Null(result.ConfigDirectory);
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
|
||||
@@ -233,7 +232,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Content = "Some streamed content that was already delivered via delta events"
|
||||
}
|
||||
};
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
const string TestId = "agent-id";
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null);
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -27,6 +27,7 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Null(options.ChatHistoryProvider);
|
||||
Assert.Null(options.AIContextProviders);
|
||||
Assert.False(options.DisableToolApproval);
|
||||
Assert.False(options.DisableNonApprovalRequiredFunctionBypassing);
|
||||
Assert.False(options.DisableFileMemory);
|
||||
Assert.False(options.DisableFileAccess);
|
||||
Assert.False(options.DisableWebSearch);
|
||||
@@ -80,6 +81,7 @@ public class HarnessAgentOptionsTests
|
||||
AIContextProviders = contextProviders,
|
||||
MaximumIterationsPerRequest = 42,
|
||||
DisableToolApproval = true,
|
||||
DisableNonApprovalRequiredFunctionBypassing = true,
|
||||
DisableFileMemory = true,
|
||||
FileMemoryStore = fileMemoryStore,
|
||||
DisableFileAccess = true,
|
||||
@@ -112,6 +114,7 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Same(contextProviders, options.AIContextProviders);
|
||||
Assert.Equal(42, options.MaximumIterationsPerRequest);
|
||||
Assert.True(options.DisableToolApproval);
|
||||
Assert.True(options.DisableNonApprovalRequiredFunctionBypassing);
|
||||
Assert.True(options.DisableFileMemory);
|
||||
Assert.Same(fileMemoryStore, options.FileMemoryStore);
|
||||
Assert.True(options.DisableFileAccess);
|
||||
|
||||
@@ -21,9 +21,12 @@ public class HarnessAgentTests
|
||||
|
||||
/// <summary>
|
||||
/// Creates a HarnessAgent with all default features disabled to isolate tests for specific behaviors.
|
||||
/// Compaction is enabled by default for backward compatibility with existing tests.
|
||||
/// </summary>
|
||||
private static HarnessAgentOptions CreateAllDisabledOptions() => new()
|
||||
{
|
||||
MaxContextWindowTokens = TestMaxContextWindowTokens,
|
||||
MaxOutputTokens = TestMaxOutputTokens,
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
@@ -43,7 +46,7 @@ public class HarnessAgentTests
|
||||
public void Constructor_ThrowsWhenChatClientIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new HarnessAgent(null!, TestMaxContextWindowTokens, TestMaxOutputTokens));
|
||||
Assert.Throws<ArgumentNullException>(() => new HarnessAgent(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,9 +57,10 @@ public class HarnessAgentTests
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = new HarnessAgentOptions { MaxContextWindowTokens = 0, MaxOutputTokens = TestMaxOutputTokens };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, 0, TestMaxOutputTokens));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -67,9 +71,10 @@ public class HarnessAgentTests
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = new HarnessAgentOptions { MaxContextWindowTokens = 100_000, MaxOutputTokens = 100_000 };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, 100_000, 100_000));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -82,7 +87,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var agent = new HarnessAgent(chatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -105,7 +110,7 @@ public class HarnessAgentTests
|
||||
options.Description = "A test agent";
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("TestAgent", agent.Name);
|
||||
@@ -124,7 +129,7 @@ public class HarnessAgentTests
|
||||
options.Id = "my-agent-id";
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-agent-id", agent.Id);
|
||||
@@ -144,7 +149,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -164,7 +169,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Temperature = 0.5f };
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -184,7 +189,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." };
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -205,7 +210,7 @@ public class HarnessAgentTests
|
||||
options.HarnessInstructions = "Custom harness rules.";
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -226,7 +231,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Instructions = "You are a research agent." };
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -247,7 +252,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Instructions = "Agent only instructions." };
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -267,7 +272,7 @@ public class HarnessAgentTests
|
||||
options.HarnessInstructions = string.Empty;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -289,7 +294,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -310,7 +315,7 @@ public class HarnessAgentTests
|
||||
options.ChatHistoryProvider = customProvider;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -332,7 +337,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -353,7 +358,7 @@ public class HarnessAgentTests
|
||||
var rawClient = mockClient.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(rawClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — the pipeline wraps the raw client, so the outer client is not the same object.
|
||||
@@ -378,7 +383,7 @@ public class HarnessAgentTests
|
||||
options.AIContextProviders = [customProvider];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — the custom provider should appear in the inner agent's AIContextProviders.
|
||||
@@ -398,7 +403,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -432,7 +437,7 @@ public class HarnessAgentTests
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ChatOptions = new ChatOptions { Tools = [tool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -459,8 +464,10 @@ public class HarnessAgentTests
|
||||
};
|
||||
|
||||
// Act
|
||||
_ = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
_ = new HarnessAgent(chatClient, new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = TestMaxContextWindowTokens,
|
||||
MaxOutputTokens = TestMaxOutputTokens,
|
||||
ChatOptions = sourceChatOptions,
|
||||
});
|
||||
|
||||
@@ -483,7 +490,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.Same(agent, agent.GetService<HarnessAgent>());
|
||||
@@ -499,7 +506,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<ChatClientAgent>());
|
||||
@@ -524,7 +531,7 @@ public class HarnessAgentTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!")));
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(mockClient.Object, CreateAllDisabledOptions());
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -565,7 +572,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var agent = chatClient.AsHarnessAgent();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -586,7 +593,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Instructions = "Custom instructions" };
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = chatClient.AsHarnessAgent(options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -603,7 +610,7 @@ public class HarnessAgentTests
|
||||
public void AsHarnessAgent_ThrowsWhenChatClientIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((IChatClient)null!).AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens));
|
||||
Assert.Throws<ArgumentNullException>(() => ((IChatClient)null!).AsHarnessAgent());
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -622,7 +629,7 @@ public class HarnessAgentTests
|
||||
options.DisableToolApproval = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
|
||||
@@ -638,12 +645,148 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.Null(agent.GetService<ToolApprovalAgent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ToolApprovalAgentOptions auto-approval rules are passed through and actually used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ToolApproval_AutoApprovalRulesAreAppliedAsync()
|
||||
{
|
||||
// Arrange — inner client returns an approval request on first call, then final response on second.
|
||||
var callCount = 0;
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
|
||||
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
return new ChatResponse(new ChatMessage(ChatRole.Assistant, [approvalRequest]));
|
||||
}
|
||||
|
||||
return new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done"));
|
||||
});
|
||||
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableToolApproval = false;
|
||||
options.ToolApprovalAgentOptions = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
|
||||
};
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — the auto-approval rule approved the request, so we get "Done" (not an approval request)
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("Done", response.Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: NonApprovalRequiredFunctionBypassing
|
||||
|
||||
/// <summary>
|
||||
/// Verify that by default, when a response contains a mix of tools that require approval and tools that do not,
|
||||
/// only the approval-required tool is surfaced to the caller. The non-approval-required tool is bypassed
|
||||
/// (stored as auto-approved) by the <c>NonApprovalRequiredFunctionBypassingChatClient</c> decorator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NonApprovalRequiredFunctionBypassing_BypassesNonApprovalToolsByDefaultAsync()
|
||||
{
|
||||
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
|
||||
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("call1", "NormalTool"),
|
||||
new FunctionCallContent("call2", "ApprovalTool"),
|
||||
])));
|
||||
|
||||
// Disable ToolApproval so the approval requests surface in the response instead of being handled.
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — only the approval-required tool surfaces as an approval request; the normal tool is bypassed.
|
||||
var approvalRequests = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
var approvalRequest = Assert.Single(approvalRequests);
|
||||
Assert.Equal("ApprovalTool", Assert.IsType<FunctionCallContent>(approvalRequest.ToolCall).Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when bypassing is disabled, all tools (including those that do not require approval) are surfaced
|
||||
/// as approval requests, reflecting the all-or-nothing behavior of <see cref="FunctionInvokingChatClient"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NonApprovalRequiredFunctionBypassing_SurfacesAllApprovalsWhenDisabledAsync()
|
||||
{
|
||||
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
|
||||
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("call1", "NormalTool"),
|
||||
new FunctionCallContent("call2", "ApprovalTool"),
|
||||
])));
|
||||
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableNonApprovalRequiredFunctionBypassing = true;
|
||||
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — both tools surface as approval requests because bypassing is disabled.
|
||||
var approvalRequests = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Select(r => ((FunctionCallContent)r.ToolCall).Name)
|
||||
.ToList();
|
||||
Assert.Equal(2, approvalRequests.Count);
|
||||
Assert.Contains("NormalTool", approvalRequests);
|
||||
Assert.Contains("ApprovalTool", approvalRequests);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: OpenTelemetry
|
||||
@@ -660,7 +803,7 @@ public class HarnessAgentTests
|
||||
options.DisableOpenTelemetry = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
|
||||
@@ -676,7 +819,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.Null(agent.GetService<OpenTelemetryAgent>());
|
||||
@@ -695,7 +838,7 @@ public class HarnessAgentTests
|
||||
options.OpenTelemetrySourceName = "MyApp.AgentTracing";
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
|
||||
@@ -722,7 +865,7 @@ public class HarnessAgentTests
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableWebSearch = false;
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -747,7 +890,7 @@ public class HarnessAgentTests
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(mockClient.Object, CreateAllDisabledOptions());
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -780,7 +923,7 @@ public class HarnessAgentTests
|
||||
options.DisableWebSearch = false;
|
||||
options.ChatOptions = new ChatOptions { Tools = [userTool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -808,7 +951,7 @@ public class HarnessAgentTests
|
||||
options.DisableTodoProvider = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -826,7 +969,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -853,7 +996,7 @@ public class HarnessAgentTests
|
||||
options.DisableAgentModeProvider = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -871,7 +1014,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -902,7 +1045,7 @@ public class HarnessAgentTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — AgentModeProvider should be present (we can't easily inspect its internal options,
|
||||
@@ -927,7 +1070,7 @@ public class HarnessAgentTests
|
||||
options.DisableFileMemory = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -945,7 +1088,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -970,7 +1113,7 @@ public class HarnessAgentTests
|
||||
options.FileMemoryStore = customStore;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — FileMemoryProvider should be present with the custom store.
|
||||
@@ -994,7 +1137,7 @@ public class HarnessAgentTests
|
||||
options.DisableFileAccess = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1012,7 +1155,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1037,7 +1180,7 @@ public class HarnessAgentTests
|
||||
options.FileAccessStore = customStore;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — FileAccessProvider should be present with the custom store.
|
||||
@@ -1061,7 +1204,7 @@ public class HarnessAgentTests
|
||||
options.DisableAgentSkillsProvider = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1079,7 +1222,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1104,7 +1247,7 @@ public class HarnessAgentTests
|
||||
options.AgentSkillsSource = customSource;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — AgentSkillsProvider should be present.
|
||||
@@ -1128,7 +1271,7 @@ public class HarnessAgentTests
|
||||
options.MaximumIterationsPerRequest = 42;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
|
||||
|
||||
@@ -1147,7 +1290,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
|
||||
|
||||
@@ -1175,7 +1318,7 @@ public class HarnessAgentTests
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var agent = new HarnessAgent(mockClient.Object);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — agent wrappers
|
||||
@@ -1218,7 +1361,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgents = [bgAgentMock.Object];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1238,7 +1381,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgents = null;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1261,7 +1404,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgents = Array.Empty<AIAgent>();
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1292,7 +1435,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgentsProviderOptions = providerOptions;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
|
||||
|
||||
@@ -1329,7 +1472,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgents = [agent1Mock.Object, agent2Mock.Object];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
|
||||
|
||||
@@ -1370,7 +1513,7 @@ public class HarnessAgentTests
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1390,7 +1533,7 @@ public class HarnessAgentTests
|
||||
options.ShellExecutor = null;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1422,7 +1565,7 @@ public class HarnessAgentTests
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClientMock.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClientMock.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
@@ -1451,7 +1594,7 @@ public class HarnessAgentTests
|
||||
options.ShellEnvironmentProviderOptions = envOptions;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — provider should exist (options wiring is validated by the provider's behavior)
|
||||
@@ -1475,7 +1618,7 @@ public class HarnessAgentTests
|
||||
var loggerFactory = new Mock<ILoggerFactory>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory);
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), loggerFactory);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -1492,7 +1635,7 @@ public class HarnessAgentTests
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: services);
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), services: services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -1510,7 +1653,7 @@ public class HarnessAgentTests
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -1528,7 +1671,7 @@ public class HarnessAgentTests
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
var agent = chatClient.AsHarnessAgent(CreateAllDisabledOptions(), loggerFactory, services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -1550,6 +1693,8 @@ public class HarnessAgentTests
|
||||
// Act — use options that leave CompactionProvider and AgentSkillsProvider enabled
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = TestMaxContextWindowTokens,
|
||||
MaxOutputTokens = TestMaxOutputTokens,
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
@@ -1558,7 +1703,7 @@ public class HarnessAgentTests
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
};
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options, mockLoggerFactory.Object);
|
||||
var agent = new HarnessAgent(chatClient, options, mockLoggerFactory.Object);
|
||||
|
||||
// Assert — CreateLogger should have been called by one or more downstream components
|
||||
Assert.NotNull(agent);
|
||||
@@ -1580,7 +1725,7 @@ public class HarnessAgentTests
|
||||
.Returns(null!);
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: mockServices.Object);
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), services: mockServices.Object);
|
||||
|
||||
// Assert — the service provider should have been queried during pipeline construction
|
||||
Assert.NotNull(agent);
|
||||
@@ -1588,4 +1733,91 @@ public class HarnessAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Compaction Opt-in
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructing without token values succeeds (compaction disabled).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithoutTokenValues()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
DisableFileAccess = true,
|
||||
DisableWebSearch = true,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableAgentSkillsProvider = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert — compaction should be disabled (no chat reducer)
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
Assert.NotNull(innerAgent);
|
||||
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
Assert.NotNull(historyProvider);
|
||||
Assert.Null(historyProvider!.ChatReducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when only MaxContextWindowTokens is provided (no MaxOutputTokens), compaction is disabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithOnlyMaxContextWindowTokens()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = TestMaxContextWindowTokens,
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
DisableFileAccess = true,
|
||||
DisableWebSearch = true,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableAgentSkillsProvider = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert — compaction should be disabled (only one token value provided)
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
Assert.NotNull(innerAgent);
|
||||
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
Assert.NotNull(historyProvider);
|
||||
Assert.Null(historyProvider!.ChatReducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when both token values are provided, the agent is constructed successfully with compaction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithBothTokenValues()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
|
||||
// Assert — compaction should be enabled (chat reducer configured)
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
Assert.NotNull(innerAgent);
|
||||
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
Assert.NotNull(historyProvider);
|
||||
Assert.NotNull(historyProvider!.ChatReducer);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task SingleAgentSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
|
||||
@@ -148,7 +148,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task MultiAgentOrchestrationConcurrentSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
|
||||
@@ -198,7 +198,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task MultiAgentOrchestrationConditionalsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
|
||||
@@ -216,7 +216,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
|
||||
@@ -272,7 +272,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task LongRunningToolsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
|
||||
@@ -362,7 +362,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task AgentAsMcpToolAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "07_AgentAsMcpTool");
|
||||
@@ -402,7 +402,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task ReliableStreamingSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming");
|
||||
|
||||
@@ -62,7 +62,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
return default;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task SequentialWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow");
|
||||
@@ -168,7 +168,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task HITLWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL");
|
||||
@@ -277,7 +277,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task WorkflowMcpToolSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool");
|
||||
@@ -333,7 +333,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task WorkflowAndAgentsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents");
|
||||
@@ -385,7 +385,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task ConcurrentWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow");
|
||||
|
||||
@@ -16,6 +16,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
private const string TestUserId = "test-user-id";
|
||||
private const string CustomClaimType = "custom-claim-type";
|
||||
private const string CustomClaimValue = "custom-claim-value";
|
||||
private const string TestAuthenticationType = "TestAuth";
|
||||
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
@@ -101,7 +102,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
@@ -111,6 +112,25 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the default claim type is the stable, unique NameIdentifier claim rather than the
|
||||
/// non-unique display name claim. This guards against the session-isolation collision described in
|
||||
/// the security report where two principals sharing the same name claim received the same key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
|
||||
{
|
||||
// Arrange - only a display-name claim is present; the default provider must not use it.
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
@@ -191,10 +211,10 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
const string SecondValue = "second-value";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
|
||||
new Claim(ClaimTypes.NameIdentifier, FirstValue),
|
||||
new Claim(ClaimTypes.NameIdentifier, SecondValue),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
@@ -219,7 +239,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
|
||||
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
@@ -229,6 +249,66 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for the session-isolation collision security report: two distinct authenticated
|
||||
/// principals that share the same display-name claim but have different stable identifiers and tenants
|
||||
/// must produce distinct isolation keys under the default options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
|
||||
{
|
||||
// Arrange - both principals share the same name claim but differ by NameIdentifier and tenant.
|
||||
const string CommonName = "John Doe";
|
||||
|
||||
var principalA = CreatePrincipal(
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
|
||||
new Claim(ClaimTypes.NameIdentifier, "oid-user-a"),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-a"));
|
||||
|
||||
var principalB = CreatePrincipal(
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
|
||||
new Claim(ClaimTypes.NameIdentifier, "oid-user-b"),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-b"));
|
||||
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalA });
|
||||
string? principalAKey = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalB });
|
||||
string? principalBKey = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("oid-user-a", principalAKey);
|
||||
Assert.Equal("oid-user-b", principalBKey);
|
||||
Assert.NotEqual(principalAKey, principalBKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the request's user is not authenticated,
|
||||
/// even if a claim of the configured type is present. The provider must not derive an isolation key
|
||||
/// from claims on an unauthenticated identity.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
|
||||
{
|
||||
// Arrange - identity has the claim but no authentication type, so IsAuthenticated is false.
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, TestUserId) };
|
||||
var unauthenticatedIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(unauthenticatedIdentity);
|
||||
var httpContext = new DefaultHttpContext { User = principal };
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.False(unauthenticatedIdentity.IsAuthenticated);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
@@ -236,7 +316,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
private void SetupHttpContextWithClaim(string claimType, string claimValue)
|
||||
{
|
||||
var claims = new[] { new Claim(claimType, claimValue) };
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
@@ -247,5 +327,8 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(params Claim[] claims)
|
||||
=> new(new ClaimsIdentity(claims, TestAuthenticationType));
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@ public sealed class PurviewClientTests : IDisposable
|
||||
Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithProcessInline_IncludesPreferHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
request.ProcessInline = true;
|
||||
var expectedResponse = new ProcessContentResponse { Id = "test-id" };
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
|
||||
|
||||
// Act
|
||||
await this._client.ProcessContentAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("evaluateInline", this._handler.PreferHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
|
||||
{
|
||||
@@ -530,6 +548,7 @@ public sealed class PurviewClientTests : IDisposable
|
||||
public HttpMethod? RequestMethod { get; private set; }
|
||||
public string? AuthorizationHeader { get; private set; }
|
||||
public string? IfNoneMatchHeader { get; private set; }
|
||||
public string? PreferHeader { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -547,6 +566,11 @@ public sealed class PurviewClientTests : IDisposable
|
||||
this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues);
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValues("Prefer", out var preferValues))
|
||||
{
|
||||
this.PreferHeader = string.Join(", ", preferValues);
|
||||
}
|
||||
|
||||
// Throw HttpRequestException if configured
|
||||
if (this.ShouldThrowHttpRequestException)
|
||||
{
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.UnitTests;
|
||||
@@ -50,10 +52,6 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -70,8 +68,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -109,10 +107,6 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -129,8 +123,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -168,10 +162,6 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -188,8 +178,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -213,6 +203,99 @@ public sealed class ScopedContentProcessorTests
|
||||
Assert.Equal("user-123", result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_DeduplicatesCombinedPolicyActionsByActionAndRestrictionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Test message")
|
||||
];
|
||||
PurviewSettings settings = CreateValidPurviewSettings();
|
||||
TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
DlpActionInfo processContentAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
|
||||
DlpActionInfo duplicateScopeAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
|
||||
DlpActionInfo restrictionOnlyAction = new() { RestrictionAction = RestrictionAction.Block };
|
||||
ProcessContentResponse pcResponse = new()
|
||||
{
|
||||
PolicyActions =
|
||||
[
|
||||
processContentAction
|
||||
]
|
||||
};
|
||||
ProtectionScopesResponse psResponse = new()
|
||||
{
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
],
|
||||
ExecutionMode = ExecutionMode.EvaluateInline,
|
||||
PolicyActions =
|
||||
[
|
||||
duplicateScopeAction,
|
||||
restrictionOnlyAction
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(pcResponse.PolicyActions);
|
||||
Assert.Equal(2, pcResponse.PolicyActions.Count);
|
||||
Assert.Same(processContentAction, pcResponse.PolicyActions[0]);
|
||||
Assert.Same(restrictionOnlyAction, pcResponse.PolicyActions[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckApplicableScopes_MatchesAnyLocationInScope()
|
||||
{
|
||||
// Arrange
|
||||
ProcessContentRequest pcRequest = CreateProcessContentRequest();
|
||||
ProtectionScopesResponse psResponse = new()
|
||||
{
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123"),
|
||||
new("microsoft.graph.policyLocationApplication", "different-app")
|
||||
],
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
(bool shouldProcess, _, ExecutionMode executionMode) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse);
|
||||
|
||||
// Assert
|
||||
Assert.True(shouldProcess);
|
||||
Assert.Equal(ExecutionMode.EvaluateInline, executionMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync()
|
||||
{
|
||||
@@ -279,12 +362,9 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
ScopeIdentifier = "etag-1",
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
@@ -299,8 +379,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -336,10 +416,6 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -355,8 +431,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -432,13 +508,9 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -467,13 +539,9 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -484,10 +552,260 @@ public sealed class ScopedContentProcessorTests
|
||||
Assert.Equal(userId, result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCallsProcessContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessContentResponse());
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert: ProcessContent runs in the foreground; GetProtectionScopes is queued as a background job.
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_ReturnsShouldBlockTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
PolicyActions =
|
||||
[
|
||||
new() { Action = DlpAction.BlockAccess }
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.shouldBlock);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenScopeJobCannotQueueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
this._mockChannelHandler.Setup(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()))
|
||||
.Throws(new PurviewJobException("queue unavailable"));
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessContentResponse());
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert: scope warmup is attempted, and ProcessContent still runs when it can't be queued.
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithCachedPaymentRequiredState_ThrowsPaymentRequiredAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
|
||||
It.IsAny<PaymentRequiredCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PaymentRequiredCacheEntry("Payment required"));
|
||||
|
||||
// Act + Assert
|
||||
await Assert.ThrowsAsync<PurviewPaymentRequiredException>(() =>
|
||||
this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None));
|
||||
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Func<Channel<BackgroundJobBase>, Task>? runner = null;
|
||||
Mock<IChannelHandler> channelHandler = new();
|
||||
Mock<IPurviewClient> purviewClient = new();
|
||||
Mock<ICacheProvider> cacheProvider = new();
|
||||
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
|
||||
ProtectionScopesRequest request = new("user-123", "tenant-123")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
]
|
||||
};
|
||||
ProtectionScopesCacheKey cacheKey = new(request);
|
||||
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
|
||||
|
||||
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
|
||||
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
|
||||
|
||||
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new PurviewPaymentRequiredException("Payment required"));
|
||||
|
||||
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
|
||||
|
||||
// Act
|
||||
Assert.NotNull(runner);
|
||||
await channel.Writer.WriteAsync(new ScopeRetrievalJob(request, cacheKey, CreateProcessContentRequest()));
|
||||
channel.Writer.Complete();
|
||||
await runner(channel);
|
||||
|
||||
// Assert
|
||||
cacheProvider.Verify(x => x.SetAsync(
|
||||
It.Is<PaymentRequiredCacheKey>(key => key.TenantId == "tenant-123"),
|
||||
It.Is<PaymentRequiredCacheEntry>(entry => entry.Message == "Payment required"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesContentActivityJobAsync()
|
||||
{
|
||||
// Arrange
|
||||
Func<Channel<BackgroundJobBase>, Task>? runner = null;
|
||||
Mock<IChannelHandler> channelHandler = new();
|
||||
Mock<IPurviewClient> purviewClient = new();
|
||||
Mock<ICacheProvider> cacheProvider = new();
|
||||
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
|
||||
ProtectionScopesRequest request = CreateProtectionScopesRequest();
|
||||
ScopeRetrievalJob job = new(request, new ProtectionScopesCacheKey(request), CreateProcessContentRequest());
|
||||
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
|
||||
|
||||
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
|
||||
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
|
||||
|
||||
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
|
||||
|
||||
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
|
||||
|
||||
// Act
|
||||
Assert.NotNull(runner);
|
||||
await channel.Writer.WriteAsync(job);
|
||||
channel.Writer.Complete();
|
||||
await runner(channel);
|
||||
|
||||
// Assert
|
||||
channelHandler.Verify(x => x.QueueJob(It.IsAny<ContentActivityJob>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ProtectionScopesRequest CreateProtectionScopesRequest()
|
||||
{
|
||||
return new ProtectionScopesRequest("user-123", "tenant-123")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessContentRequest CreateProcessContentRequest()
|
||||
{
|
||||
PurviewTextContent content = new("Test content");
|
||||
ProcessConversationMetadata metadata = new(content, "msg-123", false, "Test message", "test-correlation-id");
|
||||
ActivityMetadata activityMetadata = new(Activity.UploadText);
|
||||
DeviceMetadata deviceMetadata = new()
|
||||
{
|
||||
OperatingSystemSpecifications = new()
|
||||
{
|
||||
OperatingSystemPlatform = "Windows",
|
||||
OperatingSystemVersion = "10"
|
||||
}
|
||||
};
|
||||
IntegratedAppMetadata integratedAppMetadata = new()
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
PolicyLocation policyLocation = new("microsoft.graph.policyLocationApplication", "app-123");
|
||||
ProtectedAppMetadata protectedAppMetadata = new(policyLocation)
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
ContentToProcess contentToProcess = new(
|
||||
[metadata],
|
||||
activityMetadata,
|
||||
deviceMetadata,
|
||||
integratedAppMetadata,
|
||||
protectedAppMetadata);
|
||||
|
||||
return new ProcessContentRequest(contentToProcess, "user-123", "tenant-123");
|
||||
}
|
||||
|
||||
private static PurviewSettings CreateValidPurviewSettings()
|
||||
{
|
||||
return new PurviewSettings("TestApp")
|
||||
|
||||
@@ -51,9 +51,8 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — Content is cached
|
||||
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — Content includes parameter schema from typed script
|
||||
Assert.Contains("parameters_schema", await skill.GetContentAsync());
|
||||
Assert.Contains("value", await skill.GetContentAsync());
|
||||
// Act & Assert — Content includes parameter schema from typed script (with preserved quotes)
|
||||
Assert.Contains("\"value\"", await skill.GetContentAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -383,10 +382,9 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
// Act & Assert — Content includes reflected resources and scripts
|
||||
Assert.Contains("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("conversion-table", await skill.GetContentAsync());
|
||||
Assert.Contains("<scripts>", await skill.GetContentAsync());
|
||||
// Act & Assert — Content no longer includes resources in body; scripts are in script_schemas
|
||||
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("<script_schemas>", await skill.GetContentAsync());
|
||||
Assert.Contains("convert", await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — discovered members are cached
|
||||
@@ -504,7 +502,7 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
|
||||
public async Task Content_DoesNotRenderResources_InBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedResourcePropertiesSkill();
|
||||
@@ -512,8 +510,8 @@ public sealed class AgentClassSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — descriptions from [Description] attribute appear in synthesized content
|
||||
Assert.Contains("Some important data.", content);
|
||||
// Assert — resources are no longer rendered in body content
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -122,11 +122,10 @@ public sealed class AgentFileSkillScriptTests
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.Contains("<script name=\"build\">", content);
|
||||
Assert.Contains("<script name=\"deploy\">", content);
|
||||
Assert.Contains("<parameters_schema>", content);
|
||||
Assert.Contains("</scripts>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("<schema script=\"build\">", content);
|
||||
Assert.Contains("<schema script=\"deploy\">", content);
|
||||
Assert.Contains("</script_schemas>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -149,7 +149,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -158,13 +158,12 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("config", content);
|
||||
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -173,9 +172,8 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("dynamic", content);
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -189,7 +187,7 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("run", content);
|
||||
}
|
||||
|
||||
@@ -209,7 +207,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_IncludesScriptSchemasAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -220,9 +218,8 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("r1", content);
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("s1", content);
|
||||
}
|
||||
|
||||
@@ -236,8 +233,9 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — JSON schema should be present and XML content chars escaped
|
||||
Assert.Contains("parameters_schema", content);
|
||||
// Assert — JSON schema should be present inside <schema> element (no extra wrapper) with preserved quotes
|
||||
Assert.Contains("<schema script=\"search\">", content);
|
||||
Assert.Contains("\"query\"", content);
|
||||
Assert.DoesNotContain("<![CDATA[", content);
|
||||
}
|
||||
|
||||
@@ -429,7 +427,7 @@ public sealed class AgentInlineSkillTests
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("<scripts>", content);
|
||||
Assert.DoesNotContain("<script_schemas>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -463,7 +461,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
|
||||
public async Task Content_ScriptWithDescription_DoesNotEmitDescriptionAttributeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -472,8 +470,10 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"Runs something.\"", content);
|
||||
// Assert — description is no longer emitted in the script_schemas block;
|
||||
// the block only contains parameter schemas for calling scripts.
|
||||
Assert.Contains("<schema script=\"my-script\"", content);
|
||||
Assert.DoesNotContain("description=\"Runs something.\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -492,7 +492,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
|
||||
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -502,9 +502,10 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"A described resource.\"", content);
|
||||
Assert.DoesNotContain("no-desc\" description", content);
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("with-desc", content);
|
||||
Assert.DoesNotContain("no-desc", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -134,6 +134,7 @@ public class ChatClientAgentOptionsTests
|
||||
ClearOnChatHistoryProviderConflict = false,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
EnableNonApprovalRequiredFunctionBypassing = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -150,6 +151,7 @@ public class ChatClientAgentOptionsTests
|
||||
Assert.Equal(original.ClearOnChatHistoryProviderConflict, clone.ClearOnChatHistoryProviderConflict);
|
||||
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
|
||||
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
|
||||
Assert.Equal(original.EnableNonApprovalRequiredFunctionBypassing, clone.EnableNonApprovalRequiredFunctionBypassing);
|
||||
|
||||
// ChatOptions should be cloned, not the same reference
|
||||
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
|
||||
|
||||
@@ -347,6 +347,115 @@ public class ChatClientAgent_ChatOptionsMergingTests
|
||||
Assert.Equal(expectedSetting, capturedChatOptions.RawRepresentationFactory(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request takes priority over the agent's.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesRequestReasoningOverAgentReasoningAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
|
||||
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> falls back to the agent's when the request has none.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingFallsBackToAgentReasoningWhenRequestHasNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions()));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(agentReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(agentReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request is used when the agent has none.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesRequestReasoningWhenAgentHasNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging handles all scalar properties correctly.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public class NonApprovalRequiredFunctionBypassingChatClientTests
|
||||
{
|
||||
#region GetResponseAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello")])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session);
|
||||
|
||||
// Assert
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Equal("Hello", response.Messages[0].Text);
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_AllToolsRequireApproval_PassesThroughUnchangedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
var fcc = new FunctionCallContent("call1", "approvalTool");
|
||||
var approval = new ToolApprovalRequestContent("req1", fcc);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [approvalTool] };
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — approval request should remain
|
||||
Assert.Single(response.Messages);
|
||||
var contents = response.Messages[0].Contents;
|
||||
Assert.Single(contents);
|
||||
Assert.IsType<ToolApprovalRequestContent>(contents[0]);
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_MixedApproval_RemovesNonApprovalItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var fccApproval = new FunctionCallContent("call2", "approvalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
|
||||
])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — only the approval-required item remains in the response
|
||||
Assert.Single(response.Messages);
|
||||
var contents = response.Messages[0].Contents;
|
||||
Assert.Single(contents);
|
||||
var remainingApproval = Assert.IsType<ToolApprovalRequestContent>(contents[0]);
|
||||
Assert.Equal("req2", remainingApproval.RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var fccApproval = new FunctionCallContent("call2", "approvalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
|
||||
])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the auto-approved item should be stored in the session
|
||||
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
|
||||
Assert.NotNull(stored);
|
||||
Assert.Single(stored!);
|
||||
Assert.Equal("req1", stored![0].RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_AllNonApproval_RemovesAllApprovalsAndRemovesEmptyMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, [approvalNormal])
|
||||
])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool] };
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the message should be removed since it's now empty
|
||||
Assert.Empty(response.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NextRequest_InjectsStoredAutoApprovalsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
|
||||
var session = new ChatClientAgentSession();
|
||||
session.StateBag.SetValue(
|
||||
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
|
||||
new List<ToolApprovalRequestContent> { storedApproval },
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
var innerClient = CreateMockChatClient((messages, _, _) =>
|
||||
{
|
||||
capturedMessages = messages.ToList();
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
|
||||
});
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the inner client should receive injected messages
|
||||
Assert.NotNull(capturedMessages);
|
||||
var messagesList = capturedMessages!.ToList();
|
||||
|
||||
// Original user message + user message with approved responses.
|
||||
Assert.Equal(2, messagesList.Count);
|
||||
Assert.Equal(ChatRole.User, messagesList[0].Role);
|
||||
|
||||
// User message with the auto-approved ToolApprovalResponseContent
|
||||
Assert.Equal(ChatRole.User, messagesList[1].Role);
|
||||
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
|
||||
Assert.Single(userContent);
|
||||
Assert.Equal("req1", userContent[0].RequestId);
|
||||
Assert.True(userContent[0].Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NextRequest_ClearsStoredAfterInjectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
|
||||
var session = new ChatClientAgentSession();
|
||||
session.StateBag.SetValue(
|
||||
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
|
||||
new List<ToolApprovalRequestContent> { storedApproval },
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the stored data should be cleared after successful injection
|
||||
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_UnknownTool_TreatedAsApprovalRequiredAsync()
|
||||
{
|
||||
// Arrange — tool is not in ChatOptions.Tools
|
||||
var fccUnknown = new FunctionCallContent("call1", "unknownTool");
|
||||
var approvalUnknown = new ToolApprovalRequestContent("req1", fccUnknown);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, [approvalUnknown])
|
||||
])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [] };
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — unknown tool should NOT be auto-approved
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Single(response.Messages[0].Contents);
|
||||
Assert.IsType<ToolApprovalRequestContent>(response.Messages[0].Contents[0]);
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_StoredRequestToolSetChanged_StillInjectsAsApprovedAsync()
|
||||
{
|
||||
// Arrange — tool was previously non-approval-required but is now wrapped in ApprovalRequiredAIFunction.
|
||||
// The LLM still requires a complete set of responses, so we inject unconditionally.
|
||||
var fccTool = new FunctionCallContent("call1", "changingTool");
|
||||
var storedApproval = new ToolApprovalRequestContent("req1", fccTool);
|
||||
|
||||
var session = new ChatClientAgentSession();
|
||||
session.StateBag.SetValue(
|
||||
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
|
||||
new List<ToolApprovalRequestContent> { storedApproval },
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
var innerClient = CreateMockChatClient((messages, _, _) =>
|
||||
{
|
||||
capturedMessages = messages.ToList();
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
|
||||
});
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
|
||||
// The tool is now wrapped in ApprovalRequiredAIFunction — but we still inject unconditionally
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "changingTool"));
|
||||
var options = new ChatOptions { Tools = [approvalTool] };
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the stored request should still be injected as approved
|
||||
Assert.NotNull(capturedMessages);
|
||||
var messagesList = capturedMessages!.ToList();
|
||||
Assert.Equal(2, messagesList.Count);
|
||||
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
|
||||
Assert.Single(userContent);
|
||||
Assert.Equal("req1", userContent[0].RequestId);
|
||||
Assert.True(userContent[0].Approved);
|
||||
|
||||
// Session should be cleared
|
||||
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStreamingResponseAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Hello")));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(decorator, session, updates);
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
Assert.Equal("Hello", updates[0].Text);
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_MixedApproval_FiltersNonApprovalItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var fccApproval = new FunctionCallContent("call2", "approvalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "text"),
|
||||
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
|
||||
|
||||
// Assert — text update + filtered approval update
|
||||
Assert.Equal(2, updates.Count);
|
||||
Assert.Equal("text", updates[0].Text);
|
||||
|
||||
// Second update should only have the approval-required item
|
||||
var approvalContents = updates[1].Contents.OfType<ToolApprovalRequestContent>().ToList();
|
||||
Assert.Single(approvalContents);
|
||||
Assert.Equal("req2", approvalContents[0].RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var fccApproval = new FunctionCallContent("call2", "approvalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
|
||||
|
||||
// Assert — the auto-approved item should be stored in the session
|
||||
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
|
||||
Assert.NotNull(stored);
|
||||
Assert.Single(stored!);
|
||||
Assert.Equal("req1", stored![0].RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_AllNonApproval_SkipsEmptyUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "text"),
|
||||
new ChatResponseUpdate { Contents = [approvalNormal] }));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool] };
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
|
||||
|
||||
// Assert — the approval update should be skipped entirely
|
||||
Assert.Single(updates);
|
||||
Assert.Equal("text", updates[0].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NoRunContext_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "response")])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
|
||||
// Act & Assert — calling directly without agent context
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => decorator.GetResponseAsync([new ChatMessage(ChatRole.User, "test")]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NoSession_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "response")])));
|
||||
|
||||
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
|
||||
|
||||
// Act & Assert — run with null session
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => RunWithAgentContextAsync(decorator, session: null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Builder Extension Tests
|
||||
|
||||
[Fact]
|
||||
public void UseNonApprovalRequiredFunctionBypassing_AddsDecoratorToPipeline()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var pipeline = innerClient.AsBuilder()
|
||||
.UseNonApprovalRequiredFunctionBypassing()
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithDefaultAgentMiddleware_EnableNonApprovalRequiredFunctionBypassing_InjectsDecorator()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>().Object;
|
||||
var options = new ChatClientAgentOptions { EnableNonApprovalRequiredFunctionBypassing = true };
|
||||
|
||||
// Act
|
||||
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithDefaultAgentMiddleware_EnableNonApprovalRequiredFunctionBypassingFalse_DoesNotInjectDecorator()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>().Object;
|
||||
var options = new ChatClientAgentOptions { EnableNonApprovalRequiredFunctionBypassing = false };
|
||||
|
||||
// Act
|
||||
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
|
||||
|
||||
// Assert
|
||||
Assert.Null(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static async Task<ChatResponse> RunWithAgentContextAsync(
|
||||
NonApprovalRequiredFunctionBypassingChatClient decorator,
|
||||
AgentSession? session,
|
||||
ChatOptions? options = null)
|
||||
{
|
||||
ChatResponse? capturedResponse = null;
|
||||
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
|
||||
{
|
||||
capturedResponse = await decorator.GetResponseAsync(messages, options, ct);
|
||||
return new AgentResponse(capturedResponse);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
|
||||
return capturedResponse!;
|
||||
}
|
||||
|
||||
private static Task<ChatResponse> RunWithAgentContextAsync(
|
||||
NonApprovalRequiredFunctionBypassingChatClient decorator,
|
||||
AgentSession session)
|
||||
=> RunWithAgentContextAsync(decorator, session, options: null);
|
||||
|
||||
private static async Task RunStreamingWithAgentContextAsync(
|
||||
NonApprovalRequiredFunctionBypassingChatClient decorator,
|
||||
AgentSession session,
|
||||
List<ChatResponseUpdate> updates,
|
||||
ChatOptions? options = null)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
|
||||
{
|
||||
await foreach (var update in decorator.GetStreamingResponseAsync(messages, options, ct))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
|
||||
{
|
||||
var mock = new Mock<IChatClient>();
|
||||
mock.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockStreamingChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> onGetStreamingResponse)
|
||||
{
|
||||
var mock = new Mock<IChatClient>();
|
||||
mock.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
|
||||
{
|
||||
foreach (var update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -59,15 +59,15 @@ public class ToolApprovalAgentBuilderExtensionsTests
|
||||
/// Verify that UseToolApproval with custom JsonSerializerOptions works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseToolApproval_WithCustomJsonSerializerOptions_ReturnsToolApprovalAgent()
|
||||
public void UseToolApproval_WithCustomOptions_ReturnsToolApprovalAgent()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
var options = new JsonSerializerOptions();
|
||||
var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() };
|
||||
|
||||
// Act
|
||||
var result = builder.UseToolApproval(jsonSerializerOptions: options).Build();
|
||||
var result = builder.UseToolApproval(options: options).Build();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ToolApprovalAgent>(result);
|
||||
|
||||
@@ -47,14 +47,14 @@ public class ToolApprovalAgentTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts custom JsonSerializerOptions.
|
||||
/// Verify that constructor accepts custom options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_CustomJsonSerializerOptions_CreatesInstanceAsync()
|
||||
public void Constructor_CustomOptions_CreatesInstance()
|
||||
{
|
||||
// Arrange
|
||||
var innerAgent = new Mock<AIAgent>().Object;
|
||||
var options = new JsonSerializerOptions();
|
||||
var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() };
|
||||
|
||||
// Act
|
||||
var agent = new ToolApprovalAgent(innerAgent, options);
|
||||
@@ -1535,4 +1535,311 @@ public class ToolApprovalAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Auto-Approval Rules (Heuristics)
|
||||
|
||||
/// <summary>
|
||||
/// Verify that an auto-approval rule can approve a function call that would otherwise need user approval.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AutoApprovalRule_ApprovesMatchingToolAsync()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
|
||||
|
||||
// Inner agent: first call returns approval request, second returns final response.
|
||||
var callCount = 0;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
|
||||
});
|
||||
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(
|
||||
[new ChatMessage(ChatRole.User, "Hi")],
|
||||
session);
|
||||
|
||||
// Assert — the approval request was auto-approved, inner agent called twice
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("Done", response.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when auto-approval rule does not match, request is surfaced to the caller.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AutoApprovalRule_DoesNotMatchSurfacesToCallerAsync()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "DangerousTool"));
|
||||
|
||||
var innerAgent = CreateMockAgent(new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]));
|
||||
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")] // Only approves ReadTool
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(
|
||||
[new ChatMessage(ChatRole.User, "Hi")],
|
||||
session);
|
||||
|
||||
// Assert — request surfaced to caller since heuristic doesn't match
|
||||
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
Assert.Single(requests);
|
||||
Assert.Equal("DangerousTool", ((FunctionCallContent)requests[0].ToolCall).Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that multiple auto-approval rules are evaluated in order; first match wins.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_MultipleAutoApprovalRules_FirstMatchWinsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SpecialTool"));
|
||||
|
||||
var callCount = 0;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
|
||||
});
|
||||
|
||||
var rule1Called = false;
|
||||
var rule2Called = false;
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules =
|
||||
[
|
||||
fcc => { rule1Called = true; return new ValueTask<bool>(fcc.Name == "SpecialTool"); },
|
||||
fcc => { rule2Called = true; return new ValueTask<bool>(true); } // Should not be reached
|
||||
]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — first rule matched, second was never called
|
||||
Assert.True(rule1Called);
|
||||
Assert.False(rule2Called);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that standing rules are evaluated before auto-approval rules.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_StandingRuleTakesPrecedenceOverAutoApprovalRuleAsync()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
|
||||
|
||||
var callCount = 0;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount <= 2)
|
||||
{
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
|
||||
});
|
||||
|
||||
var heuristicCalled = false;
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [fcc => { heuristicCalled = true; return new ValueTask<bool>(true); }]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Call 1: heuristic should be called (no standing rule yet)
|
||||
var response1 = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
Assert.True(heuristicCalled);
|
||||
Assert.Equal("Done", response1.Text);
|
||||
|
||||
// Now establish a standing rule by sending AlwaysApprove
|
||||
heuristicCalled = false;
|
||||
callCount = 0;
|
||||
var alwaysApprove = new AlwaysApproveToolApprovalResponseContent(
|
||||
approvalRequest.CreateResponse(approved: true),
|
||||
alwaysApproveTool: true,
|
||||
alwaysApproveToolWithArguments: false);
|
||||
|
||||
// Call 2: standing rule should match first, heuristic should NOT be called
|
||||
var response2 = await agent.RunAsync(
|
||||
[new ChatMessage(ChatRole.User, [alwaysApprove])],
|
||||
session);
|
||||
Assert.False(heuristicCalled);
|
||||
Assert.Equal("Done", response2.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when a batch contains a mix of heuristic-approved and standing-rule-approved
|
||||
/// requests, the collected approval responses preserve the original request order rather than
|
||||
/// being grouped by approval kind.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_MixedAutoApprovals_PreserveOriginalOrderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
|
||||
// Batch ordering: first request is approved by a heuristic, second by a standing rule.
|
||||
var heuristicRequest = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "HeuristicTool"));
|
||||
var standingRequest = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "StandingTool"));
|
||||
|
||||
var batchResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [heuristicRequest, standingRequest])]);
|
||||
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
|
||||
|
||||
var callCount = 0;
|
||||
List<ChatMessage>? secondCallMessages = null;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 2)
|
||||
{
|
||||
secondCallMessages = msgs.ToList();
|
||||
}
|
||||
})
|
||||
.ReturnsAsync(() => callCount == 1 ? batchResponse : finalResponse);
|
||||
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "HeuristicTool")]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Establish a standing rule for "StandingTool" via an AlwaysApprove response in the same call.
|
||||
var alwaysApprove = standingRequest.CreateAlwaysApproveToolResponse("User said always");
|
||||
|
||||
// Act — both requests auto-approve (heuristic + standing rule), so the inner agent is re-invoked.
|
||||
var response = await agent.RunAsync(
|
||||
[new ChatMessage(ChatRole.User, [alwaysApprove])],
|
||||
session);
|
||||
|
||||
// Assert — inner agent re-called and final response returned.
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("Done", response.Text);
|
||||
|
||||
// The injected approval responses must preserve the original request order: reqA before reqB,
|
||||
// even though reqA was approved by a heuristic and reqB by a standing rule.
|
||||
Assert.NotNull(secondCallMessages);
|
||||
var injected = secondCallMessages!
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalResponseContent>()
|
||||
.Where(r => r.RequestId is "reqA" or "reqB")
|
||||
.ToList();
|
||||
Assert.Equal(2, injected.Count);
|
||||
Assert.Equal("reqA", injected[0].RequestId);
|
||||
Assert.Equal("reqB", injected[1].RequestId);
|
||||
Assert.All(injected, r => Assert.True(r.Approved));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that auto-approval rules work in the streaming path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_AutoApprovalRule_ApprovesMatchingToolAsync()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
|
||||
|
||||
var callCount = 0;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, [approvalRequest])]);
|
||||
}
|
||||
|
||||
return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "Done")]);
|
||||
});
|
||||
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")], session))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert — the approval request was auto-approved, inner agent streamed twice
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Single(updates);
|
||||
Assert.Equal("Done", updates[0].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -142,6 +142,95 @@ public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActi
|
||||
indexName: "CurrentIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeachTakeNextWithMultiFieldRecordAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CurrentValueName = "CurrentValue";
|
||||
this.SetVariableState(CurrentValueName);
|
||||
|
||||
TableDataValue tableValue = DataValue.TableFromRecords(
|
||||
DataValue.RecordFromFields(
|
||||
new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice")),
|
||||
new KeyValuePair<string, DataValue>("role", new StringDataValue("Engineer"))));
|
||||
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachTakeNextWithMultiFieldRecordAsync),
|
||||
items: ValueExpression.Literal(tableValue),
|
||||
valueName: CurrentValueName,
|
||||
indexName: null);
|
||||
ForeachExecutor action = new(model, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
||||
|
||||
// Assert
|
||||
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
|
||||
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
|
||||
Assert.Equal("Engineer", currentValue.GetField("role").ToObject());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Power Fx wraps scalar array literals such as <c>=[1, 2, 3]</c> as <c>Table({Value: 1}, ...)</c>;
|
||||
/// the loop value must expose the bare scalar, not the single-column wrapper record.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachTakeNextWithSingleColumnValueRecordAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CurrentValueName = "CurrentValue";
|
||||
this.SetVariableState(CurrentValueName);
|
||||
|
||||
TableDataValue tableValue = DataValue.TableFromRecords(
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(1))),
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(2))),
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(3))));
|
||||
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachTakeNextWithSingleColumnValueRecordAsync),
|
||||
items: ValueExpression.Literal(tableValue),
|
||||
valueName: CurrentValueName,
|
||||
indexName: null);
|
||||
ForeachExecutor action = new(model, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
||||
|
||||
// Assert
|
||||
FormulaValue currentValue = this.State.Get(CurrentValueName);
|
||||
Assert.IsNotType<RecordValue>(currentValue, exactMatch: false);
|
||||
Assert.Equal(1m, currentValue.ToObject());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-field records whose only field is NOT named <c>Value</c> are not Power Fx auto-wraps;
|
||||
/// they are preserved as records so the field name remains accessible inside the loop body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachTakeNextWithSingleFieldNonValueRecordAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CurrentValueName = "CurrentValue";
|
||||
this.SetVariableState(CurrentValueName);
|
||||
|
||||
TableDataValue tableValue = DataValue.TableFromRecords(
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice"))));
|
||||
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachTakeNextWithSingleFieldNonValueRecordAsync),
|
||||
items: ValueExpression.Literal(tableValue),
|
||||
valueName: CurrentValueName,
|
||||
indexName: null);
|
||||
ForeachExecutor action = new(model, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
||||
|
||||
// Assert
|
||||
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
|
||||
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeachTakeLastAsync()
|
||||
{
|
||||
|
||||
@@ -419,6 +419,82 @@ public class MagenticOrchestrationTests
|
||||
"final-answer synthesis must see what participants actually said");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Participant_Receives_Prior_Participant_Response_Not_InstructionAsync()
|
||||
{
|
||||
// Regression: each participant must see prior participants' *responses* (the running conversation),
|
||||
// not their *instructions*. Previously the orchestrator broadcast the per-round instruction to every
|
||||
// participant (untargeted fan-out) and never broadcast replies, so a later speaker received the earlier
|
||||
// speaker's instruction and never its answer.
|
||||
const string HealthInstruction = "HEALTH_CHECKER_INSTRUCTION_check_framework";
|
||||
const string DatabaseInstruction = "DATABASE_CHECKER_INSTRUCTION_check_database";
|
||||
const string HealthEchoPrefix = "HC_RESPONSE::";
|
||||
const string DatabaseEchoPrefix = "DB_RESPONSE::";
|
||||
|
||||
List<ChatMessage> facts = CreatePlanResponse("Facts");
|
||||
List<ChatMessage> plan = CreatePlanResponse("Plan");
|
||||
List<ChatMessage> round1Ledger = CreateProgressLedgerResponse(
|
||||
isRequestSatisfied: false,
|
||||
isInLoop: false,
|
||||
isProgressBeingMade: true,
|
||||
nextSpeaker: "HealthChecker",
|
||||
instructionOrQuestion: HealthInstruction);
|
||||
List<ChatMessage> round2Ledger = CreateProgressLedgerResponse(
|
||||
isRequestSatisfied: false,
|
||||
isInLoop: false,
|
||||
isProgressBeingMade: true,
|
||||
nextSpeaker: "DatabaseChecker",
|
||||
instructionOrQuestion: DatabaseInstruction);
|
||||
List<ChatMessage> round3Ledger = CreateProgressLedgerResponse(
|
||||
isRequestSatisfied: true,
|
||||
isInLoop: false,
|
||||
isProgressBeingMade: true,
|
||||
nextSpeaker: "DatabaseChecker",
|
||||
instructionOrQuestion: "Done");
|
||||
List<ChatMessage> finalAnswer = CreateFinalAnswerResponse("All systems checked");
|
||||
|
||||
TestReplayAgent manager = new(
|
||||
[facts, plan, round1Ledger, round2Ledger, round3Ledger, finalAnswer],
|
||||
name: "Manager");
|
||||
RecordingEchoAgent healthChecker = new(name: "HealthChecker", prefix: HealthEchoPrefix);
|
||||
RecordingEchoAgent databaseChecker = new(name: "DatabaseChecker", prefix: DatabaseEchoPrefix);
|
||||
|
||||
Workflow workflow = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(healthChecker, databaseChecker)
|
||||
.RequirePlanSignoff(false)
|
||||
.Build();
|
||||
|
||||
WorkflowRunResult runResult = await RunMagenticWorkflowAsync(
|
||||
workflow,
|
||||
[new ChatMessage(ChatRole.User, "Check system health")]);
|
||||
|
||||
runResult.Result.Should().NotBeNull();
|
||||
runResult.Result![0].Text.Should().Contain("All systems checked");
|
||||
|
||||
// Each participant takes exactly one turn.
|
||||
healthChecker.RecordedInputs.Should().ContainSingle();
|
||||
databaseChecker.RecordedInputs.Should().ContainSingle();
|
||||
|
||||
// The first speaker receives its own instruction.
|
||||
List<ChatMessage> healthInput = healthChecker.RecordedInputs[0];
|
||||
healthInput.Should().Contain(m => m.Text.Contains(HealthInstruction), "the first speaker receives its own instruction");
|
||||
|
||||
// The second speaker must see the first speaker's RESPONSE (authored by HealthChecker, carrying the echo
|
||||
// prefix that only the response — not the raw instruction — has), plus its own instruction.
|
||||
List<ChatMessage> databaseInput = databaseChecker.RecordedInputs[0];
|
||||
databaseInput.Should().Contain(
|
||||
m => m.AuthorName == "HealthChecker" && m.Text.Contains(HealthEchoPrefix),
|
||||
"the next speaker must receive the prior participant's response (the running conversation)");
|
||||
databaseInput.Should().Contain(m => m.Text.Contains(DatabaseInstruction),
|
||||
"the next speaker must receive its own instruction");
|
||||
|
||||
// The leaked-instruction bug: the second speaker must not receive HealthChecker's instruction as a
|
||||
// bare message (it should only appear, if at all, embedded in HealthChecker's prefixed response).
|
||||
databaseInput.Should().NotContain(
|
||||
m => m.AuthorName != "HealthChecker" && m.Text.Trim() == HealthInstruction,
|
||||
"the prior speaker's instruction must not leak into the next speaker's context as a standalone message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlanReview_Revised_Triggers_ReplanAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TestEchoAgent"/> that records the input messages it receives on each call.
|
||||
/// Used by tests that need to assert what context a participant was actually handed - for example,
|
||||
/// that a later speaker sees prior participants' <em>responses</em> (the running conversation) rather
|
||||
/// than their <em>instructions</em>.
|
||||
/// </summary>
|
||||
internal sealed class RecordingEchoAgent(string? id = null, string? name = null, string? prefix = null)
|
||||
: TestEchoAgent(id, name, prefix)
|
||||
{
|
||||
public List<List<ChatMessage>> RecordedInputs { get; } = [];
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Materialize once so the deferred input is recorded and replayed identically.
|
||||
List<ChatMessage> recorded = messages.ToList();
|
||||
this.RecordedInputs.Add(recorded);
|
||||
|
||||
await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(recorded, session, options, cancellationToken))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.8.1] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add MCP client OTel spans per GenAI semantic conventions ([#6349](https://github.com/microsoft/agent-framework/pull/6349))
|
||||
- **agent-framework-core**: Add MCP long-running task support ([#6319](https://github.com/microsoft/agent-framework/pull/6319))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-claude**: Bump `claude-agent-sdk` to 0.2.87 ([#6248](https://github.com/microsoft/agent-framework/pull/6248))
|
||||
- **agent-framework-core**: Document checkpoint storage security model and deserialization trust boundaries ([#6295](https://github.com/microsoft/agent-framework/pull/6295))
|
||||
- **agent-framework-azurefunctions**: Document checkpoint storage security model and deserialization trust boundaries ([#6295](https://github.com/microsoft/agent-framework/pull/6295))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Filter MCP tool kwargs to declared params via allowlist ([#6399](https://github.com/microsoft/agent-framework/pull/6399))
|
||||
- **agent-framework-core**: Fix per-service-call history persistence with server-storing clients ([#6310](https://github.com/microsoft/agent-framework/pull/6310))
|
||||
- **agent-framework-openai**: Use `getattr` for non-OpenAI provider response compatibility ([#6270](https://github.com/microsoft/agent-framework/pull/6270))
|
||||
- **agent-framework-foundry-hosting**: Refactor workflow-as-agent pending request handling ([#6259](https://github.com/microsoft/agent-framework/pull/6259))
|
||||
- **agent-framework-gemini**: Make Gemini honor declarative `outputSchema`, not just JSON mode ([#5893](https://github.com/microsoft/agent-framework/pull/5893))
|
||||
- **agent-framework-mem0**: Isolate entity retrieval and correct `app_id` payload ([#6242](https://github.com/microsoft/agent-framework/pull/6242))
|
||||
- **agent-framework-ag-ui**: Match AG-UI approval responses to requested arguments ([#6376](https://github.com/microsoft/agent-framework/pull/6376))
|
||||
|
||||
## [1.8.0] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add MCP-based skills discovery (`McpSkillsSource`) ([#6169](https://github.com/microsoft/agent-framework/pull/6169))
|
||||
- **agent-framework-core**: Progressive tool exposure via `FunctionInvocationContext` ([#6233](https://github.com/microsoft/agent-framework/pull/6233))
|
||||
- **agent-framework-core**: Add background agent support to harness agent ([#6155](https://github.com/microsoft/agent-framework/pull/6155))
|
||||
- **agent-framework-core**: Add `AgentFileStore` and `FileAccessProvider` for file access operations ([#6099](https://github.com/microsoft/agent-framework/pull/6099))
|
||||
- **agent-framework-core**: Coalesce code interpreter history chunks ([#5801](https://github.com/microsoft/agent-framework/pull/5801))
|
||||
- **agent-framework-core**: Run sync tools off the event loop ([#5773](https://github.com/microsoft/agent-framework/pull/5773))
|
||||
- **agent-framework-bedrock**: Implement native structured output support via Converse API ([#6052](https://github.com/microsoft/agent-framework/pull/6052))
|
||||
- **agent-framework-foundry**: Add Foundry Adaptive Evals integration for rubric-generation ([#6101](https://github.com/microsoft/agent-framework/pull/6101))
|
||||
- **agent-framework-foundry**: Add `timeout` parameter to `FoundryAgent` to fix `ConnectTimeout` on multi-turn conversations ([#6263](https://github.com/microsoft/agent-framework/pull/6263))
|
||||
- **agent-framework-mistral**: Add Mistral AI embedding client package ([#5480](https://github.com/microsoft/agent-framework/pull/5480))
|
||||
- **agent-framework-a2a**: Expose `supported_protocol_bindings` as configurable parameter ([#6098](https://github.com/microsoft/agent-framework/pull/6098))
|
||||
- **agent-framework-a2a**: Set `message_id` on `AgentResponseUpdate` for message-bearing paths ([#6163](https://github.com/microsoft/agent-framework/pull/6163))
|
||||
- **agent-framework-foundry-hosting**: Persist hosted MCP call/results as canonical `mcp_call` output ([#6070](https://github.com/microsoft/agent-framework/pull/6070))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-github-copilot**: [BREAKING] Upgrade `github-copilot-sdk` to v1.0.0 (stable) ([#6292](https://github.com/microsoft/agent-framework/pull/6292))
|
||||
- **agent-framework-core**: [BREAKING — experimental] Refactor Skill API to async resource and script lookup ([#6135](https://github.com/microsoft/agent-framework/pull/6135))
|
||||
- **agent-framework-github-copilot**: Promote to release candidate (`1.0.0rc1`)
|
||||
- **agent-framework-declarative**: Promote to release candidate (`1.0.0rc1`) ([#6256](https://github.com/microsoft/agent-framework/pull/6256))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Fix compaction message-id collisions and tool-loop summary persistence ([#6299](https://github.com/microsoft/agent-framework/pull/6299))
|
||||
- **agent-framework-core**: Fix observability unsafe serialization of function-call arguments containing dataclass/framework objects ([#6026](https://github.com/microsoft/agent-framework/pull/6026))
|
||||
- **agent-framework-core**: Consolidate MCP reliability fixes ([#6145](https://github.com/microsoft/agent-framework/pull/6145))
|
||||
- **agent-framework-core**: Backfill chat span request model if unknown and response model is available ([#6160](https://github.com/microsoft/agent-framework/pull/6160))
|
||||
- **agent-framework-anthropic**: Skip orphan anthropic thinking signatures ([#5784](https://github.com/microsoft/agent-framework/pull/5784))
|
||||
- **agent-framework-foundry**: Fix `FoundryAgent` stripping model from `PromptAgent` requests ([#5526](https://github.com/microsoft/agent-framework/pull/5526))
|
||||
- **agent-framework-foundry-hosting**: Fix toolbox consent flow in hosted agent ([#6249](https://github.com/microsoft/agent-framework/pull/6249))
|
||||
- **agent-framework-foundry-hosting**: Drop hosted MCP calls when reasoning is stripped ([#6210](https://github.com/microsoft/agent-framework/pull/6210))
|
||||
- **agent-framework-openai**: Fix OTLP HTTP base-endpoint losing `/v1/{signal}` auto-append ([#5913](https://github.com/microsoft/agent-framework/pull/5913))
|
||||
- **agent-framework-openai**: Drop hosted MCP calls when reasoning is stripped ([#6210](https://github.com/microsoft/agent-framework/pull/6210))
|
||||
- **agent-framework-orchestrations**: Fix spurious Magentic custom manager warning ([#6261](https://github.com/microsoft/agent-framework/pull/6261))
|
||||
- **agent-framework-azurefunctions**: Fix integration test worker crashes on Py3.13 ([#4260](https://github.com/microsoft/agent-framework/pull/4260))
|
||||
|
||||
## [1.7.0] - 2026-05-28
|
||||
|
||||
### Added
|
||||
@@ -1132,7 +1189,9 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.1...HEAD
|
||||
[1.8.1]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...python-1.8.1
|
||||
[1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0
|
||||
[1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0
|
||||
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
|
||||
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
|
||||
|
||||
@@ -33,7 +33,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
|
||||
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
|
||||
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260528"
|
||||
version = "1.0.0b260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any, cast
|
||||
from ag_ui.core import BaseEvent
|
||||
from agent_framework import SupportsAgentRun
|
||||
|
||||
from ._agent_run import run_agent_stream
|
||||
from ._agent_run import PendingApprovalEntry, run_agent_stream
|
||||
|
||||
|
||||
class AgentConfig:
|
||||
@@ -107,7 +107,7 @@ class AgentFrameworkAgent:
|
||||
# Populated when approval requests are emitted; consumed when responses arrive.
|
||||
# Prevents bypass, function name spoofing, and replay attacks.
|
||||
# Bounded to prevent unbounded growth from abandoned approval requests.
|
||||
self._pending_approvals: OrderedDict[str, str] = OrderedDict()
|
||||
self._pending_approvals: OrderedDict[str, PendingApprovalEntry] = OrderedDict()
|
||||
self._pending_approvals_max_size: int = 10_000
|
||||
|
||||
async def run(
|
||||
|
||||