Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7433aab92b | |||
| ab6c4d2dc8 | |||
| 2054d62702 | |||
| ecc1430977 | |||
| f666102d0c | |||
| 26b28b4386 | |||
| 435201b71b | |||
| d29e7be7fd | |||
| 26b9200c21 | |||
| e2938f4531 | |||
| b1377fad52 | |||
| 064751c5f3 | |||
| 10bf8d7d9e | |||
| 8be7c93063 | |||
| c527d61ac7 | |||
| ec407cf56f | |||
| 0f583ec8a3 | |||
| 9917bddc2b | |||
| da11daebe5 | |||
| e74ac4613c | |||
| 1f738cdeb7 | |||
| e6536fb459 | |||
| 58da0cc253 | |||
| 2213ef8493 | |||
| f330457042 | |||
| e33e78127f | |||
| 4be584cc53 | |||
| 4ce2804db0 | |||
| 1b45c15749 | |||
| 00d7102c54 | |||
| d80c340a06 | |||
| af4347a61d | |||
| a445e4815d | |||
| 8b8fbbba03 | |||
| 925d722acf | |||
| 6001c12cd3 | |||
| 6a3633e54a | |||
| 648a31ade6 | |||
| c6584ffaad | |||
| 74808cb6c7 | |||
| 11592495db | |||
| 047ec7eaff |
+23
-23
@@ -49,43 +49,43 @@
|
||||
/python @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
|
||||
|
||||
# Python packages
|
||||
/python/packages/a2a/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/a2a/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
|
||||
/python/packages/ag-ui/ @chetantoshniwal @moonbox3 @giles17
|
||||
/python/packages/anthropic/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/anthropic/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/azure-ai-search/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/azure-contentunderstanding/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/azure-cosmos/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/azure-cosmos-memory/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/bedrock/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/azure-cosmos/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/azure-cosmos-memory/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/bedrock/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/chatkit/ @chetantoshniwal @moonbox3 @giles17
|
||||
/python/packages/claude/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/copilotstudio/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/claude/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/copilotstudio/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @giles17
|
||||
/python/packages/core/agent_framework/_workflows/ @chetantoshniwal @moonbox3 @TaoChenOSU
|
||||
/python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg
|
||||
/python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg @moonbox3
|
||||
/python/packages/declarative/ @chetantoshniwal @moonbox3 @peibekwe
|
||||
/python/packages/devui/ @chetantoshniwal @eavanvalkenburg @moonbox3
|
||||
/python/packages/foundry/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 @giles17
|
||||
/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @eavanvalkenburg
|
||||
/python/packages/foundry_local/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/gemini/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/github_copilot/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hosting-a2a/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hosting-mcp/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hosting-responses/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hosting-telegram/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hyperlight/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/gemini/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
|
||||
/python/packages/github_copilot/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
|
||||
/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
|
||||
/python/packages/hosting-a2a/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
|
||||
/python/packages/hosting-mcp/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
|
||||
/python/packages/hosting-responses/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
|
||||
/python/packages/hosting-telegram/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
|
||||
/python/packages/hyperlight/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/lab/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
|
||||
/python/packages/mem0/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/mistral/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/monty/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/ollama/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/mem0/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/mistral/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/monty/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/ollama/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/openai/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @giles17
|
||||
/python/packages/orchestrations/ @chetantoshniwal @moonbox3 @TaoChenOSU
|
||||
/python/packages/purview/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/redis/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/tools/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/purview/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/redis/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/tools/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
|
||||
# Core .NET developers: @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
|
||||
@@ -59,7 +59,7 @@ runs:
|
||||
id: azure-login
|
||||
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }}
|
||||
continue-on-error: true
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
|
||||
@@ -24,7 +24,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Node.js environment
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -59,7 +59,7 @@ runs:
|
||||
sample-playbooks-${{ github.job }}-
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
|
||||
@@ -58,3 +58,5 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "sunday"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
@@ -74,9 +74,12 @@ code before the user has reviewed the plan**:
|
||||
approval or adjustments before implementing anything.
|
||||
4. **Implement.** Make the agreed changes.
|
||||
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
|
||||
was addressed (or the agreed outcome) — leave none unanswered.
|
||||
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
|
||||
comment has actually been addressed.
|
||||
was addressed, preferably citing the commit containing the change. If the
|
||||
feedback was not addressed, explain why. Leave no comment unanswered.
|
||||
6. **Resolve completed threads yourself.** After replying and completing any
|
||||
necessary discussion, resolve the review thread. Do not wait for the reviewer
|
||||
or a maintainer to resolve it. Leave a thread open only while it has an
|
||||
unanswered question or active discussion.
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -64,6 +64,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Check review requester team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
MEMBERSHIP_USER: ${{ github.event_name == 'issue_comment' && github.event.comment.user.login || '' }}
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
|
||||
- name: React to authorized review command
|
||||
if: ${{ github.event_name == 'issue_comment' && steps.check.outputs.is_team_member == 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -325,7 +325,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage report artifact
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
|
||||
path: ./TestResults/Reports # Directory containing files to upload
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
|
||||
- name: Upload integration test results
|
||||
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -463,14 +463,14 @@ jobs:
|
||||
- name: Fail workflow if tests failed
|
||||
id: check_tests_failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
id: check_tests_cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -528,7 +528,7 @@ jobs:
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
- name: Upload trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dotnet-integration-test-report
|
||||
path: |
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: verify-samples-results
|
||||
path: |
|
||||
|
||||
@@ -27,11 +27,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Resolve and authorize checkout ref
|
||||
id: resolve
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
- name: Check issue author team membership
|
||||
if: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -46,12 +46,12 @@ jobs:
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
repo-token: ${{ steps.github-auth.outputs.token }}
|
||||
|
||||
- name: "PR: add breaking change label from title"
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
name: "Issue/PR: update title"
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Enforce open PR limit
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
# Checks the status of hyperlinks in all files
|
||||
- name: Run linkspector
|
||||
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
|
||||
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1.5.1
|
||||
with:
|
||||
reporter: local
|
||||
filter_mode: nofilter
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
- name: Wait for required checks
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
TIMEOUT_SECONDS: "3600"
|
||||
INTERVAL_SECONDS: "30"
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Upload dependency validation reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dependency-maintenance-results
|
||||
path: |
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
- name: Create issue for failed dependency bounds test
|
||||
if: steps.validate_bounds_test.outcome != 'success'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Create issues for failed dependency candidates
|
||||
if: always()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -321,7 +321,7 @@ jobs:
|
||||
|
||||
- name: Create or update dependency maintenance tracking issue
|
||||
if: steps.commit_updates.outputs.has_changes == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -325,7 +325,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
@@ -358,7 +358,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -375,7 +375,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
@@ -427,7 +427,7 @@ jobs:
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
@@ -469,7 +469,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
@@ -535,7 +535,7 @@ jobs:
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: integration-test-report
|
||||
path: |
|
||||
@@ -559,12 +559,12 @@ jobs:
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
title: OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
title: Azure OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
title: Misc integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
@@ -422,7 +422,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -449,7 +449,7 @@ jobs:
|
||||
title: Test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
@@ -483,7 +483,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -510,7 +510,7 @@ jobs:
|
||||
title: Foundry Hosting integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
@@ -577,7 +577,7 @@ jobs:
|
||||
title: Cosmos integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
@@ -632,7 +632,7 @@ jobs:
|
||||
title: GitHub Copilot integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
@@ -695,7 +695,7 @@ jobs:
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: integration-test-report
|
||||
path: |
|
||||
@@ -720,13 +720,13 @@ jobs:
|
||||
- name: Fail workflow if tests failed
|
||||
id: check_tests_failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
id: check_tests_cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-01-get-started
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-harness
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-tools
|
||||
@@ -255,7 +255,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-openai
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-azure
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-anthropic
|
||||
@@ -375,7 +375,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-github-copilot
|
||||
@@ -411,7 +411,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-amazon
|
||||
@@ -447,7 +447,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-ollama
|
||||
@@ -493,7 +493,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-foundry
|
||||
@@ -539,7 +539,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-copilotstudio
|
||||
@@ -572,7 +572,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-custom
|
||||
@@ -614,7 +614,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-03-workflows
|
||||
@@ -661,7 +661,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-04-hosting-foundry-hosted-agents
|
||||
@@ -700,7 +700,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-04-hosting-other
|
||||
@@ -747,7 +747,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-05-end-to-end
|
||||
@@ -804,7 +804,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-autogen-migration
|
||||
@@ -873,7 +873,7 @@ jobs:
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-semantic-kernel-migration
|
||||
@@ -939,7 +939,7 @@ jobs:
|
||||
key: validation-history-${{ github.run_id }}
|
||||
|
||||
- name: Upload trend report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-trend-report
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download coverage report
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Check coverage threshold
|
||||
run: python ${{ github.workspace }}/.github/scripts/python_check_coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
path: |
|
||||
python/python-coverage.xml
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
|
||||
+20
-1
@@ -127,9 +127,28 @@ We use and recommend the following workflow:
|
||||
7. Create a PR against the repository's **main** branch.
|
||||
- State in the description what issue or improvement your change is addressing.
|
||||
- Verify that all the Continuous Integration checks are passing.
|
||||
8. Wait for feedback or approval of your changes from the code maintainers.
|
||||
8. Address feedback from the code maintainers. Reply to every review comment with
|
||||
the outcome and resolve each completed review conversation yourself before
|
||||
requesting another review.
|
||||
9. When area owners have signed off, and all checks are green, your PR will be merged.
|
||||
|
||||
### Resolving PR Review Comments
|
||||
|
||||
PR authors are responsible for closing out all review conversations on their pull
|
||||
requests, including conversations opened by reviewers. Do not wait for the reviewer
|
||||
or a maintainer to resolve completed conversations for you.
|
||||
|
||||
For every review comment:
|
||||
|
||||
- If the feedback was addressed, reply with a brief explanation and, preferably,
|
||||
the commit containing the change.
|
||||
- If the feedback was not addressed, reply with the reason why.
|
||||
|
||||
After replying and completing any necessary discussion, **resolve the conversation
|
||||
yourself**. Leave a conversation open only while it has an unanswered question or
|
||||
active discussion. Reviewers may reopen a conversation if further changes or
|
||||
discussion are needed.
|
||||
|
||||
### Development Setup
|
||||
|
||||
Each language has its own dev setup guide, coding standards, and build scripts:
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
# - Knowledge Agent: Performs generic web searches.
|
||||
# - Coder Agent: Able to write and execute code.
|
||||
# - Weather Agent: Provides weather information.
|
||||
#
|
||||
# Example input:
|
||||
# Find the current temperatures in Seattle and San Francisco, calculate the difference in Celsius and Fahrenheit, and recommend what clothing to pack for each city.
|
||||
#
|
||||
kind: Workflow
|
||||
maxTurns: 500
|
||||
@@ -264,14 +267,14 @@ trigger:
|
||||
output:
|
||||
messages: Local.Plan
|
||||
input:
|
||||
arguments:
|
||||
team: =Local.TeamDescription
|
||||
messages: |-
|
||||
=UserMessage(
|
||||
"Please briefly explain what went wrong on this last run (the root cause of the failure),
|
||||
and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes.
|
||||
As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition
|
||||
(do not involve any other outside people since we cannot contact anyone else):
|
||||
|
||||
{Local.TeamDescription}")
|
||||
As before, the new plan should be concise, be expressed in bullet-point form, and only involve the team members already described
|
||||
(do not involve any other outside people since we cannot contact anyone else).")
|
||||
|
||||
- kind: SetTextVariable
|
||||
id: setVariable_jW7tmM
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: MohammadHaroonAbuomar
|
||||
date: 2026-08-07
|
||||
deciders: agent-framework .NET maintainers
|
||||
---
|
||||
|
||||
# .NET agent-hooks enforcement: composed factory over three seams
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The [AGENT-HOOKS-0.1](https://github.com/responsibleai/agent-hooks) interception contract shipped for Python as a first-class experimental core feature (#7515): a middleware bundle emitting eight interception points with three-verdict, fail-closed enforcement, transform write-back, buffered streaming, and verdict-before-durability persistence gating. The .NET side needs the same semantics, but the .NET framework has no category-based middleware lists — interception is decorator composition (`DelegatingAIAgent`, Microsoft.Extensions.AI `DelegatingChatClient`, the function-invocation middleware seam). How should the contract's indivisibility and enforcement properties be realized in that model?
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Identical enforcement semantics to the merged Python feature (same spec, same fail-closed rules), diverging only where the .NET seam model requires it — never by weakening an enforcement property.
|
||||
- Partial installation of the enforcement must be impossible or loudly rejected, not silently degraded.
|
||||
- Denied content must never become durable; transformed content must persist post-transform.
|
||||
- No changes to existing framework source; the optional native-runtime dependency (`ResponsibleAI.AgentHooks`) must not be referenced by core packages.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**A single factory (`AsAIAgentWithAgentHooks`, per-run and host-owned-session overloads) in a new package `Microsoft.Agents.AI.AgentHooks` composes the full enforcement itself** instead of exposing middleware values:
|
||||
|
||||
- **Seam order (fixed by construction):** `AgentHooksAgent` (agent seam: `agent_startup`/`input`/`output`/`agent_shutdown`, per-run `AsyncLocal` state, buffered streaming, persistence gate) → framework function-invocation middleware (`pre_tool_call`/`post_tool_call`) → `ChatClientAgent` with its default pipeline → `AgentHooksChatClient` **below** `FunctionInvokingChatClient` (so `pre_model_call`/`post_model_call` bracket every model service call of the tool loop individually).
|
||||
- **Indivisibility:** the seam decorators are `internal`; only the factory composes them. Two pipeline-replacement affordances of `ChatClientAgent` are rejected loudly (fail closed): a caller-supplied per-run `ChatClientFactory` (the framework's own function-middleware factory is recognized and allowed — it wraps, not replaces), and a supplied chat client that already contains a `FunctionInvokingChatClient` (it would execute tools below the verdicts).
|
||||
- **Verdict-before-durability:** end-of-run history and context-provider writes defer behind the `output` verdict via gating provider wrappers installed by the factory (dropped on deny, flushed post-transform with verdicted-message substitution for streamed runs). The implicit default `InMemoryChatHistoryProvider` is materialized and gated, with the history-conflict flags set to mimic implicit-default semantics. Per-service-call persistence sits above the chat seam, so it is covered by its own `post_model_call` verdict. Per-run provider overrides are wrapped in both `AdditionalProperties` dictionaries, copy-on-write. Nested agents persist inline at their own boundaries (they have their own providers) — no run-identity bookkeeping is needed, unlike Python.
|
||||
- **Fail-closed error behavior:** interceptor crashes/timeouts surface as `host_error:*` denies; enforcement-layer failures at the tool seam halt the run through `FunctionInvocationContext.Terminate` (the loop's only loud escape — thrown exceptions are converted to tool errors by the loop, which would fail open); wire projections run inside the guarded blocks; failure notifications to providers are redacted (empty request messages) once a deny/halt stands.
|
||||
- **Streaming:** fully buffered per the spec's `buffered_output` semantics — zero egress ahead of a verdict; transformed responses re-derive the released updates (preserving continuation tokens) so egress never diverges from verdicted content.
|
||||
|
||||
### Considered Alternatives
|
||||
|
||||
- **Port Python's middleware-value model (a `MiddlewareBundle` type):** rejected — .NET has no middleware list to put a bundle into; indivisibility via runtime validation is weaker than construction ownership.
|
||||
- **Core-framework persistence gate (as Python added in `_sessions.py`):** rejected — unnecessary in .NET; construction ownership of the provider instances gives the same property with zero core changes.
|
||||
- **Per-run `ChatClientFactory` as the chat-seam install point:** rejected — it wraps the whole pipeline above the function-invocation loop, so per-model-call points would be impossible.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Good: zero existing-source changes; the optional native dependency is isolated in one leaf package; enforcement properties are structural rather than convention-based.
|
||||
- Accepted: the package ships in the release solution filter as an **alpha** package (maintainer decision on the PR) — the version suffix follows the maturity of the `ResponsibleAI.AgentHooks` dependency it is built on, and the whole surface stays `[Experimental]`; a sample follows once the API shape settles.
|
||||
- Known limitations (documented on the factory): hosted (service-executed) tools never reach the function seam and are intercepted via the `post_model_call` content projection; service-managed (conversation-id) history is durable at the service and ungateable; the deferred-OTel decorator sits above the chat seam, so sensitive-data request spans observe pre-transform content; a chat-seam projection failure fails the run closed but without a synthesized `host_error` record (SDK affordance gap, responsibleai/agent-hooks#70).
|
||||
- The trust model is the spec's: cooperative contract, not a security boundary — the misuse rejections catch accidental foot-guns loudly, not in-process adversaries.
|
||||
@@ -18,7 +18,7 @@ It covers:
|
||||
- approved, rejected, mixed, and replayed approval rounds;
|
||||
- reasoning content and opaque reasoning signatures bound to function calls;
|
||||
- history persistence and service-side continuation;
|
||||
- error, user-input, middleware-termination, and loop-limit paths;
|
||||
- error, user-input, middleware-termination, middleware-failure, and loop-limit paths;
|
||||
- provider and transport serialization of function calls and results.
|
||||
|
||||
The primary implementation is in `python/packages/core/agent_framework/_tools.py`. History replay behavior in
|
||||
@@ -319,7 +319,24 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
### Function calls and results
|
||||
|
||||
- Every actionable local `function_call` produces exactly one terminal `function_result`, unless execution pauses
|
||||
for a new user-input request.
|
||||
for a new user-input request or the run is aborted by `MiddlewareFailure`.
|
||||
- An ordinary exception raised by function middleware or a tool body becomes one terminal error `function_result`
|
||||
and the loop continues; `MiddlewareFailure` is the loop's only fail-closed escape: it is never converted into a
|
||||
tool result, the in-flight parallel batch is cancelled, no further tool call starts, no further model turn is
|
||||
consumed, and the exception propagates to the caller (for streaming runs, when the stream is consumed). On a
|
||||
service-managed conversation the loop first settles the aborted batch — one error `function_result` per dangling
|
||||
call (approval-response wrappers unwrap to their underlying calls; hosted-tool approvals are left to their own
|
||||
provider protocol), submitted with `tool_choice="none"` in a single extra request — so the hosted thread is not
|
||||
left ending in unresolved function calls that the service would reject on the session's next request; the
|
||||
persisted continuation then advances to the settlement response (for response-ID continuations the settled
|
||||
endpoint is the new handle; for conversation-object ids the advance is a no-op) and the settlement response is
|
||||
otherwise discarded. Settlement covers the approval-resolution phase too: a fatal abort while an approved tool is
|
||||
replayed settles the original, already-persisted calls. Without a service-managed conversation no extra request
|
||||
is made. Batch
|
||||
cancellation is cooperative: an async sibling stops at its next suspension point, while a synchronous tool body
|
||||
already executing in a worker thread cannot be interrupted and may complete its side effects — its result is
|
||||
discarded either way and never reaches the transcript, the model, or history. Middleware must not catch
|
||||
`MiddlewareFailure` — swallowing it converts a fail-closed abort back into a running, possibly unguarded loop.
|
||||
- Parallel calls retain model order in the returned transcript.
|
||||
- Reused `call_id` values are correlated by logical occurrence, not one global value per id.
|
||||
- A completed function call/result pair is inert on later turns.
|
||||
@@ -491,6 +508,7 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
| AG-UI provider boundary | Completed local approval controls from AG-UI request and snapshot replay are absent from raw chat-client input while deferred and hosted approvals keep their respective in-run/provider paths. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_does_not_forward_resolved_local_approval_control_to_chat_client`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_canonical_resume_preserves_hosted_approval_for_provider`, `packages/ag-ui/tests/ag_ui/test_run.py::test_filter_local_approval_responses_for_provider_removes_duplicate_completed_controls`, `packages/ag-ui/tests/ag_ui/test_run.py::test_filter_local_approval_responses_for_provider_pairs_reused_call_ids_by_occurrence`, `packages/ag-ui/tests/ag_ui/test_run.py::test_canonical_hosted_approval_resume_rejects_edited_arguments_without_mutating_pending` |
|
||||
| AG-UI standard approval payload | Agent and workflow tool approvals emit canonical `tool_call` interrupts. `approved` plus full-replacement `editedArgs` executes once and replays idempotently, while legacy `accepted` plus direct partial edits remains supported. Hosted approvals remain decision-only. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_resume_entry_applies_standard_full_replacement_edited_args`, `test_endpoint_agent_approval_replayed_standard_edited_resume_is_idempotent`, `test_endpoint_agent_approval_resume_entry_applies_edited_arguments`, `test_workflow_endpoint_emits_canonical_tool_approval_interrupt`, `test_workflow_endpoint_accepts_canonical_tool_approval_resume`, `test_workflow_endpoint_applies_canonical_approval_edited_args`, `test_workflow_endpoint_accepts_legacy_partial_approval_edits`, `test_workflow_endpoint_hosted_approval_rejects_argument_edits` |
|
||||
| AG-UI cancellation | A cancelled interrupt executes zero times and completes normally, including an identical retry during retained cancellation state; resolved siblings in the same complete resume still execute once. Workflow cancellation clears both runner correlation and the owning agent executor's pending request so later approvals remain resumable. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_cancelled_resume_entry_completes_without_execution`, `test_endpoint_agent_approval_replayed_cancellation_completes_idempotently`, `test_endpoint_agent_approval_mixed_cancelled_and_resolved_resume_executes_resolved_tool`, `test_endpoint_workflow_request_info_cancelled_resume_completes_normally`, `test_workflow_endpoint_cancelled_agent_approval_does_not_block_next_approval` |
|
||||
| AG-UI shared workflow interrupt ownership | A direct shared `Workflow` request-info interrupt can only be resolved or cancelled by the Snapshot Scope and AG-UI thread that created it. Ownership follows the authoritative pending request occurrence, and explicitly threaded cold checkpoint resumes fail closed when ownership is unavailable. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_workflow_request_info_rejects_resume_from_different_thread`, `test_endpoint_workflow_request_info_rejects_resume_from_different_scope`, `test_endpoint_workflow_request_info_rejects_cancellation_from_different_thread`, `test_endpoint_workflow_request_info_remains_owned_after_client_disconnect`, `test_endpoint_workflow_request_info_rejects_unowned_pending_interrupt`, `test_endpoint_workflow_checkpoint_resume_rejects_threaded_resume_after_restart` |
|
||||
| AG-UI approval retention and capacity | Pending authority expires automatically, indeterminate outcomes remain non-retryable until their safety window permits reclamation, and one trusted scope cannot consume another scope's occurrence quota. | `packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py::test_abandoned_pending_occurrence_expires_and_releases_capacity`, `test_indeterminate_occurrence_is_reclaimed_after_its_safety_window`, `test_capacity_is_enforced_per_trusted_scope` |
|
||||
| AG-UI local executor unavailable on resume | A claimed local occurrence whose executor disappeared releases its unstarted claim, reports temporary unavailability, and remains safely retryable. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_resume_remains_retryable_when_local_tool_is_temporarily_unavailable` |
|
||||
| AG-UI forwarded execution interruption | A provider failure, cancellation, or stream close after forwarding an approval recovers the open occurrence as indeterminate when no idempotency key proves retry safety. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_hosted_approval_becomes_indeterminate_when_provider_stream_fails` |
|
||||
@@ -506,6 +524,10 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
| Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` |
|
||||
| Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents |
|
||||
| Middleware termination | Normal non-approval loop stops without a second model call. | `test_terminate_loop_single_function_call`, `test_terminate_loop_multiple_function_calls_one_terminates`, `test_terminate_loop_streaming_single_function_call` |
|
||||
| Middleware failure (fatal) | `MiddlewareFailure` from function middleware or a tool body propagates to the caller without becoming a tool result; the tool does not execute (pre-invocation) or its result never feeds another model call (post-invocation); the cause chain is preserved; ordinary exceptions still become tool-error results and the loop continues. | `packages/core/tests/core/test_middleware_with_agent.py::TestMiddlewareFailure::test_failure_before_tool_aborts_run`, `test_failure_after_tool_aborts_run_before_next_model_turn`, `test_failure_cause_chain_reaches_caller`, `test_failure_from_tool_escapes_without_middleware`, `test_failure_streaming_reaches_stream_consumer`, `test_ordinary_exception_still_becomes_tool_error` |
|
||||
| Middleware failure batch cancellation | A fatal signal fails the whole parallel batch: in-flight sibling tool invocations are cancelled and awaited before the failure propagates. Cancellation is cooperative — an async sibling stops at its next suspension point; a synchronous tool body already executing in a worker thread cannot be interrupted and may complete its side effects, but its result is discarded and never reaches the transcript, the model, or history, and failure propagation is not delayed behind it. | `TestMiddlewareFailure::test_failure_cancels_concurrent_sibling_tool`, `test_failure_with_sync_sibling_discards_late_result` |
|
||||
| Middleware failure on a service-managed conversation | The continuation state is already persisted when the batch fails, so before propagating, the loop settles the hosted thread: one error `function_result` per dangling call, sent with `tool_choice="none"` in one extra request; the persisted continuation advances to the settlement response (required for response-ID continuations, a no-op for conversation-object ids) and the settlement response is otherwise discarded; a settlement failure never masks the abort. Without a service-managed conversation no extra request is made. | `TestMiddlewareFailure::test_failure_settles_dangling_calls_on_service_conversation`, `test_failure_settles_service_conversation_streaming`, `test_failure_settlement_advances_response_id_continuation`, `test_failure_without_service_conversation_makes_no_settlement_request` |
|
||||
| Middleware failure during approved-tool replay | A fatal abort while the approval-resolution phase replays an approved tool escapes loudly (never absorbed into a rejection result), the tool's original — already service-persisted — call is settled the same way, and the continuation advances; both response modes. | `TestMiddlewareFailure::test_failure_during_approved_replay_settles_and_escapes`, `test_failure_during_approved_replay_streaming` |
|
||||
| Maximum iterations | No orphan calls; a final no-tool response or deterministic fallback is returned. | `test_max_iterations_limit`, `test_max_iterations_no_orphaned_function_calls`, `test_max_iterations_makes_final_toolchoice_none_call`, `test_max_iterations_blank_final_fallback_synthesizes_message`, streaming equivalents |
|
||||
| Maximum function calls | Parallel overshoot is bounded after the batch; every executed result group counts even without a `function_result`; blank final responses get fallback content. | `test_max_function_calls_limits_parallel_invocations`, `test_max_function_calls_single_calls_per_iteration`, `test_user_input_request_multiple_contents_propagate`, `test_approval_resume_user_input_counts_toward_function_call_budget`, `test_max_function_calls_blank_final_fallback_synthesizes_message`, streaming equivalent |
|
||||
| Provider tool content after an active limit | Locally actionable calls and local approval requests returned despite `tool_choice="none"` are removed in both response modes. Provider-executed informational call/result pairs, hosted approval requests, and metadata-only streaming updates remain visible; fallback text never replaces retained transcript content. | `test_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_preserves_metadata_after_tool_content_is_dropped`, `test_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_function_invocation_limit_preserves_hosted_approval_request`, `test_streaming_function_invocation_limit_preserves_hosted_approval_request` |
|
||||
@@ -534,6 +556,7 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
| AG-UI approval-time follow-up | The full grouped user-input pause remains in message history and emits no synthetic `TOOL_CALL_RESULT`. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_follow_up_group_remains_in_history_without_live_tool_result` |
|
||||
| AG-UI approval execution failure | A grouped executor failure becomes one deterministic terminal error result for the approved call. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_execution_failure_emits_one_terminal_error_result` |
|
||||
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_no_approval_path_emits_no_approval_specific_duplicate_result` |
|
||||
| AG-UI client-tool request isolation | Client tool declarations are validated before use and remain request-scoped; a rejected collision or earlier successful request cannot change a later request's server-tool execution. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_failed_client_tool_collision_does_not_affect_next_request`, `test_endpoint_client_tools_do_not_persist_into_next_request` |
|
||||
| AG-UI `confirm_changes` snapshot | An accepted synthetic confirmation is replaced only when its original function call has a real result; rejection is cleaned explicitly, and missing accepted results remain inert. | `packages/ag-ui/tests/ag_ui/test_confirm_changes_snapshot.py` |
|
||||
| AG-UI malformed `confirm_changes` metadata | Non-list tool-call metadata and malformed argument JSON are ignored without guessing a target call. | `test_confirm_changes_target_ignores_non_list_tool_calls`, `test_confirm_changes_target_rejects_malformed_arguments_json` |
|
||||
| Compaction pair integrity | Adjacent and non-adjacent pairs, including assistant-embedded results and completed reused-id occurrences, remain atomic without pairing ambiguous or out-of-order ids. | `packages/core/tests/core/test_compaction.py::test_group_annotations_keep_tool_call_and_tool_result_atomic`, `test_group_annotations_include_reasoning_in_tool_call_group`, `test_group_annotations_pair_nonadjacent_function_result_by_call_id`, `test_group_annotations_pair_multiple_nonadjacent_results_with_declaration`, `test_group_annotations_pair_completed_reused_call_id_occurrences`, `test_group_annotations_close_assistant_embedded_result_before_reused_call_id`, `test_sliding_window_does_not_retain_orphan_result_after_assistant_embedded_result`, `test_sliding_window_keeps_reused_call_id_occurrences_atomic`, `test_group_annotations_do_not_pair_ambiguous_duplicate_call_ids` |
|
||||
@@ -593,6 +616,7 @@ Before accepting an update, reviewers must confirm:
|
||||
## Related issues
|
||||
|
||||
- #7241 — approval-resolution result streaming
|
||||
- #7522 — first-class fatal signal (`MiddlewareFailure`) for function middleware
|
||||
- #7267 / #7271 and #7304 — replayed calls and reused ids
|
||||
- #7043 — provider-injected approval execution
|
||||
- #6828 — AG-UI `confirm_changes` snapshot correlation
|
||||
|
||||
+6
-3
@@ -74,9 +74,12 @@ code before the user has reviewed the plan**:
|
||||
approval or adjustments before implementing anything.
|
||||
4. **Implement.** Make the agreed changes.
|
||||
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
|
||||
was addressed (or the agreed outcome) — leave none unanswered.
|
||||
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
|
||||
comment has actually been addressed.
|
||||
was addressed, preferably citing the commit containing the change. If the
|
||||
feedback was not addressed, explain why. Leave no comment unanswered.
|
||||
6. **Resolve completed threads yourself.** After replying and completing any
|
||||
necessary discussion, resolve the review thread. Do not wait for the reviewer
|
||||
or a maintainer to resolve it. Leave a thread open only while it has an
|
||||
unanswered question or active discussion.
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
@@ -42,25 +42,25 @@
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.14.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.11" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.8" />
|
||||
<!-- AG-UI .NET SDK packages (published by the AG-UI team). -->
|
||||
<PackageVersion Include="AGUI.Abstractions" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Formatting" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Protobuf" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Client" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Server" Version="0.0.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.9" />
|
||||
<PackageVersion Include="AGUI.Abstractions" Version="0.0.5" />
|
||||
<PackageVersion Include="AGUI.Formatting" Version="0.0.5" />
|
||||
<PackageVersion Include="AGUI.Protobuf" Version="0.0.5" />
|
||||
<PackageVersion Include="AGUI.Client" Version="0.0.5" />
|
||||
<PackageVersion Include="AGUI.Server" Version="0.0.5" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.11" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -80,11 +80,11 @@
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" /> <!-- Pin patched OpenAPI.NET to remediate GHSA-v5pm-xwqc-g5wc -->
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||
@@ -94,12 +94,12 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="10.7.0" />
|
||||
@@ -109,6 +109,7 @@
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.Qdrant" Version="1.0.0" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.5" />
|
||||
<PackageVersion Include="ResponsibleAI.AgentHooks" Version="0.1.0-alpha.4" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step22_AgentMode/Agent_Step22_AgentMode.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step23_TodoList/Agent_Step23_TodoList.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step24_MultiModelRouting/Agent_Step24_MultiModelRouting.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -109,6 +110,10 @@
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw/Claw_Step01_MeetYourClaw.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/Claw_Step02_WorkingWithData.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Claw_Step03_ScalingCapabilities.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgent.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/ClawAgent.Console.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/ClawAgent.Hosted.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/ClawAgent.Evals.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
@@ -605,6 +610,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AgentHooks/Microsoft.Agents.AI.AgentHooks.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
||||
@@ -642,6 +648,7 @@
|
||||
<Project Path="tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AgentHooks.UnitTests/Microsoft.Agents.AI.AgentHooks.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj" />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"projects": [
|
||||
"src\\Microsoft.Agents.AI.A2A\\Microsoft.Agents.AI.A2A.csproj",
|
||||
"src\\Microsoft.Agents.AI.Abstractions\\Microsoft.Agents.AI.Abstractions.csproj",
|
||||
"src\\Microsoft.Agents.AI.AgentHooks\\Microsoft.Agents.AI.AgentHooks.csproj",
|
||||
"src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj",
|
||||
"src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
|
||||
"src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.302",
|
||||
"version": "10.0.303",
|
||||
"rollForward": "minor",
|
||||
"allowPrerelease": false
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.17.0</VersionPrefix>
|
||||
<VersionPrefix>1.18.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260804</DateSuffix>
|
||||
<DateSuffix>260818</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.17.0</GitTag>
|
||||
<GitTag>1.18.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -51,7 +51,7 @@ dotnet run --urls http://localhost:8888
|
||||
An interactive console client that connects to an AG-UI server. Demonstrates:
|
||||
|
||||
- Creating an AG-UI client with `AGUIChatClient`
|
||||
- Managing conversation threads
|
||||
- Managing multi-turn conversations with an `AgentSession`
|
||||
- Streaming responses with `RunStreamingAsync`
|
||||
- Displaying colored console output for different content types
|
||||
- Supporting both interactive and automated modes
|
||||
@@ -133,28 +133,24 @@ Demonstrates human-in-the-loop approval workflows for sensitive operations. This
|
||||
|
||||
An AG-UI server that implements approval workflows. Demonstrates:
|
||||
|
||||
- Wrapping tools with `ApprovalRequiredAIFunction`
|
||||
- Converting `FunctionApprovalRequestContent` to approval requests
|
||||
- Middleware pattern with `ServerFunctionApprovalServerAgent`
|
||||
- Complete function call capture and restoration
|
||||
- Wrapping a tool with `ApprovalRequiredAIFunction` so it requires approval before running
|
||||
- Mapping a plain agent with `MapAGUIServer`, which natively emits an approval interrupt when the model calls the approval-required tool and resumes the run once the client sends the decision back
|
||||
|
||||
**Run the server:**
|
||||
|
||||
```bash
|
||||
cd Step04_HumanInLoop/Server
|
||||
dotnet run --urls http://localhost:8888
|
||||
dotnet run --urls http://localhost:5100
|
||||
```
|
||||
|
||||
#### Client (`Step04_HumanInLoop/Client`)
|
||||
|
||||
An interactive client that handles approval requests from the server. Demonstrates:
|
||||
|
||||
- Using `ServerFunctionApprovalClientAgent` middleware
|
||||
- Detecting `FunctionApprovalRequestContent`
|
||||
- Displaying approval details to users
|
||||
- Prompting for approval/rejection
|
||||
- Sending approval responses with `FunctionApprovalResponseContent`
|
||||
- Resuming conversation after approval
|
||||
- Detecting `ToolApprovalRequestContent` in the streamed response
|
||||
- Displaying approval details to the user and prompting for approval or rejection
|
||||
- Sending the decision back as a `ToolApprovalResponseContent` created with `approvalRequest.CreateResponse(approved)`
|
||||
- Resuming the run so the server continues after the decision is received
|
||||
|
||||
**Run the client:**
|
||||
|
||||
@@ -167,15 +163,15 @@ Try asking the agent to perform sensitive operations like "Approve expense repor
|
||||
|
||||
### Step05_StateManagement
|
||||
|
||||
An AG-UI server and client that demonstrate state management with predictive updates.
|
||||
An AG-UI server and client that demonstrate shared state management.
|
||||
|
||||
#### Server (`Step05_StateManagement/Server`)
|
||||
|
||||
Demonstrates:
|
||||
|
||||
- Defining state schemas using C# records
|
||||
- Using `SharedStateAgent` middleware for state management
|
||||
- Streaming predictive state updates with `AgentState` content
|
||||
- Exposing a `generate_recipe` tool that returns the complete recipe
|
||||
- Mapping the tool result to a `STATE_SNAPSHOT` event with `AGUIStreamOptions.MapResultAsStateSnapshot`
|
||||
- Reading the client's current recipe from `RunAgentInput.State`
|
||||
- Managing shared state between client and server
|
||||
- Using JSON serialization contexts for state types
|
||||
|
||||
@@ -210,7 +206,7 @@ dotnet run
|
||||
|
||||
### Client-Side
|
||||
|
||||
1. `AGUIAgent` sends HTTP POST request to server
|
||||
1. `AGUIChatClient` sends HTTP POST request to server
|
||||
2. Server responds with SSE stream
|
||||
3. Client parses events into `AgentResponseUpdate` objects
|
||||
4. Updates are displayed based on content type
|
||||
@@ -228,7 +224,7 @@ dotnet run
|
||||
|
||||
`ConversationId` keeps request/response continuity. It is not proof that the caller owns that conversation. In multi-user deployments, authenticate each AG-UI request and authorize conversation access using your application's real boundary, such as the authenticated user, tenant, or workspace.
|
||||
|
||||
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedAgentIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
|
||||
If your ASP.NET Core host shares session storage across users, pair `MapAGUIServer` with an isolation strategy such as `UseClaimsBasedAgentIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -50,7 +49,6 @@ try
|
||||
|
||||
// Stream the response
|
||||
bool isFirstUpdate = true;
|
||||
string? threadId = null;
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
|
||||
{
|
||||
@@ -59,11 +57,8 @@ try
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id is carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.WriteLine($"\n[Run Started - Run: {chatUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
@@ -86,8 +81,11 @@ try
|
||||
}
|
||||
}
|
||||
|
||||
// The session owns prior history, so the next run sends only the new user message.
|
||||
messages.Clear();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
|
||||
Console.WriteLine("\n[Run Finished]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -50,7 +49,6 @@ try
|
||||
|
||||
// Stream the response
|
||||
bool isFirstUpdate = true;
|
||||
string? threadId = null;
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
|
||||
{
|
||||
@@ -59,11 +57,8 @@ try
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id is carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.WriteLine($"\n[Run Started - Run: {chatUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
@@ -118,8 +113,11 @@ try
|
||||
}
|
||||
}
|
||||
|
||||
// The session owns prior history, so the next run sends only the new user message.
|
||||
messages.Clear();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
|
||||
Console.WriteLine("\n[Run Finished]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ using Microsoft.Extensions.Options;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default));
|
||||
builder.Services.AddAGUIServer();
|
||||
@@ -75,6 +74,7 @@ AITool[] tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
SearchRestaurants,
|
||||
name: "search_restaurants",
|
||||
serializerOptions: jsonOptions.SerializerOptions)
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -19,7 +18,7 @@ static string GetUserLocation()
|
||||
}
|
||||
|
||||
// Create frontend tools
|
||||
AITool[] frontendTools = [AIFunctionFactory.Create(GetUserLocation)];
|
||||
AITool[] frontendTools = [AIFunctionFactory.Create(GetUserLocation, name: "get_user_location")];
|
||||
|
||||
// Create the AG-UI client agent with tools
|
||||
using HttpClient httpClient = new()
|
||||
@@ -63,7 +62,6 @@ try
|
||||
|
||||
// Stream the response
|
||||
bool isFirstUpdate = true;
|
||||
string? threadId = null;
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
|
||||
{
|
||||
@@ -72,11 +70,8 @@ try
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id is carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.WriteLine($"\n[Run Started - Run: {chatUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
@@ -111,8 +106,11 @@ try
|
||||
}
|
||||
}
|
||||
|
||||
// The session owns prior history, so the next run sends only the new user message.
|
||||
messages.Clear();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
|
||||
Console.WriteLine("\n[Run Finished]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
|
||||
@@ -15,17 +15,12 @@ using HttpClient httpClient = new()
|
||||
|
||||
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
|
||||
|
||||
// Create agent
|
||||
ChatClientAgent baseAgent = chatClient.AsAIAgent(
|
||||
// Create agent. No custom approval agent is required: the loop below handles the approval interrupt
|
||||
// directly, and AGUIChatClient transports the decision back to the server via the AG-UI resume mechanism.
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
// Use default JSON serializer options
|
||||
JsonSerializerOptions jsonSerializerOptions = JsonSerializerOptions.Default;
|
||||
|
||||
// Wrap the agent with ServerFunctionApprovalClientAgent
|
||||
ServerFunctionApprovalClientAgent agent = new(baseAgent, jsonSerializerOptions);
|
||||
|
||||
List<ChatMessage> messages = [];
|
||||
AgentSession? session = null;
|
||||
|
||||
@@ -44,7 +39,6 @@ while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringCompa
|
||||
messages.Add(new ChatMessage(ChatRole.User, input));
|
||||
Console.WriteLine();
|
||||
|
||||
#pragma warning disable MEAI001
|
||||
List<AIContent> approvalResponses = [];
|
||||
|
||||
do
|
||||
@@ -68,15 +62,6 @@ while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringCompa
|
||||
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved);
|
||||
|
||||
if (approvalRequest.AdditionalProperties != null)
|
||||
{
|
||||
approvalResponse.AdditionalProperties = [];
|
||||
foreach (var kvp in approvalRequest.AdditionalProperties)
|
||||
{
|
||||
approvalResponse.AdditionalProperties[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
approvalResponses.Add(approvalResponse);
|
||||
break;
|
||||
|
||||
@@ -115,11 +100,10 @@ while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringCompa
|
||||
messages.AddRange(response.Messages);
|
||||
foreach (AIContent approvalResponse in approvalResponses)
|
||||
{
|
||||
messages.Add(new ChatMessage(ChatRole.Tool, [approvalResponse]));
|
||||
messages.Add(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
}
|
||||
}
|
||||
while (approvalResponses.Count > 0);
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
Console.WriteLine("\n");
|
||||
Console.ForegroundColor = ConsoleColor.White;
|
||||
@@ -127,7 +111,6 @@ while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringCompa
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001
|
||||
static void DisplayApprovalRequest(ToolApprovalRequestContent approvalRequest, FunctionCallContent fcc)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
@@ -149,4 +132,3 @@ static void DisplayApprovalRequest(ToolApprovalRequestContent approvalRequest, F
|
||||
Console.WriteLine("============================================================");
|
||||
Console.ResetColor();
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
-265
@@ -1,265 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ServerFunctionApproval;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating agent that handles server function approval requests and responses.
|
||||
/// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent
|
||||
/// and the server's request_approval tool call pattern.
|
||||
/// </summary>
|
||||
internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
public ServerFunctionApprovalClientAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunCoreStreamingAsync(messages, session, options, cancellationToken)
|
||||
.ToAgentResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Process and transform approval messages, creating a new message list
|
||||
var processedMessages = ProcessOutgoingServerFunctionApprovals(messages.ToList(), this._jsonSerializerOptions);
|
||||
|
||||
// Run the inner agent and intercept any approval requests
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(
|
||||
processedMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return ProcessIncomingServerApprovalRequests(update, this._jsonSerializerOptions);
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only
|
||||
private static FunctionResultContent ConvertApprovalResponseToToolResult(ToolApprovalResponseContent approvalResponse, JsonSerializerOptions jsonOptions)
|
||||
{
|
||||
return new FunctionResultContent(
|
||||
callId: approvalResponse.RequestId,
|
||||
result: JsonSerializer.SerializeToElement(
|
||||
new ApprovalResponse
|
||||
{
|
||||
ApprovalId = approvalResponse.RequestId,
|
||||
Approved = approvalResponse.Approved
|
||||
},
|
||||
jsonOptions));
|
||||
}
|
||||
|
||||
private static List<ChatMessage> CopyMessagesUpToIndex(List<ChatMessage> messages, int index)
|
||||
{
|
||||
var result = new List<ChatMessage>(index);
|
||||
for (int i = 0; i < index; i++)
|
||||
{
|
||||
result.Add(messages[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AIContent> CopyContentsUpToIndex(IList<AIContent> contents, int index)
|
||||
{
|
||||
var result = new List<AIContent>(index);
|
||||
for (int i = 0; i < index; i++)
|
||||
{
|
||||
result.Add(contents[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<ChatMessage> ProcessOutgoingServerFunctionApprovals(
|
||||
List<ChatMessage> messages,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
List<ChatMessage>? result = null;
|
||||
|
||||
Dictionary<string, ToolApprovalRequestContent> approvalRequests = [];
|
||||
for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++)
|
||||
{
|
||||
var message = messages[messageIndex];
|
||||
List<AIContent>? transformedContents = null;
|
||||
|
||||
// Process each content item in the message
|
||||
HashSet<string> approvalCalls = [];
|
||||
for (var contentIndex = 0; contentIndex < message.Contents.Count; contentIndex++)
|
||||
{
|
||||
var content = message.Contents[contentIndex];
|
||||
|
||||
// Handle pending approval requests (transform to tool call)
|
||||
if (content is ToolApprovalRequestContent approvalRequest &&
|
||||
approvalRequest.AdditionalProperties?.TryGetValue("original_function", out var originalFunction) == true &&
|
||||
originalFunction is FunctionCallContent original)
|
||||
{
|
||||
approvalRequests[approvalRequest.RequestId] = approvalRequest;
|
||||
transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex);
|
||||
transformedContents.Add(original);
|
||||
}
|
||||
// Handle pending approval responses (transform to tool result)
|
||||
else if (content is ToolApprovalResponseContent approvalResponse &&
|
||||
approvalRequests.TryGetValue(approvalResponse.RequestId, out var correspondingRequest))
|
||||
{
|
||||
transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex);
|
||||
transformedContents.Add(ConvertApprovalResponseToToolResult(approvalResponse, jsonSerializerOptions));
|
||||
approvalRequests.Remove(approvalResponse.RequestId);
|
||||
correspondingRequest.AdditionalProperties?.Remove("original_function");
|
||||
}
|
||||
// Skip historical approval content
|
||||
else if (content is FunctionCallContent { Name: "request_approval" } approvalCall)
|
||||
{
|
||||
transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex);
|
||||
approvalCalls.Add(approvalCall.CallId);
|
||||
}
|
||||
else if (content is FunctionResultContent functionResult &&
|
||||
approvalCalls.Contains(functionResult.CallId))
|
||||
{
|
||||
transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex);
|
||||
approvalCalls.Remove(functionResult.CallId);
|
||||
}
|
||||
else
|
||||
{
|
||||
transformedContents?.Add(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (transformedContents?.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else if (transformedContents != null)
|
||||
{
|
||||
// We made changes to contents, so use transformedContents
|
||||
var newMessage = new ChatMessage(message.Role, transformedContents)
|
||||
{
|
||||
AuthorName = message.AuthorName,
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = message.CreatedAt,
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
AdditionalProperties = message.AdditionalProperties
|
||||
};
|
||||
result ??= CopyMessagesUpToIndex(messages, messageIndex);
|
||||
result.Add(newMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
// We're already copying messages, so copy this unchanged message too
|
||||
result?.Add(message);
|
||||
}
|
||||
// If result is null, we haven't made any changes yet, so keep processing
|
||||
}
|
||||
|
||||
return result ?? messages;
|
||||
}
|
||||
|
||||
private static AgentResponseUpdate ProcessIncomingServerApprovalRequests(
|
||||
AgentResponseUpdate update,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
IList<AIContent>? updatedContents = null;
|
||||
for (var i = 0; i < update.Contents.Count; i++)
|
||||
{
|
||||
var content = update.Contents[i];
|
||||
if (content is FunctionCallContent { Name: "request_approval" } request)
|
||||
{
|
||||
updatedContents ??= [.. update.Contents];
|
||||
|
||||
// Serialize the function arguments as JsonElement
|
||||
ApprovalRequest? approvalRequest;
|
||||
if (request.Arguments?.TryGetValue("request", out var reqObj) == true &&
|
||||
reqObj is JsonElement je)
|
||||
{
|
||||
approvalRequest = (ApprovalRequest?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest)));
|
||||
}
|
||||
else
|
||||
{
|
||||
approvalRequest = null;
|
||||
}
|
||||
|
||||
if (approvalRequest == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize approval request.");
|
||||
}
|
||||
|
||||
var functionCallArgs = (Dictionary<string, object?>?)approvalRequest.FunctionArguments?
|
||||
.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
|
||||
|
||||
var approvalRequestContent = new ToolApprovalRequestContent(
|
||||
requestId: approvalRequest.ApprovalId,
|
||||
new FunctionCallContent(
|
||||
callId: approvalRequest.ApprovalId,
|
||||
name: approvalRequest.FunctionName,
|
||||
arguments: functionCallArgs));
|
||||
|
||||
approvalRequestContent.AdditionalProperties ??= [];
|
||||
approvalRequestContent.AdditionalProperties["original_function"] = content;
|
||||
|
||||
updatedContents[i] = approvalRequestContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedContents is not null)
|
||||
{
|
||||
var chatUpdate = update.AsChatResponseUpdate();
|
||||
return new AgentResponseUpdate(new ChatResponseUpdate()
|
||||
{
|
||||
Role = chatUpdate.Role,
|
||||
Contents = updatedContents,
|
||||
MessageId = chatUpdate.MessageId,
|
||||
AuthorName = chatUpdate.AuthorName,
|
||||
CreatedAt = chatUpdate.CreatedAt,
|
||||
RawRepresentation = chatUpdate.RawRepresentation,
|
||||
ResponseId = chatUpdate.ResponseId,
|
||||
AdditionalProperties = chatUpdate.AdditionalProperties
|
||||
})
|
||||
{
|
||||
AgentId = update.AgentId,
|
||||
ContinuationToken = update.ContinuationToken,
|
||||
};
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
namespace ServerFunctionApproval
|
||||
{
|
||||
public sealed class ApprovalRequest
|
||||
{
|
||||
[JsonPropertyName("approval_id")]
|
||||
public required string ApprovalId { get; init; }
|
||||
|
||||
[JsonPropertyName("function_name")]
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
[JsonPropertyName("function_arguments")]
|
||||
public JsonElement? FunctionArguments { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ApprovalResponse
|
||||
{
|
||||
[JsonPropertyName("approval_id")]
|
||||
public required string ApprovalId { get; init; }
|
||||
|
||||
[JsonPropertyName("approved")]
|
||||
public required bool Approved { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -5,26 +5,10 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.HttpLogging;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenAI.Chat;
|
||||
using ServerFunctionApproval;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddHttpLogging(logging =>
|
||||
{
|
||||
logging.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.RequestBody
|
||||
| HttpLoggingFields.ResponsePropertiesAndHeaders | HttpLoggingFields.ResponseBody;
|
||||
logging.RequestBodyLogLimit = int.MaxValue;
|
||||
logging.ResponseBodyLogLimit = int.MaxValue;
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default));
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
@@ -34,8 +18,6 @@ builder.Services.AddAGUIServer();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseHttpLogging();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
|
||||
@@ -48,13 +30,12 @@ static string ApproveExpenseReport(string expenseReportId)
|
||||
return $"Expense report {expenseReportId} approved";
|
||||
}
|
||||
|
||||
// Get JsonSerializerOptions
|
||||
var jsonOptions = app.Services.GetRequiredService<IOptions<JsonOptions>>().Value;
|
||||
|
||||
// Create approval-required tool
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only
|
||||
AITool[] tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(ApproveExpenseReport))];
|
||||
#pragma warning restore MEAI001
|
||||
// Wrap the tool in ApprovalRequiredAIFunction so the run interrupts for approval before it executes.
|
||||
AITool[] tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(
|
||||
AIFunctionFactory.Create(ApproveExpenseReport, name: "approve_expense_report"))
|
||||
];
|
||||
|
||||
// Create base agent
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
@@ -70,8 +51,7 @@ ChatClientAgent baseAgent = openAIChatClient.AsAIAgent(
|
||||
instructions: "You are a helpful assistant in charge of approving expenses",
|
||||
tools: tools);
|
||||
|
||||
// Wrap with ServerFunctionApprovalAgent
|
||||
var agent = new ServerFunctionApprovalAgent(baseAgent, jsonOptions.SerializerOptions);
|
||||
|
||||
app.MapAGUIServer("/", agent);
|
||||
// No custom approval protocol is required: MapAGUIServer emits the approval interrupt natively when the
|
||||
// model calls the approval-required tool, and resumes the run when the client sends the decision back.
|
||||
app.MapAGUIServer("/", baseAgent);
|
||||
await app.RunAsync();
|
||||
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ServerFunctionApproval;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating agent that handles function approval requests on the server side.
|
||||
/// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent
|
||||
/// and the request_approval tool call pattern for client communication.
|
||||
/// </summary>
|
||||
internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
public ServerFunctionApprovalAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunCoreStreamingAsync(messages, session, options, cancellationToken)
|
||||
.ToAgentResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Process and transform incoming approval responses from client, creating a new message list
|
||||
var processedMessages = ProcessIncomingFunctionApprovals(messages.ToList(), this._jsonSerializerOptions);
|
||||
|
||||
// Run the inner agent and intercept any approval requests
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(
|
||||
processedMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return ProcessOutgoingApprovalRequests(update, this._jsonSerializerOptions);
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only
|
||||
private static ToolApprovalRequestContent ConvertToolCallToApprovalRequest(FunctionCallContent toolCall, JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
if (toolCall.Name != "request_approval" || toolCall.Arguments == null)
|
||||
{
|
||||
throw new InvalidOperationException("Invalid request_approval tool call");
|
||||
}
|
||||
|
||||
var request = (toolCall.Arguments.TryGetValue("request", out var reqObj) &&
|
||||
reqObj is JsonElement argsElement &&
|
||||
argsElement.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is ApprovalRequest approvalRequest &&
|
||||
approvalRequest != null ? approvalRequest : null) ?? throw new InvalidOperationException("Failed to deserialize approval request from tool call");
|
||||
return new ToolApprovalRequestContent(
|
||||
requestId: request.ApprovalId,
|
||||
new FunctionCallContent(
|
||||
callId: request.ApprovalId,
|
||||
name: request.FunctionName,
|
||||
arguments: request.FunctionArguments));
|
||||
}
|
||||
|
||||
private static ToolApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, ToolApprovalRequestContent approval, JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
var approvalResponse = (result.Result is JsonElement je ?
|
||||
(ApprovalResponse?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) :
|
||||
result.Result is string str ?
|
||||
(ApprovalResponse?)JsonSerializer.Deserialize(str, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) :
|
||||
result.Result as ApprovalResponse) ?? throw new InvalidOperationException("Failed to deserialize approval response from tool result");
|
||||
return approval.CreateResponse(approvalResponse.Approved);
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
private static List<ChatMessage> CopyMessagesUpToIndex(List<ChatMessage> messages, int index)
|
||||
{
|
||||
var result = new List<ChatMessage>(index);
|
||||
for (int i = 0; i < index; i++)
|
||||
{
|
||||
result.Add(messages[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AIContent> CopyContentsUpToIndex(IList<AIContent> contents, int index)
|
||||
{
|
||||
var result = new List<AIContent>(index);
|
||||
for (int i = 0; i < index; i++)
|
||||
{
|
||||
result.Add(contents[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<ChatMessage> ProcessIncomingFunctionApprovals(
|
||||
List<ChatMessage> messages,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
List<ChatMessage>? result = null;
|
||||
|
||||
// Track approval ID to original call ID mapping
|
||||
_ = new Dictionary<string, string>();
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
Dictionary<string, ToolApprovalRequestContent> trackedRequestApprovalToolCalls = []; // Remote approvals
|
||||
for (int messageIndex = 0; messageIndex < messages.Count; messageIndex++)
|
||||
{
|
||||
var message = messages[messageIndex];
|
||||
List<AIContent>? transformedContents = null;
|
||||
for (int j = 0; j < message.Contents.Count; j++)
|
||||
{
|
||||
var content = message.Contents[j];
|
||||
if (content is FunctionCallContent { Name: "request_approval" } toolCall)
|
||||
{
|
||||
result ??= CopyMessagesUpToIndex(messages, messageIndex);
|
||||
transformedContents ??= CopyContentsUpToIndex(message.Contents, j);
|
||||
var approvalRequest = ConvertToolCallToApprovalRequest(toolCall, jsonSerializerOptions);
|
||||
transformedContents.Add(approvalRequest);
|
||||
trackedRequestApprovalToolCalls[toolCall.CallId] = approvalRequest;
|
||||
result.Add(new ChatMessage(message.Role, transformedContents)
|
||||
{
|
||||
AuthorName = message.AuthorName,
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = message.CreatedAt,
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
AdditionalProperties = message.AdditionalProperties
|
||||
});
|
||||
}
|
||||
else if (content is FunctionResultContent toolResult &&
|
||||
trackedRequestApprovalToolCalls.TryGetValue(toolResult.CallId, out var approval))
|
||||
{
|
||||
result ??= CopyMessagesUpToIndex(messages, messageIndex);
|
||||
transformedContents ??= CopyContentsUpToIndex(message.Contents, j);
|
||||
var approvalResponse = ConvertToolResultToApprovalResponse(toolResult, approval, jsonSerializerOptions);
|
||||
transformedContents.Add(approvalResponse);
|
||||
result.Add(new ChatMessage(message.Role, transformedContents)
|
||||
{
|
||||
AuthorName = message.AuthorName,
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = message.CreatedAt,
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
AdditionalProperties = message.AdditionalProperties
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
result?.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
return result ?? messages;
|
||||
}
|
||||
|
||||
private static AgentResponseUpdate ProcessOutgoingApprovalRequests(
|
||||
AgentResponseUpdate update,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
IList<AIContent>? updatedContents = null;
|
||||
for (var i = 0; i < update.Contents.Count; i++)
|
||||
{
|
||||
var content = update.Contents[i];
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only
|
||||
if (content is ToolApprovalRequestContent request && request.ToolCall is FunctionCallContent functionCall)
|
||||
{
|
||||
updatedContents ??= [.. update.Contents];
|
||||
var approvalId = request.RequestId;
|
||||
|
||||
var approvalData = new ApprovalRequest
|
||||
{
|
||||
ApprovalId = approvalId,
|
||||
FunctionName = functionCall.Name,
|
||||
FunctionArguments = functionCall.Arguments,
|
||||
Message = $"Approve execution of '{functionCall.Name}'?"
|
||||
};
|
||||
|
||||
updatedContents[i] = new FunctionCallContent(
|
||||
callId: approvalId,
|
||||
name: "request_approval",
|
||||
arguments: new Dictionary<string, object?> { ["request"] = approvalData });
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
}
|
||||
|
||||
if (updatedContents is not null)
|
||||
{
|
||||
var chatUpdate = update.AsChatResponseUpdate();
|
||||
// Yield a tool call update that represents the approval request
|
||||
return new AgentResponseUpdate(new ChatResponseUpdate()
|
||||
{
|
||||
Role = chatUpdate.Role,
|
||||
Contents = updatedContents,
|
||||
MessageId = chatUpdate.MessageId,
|
||||
AuthorName = chatUpdate.AuthorName,
|
||||
CreatedAt = chatUpdate.CreatedAt,
|
||||
RawRepresentation = chatUpdate.RawRepresentation,
|
||||
ResponseId = chatUpdate.ResponseId,
|
||||
AdditionalProperties = chatUpdate.AdditionalProperties
|
||||
})
|
||||
{
|
||||
AgentId = update.AgentId,
|
||||
ContinuationToken = update.ContinuationToken
|
||||
};
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
}
|
||||
|
||||
namespace ServerFunctionApproval
|
||||
{
|
||||
// Define approval models
|
||||
public sealed class ApprovalRequest
|
||||
{
|
||||
[JsonPropertyName("approval_id")]
|
||||
public required string ApprovalId { get; init; }
|
||||
|
||||
[JsonPropertyName("function_name")]
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
[JsonPropertyName("function_arguments")]
|
||||
public IDictionary<string, object?>? FunctionArguments { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ApprovalResponse
|
||||
{
|
||||
[JsonPropertyName("approval_id")]
|
||||
public required string ApprovalId { get; init; }
|
||||
|
||||
[JsonPropertyName("approved")]
|
||||
public required bool Approved { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(ApprovalRequest))]
|
||||
[JsonSerializable(typeof(ApprovalResponse))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
public sealed partial class ApprovalJsonContext : JsonSerializerContext;
|
||||
}
|
||||
@@ -20,16 +20,15 @@ using HttpClient httpClient = new()
|
||||
|
||||
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
|
||||
|
||||
AIAgent baseAgent = chatClient.AsAIAgent(
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "recipe-client",
|
||||
description: "AG-UI Recipe Client Agent");
|
||||
|
||||
// Wrap the base agent with state management
|
||||
JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
TypeInfoResolver = RecipeSerializerContext.Default
|
||||
};
|
||||
StatefulAgent<AgentState> agent = new(baseAgent, jsonOptions, new AgentState());
|
||||
JsonSerializerOptions jsonOptions = RecipeSerializerContext.Default.Options;
|
||||
|
||||
// The recipe lives on the client. It is sent to the server on every turn (so the agent edits the
|
||||
// existing recipe) and refreshed from each STATE_SNAPSHOT the server streams back.
|
||||
Recipe currentRecipe = new();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<ChatMessage> messages =
|
||||
@@ -42,7 +41,7 @@ try
|
||||
while (true)
|
||||
{
|
||||
// Get user input
|
||||
Console.Write("\nUser (:q to quit, :state to show state): ");
|
||||
Console.Write("\nUser (:q to quit, :state to show recipe): ");
|
||||
string? message = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
@@ -58,36 +57,51 @@ try
|
||||
|
||||
if (message.Equals(":state", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
DisplayState(agent.State.Recipe);
|
||||
DisplayRecipe(currentRecipe);
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.Add(new ChatMessage(ChatRole.User, message));
|
||||
|
||||
// Send the client's current recipe on the AG-UI RunAgentInput.State so the agent builds on it.
|
||||
JsonElement stateJson = JsonSerializer.SerializeToElement(
|
||||
new RecipeResponse { Recipe = currentRecipe }, jsonOptions);
|
||||
ChatClientAgentRunOptions runOptions = new()
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new RunAgentInput { State = stateJson }
|
||||
}
|
||||
};
|
||||
|
||||
// Stream the response
|
||||
bool isFirstUpdate = true;
|
||||
string? threadId = null;
|
||||
bool stateReceived = false;
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session, runOptions))
|
||||
{
|
||||
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
|
||||
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id is carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.WriteLine($"[Run Started - Run: {chatUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
|
||||
// Display streaming content
|
||||
// A STATE_SNAPSHOT arrives as a StateSnapshotEvent on the update's raw representation.
|
||||
if (chatUpdate.RawRepresentation is StateSnapshotEvent snapshot &&
|
||||
snapshot.Snapshot.Deserialize<RecipeResponse>(jsonOptions) is { } response)
|
||||
{
|
||||
currentRecipe = response.Recipe;
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine("\n[State Snapshot Received]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
// Display streaming text content
|
||||
foreach (AIContent content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
@@ -98,14 +112,6 @@ try
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case DataContent dataContent when dataContent.MediaType == "application/json":
|
||||
// This is a state snapshot - the StatefulAgent has already updated the state
|
||||
stateReceived = true;
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine("\n[State Snapshot Received]");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case ErrorContent errorContent:
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"\n[Error: {errorContent.Message}]");
|
||||
@@ -115,15 +121,14 @@ try
|
||||
}
|
||||
}
|
||||
|
||||
// The session owns prior history, so the next run sends only the new user message.
|
||||
messages.Clear();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
|
||||
Console.WriteLine("\n[Run Finished]");
|
||||
Console.ResetColor();
|
||||
|
||||
// Display final state if received
|
||||
if (stateReceived)
|
||||
{
|
||||
DisplayState(agent.State.Recipe);
|
||||
}
|
||||
DisplayRecipe(currentRecipe);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -131,61 +136,53 @@ catch (Exception ex)
|
||||
Console.WriteLine($"\nAn error occurred: {ex.Message}");
|
||||
}
|
||||
|
||||
static void DisplayState(RecipeState? state)
|
||||
static void DisplayRecipe(Recipe recipe)
|
||||
{
|
||||
if (state == null)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine("\n[No state available]");
|
||||
Console.ResetColor();
|
||||
return;
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine("\n" + new string('=', 60));
|
||||
Console.WriteLine("CURRENT STATE");
|
||||
Console.WriteLine("CURRENT RECIPE");
|
||||
Console.WriteLine(new string('=', 60));
|
||||
Console.ResetColor();
|
||||
|
||||
if (!string.IsNullOrEmpty(state.Title))
|
||||
if (string.IsNullOrEmpty(recipe.Title))
|
||||
{
|
||||
Console.WriteLine("\nRecipe:");
|
||||
Console.WriteLine($" Title: {state.Title}");
|
||||
if (!string.IsNullOrEmpty(state.Cuisine))
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine("\n[No recipe yet]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"\n Title: {recipe.Title}");
|
||||
if (!string.IsNullOrEmpty(recipe.SkillLevel))
|
||||
{
|
||||
Console.WriteLine($" Cuisine: {state.Cuisine}");
|
||||
Console.WriteLine($" Skill Level: {recipe.SkillLevel}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(state.SkillLevel))
|
||||
if (!string.IsNullOrEmpty(recipe.CookingTime))
|
||||
{
|
||||
Console.WriteLine($" Skill Level: {state.SkillLevel}");
|
||||
Console.WriteLine($" Cooking Time: {recipe.CookingTime}");
|
||||
}
|
||||
|
||||
if (state.PrepTimeMinutes > 0)
|
||||
if (recipe.SpecialPreferences.Count > 0)
|
||||
{
|
||||
Console.WriteLine($" Prep Time: {state.PrepTimeMinutes} minutes");
|
||||
Console.WriteLine($" Preferences: {string.Join(", ", recipe.SpecialPreferences)}");
|
||||
}
|
||||
|
||||
if (state.CookTimeMinutes > 0)
|
||||
{
|
||||
Console.WriteLine($" Cook Time: {state.CookTimeMinutes} minutes");
|
||||
}
|
||||
|
||||
if (state.Ingredients.Count > 0)
|
||||
if (recipe.Ingredients.Count > 0)
|
||||
{
|
||||
Console.WriteLine("\n Ingredients:");
|
||||
foreach (var ingredient in state.Ingredients)
|
||||
foreach (Ingredient ingredient in recipe.Ingredients)
|
||||
{
|
||||
Console.WriteLine($" - {ingredient}");
|
||||
Console.WriteLine($" {ingredient.Icon} {ingredient.Name} - {ingredient.Amount}");
|
||||
}
|
||||
}
|
||||
|
||||
if (state.Steps.Count > 0)
|
||||
if (recipe.Instructions.Count > 0)
|
||||
{
|
||||
Console.WriteLine("\n Steps:");
|
||||
for (int i = 0; i < state.Steps.Count; i++)
|
||||
Console.WriteLine("\n Instructions:");
|
||||
for (int i = 0; i < recipe.Instructions.Count; i++)
|
||||
{
|
||||
Console.WriteLine($" {i + 1}. {state.Steps[i]}");
|
||||
Console.WriteLine($" {i + 1}. {recipe.Instructions[i]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,40 +192,53 @@ static void DisplayState(RecipeState? state)
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
// State wrapper
|
||||
internal sealed class AgentState
|
||||
namespace RecipeClient
|
||||
{
|
||||
[JsonPropertyName("recipe")]
|
||||
public RecipeState Recipe { get; set; } = new();
|
||||
// State response wrapper. Its shape mirrors what the server returns and renders as state.
|
||||
internal sealed class RecipeResponse
|
||||
{
|
||||
[JsonPropertyName("recipe")]
|
||||
public Recipe Recipe { get; set; } = new();
|
||||
}
|
||||
|
||||
// Recipe state model.
|
||||
internal sealed class Recipe
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("skill_level")]
|
||||
public string SkillLevel { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cooking_time")]
|
||||
public string CookingTime { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("special_preferences")]
|
||||
public List<string> SpecialPreferences { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ingredients")]
|
||||
public List<Ingredient> Ingredients { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("instructions")]
|
||||
public List<string> Instructions { get; set; } = [];
|
||||
}
|
||||
|
||||
// A single ingredient.
|
||||
internal sealed class Ingredient
|
||||
{
|
||||
[JsonPropertyName("icon")]
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public string Amount { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// JSON serialization context.
|
||||
[JsonSerializable(typeof(RecipeResponse))]
|
||||
[JsonSerializable(typeof(Recipe))]
|
||||
[JsonSerializable(typeof(Ingredient))]
|
||||
internal sealed partial class RecipeSerializerContext : JsonSerializerContext;
|
||||
}
|
||||
|
||||
// Recipe state model
|
||||
internal sealed class RecipeState
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cuisine")]
|
||||
public string Cuisine { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ingredients")]
|
||||
public List<string> Ingredients { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public List<string> Steps { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("prep_time_minutes")]
|
||||
public int PrepTimeMinutes { get; set; }
|
||||
|
||||
[JsonPropertyName("cook_time_minutes")]
|
||||
public int CookTimeMinutes { get; set; }
|
||||
|
||||
[JsonPropertyName("skill_level")]
|
||||
public string SkillLevel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// JSON serialization context
|
||||
[JsonSerializable(typeof(AgentState))]
|
||||
[JsonSerializable(typeof(RecipeState))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
internal sealed partial class RecipeSerializerContext : JsonSerializerContext;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace RecipeClient;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating agent that manages client-side state and automatically attaches it to requests.
|
||||
/// </summary>
|
||||
/// <typeparam name="TState">The state type.</typeparam>
|
||||
internal sealed class StatefulAgent<TState> : DelegatingAIAgent
|
||||
where TState : class, new()
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current state.
|
||||
/// </summary>
|
||||
public TState State { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StatefulAgent{TState}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerAgent">The underlying agent to delegate to.</param>
|
||||
/// <param name="jsonSerializerOptions">The JSON serializer options for state serialization.</param>
|
||||
/// <param name="initialState">The initial state. If null, a new instance will be created.</param>
|
||||
public StatefulAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions, TState? initialState = null)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
this.State = initialState ?? new TState();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunCoreStreamingAsync(messages, session, options, cancellationToken)
|
||||
.ToAgentResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Add state to messages
|
||||
List<ChatMessage> messagesWithState = [.. messages];
|
||||
|
||||
// Serialize the state using AgentState wrapper
|
||||
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
this.State,
|
||||
this._jsonSerializerOptions.GetTypeInfo(typeof(TState)));
|
||||
DataContent stateContent = new(stateBytes, "application/json");
|
||||
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
|
||||
messagesWithState.Add(stateMessage);
|
||||
|
||||
// Stream the response and update state when received
|
||||
await foreach (AgentResponseUpdate update in this.InnerAgent.RunStreamingAsync(messagesWithState, session, options, cancellationToken))
|
||||
{
|
||||
// Check if this update contains a state snapshot
|
||||
foreach (AIContent content in update.Contents)
|
||||
{
|
||||
if (content is DataContent dataContent && dataContent.MediaType == "application/json")
|
||||
{
|
||||
// Deserialize the state
|
||||
if (JsonSerializer.Deserialize(
|
||||
dataContent.Data.Span,
|
||||
this._jsonSerializerOptions.GetTypeInfo(typeof(TState))) is TState newState)
|
||||
{
|
||||
this.State = newState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using AGUI.Server;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
using RecipeAssistant;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default));
|
||||
builder.Services.AddAGUIServer();
|
||||
@@ -29,10 +30,32 @@ string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
|
||||
|
||||
// Get JsonSerializerOptions
|
||||
var jsonOptions = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>().Value;
|
||||
// The tool returns the complete recipe. The hosting layer turns each result into a STATE_SNAPSHOT
|
||||
// event via AGUIStreamOptions.MapResultAsStateSnapshot("generate_recipe") - no protocol content by hand.
|
||||
[Description("Generate or update the shared recipe and display it to the user.")]
|
||||
static RecipeResponse GenerateRecipe(
|
||||
[Description("The complete recipe to display.")] Recipe recipe) => new() { Recipe = recipe };
|
||||
|
||||
// Create base agent
|
||||
AITool generateRecipe = AIFunctionFactory.Create(
|
||||
GenerateRecipe,
|
||||
name: "generate_recipe",
|
||||
description: "Generate or update the shared recipe and display it to the user.",
|
||||
RecipeSerializerContext.Default.Options);
|
||||
|
||||
const string SharedStateSystemPrompt =
|
||||
"""
|
||||
You are a helpful recipe assistant that maintains a shared recipe state with the user.
|
||||
|
||||
IMPORTANT:
|
||||
- When the user asks you to create, change, or improve a recipe, call the `generate_recipe`
|
||||
tool with a COMPLETE recipe: a title, skill_level, cooking_time, special_preferences, the
|
||||
full list of ingredients (each with an icon, name and amount) and the step-by-step
|
||||
instructions.
|
||||
- Always include every ingredient the recipe needs, keeping any the user already added.
|
||||
- When the user only asks a question about the recipe, answer in plain text and do NOT call the tool.
|
||||
""";
|
||||
|
||||
// Create the AI agent with the recipe tool.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
@@ -41,26 +64,22 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent baseAgent = chatClient.AsAIAgent(
|
||||
name: "RecipeAgent",
|
||||
instructions: """
|
||||
You are a helpful recipe assistant. When users ask you to create or suggest a recipe,
|
||||
respond with a complete AgentState JSON object that includes:
|
||||
- recipe.title: The recipe name
|
||||
- recipe.cuisine: Type of cuisine (e.g., Italian, Mexican, Japanese)
|
||||
- recipe.ingredients: Array of ingredient strings with quantities
|
||||
- recipe.steps: Array of cooking instruction strings
|
||||
- recipe.prep_time_minutes: Preparation time in minutes
|
||||
- recipe.cook_time_minutes: Cooking time in minutes
|
||||
- recipe.skill_level: One of "beginner", "intermediate", or "advanced"
|
||||
AIAgent baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "RecipeAgent",
|
||||
Description = "An agent that maintains a shared recipe state with the user.",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = SharedStateSystemPrompt,
|
||||
Tools = [generateRecipe],
|
||||
},
|
||||
});
|
||||
|
||||
Always include all fields in the response. Be creative and helpful.
|
||||
""");
|
||||
// Wrap with a thin agent that injects the client's current recipe (input side of shared state).
|
||||
AIAgent agent = new RecipeStateAgent(baseAgent);
|
||||
|
||||
// Wrap with state management middleware
|
||||
AIAgent agent = new SharedStateAgent(baseAgent, jsonOptions.SerializerOptions);
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUIServer("/", agent);
|
||||
// Map the AG-UI endpoint. A generate_recipe result becomes a STATE_SNAPSHOT event (output side).
|
||||
app.MapAGUIServer("/", agent)
|
||||
.WithMetadata(new AGUIStreamOptions().MapResultAsStateSnapshot("generate_recipe"));
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -4,40 +4,50 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace RecipeAssistant;
|
||||
|
||||
// State wrapper
|
||||
internal sealed class AgentState
|
||||
// State response wrapper returned by the tool. Its shape is what the client renders as state.
|
||||
internal sealed class RecipeResponse
|
||||
{
|
||||
[JsonPropertyName("recipe")]
|
||||
public RecipeState Recipe { get; set; } = new();
|
||||
public Recipe Recipe { get; set; } = new();
|
||||
}
|
||||
|
||||
// Recipe state model
|
||||
internal sealed class RecipeState
|
||||
// Recipe state model.
|
||||
internal sealed class Recipe
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cuisine")]
|
||||
public string Cuisine { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ingredients")]
|
||||
public List<string> Ingredients { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public List<string> Steps { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("prep_time_minutes")]
|
||||
public int PrepTimeMinutes { get; set; }
|
||||
|
||||
[JsonPropertyName("cook_time_minutes")]
|
||||
public int CookTimeMinutes { get; set; }
|
||||
|
||||
[JsonPropertyName("skill_level")]
|
||||
public string SkillLevel { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cooking_time")]
|
||||
public string CookingTime { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("special_preferences")]
|
||||
public List<string> SpecialPreferences { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ingredients")]
|
||||
public List<Ingredient> Ingredients { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("instructions")]
|
||||
public List<string> Instructions { get; set; } = [];
|
||||
}
|
||||
|
||||
// JSON serialization context
|
||||
[JsonSerializable(typeof(AgentState))]
|
||||
[JsonSerializable(typeof(RecipeState))]
|
||||
[JsonSerializable(typeof(System.Text.Json.JsonElement))]
|
||||
// A single ingredient.
|
||||
internal sealed class Ingredient
|
||||
{
|
||||
[JsonPropertyName("icon")]
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public string Amount { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// JSON serialization context for the tool payloads.
|
||||
[JsonSerializable(typeof(RecipeResponse))]
|
||||
[JsonSerializable(typeof(Recipe))]
|
||||
[JsonSerializable(typeof(Ingredient))]
|
||||
internal sealed partial class RecipeSerializerContext : JsonSerializerContext;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Server;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace RecipeAssistant;
|
||||
|
||||
/// <summary>
|
||||
/// A thin agent that reads the client's current recipe from the AG-UI <see cref="RunAgentInput.State"/>
|
||||
/// and prepends it to the conversation as a system message, so the model edits the existing recipe
|
||||
/// instead of starting over. This handles the input side of shared state only. The output side is
|
||||
/// declarative: the inner agent's <c>generate_recipe</c> tool result becomes a <c>STATE_SNAPSHOT</c>
|
||||
/// via <c>AGUIStreamOptions.MapResultAsStateSnapshot</c>.
|
||||
/// </summary>
|
||||
internal sealed class RecipeStateAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent)
|
||||
{
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunCoreStreamingAsync(messages, session, options, cancellationToken)
|
||||
.ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options is ChatClientAgentRunOptions { ChatOptions: { } chatOptions } &&
|
||||
chatOptions.TryGetRunAgentInput(out RunAgentInput? input) &&
|
||||
input.State is { ValueKind: JsonValueKind.Object } state)
|
||||
{
|
||||
ChatMessage stateMessage = new(
|
||||
ChatRole.System,
|
||||
$"The user's current recipe state is:\n{state.GetRawText()}");
|
||||
messages = [stateMessage, .. messages];
|
||||
}
|
||||
|
||||
return this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Server;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace RecipeAssistant;
|
||||
|
||||
internal sealed class SharedStateAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunCoreStreamingAsync(messages, session, options, cancellationToken)
|
||||
.ToAgentResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check if the client sent state in the request
|
||||
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions ||
|
||||
!chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) ||
|
||||
agentInput.State is not { ValueKind: JsonValueKind.Object } state)
|
||||
{
|
||||
// No state management requested, pass through to inner agent
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Check if state has properties (not empty {})
|
||||
bool hasProperties = false;
|
||||
foreach (JsonProperty _ in state.EnumerateObject())
|
||||
{
|
||||
hasProperties = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!hasProperties)
|
||||
{
|
||||
// Empty state - treat as no state
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
// First run: Generate structured state update
|
||||
var firstRunOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatOptions = chatRunOptions.ChatOptions.Clone(),
|
||||
AllowBackgroundResponses = chatRunOptions.AllowBackgroundResponses,
|
||||
ContinuationToken = chatRunOptions.ContinuationToken,
|
||||
ChatClientFactory = chatRunOptions.ChatClientFactory,
|
||||
};
|
||||
|
||||
// Configure JSON schema response format for structured state output
|
||||
firstRunOptions.ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<AgentState>(
|
||||
schemaName: "AgentState",
|
||||
schemaDescription: "A response containing a recipe with title, skill level, cooking time, ingredients, and instructions");
|
||||
|
||||
// Add current state to the conversation - state is already a JsonElement
|
||||
ChatMessage stateUpdateMessage = new(
|
||||
ChatRole.System,
|
||||
[
|
||||
new TextContent("Here is the current state in JSON format:"),
|
||||
new TextContent(JsonSerializer.Serialize(state, this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))),
|
||||
new TextContent("The new state is:")
|
||||
]);
|
||||
|
||||
var firstRunMessages = messages.Append(stateUpdateMessage);
|
||||
|
||||
// Collect all updates from first run
|
||||
var allUpdates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(firstRunMessages, session, firstRunOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
allUpdates.Add(update);
|
||||
|
||||
// Yield all non-text updates (tool calls, etc.)
|
||||
bool hasNonTextContent = update.Contents.Any(c => c is not TextContent);
|
||||
if (hasNonTextContent)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
var response = allUpdates.ToAgentResponse();
|
||||
|
||||
// Try to deserialize the structured state response
|
||||
if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot))
|
||||
{
|
||||
// Serialize and emit as STATE_SNAPSHOT via DataContent
|
||||
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
stateSnapshot,
|
||||
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new DataContent(stateBytes, "application/json")]
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Second run: Generate user-friendly summary
|
||||
var secondRunMessages = messages.Concat(response.Messages).Append(
|
||||
new ChatMessage(
|
||||
ChatRole.System,
|
||||
[new TextContent("Please provide a concise summary of the state changes in at most two sentences.")]));
|
||||
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(secondRunMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
|
||||
{
|
||||
try
|
||||
{
|
||||
T? deserialized = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
|
||||
if (deserialized is null)
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
structuredOutput = deserialized;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Multi-Model Routing — Switch the model an agent talks to, mid-conversation
|
||||
//
|
||||
// This sample shows how to use the RoutePersistingRoutingChatClient to route each agent turn to one of
|
||||
// several named chat clients (routes), where the route that is active for a session is persisted
|
||||
// in the session's state bag.
|
||||
//
|
||||
// Because the conversation history is kept client side by the agent's chat history provider, the
|
||||
// full history is replayed to whichever model handles the next turn. Switching route therefore
|
||||
// preserves the conversation — no manual rehydration is required.
|
||||
//
|
||||
// The sample runs a simple interactive loop. In addition to chatting with the agent, you can:
|
||||
// /route — show the route that is currently active for the session
|
||||
// /route <name> — switch the session to the named route
|
||||
// /exit — quit (an empty line also exits)
|
||||
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var primaryModel = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
var secondaryModel = Environment.GetEnvironmentVariable("FOUNDRY_MODEL_ALTERNATE") ?? "gpt-5.4";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var responsesClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClient();
|
||||
|
||||
// <create_routing_client>
|
||||
// Each route is an ordinary IChatClient. Here both routes target the same project but a different
|
||||
// model deployment; they could equally be clients for entirely different providers.
|
||||
// Stored output is disabled so that the conversation is carried client side and can be replayed
|
||||
// against whichever model handles the next turn.
|
||||
var routingClient = new RoutePersistingRoutingChatClient(
|
||||
new Dictionary<string, IChatClient>
|
||||
{
|
||||
[primaryModel] = responsesClient.AsIChatClientWithStoredOutputDisabled(primaryModel),
|
||||
},
|
||||
new RoutePersistingRoutingChatClientOptions { DefaultRoute = primaryModel });
|
||||
|
||||
// Routes remain mutable after construction. Add, replace, or remove entries only when no requests are in flight.
|
||||
routingClient.Routes[secondaryModel] = responsesClient.AsIChatClientWithStoredOutputDisabled(secondaryModel);
|
||||
// </create_routing_client>
|
||||
|
||||
AIAgent agent = routingClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Router",
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. Always state which model you are when asked." },
|
||||
|
||||
// Keep the conversation client side so it survives a route change.
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(),
|
||||
});
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.WriteLine($"Routes: {string.Join(", ", routingClient.Routes.Keys)}");
|
||||
Console.WriteLine($"Active route: {routingClient.GetActiveRoute(session)}");
|
||||
Console.WriteLine("Type a message, '/route <name>' to switch model, or '/exit' to quit.");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("\nYou > ");
|
||||
var input = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (input.StartsWith("/route", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleRouteCommand(input);
|
||||
continue;
|
||||
}
|
||||
|
||||
var response = await agent.RunAsync(input, session);
|
||||
Console.WriteLine($"\n[{routingClient.GetActiveRoute(session)}] Agent > {response}");
|
||||
}
|
||||
|
||||
// <switch_route>
|
||||
// Reading and changing the active route for a session. The new route is persisted in the session's
|
||||
// state bag, so it applies to every subsequent turn of that session.
|
||||
void HandleRouteCommand(string input)
|
||||
{
|
||||
var requested = input.Length > "/route".Length ? input["/route".Length..].Trim() : string.Empty;
|
||||
|
||||
if (requested.Length == 0)
|
||||
{
|
||||
Console.WriteLine($"Active route: {routingClient.GetActiveRoute(session)}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routingClient.Routes.ContainsKey(requested))
|
||||
{
|
||||
Console.WriteLine($"Unknown route '{requested}'. Available: {string.Join(", ", routingClient.Routes.Keys)}");
|
||||
return;
|
||||
}
|
||||
|
||||
routingClient.SetActiveRoute(session, requested);
|
||||
Console.WriteLine($"Switched to route: {requested}");
|
||||
}
|
||||
// </switch_route>
|
||||
@@ -0,0 +1,61 @@
|
||||
# Multi-Model Routing
|
||||
|
||||
This sample demonstrates how to use the `RoutePersistingRoutingChatClient` to route each agent turn to
|
||||
one of several named chat clients (routes), and to switch the active route mid-conversation without
|
||||
losing the conversation history.
|
||||
|
||||
The `RoutePersistingRoutingChatClient` derives from the `RoutingChatClient` in `Microsoft.Extensions.AI`
|
||||
and stores the route that is active for a session in the session's state bag. The selection
|
||||
therefore survives for the lifetime of the session and across session serialization.
|
||||
|
||||
Because the conversation history is kept client side by the agent's chat history provider, the full
|
||||
history is replayed to whichever model handles the next turn, so switching route preserves the
|
||||
conversation.
|
||||
|
||||
> [!WARNING]
|
||||
> Ensure that none of the chat clients registered as routes use service-stored chat history.
|
||||
> Routing relies on the agent keeping history client side so it can share the complete conversation
|
||||
> with whichever client handles the next turn. Service-stored history is isolated to its originating
|
||||
> service and cannot be shared across routes, so switching routes would lose conversation context.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Registering multiple named routes, each backed by an ordinary `IChatClient`.
|
||||
- Adding, replacing, or removing routes after constructing the routing client.
|
||||
- Choosing the route a new session starts on with `RoutePersistingRoutingChatClientOptions.DefaultRoute`.
|
||||
- Reading the active route for a session with `GetActiveRoute`.
|
||||
- Changing the active route for a session with `SetActiveRoute`.
|
||||
- Preserving the conversation across a route change by keeping chat history client side.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/route` | Show the route that is currently active for the session |
|
||||
| `/route <name>` | Switch the session to the named route |
|
||||
| `/exit` | Quit (an empty line also exits) |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `FOUNDRY_PROJECT_ENDPOINT` | Yes | The Foundry project endpoint. |
|
||||
| `FOUNDRY_MODEL` | No | The primary model deployment name. Defaults to `gpt-5.4-mini`. |
|
||||
| `FOUNDRY_MODEL_ALTERNATE` | No | The secondary model deployment name. Defaults to `gpt-5.4`. |
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="<your-foundry-project-endpoint>"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The routing client resolves the session from the ambient agent run context, so it must be invoked
|
||||
as part of an `AIAgent.RunAsync` or `AIAgent.RunStreamingAsync` call.
|
||||
- The `Routes` dictionary is mutable but is not thread-safe. Modify it only while no requests are in
|
||||
flight. Route entries are validated only when selected, so an unused incomplete entry does not
|
||||
prevent other routes from operating.
|
||||
- For routing policies that are not persisted per session, such as content-based or failover
|
||||
routing, use the routing clients provided by `Microsoft.Extensions.AI` directly.
|
||||
@@ -50,6 +50,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Shell tool with environment-aware system prompt](./Agent_Step21_ShellWithEnvironment/)|This sample demonstrates how to use the shell tool together with the ShellEnvironmentProvider to run commands in stateless and persistent modes, injecting environment-aware instructions so the agent emits commands in the right shell idiom.|
|
||||
|[Switching agent operating mode](./Agent_Step22_AgentMode/)|This sample demonstrates how to use the AgentModeProvider to track and switch an agent's operating mode at runtime, including the built-in plan/execute modes and custom modes, with a simple input loop that switches mode using a slash command.|
|
||||
|[Tracking work with a todo list](./Agent_Step23_TodoList/)|This sample demonstrates how to use the TodoProvider to let an agent plan and track multi-step work using a todo list that persists across turns, printing the evolving todo list after each turn.|
|
||||
|[Routing turns across multiple models](./Agent_Step24_MultiModelRouting/)|This sample demonstrates how to use the RoutePersistingRoutingChatClient to route each agent turn to one of several named chat clients, switching the active model mid-conversation while preserving the conversation history.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);OPENAI001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawAgent\ClawAgent.csproj" />
|
||||
<ProjectReference Include="..\..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.Metrics;
|
||||
using ClawAgent;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
const string ServiceName = "ClawAgent.Console";
|
||||
var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
|
||||
var telemetryEnabled = !string.IsNullOrWhiteSpace(otlpEndpoint);
|
||||
|
||||
// Export telemetry only when an OTLP endpoint is configured. We deliberately avoid the
|
||||
// console exporter: this is an interactive app whose UI is rendered by
|
||||
// HarnessConsole.RunAgentAsync, and streaming spans/metrics to stdout corrupts that UI.
|
||||
var resourceBuilder = ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0");
|
||||
using var tracerProvider = telemetryEnabled
|
||||
? Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(resourceBuilder)
|
||||
.AddSource(ClawAgentFactory.OpenTelemetrySourceName)
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint!))
|
||||
.Build()
|
||||
: null;
|
||||
|
||||
using var meterProvider = telemetryEnabled
|
||||
? Sdk.CreateMeterProviderBuilder()
|
||||
.SetResourceBuilder(resourceBuilder)
|
||||
.AddMeter(ClawAgentFactory.OpenTelemetrySourceName)
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint!))
|
||||
.Build()
|
||||
: null;
|
||||
|
||||
if (!telemetryEnabled)
|
||||
{
|
||||
Console.WriteLine("Telemetry export is off. Set OTEL_EXPORTER_OTLP_ENDPOINT to send traces/metrics to an OTLP collector.");
|
||||
}
|
||||
|
||||
using var meter = new Meter(ClawAgentFactory.OpenTelemetrySourceName);
|
||||
var sessionCounter = meter.CreateCounter<int>("claw_console_sessions_total", description: "Interactive claw console sessions started.");
|
||||
sessionCounter.Add(1);
|
||||
|
||||
await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions
|
||||
{
|
||||
Log = Console.WriteLine,
|
||||
});
|
||||
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
build.Agent,
|
||||
userPrompt: "Ask me to value a stock, score your portfolio risk, research some tickers, or tidy your trade confirmations.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers =
|
||||
[
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
build.Agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters()),
|
||||
],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(build.Agent),
|
||||
});
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# ClawAgent.Console
|
||||
|
||||
Interactive local host for the production-ready claw. It uses the shared `ClawAgentFactory` and the Step 03 console experience (`HarnessConsole.RunAgentAsync`) with planning observers and OpenAI Responses display helpers.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console
|
||||
```
|
||||
|
||||
Set `OTEL_EXPORTER_OTLP_ENDPOINT` to send traces/metrics to an OTLP collector (for example a local Aspire dashboard). When it is not set, telemetry is not exported — there is no console exporter, because streaming spans and metrics to stdout would corrupt the interactive UI rendered by `HarnessConsole.RunAgentAsync`.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);OPENAI001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawAgent\ClawAgent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using ClawAgent;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI.Evaluation;
|
||||
using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals;
|
||||
|
||||
string[] queries =
|
||||
[
|
||||
"What's the capital of France?",
|
||||
"Value MSFT for me.",
|
||||
"How risky is my portfolio?",
|
||||
];
|
||||
|
||||
await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions
|
||||
{
|
||||
// Evals run only the trusted skill scripts bundled with this sample. Auto-approve those scripts
|
||||
// so evaluation receives the completed answer instead of an approval request.
|
||||
AdditionalToolAutoApprovalRules = [AgentSkillsProvider.AllToolsAutoApprovalRule],
|
||||
Log = Console.WriteLine,
|
||||
});
|
||||
|
||||
Regex digitRegex = new(@"\d");
|
||||
LocalEvaluator localEvaluator = new(
|
||||
FunctionEvaluator.Create("off_topic_refusal_or_finance_steer", item =>
|
||||
{
|
||||
if (!item.Query.Contains("capital of France", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.Response.Contains("finance", StringComparison.OrdinalIgnoreCase)
|
||||
|| item.Response.Contains("invest", StringComparison.OrdinalIgnoreCase)
|
||||
|| item.Response.Contains("portfolio", StringComparison.OrdinalIgnoreCase)
|
||||
|| item.Response.Contains("outside", StringComparison.OrdinalIgnoreCase)
|
||||
|| item.Response.Contains("can't", StringComparison.OrdinalIgnoreCase)
|
||||
|| item.Response.Contains("cannot", StringComparison.OrdinalIgnoreCase);
|
||||
}),
|
||||
FunctionEvaluator.Create("numeric_valuation", item =>
|
||||
!item.Query.Contains("Value MSFT", StringComparison.OrdinalIgnoreCase)
|
||||
|| digitRegex.IsMatch(item.Response)),
|
||||
FunctionEvaluator.Create("portfolio_risk_runs", item =>
|
||||
!item.Query.Contains("portfolio", StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.IsNullOrWhiteSpace(item.Response)));
|
||||
|
||||
AgentEvaluationResults localResults = await build.Agent.EvaluateAsync(queries, localEvaluator, evalName: "ClawLocalFinanceEvals");
|
||||
PrintResults("Local finance evals", localResults, queries);
|
||||
|
||||
string? endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
if (!string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
FoundryEvals foundryEvals = new(projectClient, deploymentName, FoundryEvals.Relevance, FoundryEvals.Coherence);
|
||||
AgentEvaluationResults foundryResults = await build.Agent.EvaluateAsync(queries, foundryEvals, evalName: "ClawFoundryQualityEvals");
|
||||
PrintResults("Foundry quality evals", foundryResults, queries);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Skipping Foundry quality evals. Set FOUNDRY_PROJECT_ENDPOINT to enable them.");
|
||||
}
|
||||
|
||||
static void PrintResults(string title, AgentEvaluationResults results, string[] queries)
|
||||
{
|
||||
Console.WriteLine($"=== {title} ===");
|
||||
Console.WriteLine($"Provider: {results.ProviderName}");
|
||||
Console.WriteLine($"Passed: {results.Passed}/{results.Total}");
|
||||
if (results.ReportUrl is not null)
|
||||
{
|
||||
Console.WriteLine($"Report: {results.ReportUrl}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
for (int i = 0; i < results.Items.Count; i++)
|
||||
{
|
||||
Console.WriteLine($"Query: {(i < queries.Length ? queries[i] : "N/A")}");
|
||||
Console.WriteLine($"Response: {(results.InputItems?[i].Response is { } response ? response[..Math.Min(80, response.Length)] : "N/A")}...");
|
||||
foreach (var metric in results.Items[i].Metrics)
|
||||
{
|
||||
string value = metric.Value is NumericMetric numericMetric && numericMetric.Value.HasValue
|
||||
? numericMetric.Value.Value.ToString("F1")
|
||||
: metric.Value.Interpretation?.Failed == true ? "FAIL" : "PASS";
|
||||
Console.WriteLine($" {metric.Key}: {value}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# ClawAgent.Evals
|
||||
|
||||
Evaluation host for the production-ready claw.
|
||||
|
||||
It builds the shared agent with `ClawAgentFactory`, runs local finance checks with `LocalEvaluator` and `FunctionEvaluator.Create(...)`, and prints `Passed`/`Total`. When `FOUNDRY_PROJECT_ENDPOINT` is available, it also runs Foundry quality evals (`FoundryEvals.Relevance` and `FoundryEvals.Coherence`).
|
||||
|
||||
The eval host auto-approves only Agent Skills tools so the trusted scripts bundled with this sample
|
||||
can produce complete answers. Trades, shell commands, file writes, and unrelated tools remain subject
|
||||
to their normal approval behavior.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals
|
||||
```
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Files excluded from agent code deployment packaging.
|
||||
# Uses .gitignore syntax.
|
||||
# Note: only the root .agentignore is read; subdirectory files are not supported.
|
||||
#
|
||||
# To include a file that is excluded by default, use negation: !filename
|
||||
|
||||
# azd tooling files
|
||||
agent.yaml
|
||||
agent.manifest.yaml
|
||||
azure.yaml
|
||||
.agentignore
|
||||
|
||||
# Security / secrets
|
||||
.env
|
||||
.env.*
|
||||
.azure/
|
||||
.git/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# .NET
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Docker (not used in code deploy)
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
.azure/
|
||||
.checkpoints/
|
||||
agent-file-memory/
|
||||
*.log
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
.azure
|
||||
azure.yaml
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);OPENAI001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ClawAgent/ClawAgent.csproj" />
|
||||
<ProjectReference Include="../../../../../../src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj" />
|
||||
<ProjectReference Include="../../../../../../src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="../../../../../../src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="../../../../../04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Dockerfile for the ClawAgent.Hosted Foundry Hosted Agent, built from the agent-framework repo source.
|
||||
#
|
||||
# This project uses ProjectReference to local repo sources (ClawAgent, Microsoft.Agents.AI.Foundry,
|
||||
# Microsoft.Agents.AI.Foundry.Hosting, Microsoft.Agents.AI.LocalCodeAct) and to the repo's Central
|
||||
# Package Management, so a standard in-container `dotnet restore`/`publish` cannot resolve everything
|
||||
# from this folder alone. Instead, PRE-PUBLISH the app on your machine (inside the full repo, where the
|
||||
# references and package versions resolve) and COPY the output into the image:
|
||||
#
|
||||
# # 1. Build and publish separately, targeting the container runtime (glibc x64):
|
||||
# dotnet publish -c Release -f net10.0 -r linux-x64 --self-contained false -o out
|
||||
#
|
||||
# # 2. Then build the image from the pre-published output:
|
||||
# docker build -t personal-finance-claw .
|
||||
#
|
||||
# # 3. (Optional) run it locally:
|
||||
# docker run --rm -p 8088:8088 --env-file .env personal-finance-claw
|
||||
#
|
||||
# `azd deploy` performs step 2 for you (building remotely in Azure Container Registry) — but you must
|
||||
# still run step 1 (publish to ./out) first. See README.md.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
# LocalCodeAct spawns Python to run and validate model-generated code, so the image needs python3.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENV LOCAL_CODEACT_PYTHON=python3
|
||||
# The container is non-interactive: never block on a console prompt for a missing setting.
|
||||
ENV AF_DEMO_NONINTERACTIVE=1
|
||||
ENTRYPOINT ["dotnet", "ClawAgent.Hosted.dll"]
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosts the claw as a Foundry Hosted Agent (Responses API).
|
||||
//
|
||||
// Observability requires no extra wiring here: AddFoundryResponses automatically wraps the agent
|
||||
// with OpenTelemetryAgent, and the Foundry hosting runtime (Azure.AI.AgentServer.Core's
|
||||
// AddAgentHostTelemetry) registers the OTLP exporter pipeline. In the hosted environment Foundry
|
||||
// injects APPLICATIONINSIGHTS_CONNECTION_STRING automatically, so traces, metrics and logs flow to
|
||||
// Application Insights with no exporter configuration. To capture prompt/response content in traces,
|
||||
// set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true (off by default).
|
||||
//
|
||||
// File access and shell are DISABLED on the hosted agent. Granting the model arbitrary read/write
|
||||
// access to the container filesystem, or letting it run shell commands, is a serious security risk in
|
||||
// a shared hosted environment (data exfiltration, tampering, persistence) — and the local
|
||||
// confirmations vault the shell operates on does not exist here. If you genuinely need file access
|
||||
// when hosted, supply an external AgentFileStore (for example, one backed by Azure Blob Storage) via
|
||||
// ClawAgentFactoryOptions.FileStore instead of using the container disk.
|
||||
//
|
||||
// CodeAct uses LocalCodeAct here, NOT the Hyperlight provider the local hosts use. Hyperlight runs
|
||||
// guest code in a VM-isolated micro-sandbox that needs a hypervisor (KVM) and FUSE — neither of which
|
||||
// an unprivileged Foundry hosted container exposes (attempting it fails at startup while configuring
|
||||
// `fuse`, so the app never reports ready). LocalCodeAct instead runs the generated Python in a child
|
||||
// process and relies on the hosted container itself as the isolation boundary, which is exactly the
|
||||
// pattern the canonical Hosted-LocalCodeAct sample uses. SECURITY: LocalCodeAct is not itself a
|
||||
// sandbox — only deploy it to an externally sandboxed environment such as a Foundry hosted-agent
|
||||
// container.
|
||||
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using ClawAgent;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.LocalCodeAct;
|
||||
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var httpContextAccessor = new HttpContextAccessor();
|
||||
builder.Services.AddSingleton<IHttpContextAccessor>(httpContextAccessor);
|
||||
|
||||
var projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
var pythonExecutable = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON") ?? "python3";
|
||||
var purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID");
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in
|
||||
// production. Prefer a specific credential (e.g. ManagedIdentityCredential) when hosted. Here we chain
|
||||
// a temporary dev token (for local Docker debugging) ahead of DefaultAzureCredential (for local
|
||||
// dotnet run / managed identity when hosted).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions
|
||||
{
|
||||
ProjectEndpoint = projectEndpoint,
|
||||
DeploymentName = deploymentName,
|
||||
Credential = credential,
|
||||
AgentDescription = "A production-ready personal finance claw with skills, CodeAct, background agents, telemetry, and optional Purview governance.",
|
||||
PurviewCredential = string.IsNullOrWhiteSpace(purviewClientAppId) ? null : credential,
|
||||
FoundryCallIdProvider = () =>
|
||||
{
|
||||
HttpContext? context = httpContextAccessor.HttpContext;
|
||||
return context is null ? null : context.Request.Headers["x-agent-foundry-call-id"].ToString();
|
||||
},
|
||||
|
||||
// Disable filesystem and shell access on the hosted container (see risk note above).
|
||||
EnableFileAccess = false,
|
||||
EnableShell = false,
|
||||
|
||||
// Use LocalCodeAct instead of the default Hyperlight provider: the hosted container has no
|
||||
// hypervisor/FUSE for Hyperlight, and acts as the sandbox for the child Python process itself.
|
||||
CodeActProvider = new LocalCodeActProvider(pythonExecutable),
|
||||
|
||||
Log = Console.WriteLine,
|
||||
});
|
||||
|
||||
// AddFoundryResponses wires up the Responses API host for the agent and auto-applies OpenTelemetry.
|
||||
builder.Services.AddFoundryResponses(build.Agent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map the hosted-agent endpoint that live Foundry calls.
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: map the per-agent OpenAI route shape for local debugging. Not used in production.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# ClawAgent.Hosted
|
||||
|
||||
ASP.NET host that serves the shared claw through the Foundry Responses hosting APIs.
|
||||
|
||||
The host is deliberately thin:
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Wires up the Responses API host for the agent and auto-applies OpenTelemetry.
|
||||
builder.Services.AddFoundryResponses(build.Agent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// The endpoint that live Foundry calls.
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: local REPL route shape. Not used in production.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
## Observability comes for free
|
||||
|
||||
No exporter wiring is required. `AddFoundryResponses` automatically wraps the agent with
|
||||
`OpenTelemetryAgent`, and the Foundry hosting runtime (`Azure.AI.AgentServer.Core`'s
|
||||
`AddAgentHostTelemetry`) registers the OTLP exporter pipeline. When hosted, Foundry injects
|
||||
`APPLICATIONINSIGHTS_CONNECTION_STRING` automatically, so traces, metrics, and logs flow to
|
||||
Application Insights with no configuration.
|
||||
|
||||
To capture prompt and response content in traces (off by default), set:
|
||||
|
||||
```bash
|
||||
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
|
||||
```
|
||||
|
||||
## File and shell access are disabled here
|
||||
|
||||
The hosted build turns **file access and shell off**:
|
||||
|
||||
```csharp
|
||||
await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions
|
||||
{
|
||||
// ...
|
||||
EnableFileAccess = false,
|
||||
EnableShell = false,
|
||||
});
|
||||
```
|
||||
|
||||
Why: in a shared, hosted container, giving the model arbitrary read/write access to the filesystem, or
|
||||
letting it run shell commands, is a serious security risk — data exfiltration, tampering, and
|
||||
persistence — even behind a deny-list. The local confirmations vault the shell operates on doesn't
|
||||
exist in the hosted environment anyway. If you enable either capability on a hosted container, treat it
|
||||
as a production security decision and scope it tightly.
|
||||
|
||||
If you genuinely need file access when hosted, prefer supplying an **external `AgentFileStore`** (for
|
||||
example, one backed by Azure Blob Storage) rather than the container disk:
|
||||
|
||||
```csharp
|
||||
await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions
|
||||
{
|
||||
// ...
|
||||
EnableFileAccess = true,
|
||||
FileStore = new MyBlobAgentFileStore(blobContainerClient),
|
||||
});
|
||||
```
|
||||
|
||||
## CodeAct runs on LocalCodeAct here, not Hyperlight
|
||||
|
||||
The local hosts give the model a **Hyperlight**-backed CodeAct sandbox, which runs guest code in a
|
||||
VM-isolated micro-sandbox. That needs a hypervisor (KVM) and FUSE — neither of which an unprivileged
|
||||
Foundry hosted container exposes — so the Hyperlight provider can't initialize its sandbox when
|
||||
hosted, and the agent never becomes ready.
|
||||
|
||||
The hosted build instead supplies a **`LocalCodeActProvider`**, which runs the generated Python in a
|
||||
child process and relies on the hosted container itself as the isolation boundary:
|
||||
|
||||
```csharp
|
||||
await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions
|
||||
{
|
||||
// ...
|
||||
EnableFileAccess = false,
|
||||
EnableShell = false,
|
||||
|
||||
// Hyperlight needs a hypervisor + FUSE the hosted container lacks; LocalCodeAct relies on the
|
||||
// container as the sandbox. Override the interpreter with LOCAL_CODEACT_PYTHON if needed.
|
||||
CodeActProvider = new LocalCodeActProvider(
|
||||
Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON") ?? "python3"),
|
||||
});
|
||||
```
|
||||
|
||||
> **Security:** `LocalCodeAct` is not itself a sandbox — it executes model-generated Python in a child
|
||||
> process. Only deploy it to an externally sandboxed environment such as a Foundry hosted-agent
|
||||
> container. To turn CodeAct off entirely instead, set `EnableCodeAct = false`.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Deploy to Foundry (container path)
|
||||
|
||||
This project deploys as a **container image** (not Foundry's source-code/zip path).
|
||||
|
||||
The project uses `ProjectReference` to sibling and
|
||||
framework sources (`ClawAgent`, `Microsoft.Agents.AI.Foundry`, `.Foundry.Hosting`,
|
||||
`.LocalCodeAct`) and the repo's Central Package Management (`dotnet/Directory.Packages.props`).
|
||||
|
||||
Because the ProjectReferences point outside this folder, a standard in-container `dotnet publish`
|
||||
can't resolve them. So the flow is **two explicit steps**: publish locally first, then build/deploy
|
||||
the image (this is what [`Dockerfile`](./Dockerfile) expects — it just
|
||||
`COPY`s the pre-published `out/`).
|
||||
|
||||
**1. (First time only) initialize azd in container mode** (writes a `docker`-based `azure.yaml`):
|
||||
|
||||
```bash
|
||||
azd ai agent init -m agent.manifest.yaml --deploy-mode container
|
||||
```
|
||||
|
||||
`azd init` provisions (or reuses) a **container registry** and records it in the
|
||||
`AZURE_CONTAINER_REGISTRY_ENDPOINT` environment variable, so you don't need to configure a registry
|
||||
manually — azd pushes the built image there using this project's [`Dockerfile`](./Dockerfile)
|
||||
automatically.
|
||||
|
||||
By default azd builds the image **remotely in Azure Container Registry**, so you don't need local
|
||||
Docker. Set `remoteBuild: false` under the `docker:` options in `azure.yaml` to build locally
|
||||
(requires Docker Desktop).
|
||||
|
||||
**2. Build and publish the app separately** (on your machine, inside the full repo, so the
|
||||
ProjectReferences and package versions resolve). Target the container runtime (glibc x64):
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted
|
||||
dotnet publish -c Release -f net10.0 -r linux-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
This produces `out/ClawAgent.Hosted.dll` and its dependencies. `out/` is what
|
||||
`Dockerfile` copies — you must run this step **before** every image build/deploy.
|
||||
|
||||
**3. Grant the Foundry workspace identity `AcrPull` on the registry.** azd pushes the image, but the
|
||||
hosted agent runtime pulls it using the Foundry **project's** system-assigned managed identity. That
|
||||
identity needs `AcrPull` on your registry, or the deploy fails with *"Container registry
|
||||
authentication failed … verify the workspace managed identity has AcrPull permissions"*:
|
||||
|
||||
```bash
|
||||
# Get the project's system-assigned managed identity principal id:
|
||||
PRINCIPAL_ID=$(az resource show \
|
||||
--ids "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<foundry-account>/projects/<project-name>" \
|
||||
--query identity.principalId -o tsv)
|
||||
|
||||
# Grant AcrPull on the registry:
|
||||
az role assignment create \
|
||||
--role AcrPull \
|
||||
--assignee-principal-type ServicePrincipal \
|
||||
--assignee-object-id "$PRINCIPAL_ID" \
|
||||
--scope /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.ContainerRegistry/registries/<registry-name>
|
||||
```
|
||||
|
||||
> RBAC changes can take a minute or two to propagate before the deploy can pull the image.
|
||||
|
||||
**4. Deploy:**
|
||||
|
||||
```bash
|
||||
azd up # first deploy: provisions resources, builds the image, creates the agent version
|
||||
# or, once provisioned (remember to re-run step 2 first so out/ is fresh):
|
||||
azd deploy
|
||||
```
|
||||
|
||||
**Test the image locally first (optional but recommended):**
|
||||
|
||||
```bash
|
||||
# after step 2:
|
||||
docker build -t personal-finance-claw .
|
||||
docker run --rm -p 8088:8088 --env-file .env personal-finance-claw
|
||||
# in another shell — should return HTTP 200:
|
||||
curl -i http://localhost:8088/readiness
|
||||
```
|
||||
|
||||
> **Non-interactive note:** the sample helpers prompt on the console for missing settings, which would
|
||||
> block a non-interactive container. The image sets `AF_DEMO_NONINTERACTIVE=1` (and `az`-style hosts
|
||||
> have redirected stdin) so startup never blocks. Provide real values via the `env:` map in
|
||||
> `azure.yaml` or the container's environment. See the
|
||||
> [container deployment guide](https://learn.microsoft.com/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: personal-finance-claw
|
||||
displayName: "Personal Finance Claw"
|
||||
|
||||
description: >
|
||||
A production-ready personal finance claw hosted as a Foundry Hosted Agent with
|
||||
Agent Framework harness capabilities, observability, optional Purview governance,
|
||||
local finance skills, CodeAct, and background agents.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- Observability
|
||||
- Purview
|
||||
- Evaluation
|
||||
|
||||
template:
|
||||
name: personal-finance-claw
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
resources:
|
||||
cpu: "1.0"
|
||||
memory: 2Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: TOOLBOX_MCP_SERVER_URL
|
||||
value: "{{TOOLBOX_MCP_SERVER_URL}}"
|
||||
- name: PURVIEW_CLIENT_APP_ID
|
||||
value: "{{PURVIEW_CLIENT_APP_ID}}"
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: personal-finance-claw
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
resources:
|
||||
cpu: "1.0"
|
||||
memory: 2Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: TOOLBOX_MCP_SERVER_URL
|
||||
value: ${TOOLBOX_MCP_SERVER_URL}
|
||||
- name: PURVIEW_CLIENT_APP_ID
|
||||
value: ${PURVIEW_CLIENT_APP_ID}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);OPENAI001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Purview\Microsoft.Agents.AI.Purview.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace ClawAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Contains the built production-ready claw agent and resources that must live as long as the agent.
|
||||
/// </summary>
|
||||
public sealed class ClawAgentBuild : IAsyncDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _disposables;
|
||||
private readonly List<IAsyncDisposable> _asyncDisposables;
|
||||
private bool _disposed;
|
||||
|
||||
internal ClawAgentBuild(
|
||||
AIAgent agent,
|
||||
bool foundrySkillsEnabled,
|
||||
bool purviewEnabled,
|
||||
IEnumerable<IDisposable> disposables,
|
||||
IEnumerable<IAsyncDisposable> asyncDisposables)
|
||||
{
|
||||
this.Agent = agent;
|
||||
this.FoundrySkillsEnabled = foundrySkillsEnabled;
|
||||
this.PurviewEnabled = purviewEnabled;
|
||||
this._disposables = [.. disposables];
|
||||
this._asyncDisposables = [.. asyncDisposables];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fully configured claw agent.
|
||||
/// </summary>
|
||||
public AIAgent Agent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether Foundry Toolbox MCP skills were enabled.
|
||||
/// </summary>
|
||||
public bool FoundrySkillsEnabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether Purview governance was enabled.
|
||||
/// </summary>
|
||||
public bool PurviewEnabled { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (this._disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._disposed = true;
|
||||
foreach (IAsyncDisposable disposable in this._asyncDisposables)
|
||||
{
|
||||
await disposable.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (IDisposable disposable in this._disposables)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using HyperlightSandbox.Guest.Python;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Agents.AI.Purview;
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shared production-ready claw agent used by all hosts.
|
||||
/// </summary>
|
||||
public static class ClawAgentFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// The OpenTelemetry source and meter name used by the claw harness agent.
|
||||
/// </summary>
|
||||
public const string OpenTelemetrySourceName = "BuildYourOwnClaw.ProductionReady.Claw";
|
||||
|
||||
private const string DefaultDeploymentName = "gpt-5.4";
|
||||
|
||||
private const string Instructions =
|
||||
"""
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their
|
||||
portfolio and watchlist, value individual stocks, gauge portfolio risk, research the market,
|
||||
and keep their records tidy.
|
||||
|
||||
### Working style
|
||||
|
||||
- The user's holdings live in a file called portfolio.csv. Read it with the file_access tools
|
||||
before answering questions about their portfolio, and never modify it unless asked.
|
||||
- You have skills for valuation and risk-scoring. When a question matches a skill, load it and
|
||||
follow its instructions (read its references, run its scripts) rather than guessing.
|
||||
- When asked to research several tickers, delegate each one to the background research agent so
|
||||
they run concurrently, then summarize the findings together.
|
||||
- The user's trade confirmations accumulate in the working/confirmations folder. When asked to
|
||||
tidy or reorganize them, use the run_shell tool: inspect the folder first, then move files into
|
||||
a year/month layout and rename them to YYYY-MM-DD_TICKER_BUY|SELL.txt. Explain your plan before
|
||||
running commands that change anything.
|
||||
- To buy or sell, use the place_trade tool. This takes a real action, so the user will be asked
|
||||
to approve it before it runs — explain what you are about to do first.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you
|
||||
must not present your output as personalized investment advice. Remind the user to do their own
|
||||
research before making decisions.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Creates the full claw agent and returns it with the resources that must be disposed by the host.
|
||||
/// </summary>
|
||||
/// <param name="options">Optional host-specific build settings.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The built agent and disposable resources.</returns>
|
||||
public static async Task<ClawAgentBuild> CreateAsync(ClawAgentFactoryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new ClawAgentFactoryOptions();
|
||||
Action<string> log = options.Log ?? Console.WriteLine;
|
||||
|
||||
string endpoint = options.ProjectEndpoint
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = options.DeploymentName
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME")
|
||||
?? DefaultDeploymentName;
|
||||
|
||||
string workingDir = options.WorkingDirectory ?? Path.Combine(AppContext.BaseDirectory, "working");
|
||||
string vaultDir = Path.Combine(workingDir, "confirmations");
|
||||
string skillsDir = options.SkillsDirectory ?? Path.Combine(AppContext.BaseDirectory, "skills");
|
||||
|
||||
TokenCredential credential = options.Credential ?? new DefaultAzureCredential();
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri(endpoint),
|
||||
credential,
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
|
||||
|
||||
IChatClient chatClient = projectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName);
|
||||
|
||||
bool purviewEnabled = false;
|
||||
string? purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID");
|
||||
TokenCredential? purviewCredential = options.PurviewCredential;
|
||||
if (purviewCredential is null && !string.IsNullOrWhiteSpace(purviewClientAppId))
|
||||
{
|
||||
purviewCredential = new InteractiveBrowserCredential(
|
||||
new InteractiveBrowserCredentialOptions { ClientId = purviewClientAppId });
|
||||
}
|
||||
|
||||
if (purviewCredential is not null)
|
||||
{
|
||||
chatClient = chatClient
|
||||
.AsBuilder()
|
||||
.WithPurview(purviewCredential, new PurviewSettings("Claw"))
|
||||
.Build();
|
||||
purviewEnabled = true;
|
||||
log(options.PurviewCredential is not null
|
||||
? "Purview enabled (host-provided credential). "
|
||||
: "Purview enabled (interactive browser credential). ");
|
||||
}
|
||||
else
|
||||
{
|
||||
log("Purview disabled. Set PURVIEW_CLIENT_APP_ID to enable governance checks.");
|
||||
}
|
||||
|
||||
var skillsBuilder = new AgentSkillsProviderBuilder()
|
||||
.UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync);
|
||||
|
||||
HttpClient? toolboxHttpClient = null;
|
||||
ModelContextProtocol.Client.McpClient? toolboxMcpClient = null;
|
||||
string? toolboxUrl = Environment.GetEnvironmentVariable("TOOLBOX_MCP_SERVER_URL");
|
||||
bool foundrySkillsEnabled = false;
|
||||
if (!string.IsNullOrWhiteSpace(toolboxUrl))
|
||||
{
|
||||
(toolboxMcpClient, toolboxHttpClient) = await FoundrySkills.ConnectAsync(
|
||||
toolboxUrl,
|
||||
credential,
|
||||
options.FoundryCallIdProvider,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
skillsBuilder.UseMcpSkills(toolboxMcpClient);
|
||||
foundrySkillsEnabled = true;
|
||||
log("Foundry skills enabled (Toolbox MCP). ");
|
||||
}
|
||||
else
|
||||
{
|
||||
log("Foundry skills disabled. Set TOOLBOX_MCP_SERVER_URL to enable them.");
|
||||
}
|
||||
|
||||
skillsBuilder.UseOptions((options) =>
|
||||
{
|
||||
options.DisableLoadSkillApproval = true;
|
||||
options.DisableReadSkillResourceApproval = true;
|
||||
});
|
||||
|
||||
AgentSkillsProvider skillsProvider = skillsBuilder.Build();
|
||||
AIAgent researchAgent = ResearchAgent.Create(chatClient);
|
||||
|
||||
// Shell access is a powerful capability. It is confined to the local vault directory with a
|
||||
// deny-list here, but on shared/hosted deployments it is disabled entirely (see hosted host).
|
||||
LocalShellExecutor? shell = null;
|
||||
if (options.EnableShell)
|
||||
{
|
||||
shell = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
{
|
||||
WorkingDirectory = vaultDir,
|
||||
ConfineWorkingDirectory = true,
|
||||
Policy = new ShellPolicy(denyList:
|
||||
[
|
||||
@"\brm\s+-rf\b",
|
||||
@"\bsudo\b",
|
||||
@":\(\)\s*\{",
|
||||
@"\bmkfs\b",
|
||||
@">\s*/dev/sd",
|
||||
]),
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
});
|
||||
log("Shell enabled (confined to the confirmations vault). ");
|
||||
}
|
||||
else
|
||||
{
|
||||
log("Shell disabled. ");
|
||||
}
|
||||
|
||||
// File access is enabled by default via a filesystem-backed store. Hosts may disable it or
|
||||
// supply an external store (for example, backed by blob storage) instead of the container disk.
|
||||
AgentFileStore? fileStore = null;
|
||||
if (options.EnableFileAccess)
|
||||
{
|
||||
fileStore = options.FileStore ?? new FileSystemAgentFileStore(workingDir);
|
||||
log(options.FileStore is not null
|
||||
? "File access enabled (custom AgentFileStore). "
|
||||
: "File access enabled (local filesystem). ");
|
||||
}
|
||||
else
|
||||
{
|
||||
log("File access disabled. ");
|
||||
}
|
||||
|
||||
// CodeAct gives the model a sandboxed code interpreter. By default we use the Hyperlight
|
||||
// provider, which runs guest code in a VM-isolated micro-sandbox — great for local hosts, but
|
||||
// it needs a hypervisor (KVM) and FUSE, which an unprivileged Foundry hosted container does not
|
||||
// expose. Hosts running in such an environment supply their own provider via
|
||||
// options.CodeActProvider (for example a LocalCodeActProvider that relies on the container
|
||||
// itself as the isolation boundary) or disable CodeAct entirely with EnableCodeAct = false.
|
||||
AIContextProvider? codeAct = null;
|
||||
if (options.EnableCodeAct)
|
||||
{
|
||||
codeAct = options.CodeActProvider
|
||||
?? new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
|
||||
log(options.CodeActProvider is not null
|
||||
? "CodeAct enabled (custom provider). "
|
||||
: "CodeAct enabled (Hyperlight VM-isolated sandbox). ");
|
||||
}
|
||||
else
|
||||
{
|
||||
log("CodeAct disabled. ");
|
||||
}
|
||||
|
||||
List<AIContextProvider> contextProviders = [skillsProvider];
|
||||
if (codeAct is not null)
|
||||
{
|
||||
contextProviders.Add(codeAct);
|
||||
}
|
||||
|
||||
List<AITool> tools =
|
||||
[
|
||||
StockTools.CreateGetStockPriceTool(),
|
||||
TradingTools.CreatePlaceTradeTool(),
|
||||
];
|
||||
|
||||
// Shell support is composed explicitly: the context provider tells the model about the
|
||||
// environment, while the approval-gated function exposes command execution.
|
||||
if (shell is not null)
|
||||
{
|
||||
contextProviders.Add(new ShellEnvironmentProvider(shell));
|
||||
tools.Add(shell.AsAIFunction(requireApproval: true));
|
||||
}
|
||||
|
||||
List<Func<ToolAutoApprovalRuleContext, ValueTask<bool>>> autoApprovalRules =
|
||||
[
|
||||
FileAccessProvider.ReadOnlyToolsAutoApprovalRule,
|
||||
];
|
||||
if (options.AdditionalToolAutoApprovalRules is not null)
|
||||
{
|
||||
autoApprovalRules.AddRange(options.AdditionalToolAutoApprovalRules);
|
||||
}
|
||||
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
Name = options.AgentName,
|
||||
Description = options.AgentDescription,
|
||||
FileAccessStore = fileStore,
|
||||
DisableAgentSkillsProvider = true,
|
||||
BackgroundAgents = [researchAgent],
|
||||
OpenTelemetrySourceName = OpenTelemetrySourceName,
|
||||
ToolApprovalAgentOptions = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = autoApprovalRules,
|
||||
},
|
||||
AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" },
|
||||
AIContextProviders = contextProviders,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = Instructions,
|
||||
Tools = tools,
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
|
||||
List<IDisposable> disposables = [];
|
||||
if (codeAct is IDisposable disposableCodeAct)
|
||||
{
|
||||
disposables.Add(disposableCodeAct);
|
||||
}
|
||||
|
||||
if (toolboxHttpClient is not null)
|
||||
{
|
||||
disposables.Add(toolboxHttpClient);
|
||||
}
|
||||
|
||||
if (chatClient is IDisposable disposableChatClient)
|
||||
{
|
||||
disposables.Add(disposableChatClient);
|
||||
}
|
||||
|
||||
List<IAsyncDisposable> asyncDisposables = [];
|
||||
if (shell is not null)
|
||||
{
|
||||
asyncDisposables.Add(shell);
|
||||
}
|
||||
|
||||
if (toolboxMcpClient is not null)
|
||||
{
|
||||
asyncDisposables.Add(toolboxMcpClient);
|
||||
}
|
||||
|
||||
return new ClawAgentBuild(agent, foundrySkillsEnabled, purviewEnabled, disposables, asyncDisposables);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.Core;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace ClawAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Options for building the production-ready claw agent.
|
||||
/// </summary>
|
||||
public sealed class ClawAgentFactoryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Foundry project endpoint. Defaults to <c>FOUNDRY_PROJECT_ENDPOINT</c>.
|
||||
/// </summary>
|
||||
public string? ProjectEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Foundry model deployment name. Defaults to <c>AZURE_AI_MODEL_DEPLOYMENT_NAME</c> or <c>gpt-5.4</c>.
|
||||
/// </summary>
|
||||
public string? DeploymentName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token credential used for Foundry. Defaults to <see cref="Azure.Identity.DefaultAzureCredential" />.
|
||||
/// </summary>
|
||||
public TokenCredential? Credential { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token credential used for Purview. When provided, Purview is enabled with
|
||||
/// this credential. Otherwise, <c>PURVIEW_CLIENT_APP_ID</c> enables local browser authentication.
|
||||
/// </summary>
|
||||
public TokenCredential? PurviewCredential { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional provider for the current Foundry hosted call ID. When supplied, the
|
||||
/// call ID is forwarded to the Toolbox MCP endpoint as <c>x-agent-foundry-call-id</c>.
|
||||
/// </summary>
|
||||
public Func<string?>? FoundryCallIdProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the agent name exposed to hosting and telemetry.
|
||||
/// </summary>
|
||||
public string AgentName { get; set; } = "personal-finance-claw";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the agent description exposed to hosting and telemetry.
|
||||
/// </summary>
|
||||
public string AgentDescription { get; set; } = "A production-ready personal finance claw with skills, shell, CodeAct, background agents, telemetry, and optional Purview governance.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the working directory containing portfolio data and trade confirmations.
|
||||
/// </summary>
|
||||
public string? WorkingDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the directory containing file-based skills.
|
||||
/// </summary>
|
||||
public string? SkillsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the agent can read and write files on the host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enabled by default for local hosts. Disable it on shared/hosted deployments where giving the
|
||||
/// model arbitrary read/write access to the container filesystem is a data-exfiltration and
|
||||
/// tampering risk. When you still need file access in a hosted environment, prefer supplying an
|
||||
/// external <see cref="FileStore"/> (for example, a blob-storage-backed store) rather than the
|
||||
/// container disk.
|
||||
/// </remarks>
|
||||
public bool EnableFileAccess { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional custom <see cref="AgentFileStore"/> used for file access.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> (and <see cref="EnableFileAccess"/> is <see langword="true"/>), a
|
||||
/// <see cref="FileSystemAgentFileStore"/> rooted at <see cref="WorkingDirectory"/> is used. Supply
|
||||
/// your own store to keep files off the local disk — for example, a store backed by Azure Blob
|
||||
/// Storage — which is the recommended approach for hosted deployments. Ignored when
|
||||
/// <see cref="EnableFileAccess"/> is <see langword="false"/>.
|
||||
/// </remarks>
|
||||
public AgentFileStore? FileStore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the agent can run shell commands on the host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enabled by default for local hosts. Disable it on shared/hosted deployments: arbitrary command
|
||||
/// execution inside the hosted container is a serious security risk (data exfiltration, persistence,
|
||||
/// tampering) even with a deny-list, and the local vault it operates on does not exist in the
|
||||
/// hosted environment.
|
||||
/// </remarks>
|
||||
public bool EnableShell { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the agent exposes a CodeAct code interpreter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enabled by default. When <see langword="true"/> and <see cref="CodeActProvider"/> is
|
||||
/// <see langword="null"/>, a Hyperlight-backed, VM-isolated provider is used — suitable for local
|
||||
/// hosts with a hypervisor (KVM) and FUSE. Foundry hosted containers do not expose those, so a
|
||||
/// hosted host should either supply a <see cref="CodeActProvider"/> that relies on the container as
|
||||
/// the sandbox (for example a <c>LocalCodeActProvider</c>) or set this to <see langword="false"/>.
|
||||
/// </remarks>
|
||||
public bool EnableCodeAct { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional CodeAct context provider used when <see cref="EnableCodeAct"/> is
|
||||
/// <see langword="true"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> the factory creates a Hyperlight-backed provider. Supply your own
|
||||
/// (for example a <c>LocalCodeActProvider</c>) to run in an environment without a hypervisor, such
|
||||
/// as a Foundry hosted container. Ignored when <see cref="EnableCodeAct"/> is
|
||||
/// <see langword="false"/>. If the provider implements <see cref="IDisposable"/> it is
|
||||
/// disposed by the returned <see cref="ClawAgentBuild"/>.
|
||||
/// </remarks>
|
||||
public AIContextProvider? CodeActProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional tool auto-approval rules for this host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The factory always includes the read-only file-access rule. Use additional rules only in
|
||||
/// trusted hosts, such as an evaluation runner that executes bundled skill scripts.
|
||||
/// </remarks>
|
||||
public IEnumerable<Func<ToolAutoApprovalRuleContext, ValueTask<bool>>>? AdditionalToolAutoApprovalRules { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional log callback used for setup notes.
|
||||
/// </summary>
|
||||
public Action<string>? Log { get; set; }
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.Core;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace ClawAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for wiring centrally-managed Foundry skills into the claw via a Foundry Toolbox MCP endpoint.
|
||||
/// </summary>
|
||||
internal static class FoundrySkills
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects to a Foundry Toolbox MCP endpoint and returns a connected MCP client.
|
||||
/// </summary>
|
||||
public static async Task<(McpClient McpClient, HttpClient HttpClient)> ConnectAsync(
|
||||
string toolboxMcpServerUrl,
|
||||
TokenCredential credential,
|
||||
Func<string?>? foundryCallIdProvider = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default", foundryCallIdProvider)
|
||||
{
|
||||
InnerHandler = new HttpClientHandler(),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
McpClient mcpClient = await McpClient.CreateAsync(
|
||||
new HttpClientTransport(
|
||||
new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(toolboxMcpServerUrl),
|
||||
Name = "foundry_toolbox",
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||
},
|
||||
},
|
||||
httpClient),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return (mcpClient, httpClient);
|
||||
}
|
||||
catch
|
||||
{
|
||||
httpClient.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BearerTokenHandler(
|
||||
TokenCredential credential,
|
||||
string scope,
|
||||
Func<string?>? foundryCallIdProvider) : DelegatingHandler
|
||||
{
|
||||
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
string? callId = foundryCallIdProvider?.Invoke();
|
||||
if (!string.IsNullOrWhiteSpace(callId) && !request.Headers.Contains("x-agent-foundry-call-id"))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("x-agent-foundry-call-id", callId);
|
||||
}
|
||||
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# ClawAgent
|
||||
|
||||
Shared class library for the production-ready personal finance claw.
|
||||
|
||||
`ClawAgentFactory.CreateAsync(...)` returns a `ClawAgentBuild` containing the fully configured `AIAgent` plus disposable resources. It preserves the Step 03 capabilities: Foundry Responses `IChatClient`, local file skills, optional Foundry Toolbox MCP skills, background research agent, confined `LocalShellExecutor`, Hyperlight CodeAct, file access, approvals, agent modes, stock tools, and trading tools.
|
||||
|
||||
Purview is opt-in via `PURVIEW_CLIENT_APP_ID`; when unset, the chat client is not wrapped. Telemetry is always enabled through `HarnessAgentOptions.OpenTelemetrySourceName`.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the background research agent that the main claw fans work out to.
|
||||
/// </summary>
|
||||
internal static class ResearchAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a web-search-only background agent for delegated ticker research.
|
||||
/// </summary>
|
||||
public static AIAgent Create(IChatClient chatClient) =>
|
||||
chatClient.AsAIAgent(
|
||||
instructions:
|
||||
"You research a single stock ticker. Use the web search tool to find the most " +
|
||||
"recent, relevant news and commentary, then return a short, factual summary " +
|
||||
"(3-4 bullet points) with no preamble.",
|
||||
name: "TickerResearchAgent",
|
||||
description: "Searches the web for recent news and commentary about a single stock ticker.",
|
||||
tools: [new HostedWebSearchTool()]);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawAgent;
|
||||
|
||||
/// <summary>
|
||||
/// A custom function tool that gives the claw access to illustrative stock prices.
|
||||
/// </summary>
|
||||
internal static class StockTools
|
||||
{
|
||||
/// <summary>
|
||||
/// A delayed, illustrative stock quote, including trailing earnings-per-share.
|
||||
/// </summary>
|
||||
public sealed record StockQuote(string Symbol, decimal Price, decimal TrailingEps, string Currency, DateTimeOffset AsOf);
|
||||
|
||||
private static readonly Dictionary<string, (decimal Price, decimal Eps)> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MSFT"] = (462.97m, 11.80m),
|
||||
["AAPL"] = (229.35m, 6.13m),
|
||||
["GOOGL"] = (178.12m, 7.54m),
|
||||
["AMZN"] = (201.45m, 4.18m),
|
||||
["NVDA"] = (134.81m, 2.95m),
|
||||
["SPY"] = (612.40m, 23.10m),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest delayed, illustrative stock price and trailing EPS for a ticker symbol.
|
||||
/// </summary>
|
||||
[Description("Gets the latest (delayed, illustrative) stock price and trailing earnings per share for a ticker symbol.")]
|
||||
public static StockQuote GetStockPrice(
|
||||
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
|
||||
{
|
||||
if (!s_priceBook.TryGetValue(symbol, out var data))
|
||||
{
|
||||
var seed = 0;
|
||||
foreach (var ch in symbol.ToUpperInvariant())
|
||||
{
|
||||
seed = (seed * 31 + ch) % 1_000_000;
|
||||
}
|
||||
|
||||
var price = 50m + seed % 45000 / 100m;
|
||||
data = (price, Math.Round(price / 20m, 2));
|
||||
}
|
||||
|
||||
return new StockQuote(symbol.ToUpperInvariant(), data.Price, data.Eps, "USD", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the AI function wrapper used to expose the stock price tool to the agent.
|
||||
/// </summary>
|
||||
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ClawAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Executes file-based skill scripts as local subprocesses.
|
||||
/// </summary>
|
||||
internal sealed class SubprocessScriptRunner
|
||||
{
|
||||
private static readonly TimeSpan s_scriptTimeout = TimeSpan.FromSeconds(30);
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubprocessScriptRunner" /> class.
|
||||
/// </summary>
|
||||
public SubprocessScriptRunner(ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<SubprocessScriptRunner>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a skill script as a local subprocess.
|
||||
/// </summary>
|
||||
public async Task<object?> RunAsync(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this._logger.LogDebug("Running script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
|
||||
if (!File.Exists(script.FullPath))
|
||||
{
|
||||
this._logger.LogError("Script file not found for skill '{SkillName}': {ScriptPath}", skill.Frontmatter.Name, script.FullPath);
|
||||
return $"Error: Script file not found: {script.FullPath}";
|
||||
}
|
||||
|
||||
string extension = Path.GetExtension(script.FullPath);
|
||||
string? interpreter = extension switch
|
||||
{
|
||||
".py" => OperatingSystem.IsWindows() ? "python" : "python3",
|
||||
".js" => "node",
|
||||
".sh" => "bash",
|
||||
".ps1" => "pwsh",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".",
|
||||
};
|
||||
|
||||
if (interpreter is not null)
|
||||
{
|
||||
startInfo.FileName = interpreter;
|
||||
startInfo.ArgumentList.Add(script.FullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = script.FullPath;
|
||||
}
|
||||
|
||||
if (arguments is { ValueKind: JsonValueKind.Array } json)
|
||||
{
|
||||
foreach (var element in json.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " +
|
||||
"All array elements must be JSON strings.");
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(element.GetString()!);
|
||||
}
|
||||
}
|
||||
else if (arguments?.ValueKind is not null and not JsonValueKind.Null and not JsonValueKind.Undefined)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " +
|
||||
"File-based skill scripts expect positional arguments as a JSON array of strings.");
|
||||
}
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(s_scriptTimeout);
|
||||
CancellationToken runToken = timeoutCts.Token;
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
{
|
||||
process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
this._logger.LogError("Failed to start process for script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
return $"Error: Failed to start process for script '{script.Name}'.";
|
||||
}
|
||||
|
||||
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(runToken);
|
||||
Task<string> errorTask = process.StandardError.ReadToEndAsync(runToken);
|
||||
|
||||
await process.WaitForExitAsync(runToken).ConfigureAwait(false);
|
||||
|
||||
string output = await outputTask.ConfigureAwait(false);
|
||||
string error = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
this._logger.LogWarning(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' succeeded but wrote to stderr:\n{Stderr}",
|
||||
script.Name, skill.Frontmatter.Name, error.Trim());
|
||||
}
|
||||
|
||||
output += $"\nStderr:\n{error}";
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
this._logger.LogError(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' exited with code {ExitCode}.{Stderr}",
|
||||
script.Name,
|
||||
skill.Frontmatter.Name,
|
||||
process.ExitCode,
|
||||
string.IsNullOrEmpty(error) ? string.Empty : $"\nStderr:\n{error.Trim()}");
|
||||
|
||||
output += $"\nScript exited with code {process.ExitCode}";
|
||||
}
|
||||
|
||||
string result = string.IsNullOrEmpty(output) ? "(no output)" : output.Trim();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' completed successfully. Output:\n{Output}",
|
||||
script.Name,
|
||||
skill.Frontmatter.Name,
|
||||
result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
process?.Kill(entireProcessTree: true);
|
||||
this._logger.LogError(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' timed out after {Timeout} seconds.",
|
||||
script.Name,
|
||||
skill.Frontmatter.Name,
|
||||
s_scriptTimeout.TotalSeconds);
|
||||
return $"Error: Script '{script.Name}' timed out after {s_scriptTimeout.TotalSeconds:0} seconds.";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process?.Kill(entireProcessTree: true);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Failed to execute script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
return $"Error: Failed to execute script '{script.Name}': {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
process?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Sensitive claw tools that take real-world actions and therefore require human approval.
|
||||
/// </summary>
|
||||
internal static class TradingTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Places a simulated buy or sell order for a given symbol and quantity.
|
||||
/// </summary>
|
||||
[Description("Places a buy or sell order for a given symbol and quantity.")]
|
||||
public static string PlaceTrade(
|
||||
[Description("The stock ticker symbol to trade, e.g. MSFT.")] string symbol,
|
||||
[Description("Either 'buy' or 'sell'.")] string action,
|
||||
[Description("The number of shares to trade.")] int quantity)
|
||||
{
|
||||
var isBuy = action.Equals("buy", StringComparison.OrdinalIgnoreCase);
|
||||
var isSell = action.Equals("sell", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isBuy && !isSell)
|
||||
{
|
||||
return $"Invalid action '{action}'. Use 'buy' or 'sell'.";
|
||||
}
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
return $"Invalid quantity '{quantity}'. Quantity must be a positive whole number of shares.";
|
||||
}
|
||||
|
||||
var verb = isSell ? "Sold" : "Bought";
|
||||
var confirmation = $"TRADE-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
return $"{verb} {quantity} share(s) of {symbol.ToUpperInvariant()}. Confirmation: {confirmation}.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an approval-required AI function for placing trades.
|
||||
/// </summary>
|
||||
public static AIFunction CreatePlaceTradeTool() =>
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(PlaceTrade, "place_trade"));
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: risk-scoring
|
||||
description: Score how concentrated and risky a portfolio is on a 0-100 scale from its position weights. Use when the user asks how risky their portfolio is, whether it is too concentrated, or for a diversification check.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks about portfolio risk or concentration:
|
||||
|
||||
1. Read `references/risk-bands.md` to understand the score bands and what drives them.
|
||||
2. Compute each holding's market value (shares × price) — use the `get_stock_price` tool for current
|
||||
prices if you do not already have them.
|
||||
3. Run `scripts/risk_score.py` with one `--position VALUE` argument per holding,
|
||||
e.g. `--position 18518 --position 17201 --position 16177`.
|
||||
4. Report the 0-100 score, the band it falls in, and the largest single-position weight, then suggest
|
||||
(in general terms) whether the portfolio looks well diversified or concentrated.
|
||||
|
||||
Remind the user this is a crude concentration measure, not a complete risk model, and not advice.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Risk-scoring guide (illustrative)
|
||||
|
||||
This skill scores **concentration risk** — how much a portfolio depends on its largest positions —
|
||||
on a 0-100 scale, where higher means riskier.
|
||||
|
||||
## How the score is built
|
||||
|
||||
1. Convert each position to a weight: `weight = position_value / total_value`.
|
||||
2. Compute the Herfindahl-Hirschman Index (HHI): `HHI = sum(weight^2)`.
|
||||
- A perfectly even portfolio of *n* holdings has `HHI = 1/n` (low).
|
||||
- A single-stock portfolio has `HHI = 1` (maximum concentration).
|
||||
3. Scale to 0-100: `score = round(HHI * 100)`.
|
||||
|
||||
## Score bands
|
||||
|
||||
| Score | Band | Interpretation |
|
||||
|---------|--------------------|-------------------------------------------------|
|
||||
| 0-20 | Well diversified | No single holding dominates. |
|
||||
| 21-40 | Moderately diversified | Some tilt, but broadly spread. |
|
||||
| 41-60 | Concentrated | A few positions carry most of the risk. |
|
||||
| 61-100 | Highly concentrated| Heavily dependent on one or two positions. |
|
||||
|
||||
Also watch the **largest single-position weight**: above ~25% is usually worth flagging regardless
|
||||
of the overall score.
|
||||
|
||||
This measures concentration only — it ignores volatility, correlation, sector exposure, and leverage,
|
||||
so it is a starting point, not a verdict.
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Portfolio risk-scoring script
|
||||
# Scores concentration risk on a 0-100 scale using the Herfindahl-Hirschman Index (HHI).
|
||||
#
|
||||
# weight_i = position_i / total
|
||||
# HHI = sum(weight_i ^ 2)
|
||||
# score = round(HHI * 100) # higher = more concentrated = riskier
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/risk_score.py --position 18518 --position 17201 --position 16177
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Score portfolio concentration risk (0-100).")
|
||||
parser.add_argument(
|
||||
"--position",
|
||||
type=float,
|
||||
action="append",
|
||||
required=True,
|
||||
help="Market value of one holding. Pass once per position.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
positions = args.position
|
||||
if any(p <= 0 for p in positions):
|
||||
print(json.dumps({"error": "Each position value must be a positive market value."}))
|
||||
return
|
||||
|
||||
total = sum(positions)
|
||||
if total <= 0:
|
||||
print(json.dumps({"error": "Total portfolio value must be positive."}))
|
||||
return
|
||||
|
||||
weights = [p / total for p in positions]
|
||||
hhi = sum(w * w for w in weights)
|
||||
score = round(hhi * 100)
|
||||
|
||||
if score <= 20:
|
||||
band = "Well diversified"
|
||||
elif score <= 40:
|
||||
band = "Moderately diversified"
|
||||
elif score <= 60:
|
||||
band = "Concentrated"
|
||||
else:
|
||||
band = "Highly concentrated"
|
||||
|
||||
print(json.dumps({
|
||||
"positions": len(positions),
|
||||
"score": score,
|
||||
"band": band,
|
||||
"largest_weight_pct": round(max(weights) * 100, 1),
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: valuation
|
||||
description: Estimate whether a stock looks cheap or expensive using a price-to-earnings (P/E) based fair-value method. Use when the user asks if a stock is over- or under-valued, or for a fair-value / target price.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks whether a stock is fairly valued, over-valued, or under-valued:
|
||||
|
||||
1. Read `references/valuation-guide.md` to pick a sensible target P/E for the company's sector.
|
||||
2. Run `scripts/valuation_metrics.py` with the current price, trailing EPS, and the target P/E,
|
||||
e.g. `--price 462.97 --eps 11.80 --target-pe 32`.
|
||||
3. Report the computed P/E, the fair-value estimate, and the percentage upside/downside, then state
|
||||
plainly whether the stock looks cheap or expensive on this measure.
|
||||
|
||||
Always remind the user that a single P/E heuristic is not investment advice and ignores growth,
|
||||
debt, and many other factors.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Valuation guide (illustrative)
|
||||
|
||||
A quick price-to-earnings (P/E) sanity check:
|
||||
|
||||
- **P/E = price ÷ trailing earnings per share (EPS)**
|
||||
- **Fair value = trailing EPS × target P/E**
|
||||
- **Upside/downside = (fair value − price) ÷ price**
|
||||
|
||||
## Typical target P/E by sector
|
||||
|
||||
These are rough, illustrative anchors only — not live market multiples.
|
||||
|
||||
| Sector | Conservative target P/E | Growth target P/E |
|
||||
|-----------------------|-------------------------|-------------------|
|
||||
| Mega-cap technology | 28 | 35 |
|
||||
| Semiconductors | 25 | 40 |
|
||||
| Consumer staples | 18 | 22 |
|
||||
| Financials / banks | 11 | 14 |
|
||||
| Broad market (index) | 19 | 21 |
|
||||
|
||||
## How to read the result
|
||||
|
||||
- Fair value **well above** the current price ⇒ the stock looks **cheap** on this measure.
|
||||
- Fair value **well below** the current price ⇒ the stock looks **expensive** on this measure.
|
||||
- Within ~5% ⇒ roughly **fairly valued**.
|
||||
|
||||
This is one crude lens. It ignores growth rates, balance-sheet strength, and cash flow, so never
|
||||
present it as a recommendation.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Valuation metrics script
|
||||
# Computes a simple price-to-earnings (P/E) based fair-value estimate.
|
||||
#
|
||||
# fair_value = eps * target_pe
|
||||
# pe = price / eps
|
||||
# upside = (fair_value - price) / price
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/valuation_metrics.py --price 462.97 --eps 11.80 --target-pe 32
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Compute a P/E based fair-value estimate.")
|
||||
parser.add_argument("--price", type=float, required=True, help="Current share price.")
|
||||
parser.add_argument("--eps", type=float, required=True, help="Trailing earnings per share.")
|
||||
parser.add_argument("--target-pe", type=float, required=True, help="Target P/E from the guide.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.eps <= 0:
|
||||
print(json.dumps({"error": "EPS must be positive to compute a P/E ratio."}))
|
||||
return
|
||||
|
||||
if args.price <= 0:
|
||||
print(json.dumps({"error": "Price must be positive to compute valuation metrics."}))
|
||||
return
|
||||
|
||||
if args.target_pe <= 0:
|
||||
print(json.dumps({"error": "Target P/E must be positive."}))
|
||||
return
|
||||
|
||||
pe = args.price / args.eps
|
||||
fair_value = args.eps * args.target_pe
|
||||
upside = (fair_value - args.price) / args.price
|
||||
|
||||
if upside > 0.05:
|
||||
verdict = "looks cheap"
|
||||
elif upside < -0.05:
|
||||
verdict = "looks expensive"
|
||||
else:
|
||||
verdict = "roughly fairly valued"
|
||||
|
||||
print(json.dumps({
|
||||
"price": round(args.price, 2),
|
||||
"eps": round(args.eps, 2),
|
||||
"target_pe": round(args.target_pe, 2),
|
||||
"pe": round(pe, 2),
|
||||
"fair_value": round(fair_value, 2),
|
||||
"upside_pct": round(upside * 100, 1),
|
||||
"verdict": verdict,
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-55AA44BB
|
||||
Date: 2025-06-21
|
||||
Symbol: NVDA
|
||||
Action: SELL
|
||||
Quantity: 20
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-77CC88DD
|
||||
Date: 2024-05-08
|
||||
Symbol: SPY
|
||||
Action: SELL
|
||||
Quantity: 15
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-9F8E7D6C
|
||||
Date: 2024-11-03
|
||||
Symbol: AAPL
|
||||
Action: BUY
|
||||
Quantity: 75
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-1234ABCD
|
||||
Date: 2025-09-12
|
||||
Symbol: AMZN
|
||||
Action: BUY
|
||||
Quantity: 30
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-EE11FF22
|
||||
Date: 2025-01-30
|
||||
Symbol: GOOGL
|
||||
Action: BUY
|
||||
Quantity: 25
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-A1B2C3D4
|
||||
Date: 2024-02-14
|
||||
Symbol: MSFT
|
||||
Action: BUY
|
||||
Quantity: 40
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
symbol,shares,cost_basis,purchase_date
|
||||
MSFT,40,312.50,2023-02-14
|
||||
AAPL,75,168.20,2022-11-03
|
||||
NVDA,120,42.80,2021-06-21
|
||||
AMZN,30,142.10,2023-09-12
|
||||
GOOGL,25,128.45,2024-01-30
|
||||
SPY,60,418.90,2024-05-08
|
||||
|
+21
@@ -0,0 +1,21 @@
|
||||
# Production-ready claw (Post 4) — .NET
|
||||
|
||||
Post 4 restructures the Step 03 claw into a shared agent library plus three thin hosts.
|
||||
|
||||
## Projects
|
||||
|
||||
- [`ClawAgent`](./ClawAgent/README.md) — shared factory that builds the full Step 03 HarnessAgent, adds a stable OpenTelemetry source name, and enables Purview only when `PURVIEW_CLIENT_APP_ID` is set.
|
||||
- [`ClawAgent.Console`](./ClawAgent.Console/README.md) — local interactive console host with console and OTLP OpenTelemetry exporters.
|
||||
- [`ClawAgent.Hosted`](./ClawAgent.Hosted/README.md) — ASP.NET Responses host for Foundry Hosted Agent deployment. Observability is wired automatically by the hosting runtime; file access and shell are disabled on the container.
|
||||
- [`ClawAgent.Evals`](./ClawAgent.Evals/README.md) — local finance checks and optional Foundry quality evals.
|
||||
|
||||
## Common configuration
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" # optional
|
||||
export TOOLBOX_MCP_SERVER_URL="https://.../mcp?api-version=v1" # optional Foundry skills
|
||||
export PURVIEW_CLIENT_APP_ID="<app-id>" # optional Purview governance
|
||||
```
|
||||
|
||||
OpenTelemetry uses source and meter name `BuildYourOwnClaw.ProductionReady.Claw`. Hosts choose exporters.
|
||||
@@ -20,6 +20,7 @@ Samples accompanying the [*Build your own agent harness or claw with Microsoft A
|
||||
| [Claw_Step01_MeetYourClaw](./BuildYourOwnClaw/Claw_Step01_MeetYourClaw/README.md) | Post 1 — a minimal HarnessAgent with a custom `get_stock_price` tool, web search, and planning |
|
||||
| [Claw_Step02_WorkingWithData](./BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md) | Post 2 — file access, approvals, and durable memory (file memory plus optional Foundry memory) |
|
||||
| [Claw_Step03_ScalingCapabilities](./BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md) | Post 3 — scaling the claw with skills (plus optional Foundry skills), a confined shell, CodeAct, and background agents |
|
||||
| [Claw_Step04_ProductionReady](./BuildYourOwnClaw/Claw_Step04_ProductionReady/README.md) | Post 4 — production-ready shared claw library with observability, opt-in Purview governance, Foundry hosted deployment, and evals |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
|
||||
@@ -123,20 +123,27 @@ internal sealed class Program
|
||||
private static DeclarativeAgentDefinition DefinePlannerAgent(IConfiguration configuration) =>
|
||||
new(configuration.GetValue(Application.Settings.FoundryModel))
|
||||
{
|
||||
Instructions = // TODO: Use Structured Inputs / Prompt Template
|
||||
Instructions =
|
||||
"""
|
||||
Your only job is to devise an efficient plan that identifies (by name) how a team member may contribute to addressing the user request.
|
||||
|
||||
Only select the following team which is listed as "- [Name]: [Description]"
|
||||
|
||||
- WeatherAgent: Able to retrieve weather information
|
||||
- CoderAgent: Able to write and execute Python code
|
||||
- KnowledgeAgent: Able to perform generic websearches
|
||||
{{team}}
|
||||
|
||||
The plan must be a bullet point list must be in the form "- [AgentName]: [Specific action or task for that agent to perform]"
|
||||
|
||||
Remember, there is no requirement to involve the entire team -- only select team member's whose particular expertise is required for this task.
|
||||
"""
|
||||
""",
|
||||
StructuredInputs =
|
||||
{
|
||||
["team"] =
|
||||
new StructuredInputDefinition
|
||||
{
|
||||
IsRequired = true,
|
||||
Description = "The available team members and their capabilities.",
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static DeclarativeAgentDefinition DefineManagerAgent(IConfiguration configuration) =>
|
||||
@@ -151,7 +158,7 @@ internal sealed class Program
|
||||
- WeatherAgent: Able to retrieve weather information
|
||||
|
||||
To make progress on the request, please answer the following questions, including necessary reasoning:
|
||||
- Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed)
|
||||
- Is the request fully satisfied? (True if the requested work is complete and enough information is available to provide the final answer. A verified negative or empty result, such as confirming that a requested resource does not exist, can fully satisfy the request. False if work remains or if a negative or empty result may be caused by an execution, authorization, connectivity, or investigation failure.)
|
||||
- Are we in a loop where we are repeating the same requests and / or getting the same responses from an agent multiple times? Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times.
|
||||
- Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success such as the inability to read from a required file)
|
||||
- Who should speak next? (select from: KnowledgeAgent, CoderAgent, WeatherAgent)
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Controls which files are excluded from the code-deploy ZIP upload (.gitignore syntax).
|
||||
# Note: only the root .agentignore is read; subdirectory files are not supported.
|
||||
#
|
||||
# To include a file that is excluded by default, use negation: !filename
|
||||
|
||||
# azd tooling files
|
||||
azure.yaml
|
||||
.agentignore
|
||||
|
||||
# Security / secrets
|
||||
.env
|
||||
.env.*
|
||||
.azure/
|
||||
.git/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# Agent session state written by FileSystemAgentSessionStore during local runs. The hosted
|
||||
# runtime writes its own under the container's home directory, so uploading the local copy
|
||||
# would ship stale sessions with the agent.
|
||||
.checkpoints/
|
||||
|
||||
# Contributor mode (scripts/Add-LocalFrameworkFeed.ps1) generates local-feed/ and nuget.config.
|
||||
# Those are deliberately NOT excluded: the server-side restore needs them to resolve the Agent
|
||||
# Framework from the packages shipped in this upload instead of nuget.org.
|
||||
+3
-2
@@ -1,2 +1,3 @@
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
# Foundry project endpoint is not required by this echo sample (no model call).
|
||||
# Local development only: bind to the readiness port the Foundry runtime probes.
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user